mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-09 12:39:23 -03:00
Compare commits
159 Commits
a1dff6dd47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
451f74b874 | ||
|
|
a1d248baa6 | ||
|
|
18577fa336 | ||
|
|
5797ce9408 | ||
|
|
826f06255a | ||
|
|
84e16b5c5b | ||
|
|
eb22054580 | ||
|
|
08afb05ece | ||
|
|
f51f125cf1 | ||
|
|
24b2078f21 | ||
|
|
130fb5d2d5 | ||
|
|
23c6863a3a | ||
|
|
c0e2578640 | ||
|
|
e3c812367e | ||
|
|
4d239008a6 | ||
|
|
00177a06d0 | ||
|
|
568daa351e | ||
|
|
5a4664fa12 | ||
|
|
dd5b213adc | ||
|
|
d9ee9b3155 | ||
|
|
01dac57c35 | ||
|
|
7f92d09239 | ||
|
|
62f9e3f44a | ||
|
|
e55895786d | ||
|
|
82b77bf593 | ||
|
|
1beef5dea9 | ||
|
|
c8beaa64e1 | ||
|
|
fb443ed6ae | ||
|
|
151a467598 | ||
|
|
98e1d168b0 | ||
|
|
716f18e0ed | ||
|
|
b060dc99fc | ||
|
|
54bcdfab38 | ||
|
|
2e7532eecc | ||
|
|
7e5e3b1ec7 | ||
|
|
df67bd396a | ||
|
|
dd5d9cfcb2 | ||
|
|
d9fd60bec1 | ||
|
|
b633b22779 | ||
|
|
1ffa543160 | ||
|
|
cdc940586e | ||
|
|
ccf1c6f2ae | ||
|
|
bfe7b5e1c7 | ||
|
|
85c020cd12 | ||
|
|
1b202f8ec7 | ||
|
|
d02a0611d3 | ||
|
|
92166a161a | ||
|
|
b509f27cb7 | ||
|
|
5c2ef48917 | ||
|
|
ad2bd82c67 | ||
|
|
17ba350153 | ||
|
|
60175334b5 | ||
|
|
f65a01df00 | ||
|
|
430e24d70b | ||
|
|
14f0c48fdd | ||
|
|
34791c2ad7 | ||
|
|
3f6824eef6 | ||
|
|
3919dfa3f4 | ||
|
|
7124b5293f | ||
|
|
d2a04f8993 | ||
|
|
7027a7c270 | ||
|
|
0a1d7dfd4c | ||
|
|
3962b1a96d | ||
|
|
8b856276bf | ||
|
|
c97c802956 | ||
|
|
24e2909627 | ||
|
|
b768f1368f | ||
|
|
37ccd29fc0 | ||
|
|
7416080cfb | ||
|
|
26be187d42 | ||
|
|
d7caa1fa47 | ||
|
|
2629fcce23 | ||
|
|
438e7d07b9 | ||
|
|
e9932ea870 | ||
|
|
5dd8b96422 | ||
|
|
5e1cf68bbd | ||
|
|
1044fa3c83 | ||
|
|
397892bb7f | ||
|
|
f105500740 | ||
|
|
806555cf06 | ||
|
|
5cd7204101 | ||
|
|
3b602a3698 | ||
|
|
15dfaed462 | ||
|
|
0e51851025 | ||
|
|
0d0f4defca | ||
|
|
818fa34a48 | ||
|
|
78303b2a5e | ||
|
|
9ce56dd40c | ||
|
|
4e3ede23b7 | ||
|
|
33e5f3d85d | ||
|
|
031d5e4f40 | ||
|
|
4ff5774e34 | ||
|
|
94e1a8ac7b | ||
|
|
cc20d3b992 | ||
|
|
a74cbe7aa2 | ||
|
|
94edfaa190 | ||
|
|
31c54ff068 | ||
|
|
21872a8e9e | ||
|
|
612612f1c7 | ||
|
|
ff240db5b1 | ||
|
|
bcfed4b874 | ||
|
|
1352c6ecbe | ||
|
|
30b01b8a92 | ||
|
|
a105cb322b | ||
|
|
3bf396d003 | ||
|
|
60cfb3b8e0 | ||
|
|
6763abb83c | ||
|
|
5c53968caa | ||
|
|
b4f7dd75af | ||
|
|
86118d0654 | ||
|
|
df1410535e | ||
|
|
75f74d54d8 | ||
|
|
ab6100f596 | ||
|
|
5d3ab3bbf8 | ||
|
|
d9dc0dba8d | ||
|
|
3631c5eb10 | ||
|
|
6d5b4b7312 | ||
|
|
7803bd542d | ||
|
|
f0a86dbbc0 | ||
|
|
682e964f89 | ||
|
|
908464bc0a | ||
|
|
0ffee3a854 | ||
|
|
8aa9739c44 | ||
|
|
50739bbb43 | ||
|
|
e849303763 | ||
|
|
241b2e15d2 | ||
|
|
88da754504 | ||
|
|
b4a706651f | ||
|
|
ff7cc6d9bb | ||
|
|
454210a47c | ||
|
|
2d7c404ebb | ||
|
|
e23d803ecf | ||
|
|
0cc640cfaa | ||
|
|
2ac0eb0f9d | ||
|
|
f028625ce9 | ||
|
|
06acc7f576 | ||
|
|
d324b57274 | ||
|
|
502b7eab31 | ||
|
|
be75ad930e | ||
|
|
763c4f4dad | ||
|
|
d32c492bdb | ||
|
|
5dcfde36ea | ||
|
|
1d035361a4 | ||
|
|
25605c5e78 | ||
|
|
f3268a6179 | ||
|
|
055e94d77b | ||
|
|
47fcd530a0 | ||
|
|
3c32b9e088 | ||
|
|
ffe0670a27 | ||
|
|
cc147a1795 | ||
|
|
e81409bea4 | ||
|
|
b31fae4e51 | ||
|
|
c6e5467907 | ||
|
|
df0e5797d0 | ||
|
|
ebdbb36271 | ||
|
|
2eef629821 | ||
|
|
658a04736d | ||
|
|
ef7f677933 | ||
|
|
63f0942452 |
69
.agents/skills/lora-manager-runtime-context/SKILL.md
Normal file
69
.agents/skills/lora-manager-runtime-context/SKILL.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: lora-manager-runtime-context
|
||||||
|
description: Inspect ComfyUI LoRA Manager runtime configuration and local diagnostic state. Use when debugging LoRA Manager issues that require locating or reading settings.json, active library paths, model metadata JSON sidecars, recipe metadata JSON files, example image folders, SQLite caches, symlink maps, download history, aria2 state, or other cache files under the LoRA Manager user config directory.
|
||||||
|
---
|
||||||
|
|
||||||
|
# LoRA Manager Runtime Context
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
- Treat runtime state as local user data. Prefer read-only inspection unless the user explicitly asks for mutation.
|
||||||
|
- Never print secret-like settings values. Redact keys containing `key`, `token`, `secret`, `password`, `auth`, or `credential`, including `civitai_api_key`.
|
||||||
|
- Resolve paths from the runtime configuration before guessing. In this environment the settings file is normally `/home/miao/.config/ComfyUI-LoRA-Manager/settings.json`, but portable settings can override this through the repository `settings.json`.
|
||||||
|
- Use the active library when selecting per-library caches and paths. Read `active_library` from settings; fall back to `default` if missing.
|
||||||
|
- Normalize and expand `~` before comparing paths. Symlinks are common in this repo.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Use the bundled helper for a safe first pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py summary
|
||||||
|
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py caches
|
||||||
|
```
|
||||||
|
|
||||||
|
The script redacts sensitive settings, opens SQLite databases read-only, and reports inaccessible or locked databases as warnings.
|
||||||
|
|
||||||
|
For focused checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py recipes
|
||||||
|
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py model --path /path/to/model.safetensors
|
||||||
|
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py sqlite --db /path/to/cache.sqlite --limit 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Path Rules
|
||||||
|
|
||||||
|
- Settings directory: use `py/utils/settings_paths.py`. Default platform path is `platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`.
|
||||||
|
- Settings file: `<settings_dir>/settings.json`.
|
||||||
|
- Cache root: `<settings_dir>/cache`.
|
||||||
|
- Canonical cache files:
|
||||||
|
- Model cache: `cache/model/<active_library>.sqlite`.
|
||||||
|
- Recipe cache: `cache/recipe/<active_library>.sqlite`.
|
||||||
|
- Model update cache: `cache/model_update/<active_library>.sqlite`.
|
||||||
|
- Recipe FTS: `cache/fts/recipe_fts.sqlite`.
|
||||||
|
- Tag FTS: `cache/fts/tag_fts.sqlite`.
|
||||||
|
- Symlink map: `cache/symlink/symlink_map.json`.
|
||||||
|
- Download history: `cache/download_history/downloaded_versions.sqlite`.
|
||||||
|
- aria2 state: `cache/aria2/downloads.json`.
|
||||||
|
- Legacy cache locations may exist; prefer canonical paths unless diagnosing migrations.
|
||||||
|
|
||||||
|
## Data Location Rules
|
||||||
|
|
||||||
|
- Model roots come from `settings.folder_paths` and the active library payload under `settings.libraries[active_library]`.
|
||||||
|
- Model metadata JSON sidecars live next to the model file as `<model basename>.metadata.json`.
|
||||||
|
- Recipes root is `settings.recipes_path` when it is a non-empty string. If empty, use the first configured LoRA root plus `/recipes`.
|
||||||
|
- Recipe JSON files are named `*.recipe.json` under the recipes root and may be nested in folders.
|
||||||
|
- Example image root is `settings.example_images_path`.
|
||||||
|
- If multiple libraries are configured, example images are stored under `<example_images_path>/<sanitized_library>/<sha256>/`; otherwise they are under `<example_images_path>/<sha256>/`.
|
||||||
|
|
||||||
|
## Useful Cache Tables
|
||||||
|
|
||||||
|
- Model cache: `models`, `model_tags`, `hash_index`, `excluded_models`.
|
||||||
|
- Recipe cache: `recipes`, `cache_metadata`.
|
||||||
|
- Model update cache: `model_update_status`, `model_update_versions`.
|
||||||
|
- Tag FTS cache: `tags`, `fts_metadata`, plus FTS internal tables.
|
||||||
|
- Recipe FTS cache: `recipe_rowid`, `fts_metadata`, plus FTS internal tables.
|
||||||
|
- Download history: `downloaded_model_versions`.
|
||||||
|
|
||||||
|
Prefer querying only counts, schema, and a few sample rows unless the user asks for full output.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "LoRA Manager Runtime Context"
|
||||||
|
short_description: "Inspect LoRA Manager runtime state"
|
||||||
|
default_prompt: "Use $lora-manager-runtime-context to inspect LoRA Manager settings, metadata paths, and caches for debugging."
|
||||||
381
.agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py
Executable file
381
.agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py
Executable file
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
CACHE_SQLITE = {
|
||||||
|
"model": ("model", "{library}.sqlite"),
|
||||||
|
"recipe": ("recipe", "{library}.sqlite"),
|
||||||
|
"model_update": ("model_update", "{library}.sqlite"),
|
||||||
|
"recipe_fts": ("fts", "recipe_fts.sqlite"),
|
||||||
|
"tag_fts": ("fts", "tag_fts.sqlite"),
|
||||||
|
"download_history": ("download_history", "downloaded_versions.sqlite"),
|
||||||
|
}
|
||||||
|
CACHE_JSON = {
|
||||||
|
"symlink": ("symlink", "symlink_map.json"),
|
||||||
|
"aria2": ("aria2", "downloads.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Inspect LoRA Manager runtime state read-only.")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
subparsers.add_parser("summary", help="Print redacted settings and resolved paths.")
|
||||||
|
subparsers.add_parser("caches", help="Print cache paths and SQLite table summaries.")
|
||||||
|
subparsers.add_parser("recipes", help="Print resolved recipes root and recipe JSON count.")
|
||||||
|
|
||||||
|
model_parser = subparsers.add_parser("model", help="Inspect a model metadata sidecar path.")
|
||||||
|
model_parser.add_argument("--path", required=True, help="Path to a model file or metadata JSON file.")
|
||||||
|
|
||||||
|
sqlite_parser = subparsers.add_parser("sqlite", help="Inspect a SQLite database read-only.")
|
||||||
|
sqlite_parser.add_argument("--db", required=True, help="Path to the SQLite database.")
|
||||||
|
sqlite_parser.add_argument("--limit", type=int, default=3, help="Rows to sample from each user table.")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
context = build_context()
|
||||||
|
|
||||||
|
if args.command == "summary":
|
||||||
|
print_json(summary_payload(context))
|
||||||
|
elif args.command == "caches":
|
||||||
|
print_json(caches_payload(context))
|
||||||
|
elif args.command == "recipes":
|
||||||
|
print_json(recipes_payload(context))
|
||||||
|
elif args.command == "model":
|
||||||
|
print_json(model_payload(args.path))
|
||||||
|
elif args.command == "sqlite":
|
||||||
|
print_json(sqlite_payload(Path(args.db).expanduser(), args.limit))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_context() -> dict[str, Any]:
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
settings = load_json(settings_path)
|
||||||
|
settings_dir = settings_path.parent
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
safe_library = sanitize_library_name(str(active_library))
|
||||||
|
cache_root = settings_dir / "cache"
|
||||||
|
return {
|
||||||
|
"settings_path": str(settings_path),
|
||||||
|
"settings_dir": str(settings_dir),
|
||||||
|
"settings": settings,
|
||||||
|
"active_library": active_library,
|
||||||
|
"safe_library": safe_library,
|
||||||
|
"cache_root": str(cache_root),
|
||||||
|
"cache_paths": resolve_cache_paths(cache_root, safe_library),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = find_repo_root()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if config_home:
|
||||||
|
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||||
|
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def find_repo_root() -> Path:
|
||||||
|
current = Path(__file__).resolve()
|
||||||
|
for parent in current.parents:
|
||||||
|
if (parent / "py").is_dir() and (parent / "standalone.py").exists():
|
||||||
|
return parent
|
||||||
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return {"_error": f"invalid JSON: {exc}"}
|
||||||
|
except OSError as exc:
|
||||||
|
return {"_error": f"unreadable: {exc}"}
|
||||||
|
return payload if isinstance(payload, dict) else {"_error": "JSON root is not an object"}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cache_paths(cache_root: Path, library: str) -> dict[str, str]:
|
||||||
|
paths: dict[str, str] = {}
|
||||||
|
for name, (subdir, filename) in CACHE_SQLITE.items():
|
||||||
|
paths[name] = str(cache_root / subdir / filename.format(library=library))
|
||||||
|
for name, (subdir, filename) in CACHE_JSON.items():
|
||||||
|
paths[name] = str(cache_root / subdir / filename)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def summary_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
settings = context["settings"]
|
||||||
|
return {
|
||||||
|
"settings_path": context["settings_path"],
|
||||||
|
"settings_dir": context["settings_dir"],
|
||||||
|
"active_library": context["active_library"],
|
||||||
|
"settings": redact(settings),
|
||||||
|
"model_roots": model_roots(settings, context["active_library"]),
|
||||||
|
"recipes_root": str(resolve_recipes_root(settings, context["active_library"]) or ""),
|
||||||
|
"example_images": example_images_payload(settings, context["active_library"]),
|
||||||
|
"cache_root": context["cache_root"],
|
||||||
|
"cache_paths": context["cache_paths"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def caches_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
caches: dict[str, Any] = {}
|
||||||
|
for name, path_string in context["cache_paths"].items():
|
||||||
|
path = Path(path_string)
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"path": str(path),
|
||||||
|
"exists": path.exists(),
|
||||||
|
"size": path.stat().st_size if path.exists() else None,
|
||||||
|
}
|
||||||
|
if path.suffix == ".sqlite":
|
||||||
|
item["sqlite"] = sqlite_payload(path, limit=0)
|
||||||
|
elif path.suffix == ".json":
|
||||||
|
item["json"] = json_file_summary(path)
|
||||||
|
caches[name] = item
|
||||||
|
return {"active_library": context["active_library"], "caches": caches}
|
||||||
|
|
||||||
|
|
||||||
|
def recipes_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
root = resolve_recipes_root(context["settings"], context["active_library"])
|
||||||
|
files: list[str] = []
|
||||||
|
if root and root.exists():
|
||||||
|
files = [str(path) for path in sorted(root.rglob("*.recipe.json"))[:20]]
|
||||||
|
return {
|
||||||
|
"recipes_root": str(root or ""),
|
||||||
|
"exists": bool(root and root.exists()),
|
||||||
|
"recipe_json_count": count_recipe_files(root),
|
||||||
|
"sample_recipe_json": files,
|
||||||
|
"recipe_cache": context["cache_paths"].get("recipe"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def model_payload(raw_path: str) -> dict[str, Any]:
|
||||||
|
path = Path(raw_path).expanduser()
|
||||||
|
metadata_path = path if path.name.endswith(".metadata.json") else path.with_suffix(".metadata.json")
|
||||||
|
payload = {
|
||||||
|
"input_path": str(path),
|
||||||
|
"metadata_path": str(metadata_path),
|
||||||
|
"model_exists": path.exists(),
|
||||||
|
"metadata_exists": metadata_path.exists(),
|
||||||
|
}
|
||||||
|
if metadata_path.exists():
|
||||||
|
data = load_json(metadata_path)
|
||||||
|
payload["metadata_summary"] = redact(summarize_value(data))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_payload(path: Path, limit: int = 3, allow_copy: bool = True) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"path": str(path), "exists": path.exists(), "tables": {}}
|
||||||
|
if not path.exists():
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
conn = connect_sqlite_readonly(path)
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
result["error"] = str(exc)
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
table_rows = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||||
|
).fetchall()
|
||||||
|
for table_row in table_rows:
|
||||||
|
table = table_row["name"]
|
||||||
|
columns = [
|
||||||
|
row["name"]
|
||||||
|
for row in conn.execute(f"PRAGMA table_info({quote_identifier(table)})").fetchall()
|
||||||
|
]
|
||||||
|
table_info: dict[str, Any] = {"columns": columns}
|
||||||
|
try:
|
||||||
|
table_info["count"] = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {quote_identifier(table)}"
|
||||||
|
).fetchone()[0]
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
table_info["count_error"] = str(exc)
|
||||||
|
if limit > 0 and columns and not is_internal_sqlite_table(table):
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM {quote_identifier(table)} LIMIT ?", (limit,)
|
||||||
|
).fetchall()
|
||||||
|
table_info["sample"] = [redact(dict(row)) for row in rows]
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
table_info["sample_error"] = str(exc)
|
||||||
|
result["tables"][table] = table_info
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
fallback = sqlite_copy_payload(path, limit, str(exc)) if allow_copy else None
|
||||||
|
if fallback is not None:
|
||||||
|
result.update(fallback)
|
||||||
|
else:
|
||||||
|
result["error"] = str(exc)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def connect_sqlite_readonly(path: Path) -> sqlite3.Connection:
|
||||||
|
errors: list[str] = []
|
||||||
|
for query in ("mode=ro", "mode=ro&immutable=1"):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{path}?{query}", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
errors.append(f"{query}: {exc}")
|
||||||
|
raise sqlite3.OperationalError("; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_copy_payload(path: Path, limit: int, original_error: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="lm-cache-inspect-") as temp_dir:
|
||||||
|
copy_path = Path(temp_dir) / path.name
|
||||||
|
shutil.copy2(path, copy_path)
|
||||||
|
payload = sqlite_payload(copy_path, limit, allow_copy=False)
|
||||||
|
payload["path"] = str(path)
|
||||||
|
payload["inspected_copy"] = True
|
||||||
|
payload["original_error"] = original_error
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def json_file_summary(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {"exists": False}
|
||||||
|
data = load_json(path)
|
||||||
|
return {"exists": True, "summary": redact(summarize_value(data))}
|
||||||
|
|
||||||
|
|
||||||
|
def model_roots(settings: dict[str, Any], active_library: str) -> dict[str, list[str]]:
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(expand_path(value))
|
||||||
|
return {key: dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_recipes_root(settings: dict[str, Any], active_library: str) -> Path | None:
|
||||||
|
recipes_path = settings.get("recipes_path")
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict) and isinstance(library.get("recipes_path"), str):
|
||||||
|
recipes_path = library["recipes_path"] or recipes_path
|
||||||
|
if isinstance(recipes_path, str) and recipes_path.strip():
|
||||||
|
return Path(expand_path(recipes_path.strip()))
|
||||||
|
lora_roots = model_roots(settings, active_library).get("loras") or []
|
||||||
|
return Path(lora_roots[0]) / "recipes" if lora_roots else None
|
||||||
|
|
||||||
|
|
||||||
|
def example_images_payload(settings: dict[str, Any], active_library: str) -> dict[str, Any]:
|
||||||
|
root = settings.get("example_images_path") or ""
|
||||||
|
libraries = settings.get("libraries")
|
||||||
|
library_count = len(libraries) if isinstance(libraries, dict) else 0
|
||||||
|
scoped = library_count > 1
|
||||||
|
root_path = Path(expand_path(root)) if isinstance(root, str) and root else None
|
||||||
|
library_root = root_path / sanitize_library_name(active_library) if root_path and scoped else root_path
|
||||||
|
return {
|
||||||
|
"root": str(root_path or ""),
|
||||||
|
"uses_library_scoped_folders": scoped,
|
||||||
|
"library_root": str(library_root or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def count_recipe_files(root: Path | None) -> int:
|
||||||
|
if not root or not root.exists():
|
||||||
|
return 0
|
||||||
|
return sum(1 for _ in root.rglob("*.recipe.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_library_name(name: str) -> str:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "default")
|
||||||
|
return safe or "default"
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def redact(value: Any, key: str = "") -> Any:
|
||||||
|
if key and SECRET_PATTERN.search(key):
|
||||||
|
return "<redacted>"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(k): redact(v, str(k)) for k, v in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [redact(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: summarize_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return {
|
||||||
|
"type": "array",
|
||||||
|
"length": len(value),
|
||||||
|
"first": summarize_value(value[0]) if value else None,
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def quote_identifier(identifier: str) -> str:
|
||||||
|
return '"' + identifier.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def is_internal_sqlite_table(table: str) -> bool:
|
||||||
|
return table.startswith("sqlite_") or table.endswith(("_data", "_idx", "_docsize", "_config", "_content"))
|
||||||
|
|
||||||
|
|
||||||
|
def print_json(payload: Any) -> None:
|
||||||
|
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -13,8 +13,5 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
|
|||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,7 +15,9 @@ model_cache/
|
|||||||
# agent
|
# agent
|
||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
|
.sisyphus/
|
||||||
.codex
|
.codex
|
||||||
|
.omo
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
vue-widgets/node_modules/
|
vue-widgets/node_modules/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,183 +0,0 @@
|
|||||||
## Overview
|
|
||||||
|
|
||||||
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
|
|
||||||
|
|
||||||
✅ Instantly see which models are already present in your local library
|
|
||||||
✅ Download new models with a single click
|
|
||||||
✅ Manage downloads efficiently with queue and parallel download support
|
|
||||||
✅ Keep your downloaded models automatically organized according to your custom settings
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Supporter Access?
|
|
||||||
|
|
||||||
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
|
|
||||||
|
|
||||||
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
|
|
||||||
|
|
||||||
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Supported Browsers & Installation Methods
|
|
||||||
|
|
||||||
| Browser | Installation Method |
|
|
||||||
|--------------------|-------------------------------------------------------------------------------------|
|
|
||||||
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
|
|
||||||
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Brave Browser** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Opera** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
|
|
||||||
|
|
||||||
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extension’s Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Privacy & Security
|
|
||||||
|
|
||||||
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
|
|
||||||
|
|
||||||
- **Reviewed and Verified**
|
|
||||||
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozilla’s Add-on review**.
|
|
||||||
|
|
||||||
- **Minimal Network Access**
|
|
||||||
The only external server this extension connects to is:
|
|
||||||
**`https://willmiao.shop`** — used solely for **license validation**.
|
|
||||||
|
|
||||||
It does **not collect, transmit, or store any personal or usage data**.
|
|
||||||
No browsing history, no user IDs, no analytics, no hidden trackers.
|
|
||||||
|
|
||||||
- **Local-Only Model Detection**
|
|
||||||
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
|
|
||||||
|
|
||||||
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
|
|
||||||
|
|
||||||
When the extension is correctly installed and your license is valid:
|
|
||||||
|
|
||||||
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
|
|
||||||
- ✅ Models already present in your local library
|
|
||||||
- ⬇️ A download button for models not in your library
|
|
||||||
|
|
||||||
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
|
|
||||||
|
|
||||||
### Visual Indicators Appear On:
|
|
||||||
|
|
||||||
- **Home Page** — Featured models
|
|
||||||
- **Models Page**
|
|
||||||
- **Creator Profiles** — If the creator has set their models to be visible
|
|
||||||
- **Recommended Resources** — On individual model pages
|
|
||||||
|
|
||||||
### Version Buttons on Model Pages
|
|
||||||
|
|
||||||
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
|
|
||||||
|
|
||||||
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
|
|
||||||
|
|
||||||
- The new **dedicated download button** directly triggers download via **LoRA Manager**
|
|
||||||
- The **original download button** remains unchanged for standard browser downloads
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Hide Models Already in Library (Beta)
|
|
||||||
|
|
||||||
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
|
|
||||||
|
|
||||||
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
|
|
||||||
|
|
||||||
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
|
|
||||||
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
|
|
||||||
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Model Download Location & LoRA Manager Settings
|
|
||||||
|
|
||||||
To use the **one-click download function**, you must first set:
|
|
||||||
|
|
||||||
- Your **Default LoRAs Root**
|
|
||||||
- Your **Default Checkpoints Root**
|
|
||||||
|
|
||||||
These are set within LoRA Manager's settings.
|
|
||||||
|
|
||||||
When everything is configured, downloaded model files will be placed in:
|
|
||||||
|
|
||||||
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
|
|
||||||
|
|
||||||
|
|
||||||
### Update: Default Path Customization (2025-07-21)
|
|
||||||
|
|
||||||
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Port Configuration
|
|
||||||
|
|
||||||
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
|
|
||||||
|
|
||||||
After correctly setting and saving the port, you'll see in the extension's header area:
|
|
||||||
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Connecting to a Remote LoRA Manager
|
|
||||||
|
|
||||||
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
|
|
||||||
|
|
||||||
> **Why can't you set a remote IP directly?**
|
|
||||||
>
|
|
||||||
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
|
|
||||||
|
|
||||||
**Solution: Port Forwarding with `socat`**
|
|
||||||
|
|
||||||
On your browser computer, run:
|
|
||||||
|
|
||||||
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
|
|
||||||
|
|
||||||
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
|
|
||||||
- Adjust the port if needed.
|
|
||||||
|
|
||||||
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
|
|
||||||
|
|
||||||
_Thanks to user **Temikus** for sharing this solution!_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
|
|
||||||
|
|
||||||
- [x] Support for **additional model types** (e.g., embeddings)
|
|
||||||
- [x] One-click **Recipe Import**
|
|
||||||
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
|
||||||
- [x] One-click **Auto-organize Models**
|
|
||||||
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
|
|
||||||
|
|
||||||
**Stay tuned — and thank you for your support!**
|
|
||||||
|
|
||||||
---
|
|
||||||
141
locales/de.json
141
locales/de.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"close": "Schließen"
|
"close": "Schließen",
|
||||||
|
"menu": "Menü",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"change": "Ändern"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||||
|
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||||
"noTags": "Keine Tags",
|
"noTags": "Keine Tags",
|
||||||
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||||
"clearAll": "Alle Filter löschen",
|
"clearAll": "Alle Filter löschen",
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Download-Backend",
|
"label": "Download-Backend",
|
||||||
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den experimentellen externen Downloader-Prozess.",
|
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den empfohlenen externen Downloader-Prozess.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (integriert)",
|
"python": "Python (integriert)",
|
||||||
"aria2": "aria2 (experimentell)"
|
"aria2": "aria2 (empfohlen)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "Optionaler Pfad zur ausführbaren aria2c-Datei. Leer lassen, um aria2c aus dem System-PATH zu verwenden.",
|
"help": "Optionaler Pfad zur ausführbaren aria2c-Datei. Leer lassen, um aria2c aus dem System-PATH zu verwenden.",
|
||||||
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
|
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Civitai-Host-Einstellung verfügbar",
|
"title": "Civitai-Host-Einstellung verfügbar",
|
||||||
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "Bei Hover anzeigen"
|
"hover": "Bei Hover anzeigen"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||||
|
"showVersionOnCard": "Version auf Karte anzeigen",
|
||||||
|
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
|
||||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Beispielbilder öffnen",
|
"exampleImages": "Beispielbilder öffnen",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Geben Sie den Ordnerpfad ein, wo Beispielbilder von Civitai gespeichert werden",
|
"downloadLocationHelp": "Geben Sie den Ordnerpfad ein, wo Beispielbilder von Civitai gespeichert werden",
|
||||||
"autoDownload": "Beispielbilder automatisch herunterladen",
|
"autoDownload": "Beispielbilder automatisch herunterladen",
|
||||||
"autoDownloadHelp": "Beispielbilder automatisch für Modelle herunterladen, die keine haben (erfordert gesetzten Download-Speicherort)",
|
"autoDownloadHelp": "Beispielbilder automatisch für Modelle herunterladen, die keine haben (erfordert gesetzten Download-Speicherort)",
|
||||||
|
"openMode": "Aktion für Beispielbilder öffnen",
|
||||||
|
"openModeHelp": "Wählen Sie, ob die Aktion auf dem Server geöffnet, ein zugeordneter lokaler Pfad kopiert oder eine benutzerdefinierte URI gestartet werden soll.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "Auf Server öffnen",
|
||||||
|
"clipboard": "Lokalen Pfad kopieren",
|
||||||
|
"uriTemplate": "Benutzerdefinierte URI öffnen"
|
||||||
|
},
|
||||||
|
"localRoot": "Lokales Stammverzeichnis für Beispielbilder",
|
||||||
|
"localRootHelp": "Optionales lokales oder eingebundenes Stammverzeichnis, das das Beispielbild-Verzeichnis des Servers widerspiegelt. Wenn leer, wird der Serverpfad wiederverwendet.",
|
||||||
|
"localRootPlaceholder": "Beispiel: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "URI-Vorlage öffnen",
|
||||||
|
"uriTemplateHelp": "Verwenden Sie einen benutzerdefinierten Deeplink wie eine Datei-URI oder einen Shortcuts-Link.",
|
||||||
|
"uriTemplatePlaceholder": "Beispiel: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "Verfügbare Platzhalter: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "Mehr über Remote-Open-Modi erfahren",
|
||||||
"optimizeImages": "Heruntergeladene Bilder optimieren",
|
"optimizeImages": "Heruntergeladene Bilder optimieren",
|
||||||
"optimizeImagesHelp": "Beispielbilder optimieren, um Dateigröße zu reduzieren und Ladegeschwindigkeit zu verbessern (Metadaten bleiben erhalten)",
|
"optimizeImagesHelp": "Beispielbilder optimieren, um Dateigröße zu reduzieren und Ladegeschwindigkeit zu verbessern (Metadaten bleiben erhalten)",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
|
||||||
|
"loraSyntaxFormat": "LoRA-Syntaxformat",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA-Syntaxformat. Der vollständige Pfad enthält den Unterordnerpfad (<lora:style/anime/x:1.0>) für verlustfreie Modellauflösung. Legacy verwendet nur den Dateinamen (<lora:x:1.0>) — A1111-Konvention, kann bei doppelten Dateinamen in verschiedenen Ordnern zu Mehrdeutigkeiten führen.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Vollständiger Pfad (Unterordner/Name)",
|
||||||
|
"legacy": "Legacy A1111 (nur Name)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
"quick": "Änderungen synchronisieren",
|
|
||||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
|
||||||
"full": "Cache neu aufbauen",
|
"full": "Cache neu aufbauen",
|
||||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||||
"copyAll": "Alle Syntax kopieren",
|
"copyAll": "Alle Syntax kopieren",
|
||||||
"refreshAll": "Alle Metadaten aktualisieren",
|
"refreshAll": "Alle Metadaten aktualisieren",
|
||||||
|
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||||
"moveAll": "Alle in Ordner verschieben",
|
"moveAll": "Alle in Ordner verschieben",
|
||||||
"autoOrganize": "Automatisch organisieren",
|
"autoOrganize": "Automatisch organisieren",
|
||||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||||
"deleteAll": "Alle Modelle löschen",
|
"setFavorite": "Als Favorit setzen",
|
||||||
|
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
|
||||||
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
|
"deleteAll": "Ausgewählte löschen",
|
||||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||||
|
"downloadExamples": "Beispielbilder herunterladen",
|
||||||
"clear": "Auswahl löschen",
|
"clear": "Auswahl löschen",
|
||||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||||
|
"sendToWorkflow": "An Workflow senden",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadaten",
|
||||||
|
"attributes": "Attribute",
|
||||||
|
"organize": "Organisieren",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Automatische Organisation wird initialisiert...",
|
"initializing": "Automatische Organisation wird initialisiert...",
|
||||||
"starting": "Automatische Organisation für {type} wird gestartet...",
|
"starting": "Automatische Organisation für {type} wird gestartet...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren",
|
"title": "Rezeptliste aktualisieren",
|
||||||
"quick": "Änderungen synchronisieren",
|
|
||||||
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
|
|
||||||
"full": "Cache neu aufbauen",
|
"full": "Cache neu aufbauen",
|
||||||
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Keine Ordner gefunden",
|
"noFolders": "Keine Ordner gefunden",
|
||||||
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Auf Updates in diesem Ordner prüfen",
|
||||||
|
"loading": "Prüfe {type}-Updates in diesem Ordner...",
|
||||||
|
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
|
||||||
|
"none": "Alle {type}s in diesem Ordner sind aktuell",
|
||||||
|
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Modell von URL herunterladen",
|
"title": "Modell von URL herunterladen",
|
||||||
"titleWithType": "{type} von URL herunterladen",
|
"titleWithType": "{type} von URL herunterladen",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
||||||
"locationPreview": "Download-Speicherort Vorschau",
|
"locationPreview": "Download-Speicherort Vorschau",
|
||||||
"useDefaultPath": "Standardpfad verwenden",
|
"useDefaultPath": "Standardpfad verwenden",
|
||||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Dateiformat auswählen",
|
||||||
|
"files": "Dateien",
|
||||||
|
"select": "Datei auswählen"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||||
"action": "Alle löschen"
|
"action": "Alle löschen"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Mehrere Rezepte löschen",
|
||||||
|
"message": "Sind Sie sicher, dass Sie alle ausgewählten Rezepte und ihre zugehörigen Dateien löschen möchten?",
|
||||||
|
"countMessage": "Rezepte werden dauerhaft gelöscht.",
|
||||||
|
"action": "Alle löschen"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
"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.",
|
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "Modellname bearbeiten",
|
"editModelName": "Modellname bearbeiten",
|
||||||
"editFileName": "Dateiname bearbeiten",
|
"editFileName": "Dateiname bearbeiten",
|
||||||
"editBaseModel": "Basis-Modell bearbeiten",
|
"editBaseModel": "Basis-Modell bearbeiten",
|
||||||
|
"editVersionName": "Versionsname bearbeiten",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notizen erfolgreich gespeichert",
|
"saved": "Notizen erfolgreich gespeichert",
|
||||||
"saveFailed": "Fehler beim Speichern der Notizen"
|
"saveFailed": "Fehler beim Speichern der Notizen",
|
||||||
|
"showMore": "Mehr anzeigen",
|
||||||
|
"showLess": "Weniger anzeigen"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
"ignored": "Ignoriert",
|
"ignored": "Ignoriert",
|
||||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||||
|
"onSiteOnly": "Nur On-Site",
|
||||||
|
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadTooltip": "Diese Version herunterladen",
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
|
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"deleteTooltip": "Diese lokale Version löschen",
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
"ignore": "Ignorieren",
|
"ignore": "Ignorieren",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "Beispielbilder-Ordner geöffnet",
|
"opened": "Beispielbilder-Ordner geöffnet",
|
||||||
"openingFolder": "Beispielbilder-Ordner wird geöffnet",
|
"openingFolder": "Beispielbilder-Ordner wird geöffnet",
|
||||||
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners",
|
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners",
|
||||||
|
"copiedPath": "Pfad in Zwischenablage kopiert: {{path}}",
|
||||||
|
"clipboardFallback": "Pfad: {{path}}",
|
||||||
|
"copiedUri": "Link in Zwischenablage kopiert: {{uri}}",
|
||||||
|
"uriClipboardFallback": "Link: {{uri}}",
|
||||||
"setupRequired": "Beispielbilder-Speicher",
|
"setupRequired": "Beispielbilder-Speicher",
|
||||||
"setupDescription": "Um benutzerdefinierte Beispielbilder hinzuzufügen, müssen Sie zuerst einen Download-Speicherort festlegen.",
|
"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.",
|
"setupUsage": "Dieser Pfad wird sowohl für heruntergeladene als auch für benutzerdefinierte Beispielbilder verwendet.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "Keine Rezept-ID verfügbar",
|
"noRecipeId": "Keine Rezept-ID verfügbar",
|
||||||
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
||||||
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
||||||
|
"createError": "Fehler beim Erstellen des Rezepts:{message}",
|
||||||
|
"createFailed": "Fehler beim Erstellen des Rezepts:{error}",
|
||||||
|
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||||
|
"created": "Rezept erfolgreich erstellt",
|
||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||||
|
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||||
|
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||||
|
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||||
|
"bulkFavoriteUpdating": "Füge {count} Modell(e) zu Favoriten hinzu...",
|
||||||
|
"bulkUnfavoriteUpdating": "Entferne {count} Modell(e) aus Favoriten...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} Modell(e) zu Favoriten hinzugefügt, {failed} fehlgeschlagen",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} Modell(e) aus Favoriten entfernt, {failed} fehlgeschlagen",
|
||||||
|
"bulkFavoriteFailed": "Fehler beim Aktualisieren des Favoritenstatus",
|
||||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "Handlungsbedarf",
|
"warning": "Handlungsbedarf",
|
||||||
"error": "Aktion erforderlich"
|
"error": "Aktion erforderlich"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Erneut ausführen",
|
"runAgain": "Erneut ausführen",
|
||||||
"exportBundle": "Paket exportieren"
|
"exportBundle": "Paket exportieren",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||||
"exportSuccess": "Diagnosepaket exportiert.",
|
"exportSuccess": "Diagnosepaket exportiert.",
|
||||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
|
||||||
|
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
|
||||||
|
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Dateinamenskonflikte auflösen",
|
||||||
|
"message": "Umbenennen durch Anhängen eines 4-stelligen Hashs an jeden doppelten Dateinamen.",
|
||||||
|
"note": "Dieser Vorgang benennt Dateien auf der Festplatte um. Modellreferenzen in vorhandenen Workflows müssen möglicherweise aktualisiert werden, wenn Sie das A1111-Syntaxformat verwenden.",
|
||||||
|
"detail": "Beispiel: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Benennt <strong>{count}</strong> Datei(en) in <strong>{groups}</strong> Duplikatgruppe(n) um",
|
||||||
|
"confirm": "Dateien umbenennen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Anwendungs-Update erkannt",
|
"title": "Anwendungs-Update erkannt",
|
||||||
|
|||||||
145
locales/en.json
145
locales/en.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"menu": "Menu",
|
||||||
|
"remove": "Remove",
|
||||||
|
"change": "Change"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||||
|
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
|
"autoTags": "Auto Tags",
|
||||||
"noBaseModelMatches": "No base models match the current search.",
|
"noBaseModelMatches": "No base models match the current search.",
|
||||||
"clearAll": "Clear All Filters",
|
"clearAll": "Clear All Filters",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Download backend",
|
"label": "Download backend",
|
||||||
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the experimental external downloader process.",
|
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (built-in)",
|
"python": "Python (built-in)",
|
||||||
"aria2": "aria2 (experimental)"
|
"aria2": "aria2 (recommended)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system PATH.",
|
"help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system PATH.",
|
||||||
"placeholder": "Leave empty to use aria2c from PATH"
|
"placeholder": "Leave empty to use aria2c from PATH"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "Learn how to set up the aria2 download backend",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Civitai host preference available",
|
"title": "Civitai host preference available",
|
||||||
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "Reveal on Hover"
|
"hover": "Reveal on Hover"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||||
|
"showVersionOnCard": "Show Version on Card",
|
||||||
|
"showVersionOnCardHelp": "Show or hide the version name on model cards",
|
||||||
"modelCardFooterAction": "Model Card Button Action",
|
"modelCardFooterAction": "Model Card Button Action",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Open Example Images",
|
"exampleImages": "Open Example Images",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved",
|
"downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved",
|
||||||
"autoDownload": "Auto Download Example Images",
|
"autoDownload": "Auto Download Example Images",
|
||||||
"autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)",
|
"autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)",
|
||||||
|
"openMode": "Open Example Images Action",
|
||||||
|
"openModeHelp": "Choose whether the action opens on the server, copies a mapped local path, or launches a custom URI.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "Open on server",
|
||||||
|
"clipboard": "Copy local path",
|
||||||
|
"uriTemplate": "Open custom URI"
|
||||||
|
},
|
||||||
|
"localRoot": "Local Example Images Root",
|
||||||
|
"localRootHelp": "Optional local or mounted root that mirrors the server example images directory. If blank, the server path is reused.",
|
||||||
|
"localRootPlaceholder": "Example: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "Open URI Template",
|
||||||
|
"uriTemplateHelp": "Use a custom deep link such as a file URI or a Shortcuts link.",
|
||||||
|
"uriTemplatePlaceholder": "Example: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "Available placeholders: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "Learn more about remote open modes",
|
||||||
"optimizeImages": "Optimize Downloaded Images",
|
"optimizeImages": "Optimize Downloaded Images",
|
||||||
"optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)",
|
"optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||||
|
"loraSyntaxFormat": "LoRA Syntax Format",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path (<lora:style/anime/x:1.0>) for lossless model resolution. Legacy uses filename only (<lora:x:1.0>) — A1111 convention, may be ambiguous with duplicate filenames across folders.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Full path (subfolder/name)",
|
||||||
|
"legacy": "Legacy A1111 (name only)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Enable Metadata Archive Database",
|
"enableArchiveDb": "Enable Metadata Archive Database",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
"quick": "Sync Changes",
|
|
||||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
|
||||||
"full": "Rebuild Cache",
|
"full": "Rebuild Cache",
|
||||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "Set Content Rating for Selected",
|
"setContentRating": "Set Content Rating for Selected",
|
||||||
"copyAll": "Copy Selected Syntax",
|
"copyAll": "Copy Selected Syntax",
|
||||||
"refreshAll": "Refresh Selected Metadata",
|
"refreshAll": "Refresh Selected Metadata",
|
||||||
|
"repairMetadata": "Repair Metadata for Selected",
|
||||||
"checkUpdates": "Check Updates for Selected",
|
"checkUpdates": "Check Updates for Selected",
|
||||||
"moveAll": "Move Selected to Folder",
|
"moveAll": "Move Selected to Folder",
|
||||||
"autoOrganize": "Auto-Organize Selected",
|
"autoOrganize": "Auto-Organize Selected",
|
||||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||||
"deleteAll": "Delete Selected Models",
|
"setFavorite": "Set as Favorite",
|
||||||
|
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
|
||||||
|
"unfavorite": "Remove from Favorites",
|
||||||
|
"deleteAll": "Delete Selected",
|
||||||
"downloadMissingLoras": "Download Missing LoRAs",
|
"downloadMissingLoras": "Download Missing LoRAs",
|
||||||
|
"downloadExamples": "Download Example Images",
|
||||||
"clear": "Clear Selection",
|
"clear": "Clear Selection",
|
||||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||||
|
"sendToWorkflow": "Send to Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"attributes": "Attributes",
|
||||||
|
"organize": "Organize",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initializing auto-organize...",
|
"initializing": "Initializing auto-organize...",
|
||||||
"starting": "Starting auto-organize for {type}...",
|
"starting": "Starting auto-organize for {type}...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list",
|
"title": "Refresh recipe list",
|
||||||
"quick": "Sync Changes",
|
|
||||||
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
|
|
||||||
"full": "Rebuild Cache",
|
"full": "Rebuild Cache",
|
||||||
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No folders found",
|
"noFolders": "No folders found",
|
||||||
"dragHint": "Drag items here to create folders"
|
"dragHint": "Drag items here to create folders"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Check for updates in this folder",
|
||||||
|
"loading": "Checking {type} updates for this folder...",
|
||||||
|
"success": "Found {count} update(s) for {type}s in this folder",
|
||||||
|
"none": "All {type}s in this folder are up to date",
|
||||||
|
"error": "Failed to check folder for {type} updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Download Model from URL",
|
"title": "Download Model from URL",
|
||||||
"titleWithType": "Download {type} from URL",
|
"titleWithType": "Download {type} from URL",
|
||||||
"url": "Civitai URL",
|
"civitaiUrl": "Civitai URL(s):",
|
||||||
"civitaiUrl": "Civitai URL:",
|
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
|
||||||
"locationPreview": "Download Location Preview",
|
"locationPreview": "Download Location Preview",
|
||||||
"useDefaultPath": "Use Default Path",
|
"useDefaultPath": "Use Default Path",
|
||||||
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||||
"alreadyInLibrary": "Already in Library",
|
"alreadyInLibrary": "Already in Library",
|
||||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Select File Format",
|
||||||
|
"files": "files",
|
||||||
|
"select": "Select File"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
"noVersions": "No versions available for this model"
|
"noVersions": "No versions available for this model"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "models will be permanently deleted.",
|
"countMessage": "models will be permanently deleted.",
|
||||||
"action": "Delete All"
|
"action": "Delete All"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Delete Multiple Recipes",
|
||||||
|
"message": "Are you sure you want to delete all selected recipes and their associated files?",
|
||||||
|
"countMessage": "recipes will be permanently deleted.",
|
||||||
|
"action": "Delete All"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Check updates for all {typePlural}?",
|
"title": "Check updates for all {typePlural}?",
|
||||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "Edit model name",
|
"editModelName": "Edit model name",
|
||||||
"editFileName": "Edit file name",
|
"editFileName": "Edit file name",
|
||||||
"editBaseModel": "Edit base model",
|
"editBaseModel": "Edit base model",
|
||||||
|
"editVersionName": "Edit version name",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"viewOnCivitaiText": "View on Civitai",
|
"viewOnCivitaiText": "View on Civitai",
|
||||||
"viewCreatorProfile": "View Creator Profile",
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes saved successfully",
|
"saved": "Notes saved successfully",
|
||||||
"saveFailed": "Failed to save notes"
|
"saveFailed": "Failed to save notes",
|
||||||
|
"showMore": "Show more",
|
||||||
|
"showLess": "Show less"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Add preset parameter...",
|
"addPresetParameter": "Add preset parameter...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||||
"ignored": "Ignored",
|
"ignored": "Ignored",
|
||||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
"ignoredTooltip": "Update notifications are disabled for this version",
|
||||||
|
"onSiteOnly": "On-Site Only",
|
||||||
|
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTooltip": "Download this version",
|
"downloadTooltip": "Download this version",
|
||||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteTooltip": "Delete this local version",
|
"deleteTooltip": "Delete this local version",
|
||||||
"ignore": "Ignore",
|
"ignore": "Ignore",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "Example images folder opened",
|
"opened": "Example images folder opened",
|
||||||
"openingFolder": "Opening example images folder",
|
"openingFolder": "Opening example images folder",
|
||||||
"failedToOpen": "Failed to open example images folder",
|
"failedToOpen": "Failed to open example images folder",
|
||||||
|
"copiedPath": "Path copied to clipboard: {{path}}",
|
||||||
|
"clipboardFallback": "Path: {{path}}",
|
||||||
|
"copiedUri": "Link copied to clipboard: {{uri}}",
|
||||||
|
"uriClipboardFallback": "Link: {{uri}}",
|
||||||
"setupRequired": "Example Images Storage",
|
"setupRequired": "Example Images Storage",
|
||||||
"setupDescription": "To add custom example images, you need to set a download location first.",
|
"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.",
|
"setupUsage": "This path is used for both downloaded and custom example images.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "No recipe ID available",
|
"noRecipeId": "No recipe ID available",
|
||||||
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
||||||
"copyFailed": "Error copying recipe syntax: {message}",
|
"copyFailed": "Error copying recipe syntax: {message}",
|
||||||
|
"createError": "Error creating recipe: {message}",
|
||||||
|
"createFailed": "Failed to create recipe: {error}",
|
||||||
|
"createMissingData": "Missing required data to create recipe",
|
||||||
|
"created": "Recipe created successfully",
|
||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No recipes selected",
|
"noRecipesSelected": "No recipes selected",
|
||||||
|
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||||
|
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||||
|
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||||
|
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
|
||||||
|
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
|
||||||
|
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
|
||||||
|
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
|
||||||
|
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
|
||||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "Needs Attention",
|
"warning": "Needs Attention",
|
||||||
"error": "Action Required"
|
"error": "Action Required"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Run Again",
|
"runAgain": "Run Again",
|
||||||
"exportBundle": "Export Bundle"
|
"exportBundle": "Export Bundle",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Failed to load diagnostics: {message}",
|
"loadFailed": "Failed to load diagnostics: {message}",
|
||||||
"repairSuccess": "Cache rebuild completed.",
|
"repairSuccess": "Cache rebuild completed.",
|
||||||
"repairFailed": "Cache rebuild failed: {message}",
|
"repairFailed": "Cache rebuild failed: {message}",
|
||||||
"exportSuccess": "Diagnostics bundle exported.",
|
"exportSuccess": "Diagnostics bundle exported.",
|
||||||
"exportFailed": "Failed to export diagnostics bundle: {message}"
|
"exportFailed": "Failed to export diagnostics bundle: {message}",
|
||||||
|
"conflictsResolved": "{count} filename conflict(s) resolved.",
|
||||||
|
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Resolve Filename Conflicts",
|
||||||
|
"message": "Renaming by appending a 4-character hash to each duplicate filename.",
|
||||||
|
"note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.",
|
||||||
|
"detail": "Example: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Will rename <strong>{count}</strong> file(s) across <strong>{groups}</strong> duplicate group(s).",
|
||||||
|
"confirm": "Rename Files",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Application Update Detected",
|
"title": "Application Update Detected",
|
||||||
@@ -1916,4 +2029,4 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
141
locales/es.json
141
locales/es.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"close": "Cerrar"
|
"close": "Cerrar",
|
||||||
|
"menu": "Menú",
|
||||||
|
"remove": "Eliminar",
|
||||||
|
"change": "Cambiar"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||||
|
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
|
"autoTags": "Etiquetas automáticas",
|
||||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||||
"clearAll": "Limpiar todos los filtros",
|
"clearAll": "Limpiar todos los filtros",
|
||||||
"any": "Cualquiera",
|
"any": "Cualquiera",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Backend de descarga",
|
"label": "Backend de descarga",
|
||||||
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo experimental de descarga.",
|
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo recomendado de descarga.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (integrado)",
|
"python": "Python (integrado)",
|
||||||
"aria2": "aria2 (experimental)"
|
"aria2": "aria2 (recomendado)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "Ruta opcional al ejecutable aria2c. Déjalo vacío para usar aria2c desde el PATH del sistema.",
|
"help": "Ruta opcional al ejecutable aria2c. Déjalo vacío para usar aria2c desde el PATH del sistema.",
|
||||||
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
|
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Preferencia de host de Civitai disponible",
|
"title": "Preferencia de host de Civitai disponible",
|
||||||
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "Mostrar al pasar el ratón"
|
"hover": "Mostrar al pasar el ratón"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||||
|
"showVersionOnCard": "Mostrar versión en la tarjeta",
|
||||||
|
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
|
||||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Abrir imágenes de ejemplo",
|
"exampleImages": "Abrir imágenes de ejemplo",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Introduce la ruta de la carpeta donde se guardarán las imágenes de ejemplo de Civitai",
|
"downloadLocationHelp": "Introduce la ruta de la carpeta donde se guardarán las imágenes de ejemplo de Civitai",
|
||||||
"autoDownload": "Descargar automáticamente imágenes de ejemplo",
|
"autoDownload": "Descargar automáticamente imágenes de ejemplo",
|
||||||
"autoDownloadHelp": "Descargar automáticamente imágenes de ejemplo para modelos que no las tengan (requiere que se establezca la ubicación de descarga)",
|
"autoDownloadHelp": "Descargar automáticamente imágenes de ejemplo para modelos que no las tengan (requiere que se establezca la ubicación de descarga)",
|
||||||
|
"openMode": "Acción al abrir imágenes de ejemplo",
|
||||||
|
"openModeHelp": "Elige si la acción se abre en el servidor, copia una ruta local asignada o lanza una URI personalizada.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "Abrir en el servidor",
|
||||||
|
"clipboard": "Copiar ruta local",
|
||||||
|
"uriTemplate": "Abrir URI personalizada"
|
||||||
|
},
|
||||||
|
"localRoot": "Raíz local de imágenes de ejemplo",
|
||||||
|
"localRootHelp": "Raíz local u montada opcional que refleja el directorio de imágenes de ejemplo del servidor. Si se deja en blanco, se reutiliza la ruta del servidor.",
|
||||||
|
"localRootPlaceholder": "Ejemplo: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "Abrir plantilla de URI",
|
||||||
|
"uriTemplateHelp": "Usa un enlace profundo personalizado, como un URI de archivo o un enlace de Shortcuts.",
|
||||||
|
"uriTemplatePlaceholder": "Ejemplo: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "Marcadores disponibles: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "Más información sobre los modos de apertura remota",
|
||||||
"optimizeImages": "Optimizar imágenes descargadas",
|
"optimizeImages": "Optimizar imágenes descargadas",
|
||||||
"optimizeImagesHelp": "Optimizar imágenes de ejemplo para reducir el tamaño del archivo y mejorar la velocidad de carga (se preservarán los metadatos)",
|
"optimizeImagesHelp": "Optimizar imágenes de ejemplo para reducir el tamaño del archivo y mejorar la velocidad de carga (se preservarán los metadatos)",
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
|
||||||
|
"loraSyntaxFormat": "Formato de sintaxis LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "Formato de sintaxis LoRA. El formato completo incluye la ruta de la subcarpeta (<lora:style/anime/x:1.0>) para una resolución de modelo sin pérdidas. El formato heredado usa solo el nombre del archivo (<lora:x:1.0>) — convención A1111, puede ser ambiguo con nombres de archivo duplicados entre carpetas.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Ruta completa (subcarpeta/nombre)",
|
||||||
|
"legacy": "A1111 heredado (solo nombre)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
"quick": "Sincronizar cambios",
|
|
||||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
|
||||||
"full": "Reconstruir caché",
|
"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."
|
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||||
"copyAll": "Copiar toda la sintaxis",
|
"copyAll": "Copiar toda la sintaxis",
|
||||||
"refreshAll": "Actualizar todos los metadatos",
|
"refreshAll": "Actualizar todos los metadatos",
|
||||||
|
"repairMetadata": "Reparar metadatos de la selección",
|
||||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||||
"moveAll": "Mover todos a carpeta",
|
"moveAll": "Mover todos a carpeta",
|
||||||
"autoOrganize": "Auto-organizar seleccionados",
|
"autoOrganize": "Auto-organizar seleccionados",
|
||||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||||
"deleteAll": "Eliminar todos los modelos",
|
"setFavorite": "Marcar como favorito",
|
||||||
|
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
|
||||||
|
"unfavorite": "Quitar de favoritos",
|
||||||
|
"deleteAll": "Eliminar seleccionados",
|
||||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||||
|
"downloadExamples": "Descargar imágenes de ejemplo",
|
||||||
"clear": "Limpiar selección",
|
"clear": "Limpiar selección",
|
||||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||||
|
"sendToWorkflow": "Enviar al workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadatos",
|
||||||
|
"attributes": "Atributos",
|
||||||
|
"organize": "Organizar",
|
||||||
|
"download": "Descargar"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Inicializando auto-organización...",
|
"initializing": "Inicializando auto-organización...",
|
||||||
"starting": "Iniciando auto-organización para {type}...",
|
"starting": "Iniciando auto-organización para {type}...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas",
|
"title": "Actualizar lista de recetas",
|
||||||
"quick": "Sincronizar cambios",
|
|
||||||
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
|
|
||||||
"full": "Reconstruir caché",
|
"full": "Reconstruir caché",
|
||||||
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No se encontraron carpetas",
|
"noFolders": "No se encontraron carpetas",
|
||||||
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Buscar actualizaciones en esta carpeta",
|
||||||
|
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
|
||||||
|
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
|
||||||
|
"none": "Todos los {type}s en esta carpeta están actualizados",
|
||||||
|
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Descargar modelo desde URL",
|
"title": "Descargar modelo desde URL",
|
||||||
"titleWithType": "Descargar {type} desde URL",
|
"titleWithType": "Descargar {type} desde URL",
|
||||||
"url": "URL de Civitai",
|
|
||||||
"civitaiUrl": "URL de Civitai:",
|
"civitaiUrl": "URL de Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
|
||||||
"locationPreview": "Vista previa de ubicación de descarga",
|
"locationPreview": "Vista previa de ubicación de descarga",
|
||||||
"useDefaultPath": "Usar ruta predeterminada",
|
"useDefaultPath": "Usar ruta predeterminada",
|
||||||
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||||
"alreadyInLibrary": "Ya en la biblioteca",
|
"alreadyInLibrary": "Ya en la biblioteca",
|
||||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Seleccionar formato de archivo",
|
||||||
|
"files": "archivos",
|
||||||
|
"select": "Seleccionar archivo"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||||
"noVersions": "No hay versiones disponibles para este modelo"
|
"noVersions": "No hay versiones disponibles para este modelo"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "modelos serán eliminados permanentemente.",
|
"countMessage": "modelos serán eliminados permanentemente.",
|
||||||
"action": "Eliminar todo"
|
"action": "Eliminar todo"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Eliminar múltiples recetas",
|
||||||
|
"message": "¿Estás seguro de que quieres eliminar todas las recetas seleccionadas y sus archivos asociados?",
|
||||||
|
"countMessage": "recetas serán eliminadas permanentemente.",
|
||||||
|
"action": "Eliminar todo"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
"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.",
|
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "Editar nombre del modelo",
|
"editModelName": "Editar nombre del modelo",
|
||||||
"editFileName": "Editar nombre de archivo",
|
"editFileName": "Editar nombre de archivo",
|
||||||
"editBaseModel": "Editar modelo base",
|
"editBaseModel": "Editar modelo base",
|
||||||
|
"editVersionName": "Editar nombre de versión",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"viewOnCivitaiText": "Ver en Civitai",
|
"viewOnCivitaiText": "Ver en Civitai",
|
||||||
"viewCreatorProfile": "Ver perfil del creador",
|
"viewCreatorProfile": "Ver perfil del creador",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notas guardadas exitosamente",
|
"saved": "Notas guardadas exitosamente",
|
||||||
"saveFailed": "Error al guardar notas"
|
"saveFailed": "Error al guardar notas",
|
||||||
|
"showMore": "Mostrar más",
|
||||||
|
"showLess": "Mostrar menos"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Añadir parámetro preestablecido...",
|
"addPresetParameter": "Añadir parámetro preestablecido...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
"ignored": "Ignorada",
|
"ignored": "Ignorada",
|
||||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||||
|
"onSiteOnly": "Solo en Sitio",
|
||||||
|
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloadTooltip": "Descargar esta versión",
|
"downloadTooltip": "Descargar esta versión",
|
||||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleteTooltip": "Eliminar esta versión local",
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
"ignore": "Ignorar",
|
"ignore": "Ignorar",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "Carpeta de imágenes de ejemplo abierta",
|
"opened": "Carpeta de imágenes de ejemplo abierta",
|
||||||
"openingFolder": "Abriendo carpeta de imágenes de ejemplo",
|
"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",
|
||||||
|
"copiedPath": "Ruta copiada al portapapeles: {{path}}",
|
||||||
|
"clipboardFallback": "Ruta: {{path}}",
|
||||||
|
"copiedUri": "Enlace copiado al portapapeles: {{uri}}",
|
||||||
|
"uriClipboardFallback": "Enlace: {{uri}}",
|
||||||
"setupRequired": "Almacenamiento 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.",
|
"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.",
|
"setupUsage": "Esta ruta se utiliza tanto para imágenes de ejemplo descargadas como personalizadas.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "No hay ID de receta disponible",
|
"noRecipeId": "No hay ID de receta disponible",
|
||||||
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
||||||
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
||||||
|
"createError": "Error al crear la receta:{message}",
|
||||||
|
"createFailed": "Error al crear la receta:{error}",
|
||||||
|
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||||
|
"created": "Receta creada exitosamente",
|
||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No se han seleccionado recetas",
|
"noRecipesSelected": "No se han seleccionado recetas",
|
||||||
|
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||||
|
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||||
|
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
"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",
|
"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",
|
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||||
|
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
|
||||||
|
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
|
||||||
|
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
|
||||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "Requiere atención",
|
"warning": "Requiere atención",
|
||||||
"error": "Se requiere acción"
|
"error": "Se requiere acción"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Ejecutar de nuevo",
|
"runAgain": "Ejecutar de nuevo",
|
||||||
"exportBundle": "Exportar paquete"
|
"exportBundle": "Exportar paquete",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||||
"repairSuccess": "Reconstrucción de caché completada.",
|
"repairSuccess": "Reconstrucción de caché completada.",
|
||||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||||
"exportSuccess": "Paquete de diagnósticos exportado.",
|
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
|
||||||
|
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
|
||||||
|
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Resolver conflictos de nombres de archivo",
|
||||||
|
"message": "Renombrar añadiendo un hash de 4 caracteres a cada nombre de archivo duplicado.",
|
||||||
|
"note": "Esta operación renombra archivos en el disco. Es posible que las referencias a modelos en flujos de trabajo existentes deban actualizarse si usas el formato de sintaxis A1111.",
|
||||||
|
"detail": "Ejemplo: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Renombrará <strong>{count}</strong> archivo(s) en <strong>{groups}</strong> grupo(s) de duplicados",
|
||||||
|
"confirm": "Renombrar archivos",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Actualización de la aplicación detectada",
|
"title": "Actualización de la aplicación detectada",
|
||||||
|
|||||||
141
locales/fr.json
141
locales/fr.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"close": "Fermer"
|
"close": "Fermer",
|
||||||
|
"menu": "Menu",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"change": "Modifier"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||||
|
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||||
"noTags": "Aucun tag",
|
"noTags": "Aucun tag",
|
||||||
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||||
"clearAll": "Effacer tous les filtres",
|
"clearAll": "Effacer tous les filtres",
|
||||||
"any": "N'importe quel",
|
"any": "N'importe quel",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Moteur de téléchargement",
|
"label": "Moteur de téléchargement",
|
||||||
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe expérimental de téléchargement.",
|
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe recommandé de téléchargement.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (intégré)",
|
"python": "Python (intégré)",
|
||||||
"aria2": "aria2 (expérimental)"
|
"aria2": "aria2 (recommandé)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "Chemin facultatif vers l’exécutable aria2c. Laissez vide pour utiliser aria2c depuis le PATH système.",
|
"help": "Chemin facultatif vers l’exécutable aria2c. Laissez vide pour utiliser aria2c depuis le PATH système.",
|
||||||
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
|
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Préférence d’hôte Civitai disponible",
|
"title": "Préférence d’hôte Civitai disponible",
|
||||||
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "Révéler au survol"
|
"hover": "Révéler au survol"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||||
|
"showVersionOnCard": "Afficher la version sur la carte",
|
||||||
|
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
|
||||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Ouvrir les images d'exemple",
|
"exampleImages": "Ouvrir les images d'exemple",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Entrez le chemin du dossier où les images d'exemple de Civitai seront sauvegardées",
|
"downloadLocationHelp": "Entrez le chemin du dossier où les images d'exemple de Civitai seront sauvegardées",
|
||||||
"autoDownload": "Téléchargement automatique des images d'exemple",
|
"autoDownload": "Téléchargement automatique des images d'exemple",
|
||||||
"autoDownloadHelp": "Télécharger automatiquement les images d'exemple pour les modèles qui n'en ont pas (nécessite que l'emplacement de téléchargement soit défini)",
|
"autoDownloadHelp": "Télécharger automatiquement les images d'exemple pour les modèles qui n'en ont pas (nécessite que l'emplacement de téléchargement soit défini)",
|
||||||
|
"openMode": "Action d’ouverture des images d’exemple",
|
||||||
|
"openModeHelp": "Choisissez si l’action s’ouvre sur le serveur, copie un chemin local mappé ou lance une URI personnalisée.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "Ouvrir sur le serveur",
|
||||||
|
"clipboard": "Copier le chemin local",
|
||||||
|
"uriTemplate": "Ouvrir une URI personnalisée"
|
||||||
|
},
|
||||||
|
"localRoot": "Racine locale des images d’exemple",
|
||||||
|
"localRootHelp": "Racine locale ou montée facultative qui reflète le répertoire des images d’exemple du serveur. Si vide, le chemin du serveur est réutilisé.",
|
||||||
|
"localRootPlaceholder": "Exemple : /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "Ouvrir le modèle d’URI",
|
||||||
|
"uriTemplateHelp": "Utilisez un lien profond personnalisé, tel qu’une URI de fichier ou un lien Shortcuts.",
|
||||||
|
"uriTemplatePlaceholder": "Exemple : shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "Paramètres disponibles : {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "En savoir plus sur les modes d'ouverture à distance",
|
||||||
"optimizeImages": "Optimiser les images téléchargées",
|
"optimizeImages": "Optimiser les images téléchargées",
|
||||||
"optimizeImagesHelp": "Optimiser les images d'exemple pour réduire la taille du fichier et améliorer la vitesse de chargement (les métadonnées seront préservées)",
|
"optimizeImagesHelp": "Optimiser les images d'exemple pour réduire la taille du fichier et améliorer la vitesse de chargement (les métadonnées seront préservées)",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
"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"
|
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
|
||||||
|
"loraSyntaxFormat": "Format de syntaxe LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "Format de syntaxe LoRA. Le format complet inclut le chemin du sous-dossier (<lora:style/anime/x:1.0>) pour une résolution de modèle sans perte. Le format hérité utilise uniquement le nom du fichier (<lora:x:1.0>) — convention A1111, peut être ambiguë en cas de noms de fichiers en double dans différents dossiers.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Chemin complet (sous-dossier/nom)",
|
||||||
|
"legacy": "A1111 hérité (nom uniquement)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
"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",
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "Définir la classification du contenu pour tous",
|
"setContentRating": "Définir la classification du contenu pour tous",
|
||||||
"copyAll": "Copier toute la syntaxe",
|
"copyAll": "Copier toute la syntaxe",
|
||||||
"refreshAll": "Actualiser toutes les métadonnées",
|
"refreshAll": "Actualiser toutes les métadonnées",
|
||||||
|
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||||
"moveAll": "Déplacer tout vers un dossier",
|
"moveAll": "Déplacer tout vers un dossier",
|
||||||
"autoOrganize": "Auto-organiser la sélection",
|
"autoOrganize": "Auto-organiser la sélection",
|
||||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||||
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
||||||
"deleteAll": "Supprimer tous les modèles",
|
"setFavorite": "Définir comme favori",
|
||||||
|
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
|
||||||
|
"unfavorite": "Retirer des favoris",
|
||||||
|
"deleteAll": "Supprimer la sélection",
|
||||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||||
|
"downloadExamples": "Télécharger les images d'exemple",
|
||||||
"clear": "Effacer la sélection",
|
"clear": "Effacer la sélection",
|
||||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||||
|
"sendToWorkflow": "Envoyer au workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Métadonnées",
|
||||||
|
"attributes": "Attributs",
|
||||||
|
"organize": "Organiser",
|
||||||
|
"download": "Télécharger"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initialisation de l'auto-organisation...",
|
"initializing": "Initialisation de l'auto-organisation...",
|
||||||
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes",
|
"title": "Actualiser la liste des recipes",
|
||||||
"quick": "Synchroniser les changements",
|
|
||||||
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
|
|
||||||
"full": "Reconstruire le cache",
|
"full": "Reconstruire le cache",
|
||||||
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Aucun dossier trouvé",
|
"noFolders": "Aucun dossier trouvé",
|
||||||
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Vérifier les mises à jour dans ce dossier",
|
||||||
|
"loading": "Vérification des mises à jour {type} dans ce dossier...",
|
||||||
|
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
|
||||||
|
"none": "Tous les {type}s dans ce dossier sont à jour",
|
||||||
|
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Télécharger un modèle depuis une URL",
|
"title": "Télécharger un modèle depuis une URL",
|
||||||
"titleWithType": "Télécharger {type} depuis une URL",
|
"titleWithType": "Télécharger {type} depuis une URL",
|
||||||
"url": "URL Civitai",
|
|
||||||
"civitaiUrl": "URL Civitai :",
|
"civitaiUrl": "URL Civitai :",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
|
||||||
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
||||||
"useDefaultPath": "Utiliser le chemin par défaut",
|
"useDefaultPath": "Utiliser le chemin par défaut",
|
||||||
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Choisir le format de fichier",
|
||||||
|
"files": "fichiers",
|
||||||
|
"select": "Choisir le fichier"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"invalidUrl": "Format d'URL Civitai invalide",
|
||||||
"noVersions": "Aucune version disponible pour ce modèle"
|
"noVersions": "Aucune version disponible pour ce modèle"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "modèles seront définitivement supprimés.",
|
"countMessage": "modèles seront définitivement supprimés.",
|
||||||
"action": "Tout supprimer"
|
"action": "Tout supprimer"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Supprimer plusieurs recipes",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer toutes les recipes sélectionnées et leurs fichiers associés ?",
|
||||||
|
"countMessage": "recipes seront définitivement supprimées.",
|
||||||
|
"action": "Tout supprimer"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
"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.",
|
"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.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "Modifier le nom du modèle",
|
"editModelName": "Modifier le nom du modèle",
|
||||||
"editFileName": "Modifier le nom de fichier",
|
"editFileName": "Modifier le nom de fichier",
|
||||||
"editBaseModel": "Modifier le modèle de base",
|
"editBaseModel": "Modifier le modèle de base",
|
||||||
|
"editVersionName": "Modifier le nom de la version",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"viewOnCivitaiText": "Voir sur Civitai",
|
"viewOnCivitaiText": "Voir sur Civitai",
|
||||||
"viewCreatorProfile": "Voir le profil du créateur",
|
"viewCreatorProfile": "Voir le profil du créateur",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes sauvegardées avec succès",
|
"saved": "Notes sauvegardées avec succès",
|
||||||
"saveFailed": "Échec de la sauvegarde des notes"
|
"saveFailed": "Échec de la sauvegarde des notes",
|
||||||
|
"showMore": "Afficher plus",
|
||||||
|
"showLess": "Afficher moins"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
"ignored": "Ignorée",
|
"ignored": "Ignorée",
|
||||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||||
|
"onSiteOnly": "Uniquement sur Site",
|
||||||
|
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloadTooltip": "Télécharger cette version",
|
"downloadTooltip": "Télécharger cette version",
|
||||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleteTooltip": "Supprimer cette version locale",
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
"ignore": "Ignorer",
|
"ignore": "Ignorer",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "Dossier d'images d'exemple ouvert",
|
"opened": "Dossier d'images d'exemple ouvert",
|
||||||
"openingFolder": "Ouverture du dossier d'images d'exemple",
|
"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",
|
||||||
|
"copiedPath": "Chemin copié dans le presse-papiers : {{path}}",
|
||||||
|
"clipboardFallback": "Chemin : {{path}}",
|
||||||
|
"copiedUri": "Lien copié dans le presse-papiers : {{uri}}",
|
||||||
|
"uriClipboardFallback": "Lien : {{uri}}",
|
||||||
"setupRequired": "Stockage 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.",
|
"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.",
|
"setupUsage": "Ce chemin est utilisé pour les images d'exemple téléchargées et personnalisées.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "Aucun ID de recipe disponible",
|
"noRecipeId": "Aucun ID de recipe disponible",
|
||||||
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
||||||
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
||||||
|
"createError": "Erreur lors de la création du Recipe :{message}",
|
||||||
|
"createFailed": "Échec de la création du Recipe :{error}",
|
||||||
|
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||||
|
"created": "Recipe créé avec succès",
|
||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Aucune recette sélectionnée",
|
"noRecipesSelected": "Aucune recette sélectionnée",
|
||||||
|
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||||
|
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
||||||
|
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
||||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
"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)",
|
"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",
|
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||||
|
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
|
||||||
|
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
|
||||||
|
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
|
||||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} 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",
|
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "Nécessite une attention",
|
"warning": "Nécessite une attention",
|
||||||
"error": "Action requise"
|
"error": "Action requise"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Relancer",
|
"runAgain": "Relancer",
|
||||||
"exportBundle": "Exporter le lot"
|
"exportBundle": "Exporter le lot",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||||
"repairSuccess": "Reconstruction du cache terminée.",
|
"repairSuccess": "Reconstruction du cache terminée.",
|
||||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||||
"exportSuccess": "Lot de diagnostics exporté.",
|
"exportSuccess": "Lot de diagnostics exporté.",
|
||||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
"exportFailed": "Échec de l'export du lot de diagnostics : {message}",
|
||||||
|
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
|
||||||
|
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Résoudre les conflits de noms de fichiers",
|
||||||
|
"message": "Renommer en ajoutant un hachage de 4 caractères à chaque nom de fichier en double.",
|
||||||
|
"note": "Cette opération renomme les fichiers sur le disque. Les références de modèle dans les workflows existants peuvent nécessiter une mise à jour si vous utilisez le format de syntaxe A1111.",
|
||||||
|
"detail": "Exemple : <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Renommera <strong>{count}</strong> fichier(s) dans <strong>{groups}</strong> groupe(s) de doublons",
|
||||||
|
"confirm": "Renommer les fichiers",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Mise à jour de l'application détectée",
|
"title": "Mise à jour de l'application détectée",
|
||||||
|
|||||||
141
locales/he.json
141
locales/he.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה",
|
"add": "הוספה",
|
||||||
"close": "סגור"
|
"close": "סגור",
|
||||||
|
"menu": "תפריט",
|
||||||
|
"remove": "הסר",
|
||||||
|
"change": "שנה"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
|
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||||
|
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
|
"autoTags": "תגיות אוטומטיות",
|
||||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||||
"clearAll": "נקה את כל המסננים",
|
"clearAll": "נקה את כל המסננים",
|
||||||
"any": "כלשהו",
|
"any": "כלשהו",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "מנגנון הורדה",
|
"label": "מנגנון הורדה",
|
||||||
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.",
|
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (מובנה)",
|
"python": "Python (מובנה)",
|
||||||
"aria2": "aria2 (ניסיוני)"
|
"aria2": "aria2 (מומלץ)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.",
|
"help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.",
|
||||||
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
|
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "העדפת מארח Civitai זמינה",
|
"title": "העדפת מארח Civitai זמינה",
|
||||||
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "חשוף בריחוף"
|
"hover": "חשוף בריחוף"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||||
|
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||||
|
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "פתח תמונות דוגמה",
|
"exampleImages": "פתח תמונות דוגמה",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "הזן את נתיב התיקייה שבו יישמרו תמונות דוגמה מ-Civitai",
|
"downloadLocationHelp": "הזן את נתיב התיקייה שבו יישמרו תמונות דוגמה מ-Civitai",
|
||||||
"autoDownload": "הורדה אוטומטית של תמונות דוגמה",
|
"autoDownload": "הורדה אוטומטית של תמונות דוגמה",
|
||||||
"autoDownloadHelp": "הורד אוטומטית תמונות דוגמה למודלים שאין להם (דורש הגדרת מיקום הורדה)",
|
"autoDownloadHelp": "הורד אוטומטית תמונות דוגמה למודלים שאין להם (דורש הגדרת מיקום הורדה)",
|
||||||
|
"openMode": "פעולת פתיחת תמונות דוגמה",
|
||||||
|
"openModeHelp": "בחר אם הפעולה תיפתח בשרת, תעתיק נתיב מקומי ממופה או תפעיל URI מותאם אישית.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "פתח בשרת",
|
||||||
|
"clipboard": "העתק נתיב מקומי",
|
||||||
|
"uriTemplate": "פתח URI מותאם אישית"
|
||||||
|
},
|
||||||
|
"localRoot": "שורש מקומי לתמונות דוגמה",
|
||||||
|
"localRootHelp": "שורש מקומי או ממופה אופציונלי שמשקף את תיקיית תמונות הדוגמה בשרת. אם השדה ריק, ייעשה שימוש חוזר בנתיב השרת.",
|
||||||
|
"localRootPlaceholder": "דוגמה: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "תבנית URI לפתיחה",
|
||||||
|
"uriTemplateHelp": "השתמש בקישור עומק מותאם אישית כמו URI של קובץ או קישור Shortcuts.",
|
||||||
|
"uriTemplatePlaceholder": "דוגמה: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "מצייני מקום זמינים: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "למידע נוסף על מצבי פתיחה מרחוק",
|
||||||
"optimizeImages": "מטב תמונות שהורדו",
|
"optimizeImages": "מטב תמונות שהורדו",
|
||||||
"optimizeImagesHelp": "מטב תמונות דוגמה כדי להקטין את גודל הקובץ ולשפר את מהירות הטעינה (מטא-דאטה תישמר)",
|
"optimizeImagesHelp": "מטב תמונות דוגמה כדי להקטין את גודל הקובץ ולשפר את מהירות הטעינה (מטא-דאטה תישמר)",
|
||||||
"download": "הורד",
|
"download": "הורד",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
|
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||||
|
"loraSyntaxFormat": "פורמט תחביר LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "פורמט תחביר LoRA. נתיב מלא כולל תת-תיקייה (<lora:style/anime/x:1.0>) לפתרון מודל ללא אובדן. גרסה ישנה משתמשת בשם קובץ בלבד (<lora:x:1.0>) — מוסכמת A1111, עלולה להיות לא חד משמעית עם שמות קבצים כפולים בתיקיות שונות.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "נתיב מלא (תת-תיקייה/שם)",
|
||||||
|
"legacy": "A1111 ישן (שם בלבד)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
"quick": "סנכרון שינויים",
|
|
||||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
|
||||||
"full": "בניית מטמון מחדש",
|
"full": "בניית מטמון מחדש",
|
||||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-דאטה",
|
"refreshAll": "רענן את כל המטא-דאטה",
|
||||||
|
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
"moveAll": "העבר הכל לתיקייה",
|
"moveAll": "העבר הכל לתיקייה",
|
||||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||||
"deleteAll": "מחק את כל המודלים",
|
"setFavorite": "הגדר כמועדף",
|
||||||
|
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
|
||||||
|
"unfavorite": "הסר ממועדפים",
|
||||||
|
"deleteAll": "מחק נבחרים",
|
||||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||||
|
"downloadExamples": "הורד תמונות דוגמה",
|
||||||
"clear": "נקה בחירה",
|
"clear": "נקה בחירה",
|
||||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||||
|
"sendToWorkflow": "שלח ל-Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "מטא-נתונים",
|
||||||
|
"attributes": "מאפיינים",
|
||||||
|
"organize": "ארגן",
|
||||||
|
"download": "הורדה"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "מאתחל ארגון אוטומטי...",
|
"initializing": "מאתחל ארגון אוטומטי...",
|
||||||
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים",
|
"title": "רענן רשימת מתכונים",
|
||||||
"quick": "סנכרן שינויים",
|
|
||||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
|
||||||
"full": "בנה מטמון מחדש",
|
"full": "בנה מטמון מחדש",
|
||||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "לא נמצאו תיקיות",
|
"noFolders": "לא נמצאו תיקיות",
|
||||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "בדוק עדכונים בתיקייה זו",
|
||||||
|
"loading": "בודק עדכוני {type} בתיקייה זו...",
|
||||||
|
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||||
|
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||||
|
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "הורד מודל מכתובת URL",
|
"title": "הורד מודל מכתובת URL",
|
||||||
"titleWithType": "הורד {type} מכתובת URL",
|
"titleWithType": "הורד {type} מכתובת URL",
|
||||||
"url": "כתובת URL של Civitai",
|
|
||||||
"civitaiUrl": "כתובת URL של Civitai:",
|
"civitaiUrl": "כתובת URL של Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
|
||||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||||
"alreadyInLibrary": "כבר בספרייה",
|
"alreadyInLibrary": "כבר בספרייה",
|
||||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "בחר פורמט קובץ",
|
||||||
|
"files": "קבצים",
|
||||||
|
"select": "בחר קובץ"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
"noVersions": "אין גרסאות זמינות למודל זה"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||||
"action": "מחק הכל"
|
"action": "מחק הכל"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "מחק מספר מתכונים",
|
||||||
|
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
|
||||||
|
"countMessage": "מתכונים יימחקו לצמיתות.",
|
||||||
|
"action": "מחק הכל"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "ערוך שם מודל",
|
"editModelName": "ערוך שם מודל",
|
||||||
"editFileName": "ערוך שם קובץ",
|
"editFileName": "ערוך שם קובץ",
|
||||||
"editBaseModel": "ערוך מודל בסיס",
|
"editBaseModel": "ערוך מודל בסיס",
|
||||||
|
"editVersionName": "ערוך שם גרסה",
|
||||||
"viewOnCivitai": "הצג ב-Civitai",
|
"viewOnCivitai": "הצג ב-Civitai",
|
||||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "הערות נשמרו בהצלחה",
|
"saved": "הערות נשמרו בהצלחה",
|
||||||
"saveFailed": "שמירת ההערות נכשלה"
|
"saveFailed": "שמירת ההערות נכשלה",
|
||||||
|
"showMore": "הצג עוד",
|
||||||
|
"showLess": "הצג פחות"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
"ignored": "התעלם",
|
"ignored": "התעלם",
|
||||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||||
|
"onSiteOnly": "רק באתר",
|
||||||
|
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
"downloadTooltip": "הורד את הגרסה הזו",
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
"ignore": "התעלם",
|
"ignore": "התעלם",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "תיקיית תמונות הדוגמה נפתחה",
|
"opened": "תיקיית תמונות הדוגמה נפתחה",
|
||||||
"openingFolder": "פותח תיקיית תמונות דוגמה",
|
"openingFolder": "פותח תיקיית תמונות דוגמה",
|
||||||
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
|
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
|
||||||
|
"copiedPath": "הנתיב הועתק ללוח: {{path}}",
|
||||||
|
"clipboardFallback": "נתיב: {{path}}",
|
||||||
|
"copiedUri": "הקישור הועתק ללוח: {{uri}}",
|
||||||
|
"uriClipboardFallback": "קישור: {{uri}}",
|
||||||
"setupRequired": "אחסון תמונות דוגמה",
|
"setupRequired": "אחסון תמונות דוגמה",
|
||||||
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
|
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
|
||||||
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
|
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "אין מזהה מתכון זמין",
|
"noRecipeId": "אין מזהה מתכון זמין",
|
||||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||||
|
"createError": "שגיאה ביצירת המתכון:{message}",
|
||||||
|
"createFailed": "יצירת המתכון נכשלה:{error}",
|
||||||
|
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||||
|
"created": "המתכון נוצר בהצלחה",
|
||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
|
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||||
|
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||||
|
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||||
|
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
|
||||||
|
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
|
||||||
|
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
|
||||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "דורש תשומת לב",
|
"warning": "דורש תשומת לב",
|
||||||
"error": "נדרשת פעולה"
|
"error": "נדרשת פעולה"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "הפעל שוב",
|
"runAgain": "הפעל שוב",
|
||||||
"exportBundle": "ייצוא חבילה"
|
"exportBundle": "ייצוא חבילה",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
|
||||||
|
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
|
||||||
|
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "פתור התנגשויות בשמות קבצים",
|
||||||
|
"message": "שינוי שם על ידי הוספת האש באורך 4 תווים לכל שם קובץ כפול.",
|
||||||
|
"note": "פעולה זו משנה שמות של קבצים בדיסק. ייתכן שיהיה צורך לעדכן הפניות למודלים בזרימות עבודה קיימות אם אתה משתמש בפורמט התחביר A1111.",
|
||||||
|
"detail": "דוגמה: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "ישנה שם של <strong>{count}</strong> קבצים ב-<strong>{groups}</strong> קבוצות כפולות",
|
||||||
|
"confirm": "שנה שמות קבצים",
|
||||||
|
"cancel": "ביטול"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "זוהה עדכון יישום",
|
"title": "זוהה עדכון יישום",
|
||||||
|
|||||||
141
locales/ja.json
141
locales/ja.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"menu": "メニュー",
|
||||||
|
"remove": "削除",
|
||||||
|
"change": "変更"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
|
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||||
|
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
|
"autoTags": "自動タグ",
|
||||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||||
"clearAll": "すべてのフィルタをクリア",
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
"any": "いずれか",
|
"any": "いずれか",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "ダウンロードバックエンド",
|
"label": "ダウンロードバックエンド",
|
||||||
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。",
|
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(内蔵)",
|
"python": "Python(内蔵)",
|
||||||
"aria2": "aria2(実験的)"
|
"aria2": "aria2(推奨)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。",
|
"help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。",
|
||||||
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
|
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Civitai ホスト設定を利用できます",
|
"title": "Civitai ホスト設定を利用できます",
|
||||||
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "ホバー時に表示"
|
"hover": "ホバー時に表示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||||
|
"showVersionOnCard": "カードにバージョンを表示",
|
||||||
|
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "例画像を開く",
|
"exampleImages": "例画像を開く",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
|
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
|
||||||
"autoDownload": "例画像の自動ダウンロード",
|
"autoDownload": "例画像の自動ダウンロード",
|
||||||
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
|
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
|
||||||
|
"openMode": "サンプル画像を開く動作",
|
||||||
|
"openModeHelp": "サーバー上で開くか、対応するローカルパスをコピーするか、カスタム URI を起動するかを選択します。",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "サーバー上で開く",
|
||||||
|
"clipboard": "ローカルパスをコピー",
|
||||||
|
"uriTemplate": "カスタム URI を開く"
|
||||||
|
},
|
||||||
|
"localRoot": "ローカルのサンプル画像ルート",
|
||||||
|
"localRootHelp": "サーバーのサンプル画像ディレクトリを反映する任意のローカルまたはマウント済みルートです。空欄の場合はサーバーのパスを再利用します。",
|
||||||
|
"localRootPlaceholder": "例: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "URI テンプレートを開く",
|
||||||
|
"uriTemplateHelp": "ファイル URI や Shortcuts リンクなどのカスタムディープリンクを使用します。",
|
||||||
|
"uriTemplatePlaceholder": "例: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "使用可能なプレースホルダー: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "リモートオープンモードの詳細",
|
||||||
"optimizeImages": "ダウンロード画像の最適化",
|
"optimizeImages": "ダウンロード画像の最適化",
|
||||||
"optimizeImagesHelp": "例画像を最適化してファイルサイズを縮小し、読み込み速度を向上させます(メタデータは保持されます)",
|
"optimizeImagesHelp": "例画像を最適化してファイルサイズを縮小し、読み込み速度を向上させます(メタデータは保持されます)",
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||||
|
"loraSyntaxFormat": "LoRA構文形式",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA構文形式。フルパスはサブフォルダパスを含み(<lora:style/anime/x:1.0>)、モデルをロスレスで解決します。レガシーはファイル名のみ(<lora:x:1.0>)— A1111規約ですが、フォルダ間でファイル名が重複する場合に曖昧になる可能性があります。",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "フルパス(サブフォルダ/名前)",
|
||||||
|
"legacy": "レガシーA1111(名前のみ)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
|
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
"moveAll": "すべてをフォルダに移動",
|
"moveAll": "すべてをフォルダに移動",
|
||||||
"autoOrganize": "自動整理を実行",
|
"autoOrganize": "自動整理を実行",
|
||||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||||
"deleteAll": "すべてのモデルを削除",
|
"setFavorite": "お気に入りに設定",
|
||||||
|
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
|
||||||
|
"unfavorite": "お気に入りから削除",
|
||||||
|
"deleteAll": "選択したものを削除",
|
||||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||||
|
"downloadExamples": "例画像をダウンロード",
|
||||||
"clear": "選択をクリア",
|
"clear": "選択をクリア",
|
||||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||||
|
"sendToWorkflow": "ワークフローに送信",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "ワークフロー",
|
||||||
|
"metadata": "メタデータ",
|
||||||
|
"attributes": "属性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "ダウンロード"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "自動整理を初期化中...",
|
"initializing": "自動整理を初期化中...",
|
||||||
"starting": "{type}の自動整理を開始中...",
|
"starting": "{type}の自動整理を開始中...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新",
|
"title": "レシピリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "フォルダが見つかりません",
|
"noFolders": "フォルダが見つかりません",
|
||||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "このフォルダのアップデートを確認",
|
||||||
|
"loading": "このフォルダの{type}アップデートを確認中...",
|
||||||
|
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||||
|
"none": "このフォルダのすべての{type}sは最新です",
|
||||||
|
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "URLからモデルをダウンロード",
|
"title": "URLからモデルをダウンロード",
|
||||||
"titleWithType": "URLから{type}をダウンロード",
|
"titleWithType": "URLから{type}をダウンロード",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||||
"locationPreview": "ダウンロード場所プレビュー",
|
"locationPreview": "ダウンロード場所プレビュー",
|
||||||
"useDefaultPath": "デフォルトパスを使用",
|
"useDefaultPath": "デフォルトパスを使用",
|
||||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||||
"alreadyInLibrary": "既にライブラリ内",
|
"alreadyInLibrary": "既にライブラリ内",
|
||||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "ファイル形式を選択",
|
||||||
|
"files": "ファイル",
|
||||||
|
"select": "ファイルを選択"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
"noVersions": "このモデルの利用可能なバージョンがありません"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "モデルが完全に削除されます。",
|
"countMessage": "モデルが完全に削除されます。",
|
||||||
"action": "すべて削除"
|
"action": "すべて削除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "複数のレシピを削除",
|
||||||
|
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
|
||||||
|
"countMessage": "レシピが完全に削除されます。",
|
||||||
|
"action": "すべて削除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "すべての{type}の更新を確認しますか?",
|
"title": "すべての{type}の更新を確認しますか?",
|
||||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "モデル名を編集",
|
"editModelName": "モデル名を編集",
|
||||||
"editFileName": "ファイル名を編集",
|
"editFileName": "ファイル名を編集",
|
||||||
"editBaseModel": "ベースモデルを編集",
|
"editBaseModel": "ベースモデルを編集",
|
||||||
|
"editVersionName": "バージョン名を編集",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"viewOnCivitaiText": "Civitaiで表示",
|
"viewOnCivitaiText": "Civitaiで表示",
|
||||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "メモが正常に保存されました",
|
"saved": "メモが正常に保存されました",
|
||||||
"saveFailed": "メモの保存に失敗しました"
|
"saveFailed": "メモの保存に失敗しました",
|
||||||
|
"showMore": "もっと見る",
|
||||||
|
"showLess": "折りたたむ"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "プリセットパラメータを追加...",
|
"addPresetParameter": "プリセットパラメータを追加...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
"ignored": "無視中",
|
"ignored": "無視中",
|
||||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||||
|
"onSiteOnly": "サイト内のみ",
|
||||||
|
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloadTooltip": "このバージョンをダウンロード",
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
|
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteTooltip": "このローカルバージョンを削除",
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
"ignore": "無視",
|
"ignore": "無視",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "例画像フォルダが開かれました",
|
"opened": "例画像フォルダが開かれました",
|
||||||
"openingFolder": "例画像フォルダを開いています",
|
"openingFolder": "例画像フォルダを開いています",
|
||||||
"failedToOpen": "例画像フォルダを開くのに失敗しました",
|
"failedToOpen": "例画像フォルダを開くのに失敗しました",
|
||||||
|
"copiedPath": "パスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"clipboardFallback": "パス: {{path}}",
|
||||||
|
"copiedUri": "リンクをクリップボードにコピーしました: {{uri}}",
|
||||||
|
"uriClipboardFallback": "リンク: {{uri}}",
|
||||||
"setupRequired": "例画像ストレージ",
|
"setupRequired": "例画像ストレージ",
|
||||||
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
|
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
|
||||||
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
|
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "レシピIDが利用できません",
|
"noRecipeId": "レシピIDが利用できません",
|
||||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||||
|
"createError": "レシピ作成中にエラーが発生しました:{message}",
|
||||||
|
"createFailed": "レシピの作成に失敗しました:{error}",
|
||||||
|
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||||
|
"created": "レシピを作成しました",
|
||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
|
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||||
|
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||||
|
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||||
|
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
|
||||||
|
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
|
||||||
|
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
|
||||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "要注意",
|
"warning": "要注意",
|
||||||
"error": "対応が必要"
|
"error": "対応が必要"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API キー"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "モデルキャッシュの健全性"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "ファイル名重複競合"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI バージョン"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "再実行",
|
"runAgain": "再実行",
|
||||||
"exportBundle": "パッケージをエクスポート"
|
"exportBundle": "パッケージをエクスポート",
|
||||||
|
"open-settings": "設定を開く",
|
||||||
|
"open-settings-syntax-format": "フルパス構文に切り替え",
|
||||||
|
"repair-cache": "キャッシュを再構築",
|
||||||
|
"resolve-filename-conflicts": "競合を解決",
|
||||||
|
"reload-page": "UI をリロード"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "競合",
|
||||||
|
"version": "バージョン"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
|
||||||
|
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
|
||||||
|
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "ファイル名の競合を解決",
|
||||||
|
"message": "重複したファイル名に4文字のハッシュを追加してリネームします。",
|
||||||
|
"note": "この操作はディスク上のファイルをリネームします。A1111 構文形式を使用している場合、既存のワークフロー内のモデル参照を更新する必要があるかもしれません。",
|
||||||
|
"detail": "例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "<strong>{groups}</strong> 組の重複にわたって <strong>{count}</strong> 個のファイルをリネームします",
|
||||||
|
"confirm": "ファイルをリネーム",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "アプリケーション更新が検出されました",
|
"title": "アプリケーション更新が検出されました",
|
||||||
|
|||||||
141
locales/ko.json
141
locales/ko.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"close": "닫기"
|
"close": "닫기",
|
||||||
|
"menu": "메뉴",
|
||||||
|
"remove": "제거",
|
||||||
|
"change": "변경"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
|
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||||
|
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
|
"autoTags": "자동 태그",
|
||||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||||
"clearAll": "모든 필터 지우기",
|
"clearAll": "모든 필터 지우기",
|
||||||
"any": "아무",
|
"any": "아무",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "다운로드 백엔드",
|
"label": "다운로드 백엔드",
|
||||||
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.",
|
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(내장)",
|
"python": "Python(내장)",
|
||||||
"aria2": "aria2(실험적)"
|
"aria2": "aria2(권장)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.",
|
"help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.",
|
||||||
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
|
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Civitai 호스트 기본 설정 사용 가능",
|
"title": "Civitai 호스트 기본 설정 사용 가능",
|
||||||
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "호버 시 표시"
|
"hover": "호버 시 표시"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||||
|
"showVersionOnCard": "카드에 버전 표시",
|
||||||
|
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "예시 이미지 열기",
|
"exampleImages": "예시 이미지 열기",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요",
|
"downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요",
|
||||||
"autoDownload": "예시 이미지 자동 다운로드",
|
"autoDownload": "예시 이미지 자동 다운로드",
|
||||||
"autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)",
|
"autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)",
|
||||||
|
"openMode": "예시 이미지 열기 동작",
|
||||||
|
"openModeHelp": "서버에서 열지, 매핑된 로컬 경로를 복사할지, 사용자 지정 URI를 실행할지 선택합니다.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "서버에서 열기",
|
||||||
|
"clipboard": "로컬 경로 복사",
|
||||||
|
"uriTemplate": "사용자 지정 URI 열기"
|
||||||
|
},
|
||||||
|
"localRoot": "로컬 예시 이미지 루트",
|
||||||
|
"localRootHelp": "서버 예시 이미지 디렉터리를 반영하는 선택적 로컬 또는 마운트된 루트입니다. 비워 두면 서버 경로를 재사용합니다.",
|
||||||
|
"localRootPlaceholder": "예: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "URI 템플릿 열기",
|
||||||
|
"uriTemplateHelp": "파일 URI 또는 Shortcuts 링크 같은 사용자 지정 딥링크를 사용합니다.",
|
||||||
|
"uriTemplatePlaceholder": "예: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "사용 가능한 플레이스홀더: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "원격 열기 모드에 대해 자세히 알아보기",
|
||||||
"optimizeImages": "다운로드된 이미지 최적화",
|
"optimizeImages": "다운로드된 이미지 최적화",
|
||||||
"optimizeImagesHelp": "파일 크기를 줄이고 로딩 속도를 향상시키기 위해 예시 이미지를 최적화합니다 (메타데이터는 보존됨)",
|
"optimizeImagesHelp": "파일 크기를 줄이고 로딩 속도를 향상시키기 위해 예시 이미지를 최적화합니다 (메타데이터는 보존됨)",
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||||
|
"loraSyntaxFormat": "LoRA 구문 형식",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA 구문 형식. 전체 경로는 하위 폴더 경로(<lora:style/anime/x:1.0>)를 포함하여 손실 없는 모델 해상도를 제공합니다. 레거시는 파일 이름만(<lora:x:1.0>) 사용 — A1111 규칙이지만, 폴더 간 파일명 중복 시 모호할 수 있습니다.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "전체 경로(하위 폴더/이름)",
|
||||||
|
"legacy": "레거시 A1111(이름만)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
|
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
"moveAll": "모두 폴더로 이동",
|
"moveAll": "모두 폴더로 이동",
|
||||||
"autoOrganize": "자동 정리 선택",
|
"autoOrganize": "자동 정리 선택",
|
||||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||||
"deleteAll": "모든 모델 삭제",
|
"setFavorite": "즐겨찾기로 설정",
|
||||||
|
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
|
||||||
|
"unfavorite": "즐겨찾기 해제",
|
||||||
|
"deleteAll": "선택된 항목 삭제",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
|
"downloadExamples": "예시 이미지 다운로드",
|
||||||
"clear": "선택 지우기",
|
"clear": "선택 지우기",
|
||||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||||
|
"sendToWorkflow": "워크플로우로 보내기",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "워크플로우",
|
||||||
|
"metadata": "메타데이터",
|
||||||
|
"attributes": "속성",
|
||||||
|
"organize": "정리",
|
||||||
|
"download": "다운로드"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "자동 정리 초기화 중...",
|
"initializing": "자동 정리 초기화 중...",
|
||||||
"starting": "{type}에 대한 자동 정리 시작...",
|
"starting": "{type}에 대한 자동 정리 시작...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침",
|
"title": "레시피 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "폴더를 찾을 수 없습니다",
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "이 폴더의 업데이트 확인",
|
||||||
|
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
|
||||||
|
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||||
|
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||||
|
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "URL에서 모델 다운로드",
|
"title": "URL에서 모델 다운로드",
|
||||||
"titleWithType": "URL에서 {type} 다운로드",
|
"titleWithType": "URL에서 {type} 다운로드",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||||
"locationPreview": "다운로드 위치 미리보기",
|
"locationPreview": "다운로드 위치 미리보기",
|
||||||
"useDefaultPath": "기본 경로 사용",
|
"useDefaultPath": "기본 경로 사용",
|
||||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "파일 형식 선택",
|
||||||
|
"files": "개 파일",
|
||||||
|
"select": "파일 선택"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||||
"action": "모두 삭제"
|
"action": "모두 삭제"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "여러 레시피 삭제",
|
||||||
|
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
|
||||||
|
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
|
||||||
|
"action": "모두 삭제"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "모델명 편집",
|
"editModelName": "모델명 편집",
|
||||||
"editFileName": "파일명 편집",
|
"editFileName": "파일명 편집",
|
||||||
"editBaseModel": "베이스 모델 편집",
|
"editBaseModel": "베이스 모델 편집",
|
||||||
|
"editVersionName": "버전명 편집",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"viewOnCivitaiText": "Civitai에서 보기",
|
"viewOnCivitaiText": "Civitai에서 보기",
|
||||||
"viewCreatorProfile": "제작자 프로필 보기",
|
"viewCreatorProfile": "제작자 프로필 보기",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "메모가 성공적으로 저장됨",
|
"saved": "메모가 성공적으로 저장됨",
|
||||||
"saveFailed": "메모 저장 실패"
|
"saveFailed": "메모 저장 실패",
|
||||||
|
"showMore": "더 보기",
|
||||||
|
"showLess": "접기"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "프리셋 매개변수 추가...",
|
"addPresetParameter": "프리셋 매개변수 추가...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
"ignored": "무시됨",
|
"ignored": "무시됨",
|
||||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||||
|
"onSiteOnly": "사이트 내 전용",
|
||||||
|
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloadTooltip": "이 버전 다운로드",
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
|
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleteTooltip": "이 로컬 버전 삭제",
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
"ignore": "무시",
|
"ignore": "무시",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "예시 이미지 폴더가 열렸습니다",
|
"opened": "예시 이미지 폴더가 열렸습니다",
|
||||||
"openingFolder": "예시 이미지 폴더를 여는 중",
|
"openingFolder": "예시 이미지 폴더를 여는 중",
|
||||||
"failedToOpen": "예시 이미지 폴더 열기 실패",
|
"failedToOpen": "예시 이미지 폴더 열기 실패",
|
||||||
|
"copiedPath": "경로를 클립보드에 복사했습니다: {{path}}",
|
||||||
|
"clipboardFallback": "경로: {{path}}",
|
||||||
|
"copiedUri": "링크를 클립보드에 복사했습니다: {{uri}}",
|
||||||
|
"uriClipboardFallback": "링크: {{uri}}",
|
||||||
"setupRequired": "예시 이미지 저장소",
|
"setupRequired": "예시 이미지 저장소",
|
||||||
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
|
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
|
||||||
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
|
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||||
|
"createError": "레시피 생성 중 오류 발생:{message}",
|
||||||
|
"createFailed": "레시피 생성 실패:{error}",
|
||||||
|
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||||
|
"created": "레시피가 생성되었습니다",
|
||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
|
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||||
|
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||||
|
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||||
|
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
|
||||||
|
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
|
||||||
|
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
|
||||||
|
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
|
||||||
|
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
|
||||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "주의 필요",
|
"warning": "주의 필요",
|
||||||
"error": "조치 필요"
|
"error": "조치 필요"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API 키"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "모델 캐시 상태"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "파일명 중복 충돌"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI 버전"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "다시 실행",
|
"runAgain": "다시 실행",
|
||||||
"exportBundle": "번들 내보내기"
|
"exportBundle": "번들 내보내기",
|
||||||
|
"open-settings": "설정 열기",
|
||||||
|
"open-settings-syntax-format": "전체 경로 구문으로 전환",
|
||||||
|
"repair-cache": "캐시 재구축",
|
||||||
|
"resolve-filename-conflicts": "충돌 해결",
|
||||||
|
"reload-page": "UI 새로고침"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "충돌",
|
||||||
|
"version": "버전"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "진단 로드 실패: {message}",
|
"loadFailed": "진단 로드 실패: {message}",
|
||||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||||
"repairFailed": "캐시 재구성 실패: {message}",
|
"repairFailed": "캐시 재구성 실패: {message}",
|
||||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
"exportFailed": "진단 번들 내보내기 실패: {message}",
|
||||||
|
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
|
||||||
|
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "파일명 충돌 해결",
|
||||||
|
"message": "중복 파일명에 4자리 해시를 추가하여 이름을 변경합니다.",
|
||||||
|
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로우의 모델 참조를 업데이트해야 할 수 있습니다.",
|
||||||
|
"detail": "예시: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "<strong>{groups}</strong>개 중복 그룹에서 <strong>{count}</strong>개 파일 이름을 변경합니다",
|
||||||
|
"confirm": "파일 이름 변경",
|
||||||
|
"cancel": "취소"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "애플리케이션 업데이트 감지",
|
"title": "애플리케이션 업데이트 감지",
|
||||||
|
|||||||
141
locales/ru.json
141
locales/ru.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"close": "Закрыть"
|
"close": "Закрыть",
|
||||||
|
"menu": "Меню",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"change": "Изменить"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||||
|
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
|
"autoTags": "Авто-теги",
|
||||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||||
"clearAll": "Очистить все фильтры",
|
"clearAll": "Очистить все фильтры",
|
||||||
"any": "Любой",
|
"any": "Любой",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Бэкенд загрузки",
|
"label": "Бэкенд загрузки",
|
||||||
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.",
|
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (встроенный)",
|
"python": "Python (встроенный)",
|
||||||
"aria2": "aria2 (экспериментальный)"
|
"aria2": "aria2 (рекомендуемый)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.",
|
"help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.",
|
||||||
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
|
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "Доступна настройка хоста Civitai",
|
"title": "Доступна настройка хоста Civitai",
|
||||||
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "Показать при наведении"
|
"hover": "Показать при наведении"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||||
|
"showVersionOnCard": "Показывать версию на карточке",
|
||||||
|
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Открыть примеры изображений",
|
"exampleImages": "Открыть примеры изображений",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "Введите путь к папке, где будут сохраняться примеры изображений с Civitai",
|
"downloadLocationHelp": "Введите путь к папке, где будут сохраняться примеры изображений с Civitai",
|
||||||
"autoDownload": "Автозагрузка примеров изображений",
|
"autoDownload": "Автозагрузка примеров изображений",
|
||||||
"autoDownloadHelp": "Автоматически загружать примеры изображений для моделей, у которых их нет (требует настройки места загрузки)",
|
"autoDownloadHelp": "Автоматически загружать примеры изображений для моделей, у которых их нет (требует настройки места загрузки)",
|
||||||
|
"openMode": "Действие открытия примеров изображений",
|
||||||
|
"openModeHelp": "Выберите, будет ли действие открывать папку на сервере, копировать сопоставленный локальный путь или запускать пользовательский URI.",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "Открыть на сервере",
|
||||||
|
"clipboard": "Скопировать локальный путь",
|
||||||
|
"uriTemplate": "Открыть пользовательский URI"
|
||||||
|
},
|
||||||
|
"localRoot": "Локальный корень примеров изображений",
|
||||||
|
"localRootHelp": "Необязательный локальный или смонтированный корневой путь, отражающий каталог примеров изображений на сервере. Если оставить пустым, будет использован путь сервера.",
|
||||||
|
"localRootPlaceholder": "Пример: /Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "Шаблон URI для открытия",
|
||||||
|
"uriTemplateHelp": "Используйте пользовательскую deep link-ссылку, например file URI или ссылку Shortcuts.",
|
||||||
|
"uriTemplatePlaceholder": "Пример: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "Доступные плейсхолдеры: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "Подробнее об удаленных режимах открытия",
|
||||||
"optimizeImages": "Оптимизировать загруженные изображения",
|
"optimizeImages": "Оптимизировать загруженные изображения",
|
||||||
"optimizeImagesHelp": "Оптимизировать примеры изображений для уменьшения размера файла и улучшения скорости загрузки (метаданные будут сохранены)",
|
"optimizeImagesHelp": "Оптимизировать примеры изображений для уменьшения размера файла и улучшения скорости загрузки (метаданные будут сохранены)",
|
||||||
"download": "Загрузить",
|
"download": "Загрузить",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||||
|
"loraSyntaxFormat": "Формат синтаксиса LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "Формат синтаксиса LoRA. Полный путь включает подпапку (<lora:style/anime/x:1.0>) для безпотерьного разрешения модели. Устаревший использует только имя файла (<lora:x:1.0>) — соглашение A1111, может быть неоднозначным при дублировании имён файлов в разных папках.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Полный путь (подпапка/имя)",
|
||||||
|
"legacy": "Устаревший A1111 (только имя)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Включить архив метаданных",
|
"enableArchiveDb": "Включить архив метаданных",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "Установить рейтинг контента для всех",
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
|
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
"moveAll": "Переместить все в папку",
|
"moveAll": "Переместить все в папку",
|
||||||
"autoOrganize": "Автоматически организовать выбранные",
|
"autoOrganize": "Автоматически организовать выбранные",
|
||||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||||
"deleteAll": "Удалить все модели",
|
"setFavorite": "Добавить в избранное",
|
||||||
|
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
|
||||||
|
"unfavorite": "Удалить из избранного",
|
||||||
|
"deleteAll": "Удалить выбранные",
|
||||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
|
"downloadExamples": "Загрузить примеры изображений",
|
||||||
"clear": "Очистить выбор",
|
"clear": "Очистить выбор",
|
||||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||||
|
"sendToWorkflow": "Отправить в Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Метаданные",
|
||||||
|
"attributes": "Атрибуты",
|
||||||
|
"organize": "Организовать",
|
||||||
|
"download": "Скачать"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Инициализация автоматической организации...",
|
"initializing": "Инициализация автоматической организации...",
|
||||||
"starting": "Запуск автоматической организации для {type}...",
|
"starting": "Запуск автоматической организации для {type}...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов",
|
"title": "Обновить список рецептов",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Папки не найдены",
|
"noFolders": "Папки не найдены",
|
||||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Проверить обновления в этой папке",
|
||||||
|
"loading": "Проверка обновлений {type} в этой папке...",
|
||||||
|
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||||
|
"none": "Все {type}s в этой папке актуальны",
|
||||||
|
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Скачать модель по URL",
|
"title": "Скачать модель по URL",
|
||||||
"titleWithType": "Скачать {type} по URL",
|
"titleWithType": "Скачать {type} по URL",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
|
||||||
"locationPreview": "Предпросмотр места загрузки",
|
"locationPreview": "Предпросмотр места загрузки",
|
||||||
"useDefaultPath": "Использовать путь по умолчанию",
|
"useDefaultPath": "Использовать путь по умолчанию",
|
||||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||||
"alreadyInLibrary": "Уже в библиотеке",
|
"alreadyInLibrary": "Уже в библиотеке",
|
||||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Выбрать формат файла",
|
||||||
|
"files": "файлов",
|
||||||
|
"select": "Выбрать файл"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
"noVersions": "Нет доступных версий для этой модели"
|
"noVersions": "Нет доступных версий для этой модели"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "моделей будут удалены навсегда.",
|
"countMessage": "моделей будут удалены навсегда.",
|
||||||
"action": "Удалить все"
|
"action": "Удалить все"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Удалить несколько рецептов",
|
||||||
|
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
|
||||||
|
"countMessage": "рецептов будут удалены навсегда.",
|
||||||
|
"action": "Удалить все"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Проверить обновления для всех {typePlural}?",
|
"title": "Проверить обновления для всех {typePlural}?",
|
||||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "Редактировать название модели",
|
"editModelName": "Редактировать название модели",
|
||||||
"editFileName": "Редактировать имя файла",
|
"editFileName": "Редактировать имя файла",
|
||||||
"editBaseModel": "Редактировать базовую модель",
|
"editBaseModel": "Редактировать базовую модель",
|
||||||
|
"editVersionName": "Редактировать название версии",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Заметки успешно сохранены",
|
"saved": "Заметки успешно сохранены",
|
||||||
"saveFailed": "Не удалось сохранить заметки"
|
"saveFailed": "Не удалось сохранить заметки",
|
||||||
|
"showMore": "Показать больше",
|
||||||
|
"showLess": "Свернуть"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Добавить предустановленный параметр...",
|
"addPresetParameter": "Добавить предустановленный параметр...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
"ignored": "Игнорируется",
|
"ignored": "Игнорируется",
|
||||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||||
|
"onSiteOnly": "Только на Сайте",
|
||||||
|
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloadTooltip": "Скачать эту версию",
|
"downloadTooltip": "Скачать эту версию",
|
||||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleteTooltip": "Удалить эту локальную версию",
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
"ignore": "Игнорировать",
|
"ignore": "Игнорировать",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "Папка с примерами изображений открыта",
|
"opened": "Папка с примерами изображений открыта",
|
||||||
"openingFolder": "Открытие папки с примерами изображений",
|
"openingFolder": "Открытие папки с примерами изображений",
|
||||||
"failedToOpen": "Не удалось открыть папку с примерами изображений",
|
"failedToOpen": "Не удалось открыть папку с примерами изображений",
|
||||||
|
"copiedPath": "Путь скопирован в буфер обмена: {{path}}",
|
||||||
|
"clipboardFallback": "Путь: {{path}}",
|
||||||
|
"copiedUri": "Ссылка скопирована в буфер обмена: {{uri}}",
|
||||||
|
"uriClipboardFallback": "Ссылка: {{uri}}",
|
||||||
"setupRequired": "Хранилище примеров изображений",
|
"setupRequired": "Хранилище примеров изображений",
|
||||||
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
|
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
|
||||||
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
|
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "ID рецепта недоступен",
|
"noRecipeId": "ID рецепта недоступен",
|
||||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||||
|
"createError": "Ошибка при создании рецепта:{message}",
|
||||||
|
"createFailed": "Не удалось создать рецепт:{error}",
|
||||||
|
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||||
|
"created": "Рецепт успешно создан",
|
||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
|
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||||
|
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||||
|
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||||
|
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
|
||||||
|
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
|
||||||
|
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
|
||||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "Требует внимания",
|
"warning": "Требует внимания",
|
||||||
"error": "Требуется действие"
|
"error": "Требуется действие"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Запустить снова",
|
"runAgain": "Запустить снова",
|
||||||
"exportBundle": "Экспортировать пакет"
|
"exportBundle": "Экспортировать пакет",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||||
"repairSuccess": "Перестройка кэша завершена.",
|
"repairSuccess": "Перестройка кэша завершена.",
|
||||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
|
||||||
|
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
|
||||||
|
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Разрешить конфликты имён файлов",
|
||||||
|
"message": "Переименование с добавлением 4-символьного хеша к каждому дублирующемуся имени файла.",
|
||||||
|
"note": "Эта операция переименовывает файлы на диске. Если вы используете синтаксис A1111, ссылки на модели в существующих рабочих процессах могут потребовать обновления.",
|
||||||
|
"detail": "Пример: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Будет переименовано <strong>{count}</strong> файл(ов) в <strong>{groups}</strong> группе(ах) дубликатов",
|
||||||
|
"confirm": "Переименовать файлы",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Обнаружено обновление приложения",
|
"title": "Обнаружено обновление приложения",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"menu": "菜单",
|
||||||
|
"remove": "移除",
|
||||||
|
"change": "更换"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
|
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||||
|
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
|
"autoTags": "自动标签",
|
||||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||||
"clearAll": "清除所有筛选",
|
"clearAll": "清除所有筛选",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "下载后端",
|
"label": "下载后端",
|
||||||
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。",
|
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(内置)",
|
"python": "Python(内置)",
|
||||||
"aria2": "aria2(实验性)"
|
"aria2": "aria2(推荐)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。",
|
"help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。",
|
||||||
"placeholder": "留空则使用 PATH 中的 aria2c"
|
"placeholder": "留空则使用 PATH 中的 aria2c"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "了解如何配置 aria2 下载后端",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "已提供 Civitai 站点偏好设置",
|
"title": "已提供 Civitai 站点偏好设置",
|
||||||
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "悬停时显示"
|
"hover": "悬停时显示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||||
|
"showVersionOnCard": "在卡片上显示版本",
|
||||||
|
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||||
"modelCardFooterAction": "模型卡片按钮操作",
|
"modelCardFooterAction": "模型卡片按钮操作",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "打开示例图片",
|
"exampleImages": "打开示例图片",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
|
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
|
||||||
"autoDownload": "自动下载示例图片",
|
"autoDownload": "自动下载示例图片",
|
||||||
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
|
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
|
||||||
|
"openMode": "打开示例图片操作",
|
||||||
|
"openModeHelp": "选择是在服务器上打开、复制映射后的本地路径,还是启动自定义 URI。",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "在服务器上打开",
|
||||||
|
"clipboard": "复制本地路径",
|
||||||
|
"uriTemplate": "打开自定义 URI"
|
||||||
|
},
|
||||||
|
"localRoot": "本地示例图片根目录",
|
||||||
|
"localRootHelp": "可选的本地或挂载根目录,用于映射服务器上的示例图片目录。若留空,则复用服务器路径。",
|
||||||
|
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "打开 URI 模板",
|
||||||
|
"uriTemplateHelp": "使用自定义深链接,例如文件 URI 或 Shortcuts 链接。",
|
||||||
|
"uriTemplatePlaceholder": "例如:shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "可用占位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "了解远程打开模式",
|
||||||
"optimizeImages": "优化下载图片",
|
"optimizeImages": "优化下载图片",
|
||||||
"optimizeImagesHelp": "优化示例图片以减少文件大小并提升加载速度(保留元数据)",
|
"optimizeImagesHelp": "优化示例图片以减少文件大小并提升加载速度(保留元数据)",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||||
|
"loraSyntaxFormat": "LoRA 语法格式",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA 语法格式。完整路径(Full)包含子文件夹路径 (<lora:style/anime/x:1.0>),解析精确无歧义。旧版(Legacy)仅使用文件名 (<lora:x:1.0>)——A1111 原始约定,同名文件跨文件夹时可能产生歧义。",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "完整路径(子文件夹/名称)",
|
||||||
|
"legacy": "旧版 A1111(仅名称)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "启用元数据归档数据库",
|
"enableArchiveDb": "启用元数据归档数据库",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "为所选中设置内容评级",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
|
"repairMetadata": "修复所选中元数据",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
"moveAll": "移动所选中到文件夹",
|
"moveAll": "移动所选中到文件夹",
|
||||||
"autoOrganize": "自动整理所选模型",
|
"autoOrganize": "自动整理所选模型",
|
||||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||||
"deleteAll": "删除选中模型",
|
"setFavorite": "设为收藏",
|
||||||
|
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
|
"deleteAll": "删除已选",
|
||||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||||
|
"downloadExamples": "下载示例图片",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||||
|
"sendToWorkflow": "发送到工作流",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "工作流",
|
||||||
|
"metadata": "元数据",
|
||||||
|
"attributes": "属性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "下载"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自动整理...",
|
"initializing": "正在初始化自动整理...",
|
||||||
"starting": "正在为 {type} 启动自动整理...",
|
"starting": "正在为 {type} 启动自动整理...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表",
|
"title": "刷新配方列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到文件夹",
|
"noFolders": "未找到文件夹",
|
||||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "检查此文件夹的更新",
|
||||||
|
"loading": "正在检查此文件夹中的{type}更新...",
|
||||||
|
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||||
|
"none": "此文件夹中的所有{type}都是最新版本",
|
||||||
|
"error": "检查文件夹{type}更新失败: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "从 URL 下载模型",
|
"title": "从 URL 下载模型",
|
||||||
"titleWithType": "从 URL 下载 {type}",
|
"titleWithType": "从 URL 下载 {type}",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
|
||||||
"locationPreview": "下载位置预览",
|
"locationPreview": "下载位置预览",
|
||||||
"useDefaultPath": "使用默认路径",
|
"useDefaultPath": "使用默认路径",
|
||||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||||
"alreadyInLibrary": "已存在于库中",
|
"alreadyInLibrary": "已存在于库中",
|
||||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "选择文件格式",
|
||||||
|
"files": "个文件",
|
||||||
|
"select": "选择文件"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
"noVersions": "此模型没有可用版本"
|
"noVersions": "此模型没有可用版本"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "模型将被永久删除。",
|
"countMessage": "模型将被永久删除。",
|
||||||
"action": "全部删除"
|
"action": "全部删除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "删除多个配方",
|
||||||
|
"message": "你确定要删除所有选中的配方及其相关文件吗?",
|
||||||
|
"countMessage": "配方将被永久删除。",
|
||||||
|
"action": "全部删除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "检查所有 {type} 的更新?",
|
"title": "检查所有 {type} 的更新?",
|
||||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "编辑模型名称",
|
"editModelName": "编辑模型名称",
|
||||||
"editFileName": "编辑文件名",
|
"editFileName": "编辑文件名",
|
||||||
"editBaseModel": "编辑基础模型",
|
"editBaseModel": "编辑基础模型",
|
||||||
|
"editVersionName": "编辑版本名称",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
"viewCreatorProfile": "查看创作者主页",
|
"viewCreatorProfile": "查看创作者主页",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "备注保存成功",
|
"saved": "备注保存成功",
|
||||||
"saveFailed": "备注保存失败"
|
"saveFailed": "备注保存失败",
|
||||||
|
"showMore": "展开",
|
||||||
|
"showLess": "收起"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "添加预设参数...",
|
"addPresetParameter": "添加预设参数...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已关闭更新通知"
|
"ignoredTooltip": "此版本已关闭更新通知",
|
||||||
|
"onSiteOnly": "仅站内生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTooltip": "下载此版本",
|
"downloadTooltip": "下载此版本",
|
||||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteTooltip": "删除此本地版本",
|
"deleteTooltip": "删除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "示例图片文件夹已打开",
|
"opened": "示例图片文件夹已打开",
|
||||||
"openingFolder": "正在打开示例图片文件夹",
|
"openingFolder": "正在打开示例图片文件夹",
|
||||||
"failedToOpen": "打开示例图片文件夹失败",
|
"failedToOpen": "打开示例图片文件夹失败",
|
||||||
|
"copiedPath": "路径已复制到剪贴板:{{path}}",
|
||||||
|
"clipboardFallback": "路径:{{path}}",
|
||||||
|
"copiedUri": "链接已复制到剪贴板:{{uri}}",
|
||||||
|
"uriClipboardFallback": "链接:{{uri}}",
|
||||||
"setupRequired": "示例图片存储",
|
"setupRequired": "示例图片存储",
|
||||||
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
||||||
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "无配方 ID",
|
"noRecipeId": "无配方 ID",
|
||||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||||
"copyFailed": "复制配方语法出错:{message}",
|
"copyFailed": "复制配方语法出错:{message}",
|
||||||
|
"createError": "创建配方时出错:{message}",
|
||||||
|
"createFailed": "创建配方失败:{error}",
|
||||||
|
"createMissingData": "缺少创建配方所需的数据",
|
||||||
|
"created": "配方创建成功",
|
||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
|
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||||
|
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||||
|
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||||
|
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
|
||||||
|
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
|
||||||
|
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
|
||||||
|
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
|
||||||
|
"bulkFavoriteFailed": "更新收藏状态失败",
|
||||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "需要关注",
|
"warning": "需要关注",
|
||||||
"error": "需要处理"
|
"error": "需要处理"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API 密钥"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "模型缓存健康状态"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "文件名重复冲突"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI 版本"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "重新检查",
|
"runAgain": "重新检查",
|
||||||
"exportBundle": "导出诊断包"
|
"exportBundle": "导出诊断包",
|
||||||
|
"open-settings": "打开设置",
|
||||||
|
"open-settings-syntax-format": "切换为完整路径语法",
|
||||||
|
"repair-cache": "重建缓存",
|
||||||
|
"resolve-filename-conflicts": "解决冲突",
|
||||||
|
"reload-page": "刷新 UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "冲突详情",
|
||||||
|
"version": "版本信息"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "加载诊断结果失败:{message}",
|
"loadFailed": "加载诊断结果失败:{message}",
|
||||||
"repairSuccess": "缓存重建完成。",
|
"repairSuccess": "缓存重建完成。",
|
||||||
"repairFailed": "缓存重建失败:{message}",
|
"repairFailed": "缓存重建失败:{message}",
|
||||||
"exportSuccess": "诊断包已导出。",
|
"exportSuccess": "诊断包已导出。",
|
||||||
"exportFailed": "导出诊断包失败:{message}"
|
"exportFailed": "导出诊断包失败:{message}",
|
||||||
|
"conflictsResolved": "已解决 {count} 个文件名冲突。",
|
||||||
|
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "解决文件名冲突",
|
||||||
|
"message": "通过在每个重复文件名后附加 4 位哈希值来重命名文件。",
|
||||||
|
"note": "此操作会重命名磁盘上的文件。如果使用 A1111 语法格式,现有工作流中的模型引用可能需要更新。",
|
||||||
|
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "将重命名 <strong>{count}</strong> 个文件(共 <strong>{groups}</strong> 组重复)",
|
||||||
|
"confirm": "重命名文件",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "检测到应用更新",
|
"title": "检测到应用更新",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"close": "關閉"
|
"close": "關閉",
|
||||||
|
"menu": "選單",
|
||||||
|
"remove": "移除",
|
||||||
|
"change": "更換"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
|
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||||
|
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
|
"autoTags": "自動標籤",
|
||||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||||
"clearAll": "清除所有篩選",
|
"clearAll": "清除所有篩選",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "下載後端",
|
"label": "下載後端",
|
||||||
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。",
|
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(內建)",
|
"python": "Python(內建)",
|
||||||
"aria2": "aria2(實驗性)"
|
"aria2": "aria2(推薦)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -276,6 +282,7 @@
|
|||||||
"help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。",
|
"help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。",
|
||||||
"placeholder": "留空則使用 PATH 中的 aria2c"
|
"placeholder": "留空則使用 PATH 中的 aria2c"
|
||||||
},
|
},
|
||||||
|
"aria2HelpLink": "了解如何設定 aria2 下載後端",
|
||||||
"civitaiHostBanner": {
|
"civitaiHostBanner": {
|
||||||
"title": "已提供 Civitai 站點偏好設定",
|
"title": "已提供 Civitai 站點偏好設定",
|
||||||
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
||||||
@@ -427,6 +434,8 @@
|
|||||||
"hover": "滑鼠懸停顯示"
|
"hover": "滑鼠懸停顯示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||||
|
"showVersionOnCard": "在卡片上顯示版本",
|
||||||
|
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "開啟範例圖片",
|
"exampleImages": "開啟範例圖片",
|
||||||
@@ -538,6 +547,21 @@
|
|||||||
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
|
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
|
||||||
"autoDownload": "自動下載範例圖片",
|
"autoDownload": "自動下載範例圖片",
|
||||||
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
|
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
|
||||||
|
"openMode": "開啟範例圖片動作",
|
||||||
|
"openModeHelp": "選擇是在伺服器上開啟、複製對應的本機路徑,或啟動自訂 URI。",
|
||||||
|
"openModeOptions": {
|
||||||
|
"system": "在伺服器上開啟",
|
||||||
|
"clipboard": "複製本機路徑",
|
||||||
|
"uriTemplate": "開啟自訂 URI"
|
||||||
|
},
|
||||||
|
"localRoot": "本機範例圖片根目錄",
|
||||||
|
"localRootHelp": "可選的本機或掛載根目錄,用於對應伺服器上的範例圖片目錄。若留白,則會重用伺服器路徑。",
|
||||||
|
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
|
||||||
|
"uriTemplate": "開啟 URI 範本",
|
||||||
|
"uriTemplateHelp": "使用自訂深層連結,例如檔案 URI 或 Shortcuts 連結。",
|
||||||
|
"uriTemplatePlaceholder": "例如:shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||||
|
"uriTemplatePlaceholders": "可用佔位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
|
||||||
|
"openModeWikiLink": "了解遠端開啟模式",
|
||||||
"optimizeImages": "最佳化下載圖片",
|
"optimizeImages": "最佳化下載圖片",
|
||||||
"optimizeImagesHelp": "最佳化範例圖片以減少檔案大小並提升載入速度(會保留原有的 metadata)",
|
"optimizeImagesHelp": "最佳化範例圖片以減少檔案大小並提升載入速度(會保留原有的 metadata)",
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
@@ -557,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||||
|
"loraSyntaxFormat": "LoRA 語法格式",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA 語法格式。完整路徑(Full)包含子資料夾路徑 (<lora:style/anime/x:1.0>),解析精確無歧義。舊版(Legacy)僅使用檔名 (<lora:x:1.0>)——A1111 原始約定,同名檔案跨資料夾時可能產生歧義。",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "完整路徑(子資料夾/名稱)",
|
||||||
|
"legacy": "舊版 A1111(僅名稱)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||||
@@ -621,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||||
},
|
},
|
||||||
@@ -663,16 +691,29 @@
|
|||||||
"setContentRating": "為全部設定內容分級",
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
|
"repairMetadata": "修復所選中元數據",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
"moveAll": "全部移動到資料夾",
|
"moveAll": "全部移動到資料夾",
|
||||||
"autoOrganize": "自動整理所選模型",
|
"autoOrganize": "自動整理所選模型",
|
||||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||||
"deleteAll": "刪除全部模型",
|
"setFavorite": "設為收藏",
|
||||||
|
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
|
"deleteAll": "刪除所選",
|
||||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||||
|
"downloadExamples": "下載範例圖片",
|
||||||
"clear": "清除選取",
|
"clear": "清除選取",
|
||||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||||
|
"sendToWorkflow": "發送到工作流",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "工作流",
|
||||||
|
"metadata": "元數據",
|
||||||
|
"attributes": "屬性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "下載"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自動整理...",
|
"initializing": "正在初始化自動整理...",
|
||||||
"starting": "正在開始自動整理 {type}...",
|
"starting": "正在開始自動整理 {type}...",
|
||||||
@@ -785,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表",
|
"title": "重新整理配方列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||||
},
|
},
|
||||||
@@ -926,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到資料夾",
|
"noFolders": "未找到資料夾",
|
||||||
"dragHint": "將項目拖到此處以建立資料夾"
|
"dragHint": "將項目拖到此處以建立資料夾"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "檢查此資料夾的更新",
|
||||||
|
"loading": "正在檢查此資料夾中的{type}更新...",
|
||||||
|
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||||
|
"none": "此資料夾中的所有{type}都是最新版本",
|
||||||
|
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -970,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "從網址下載模型",
|
"title": "從網址下載模型",
|
||||||
"titleWithType": "從網址下載 {type}",
|
"titleWithType": "從網址下載 {type}",
|
||||||
"url": "Civitai 網址",
|
|
||||||
"civitaiUrl": "Civitai 網址:",
|
"civitaiUrl": "Civitai 網址:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
|
||||||
"locationPreview": "下載位置預覽",
|
"locationPreview": "下載位置預覽",
|
||||||
"useDefaultPath": "使用預設路徑",
|
"useDefaultPath": "使用預設路徑",
|
||||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||||
@@ -994,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||||
"alreadyInLibrary": "已在庫存",
|
"alreadyInLibrary": "已在庫存",
|
||||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "選擇檔案格式",
|
||||||
|
"files": "個檔案",
|
||||||
|
"select": "選擇檔案"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
"noVersions": "此模型無可用版本"
|
"noVersions": "此模型無可用版本"
|
||||||
@@ -1058,6 +1109,12 @@
|
|||||||
"countMessage": "模型將被永久刪除。",
|
"countMessage": "模型將被永久刪除。",
|
||||||
"action": "全部刪除"
|
"action": "全部刪除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "刪除多個配方",
|
||||||
|
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
|
||||||
|
"countMessage": "配方將被永久刪除。",
|
||||||
|
"action": "全部刪除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "要檢查所有 {type} 的更新嗎?",
|
"title": "要檢查所有 {type} 的更新嗎?",
|
||||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||||
@@ -1138,6 +1195,7 @@
|
|||||||
"editModelName": "編輯模型名稱",
|
"editModelName": "編輯模型名稱",
|
||||||
"editFileName": "編輯檔案名稱",
|
"editFileName": "編輯檔案名稱",
|
||||||
"editBaseModel": "編輯基礎模型",
|
"editBaseModel": "編輯基礎模型",
|
||||||
|
"editVersionName": "編輯版本名稱",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
"viewCreatorProfile": "查看創作者個人檔案",
|
"viewCreatorProfile": "查看創作者個人檔案",
|
||||||
@@ -1169,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "備註已儲存",
|
"saved": "備註已儲存",
|
||||||
"saveFailed": "儲存備註失敗"
|
"saveFailed": "儲存備註失敗",
|
||||||
|
"showMore": "展開",
|
||||||
|
"showLess": "收起"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "新增預設參數...",
|
"addPresetParameter": "新增預設參數...",
|
||||||
@@ -1274,12 +1334,15 @@
|
|||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已關閉更新通知"
|
"ignoredTooltip": "此版本已關閉更新通知",
|
||||||
|
"onSiteOnly": "僅站內生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"downloadTooltip": "下載此版本",
|
"downloadTooltip": "下載此版本",
|
||||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteTooltip": "刪除此本地版本",
|
"deleteTooltip": "刪除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1442,6 +1505,10 @@
|
|||||||
"opened": "範例圖片資料夾已開啟",
|
"opened": "範例圖片資料夾已開啟",
|
||||||
"openingFolder": "正在開啟範例圖片資料夾",
|
"openingFolder": "正在開啟範例圖片資料夾",
|
||||||
"failedToOpen": "開啟範例圖片資料夾失敗",
|
"failedToOpen": "開啟範例圖片資料夾失敗",
|
||||||
|
"copiedPath": "路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"clipboardFallback": "路徑:{{path}}",
|
||||||
|
"copiedUri": "連結已複製到剪貼簿:{{uri}}",
|
||||||
|
"uriClipboardFallback": "連結:{{uri}}",
|
||||||
"setupRequired": "範例圖片儲存",
|
"setupRequired": "範例圖片儲存",
|
||||||
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
|
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
|
||||||
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
|
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
|
||||||
@@ -1605,6 +1672,10 @@
|
|||||||
"noRecipeId": "無配方 ID",
|
"noRecipeId": "無配方 ID",
|
||||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||||
"copyFailed": "複製配方語法錯誤:{message}",
|
"copyFailed": "複製配方語法錯誤:{message}",
|
||||||
|
"createError": "建立配方時發生錯誤:{message}",
|
||||||
|
"createFailed": "建立配方失敗:{error}",
|
||||||
|
"createMissingData": "缺少建立配方所需的資料",
|
||||||
|
"created": "配方建立成功",
|
||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||||
@@ -1643,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何食譜",
|
"noRecipesSelected": "未選取任何食譜",
|
||||||
|
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||||
|
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||||
|
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||||
},
|
},
|
||||||
@@ -1673,6 +1747,11 @@
|
|||||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||||
|
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
|
||||||
|
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
|
||||||
|
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
|
||||||
|
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
|
||||||
|
"bulkFavoriteFailed": "更新收藏狀態失敗",
|
||||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||||
@@ -1875,18 +1954,52 @@
|
|||||||
"warning": "需要注意",
|
"warning": "需要注意",
|
||||||
"error": "需要處理"
|
"error": "需要處理"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API 金鑰"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "模型快取健康狀態"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "檔案名稱重複衝突"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI 版本"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "重新執行",
|
"runAgain": "重新執行",
|
||||||
"exportBundle": "匯出套件"
|
"exportBundle": "匯出套件",
|
||||||
|
"open-settings": "開啟設定",
|
||||||
|
"open-settings-syntax-format": "切換為完整路徑語法",
|
||||||
|
"repair-cache": "重建快取",
|
||||||
|
"resolve-filename-conflicts": "解決衝突",
|
||||||
|
"reload-page": "重新載入 UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "衝突詳情",
|
||||||
|
"version": "版本"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "載入診斷失敗:{message}",
|
"loadFailed": "載入診斷失敗:{message}",
|
||||||
"repairSuccess": "快取重建完成。",
|
"repairSuccess": "快取重建完成。",
|
||||||
"repairFailed": "快取重建失敗:{message}",
|
"repairFailed": "快取重建失敗:{message}",
|
||||||
"exportSuccess": "診斷套件已匯出。",
|
"exportSuccess": "診斷套件已匯出。",
|
||||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
"exportFailed": "匯出診斷套件失敗:{message}",
|
||||||
|
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
|
||||||
|
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "解決檔案名稱衝突",
|
||||||
|
"message": "通過在每個重複檔案名稱後附加 4 位元哈希值來重新命名檔案。",
|
||||||
|
"note": "此操作會重新命名磁碟上的檔案。如果使用 A1111 語法格式,現有工作流程中的模型參考可能需要更新。",
|
||||||
|
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "將重新命名 <strong>{count}</strong> 個檔案(共 <strong>{groups}</strong> 組重複)",
|
||||||
|
"confirm": "重新命名檔案",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "偵測到應用程式更新",
|
"title": "偵測到應用程式更新",
|
||||||
|
|||||||
96
py/config.py
96
py/config.py
@@ -172,6 +172,12 @@ class Config:
|
|||||||
self.extra_unet_roots: List[str] = []
|
self.extra_unet_roots: List[str] = []
|
||||||
self.extra_embeddings_roots: List[str] = []
|
self.extra_embeddings_roots: List[str] = []
|
||||||
self.recipes_path: str = ""
|
self.recipes_path: str = ""
|
||||||
|
|
||||||
|
# Load extra folder paths from active library settings before symlink scan
|
||||||
|
# so both primary and extra paths are discovered in a single pass.
|
||||||
|
if not standalone_mode:
|
||||||
|
self._load_extra_paths_from_settings()
|
||||||
|
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
|
|
||||||
@@ -179,6 +185,96 @@ class Config:
|
|||||||
# Save the paths to settings.json when running in ComfyUI mode
|
# Save the paths to settings.json when running in ComfyUI mode
|
||||||
self.save_folder_paths_to_settings()
|
self.save_folder_paths_to_settings()
|
||||||
|
|
||||||
|
def _load_extra_paths_from_settings(self) -> None:
|
||||||
|
"""Read extra folder paths from the active library and apply them.
|
||||||
|
|
||||||
|
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||||
|
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||||
|
portion of ``_apply_library_paths`` without replacing the primary roots
|
||||||
|
that were already resolved from ComfyUI's ``folder_paths``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
library_name = settings_manager.get_active_library_name()
|
||||||
|
libraries = settings_manager.get_libraries()
|
||||||
|
|
||||||
|
if not library_name or library_name not in libraries:
|
||||||
|
return
|
||||||
|
|
||||||
|
library_config = libraries[library_name]
|
||||||
|
if not isinstance(library_config, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||||
|
if not isinstance(extra_folder_paths, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
extra_lora = extra_folder_paths.get("loras", []) or []
|
||||||
|
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
|
||||||
|
extra_unet = extra_folder_paths.get("unet", []) or []
|
||||||
|
extra_embedding = extra_folder_paths.get("embeddings", []) or []
|
||||||
|
|
||||||
|
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
|
||||||
|
self.loras_roots, extra_lora
|
||||||
|
)
|
||||||
|
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
self.extra_checkpoints_roots,
|
||||||
|
self.extra_unet_roots,
|
||||||
|
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
|
||||||
|
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
||||||
|
extra_embedding
|
||||||
|
)
|
||||||
|
|
||||||
|
recipes_path = library_config.get("recipes_path", "")
|
||||||
|
if isinstance(recipes_path, str) and recipes_path:
|
||||||
|
self.recipes_path = recipes_path
|
||||||
|
|
||||||
|
if self.extra_loras_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra LoRA roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_loras_roots)
|
||||||
|
)
|
||||||
|
if self.extra_checkpoints_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra checkpoint roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_checkpoints_roots)
|
||||||
|
)
|
||||||
|
if self.extra_unet_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra diffusion model roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_unet_roots)
|
||||||
|
)
|
||||||
|
if self.extra_embeddings_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra embedding roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_embeddings_roots)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied library settings for '%s' with extra paths: loras=%s, "
|
||||||
|
"checkpoints=%s, embeddings=%s",
|
||||||
|
library_name,
|
||||||
|
extra_lora,
|
||||||
|
extra_checkpoint,
|
||||||
|
extra_embedding,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not load extra paths from library settings: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
def save_folder_paths_to_settings(self):
|
def save_folder_paths_to_settings(self):
|
||||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -184,39 +184,6 @@ class LoraManager:
|
|||||||
async def _initialize_services(cls):
|
async def _initialize_services(cls):
|
||||||
"""Initialize all services using the ServiceRegistry"""
|
"""Initialize all services using the ServiceRegistry"""
|
||||||
try:
|
try:
|
||||||
# Apply library settings to load extra folder paths before scanning
|
|
||||||
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
|
|
||||||
try:
|
|
||||||
from .services.settings_manager import get_settings_manager
|
|
||||||
|
|
||||||
settings_manager = get_settings_manager()
|
|
||||||
library_name = settings_manager.get_active_library_name()
|
|
||||||
libraries = settings_manager.get_libraries()
|
|
||||||
if library_name and library_name in libraries:
|
|
||||||
library_config = libraries[library_name]
|
|
||||||
# Only apply settings if extra paths are not already configured
|
|
||||||
# This preserves values set by tests via monkeypatch
|
|
||||||
extra_paths = library_config.get("extra_folder_paths", {})
|
|
||||||
has_extra_paths = (
|
|
||||||
config.extra_loras_roots
|
|
||||||
or config.extra_checkpoints_roots
|
|
||||||
or config.extra_unet_roots
|
|
||||||
or config.extra_embeddings_roots
|
|
||||||
)
|
|
||||||
if not has_extra_paths and any(extra_paths.values()):
|
|
||||||
config.apply_library_settings(library_config)
|
|
||||||
logger.info(
|
|
||||||
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
|
|
||||||
library_name,
|
|
||||||
extra_paths.get("loras", []),
|
|
||||||
extra_paths.get("checkpoints", []),
|
|
||||||
extra_paths.get("embeddings", []),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to apply library settings during initialization: %s", exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize CivitaiClient first to ensure it's ready for other services
|
# Initialize CivitaiClient first to ensure it's ready for other services
|
||||||
await ServiceRegistry.get_civitai_client()
|
await ServiceRegistry.get_civitai_client()
|
||||||
|
|
||||||
|
|||||||
@@ -352,50 +352,101 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
# Check if we have stored conditioning objects for this sampler
|
# Check if we have stored conditioning objects for this sampler
|
||||||
if sampler_id in metadata.get(PROMPTS, {}) and (
|
if sampler_id in metadata.get(PROMPTS, {}) and (
|
||||||
"pos_conditioning" in metadata[PROMPTS][sampler_id] or
|
"pos_conditioning" in metadata[PROMPTS][sampler_id] or
|
||||||
"neg_conditioning" in metadata[PROMPTS][sampler_id]):
|
"neg_conditioning" in metadata[PROMPTS][sampler_id]
|
||||||
|
):
|
||||||
pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning")
|
pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning")
|
||||||
neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning")
|
neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning")
|
||||||
|
|
||||||
# Helper function to recursively find prompt text for a conditioning object
|
def extend_unique(target, values):
|
||||||
def find_prompt_text_for_conditioning(conditioning_obj, is_positive=True):
|
for value in values:
|
||||||
|
if value and value not in target:
|
||||||
|
target.append(value)
|
||||||
|
|
||||||
|
# Helper function to recursively find prompt texts for a conditioning object.
|
||||||
|
# Transform nodes can map one output conditioning to multiple source conditionings.
|
||||||
|
def find_prompt_texts_for_conditioning(
|
||||||
|
conditioning_obj, is_positive=True, visited=None
|
||||||
|
):
|
||||||
if conditioning_obj is None:
|
if conditioning_obj is None:
|
||||||
return ""
|
return []
|
||||||
|
|
||||||
|
if visited is None:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
conditioning_id = id(conditioning_obj)
|
||||||
|
if conditioning_id in visited:
|
||||||
|
return []
|
||||||
|
visited.add(conditioning_id)
|
||||||
|
|
||||||
|
prompt_texts = []
|
||||||
|
|
||||||
# Try to match conditioning objects with those stored by extractors
|
# Try to match conditioning objects with those stored by extractors
|
||||||
for prompt_node_id, prompt_data in metadata[PROMPTS].items():
|
for prompt_node_id, prompt_data in metadata[PROMPTS].items():
|
||||||
# For nodes with single conditioning output
|
if not isinstance(prompt_data, dict):
|
||||||
if "conditioning" in prompt_data:
|
continue
|
||||||
if id(prompt_data["conditioning"]) == id(conditioning_obj):
|
|
||||||
return prompt_data.get("text", "")
|
# For CLIP text nodes with a single conditioning output.
|
||||||
|
if id(prompt_data.get("conditioning")) == conditioning_id:
|
||||||
# For nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader)
|
text = prompt_data.get("text", "")
|
||||||
if is_positive and "positive_encoded" in prompt_data:
|
if text:
|
||||||
if id(prompt_data["positive_encoded"]) == id(conditioning_obj):
|
extend_unique(prompt_texts, [text])
|
||||||
if "positive_text" in prompt_data:
|
|
||||||
return prompt_data["positive_text"]
|
# Generic provenance for passthrough/transform/combine nodes.
|
||||||
else:
|
for source in prompt_data.get("conditioning_sources", []):
|
||||||
orig_conditioning = prompt_data.get("orig_pos_cond", None)
|
if id(source.get("output")) != conditioning_id:
|
||||||
if orig_conditioning is not None:
|
continue
|
||||||
# Recursively find the prompt text for the original conditioning
|
for input_conditioning in source.get("inputs", []):
|
||||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=True)
|
extend_unique(
|
||||||
|
prompt_texts,
|
||||||
if not is_positive and "negative_encoded" in prompt_data:
|
find_prompt_texts_for_conditioning(
|
||||||
if id(prompt_data["negative_encoded"]) == id(conditioning_obj):
|
input_conditioning, is_positive, visited
|
||||||
if "negative_text" in prompt_data:
|
),
|
||||||
return prompt_data["negative_text"]
|
)
|
||||||
else:
|
|
||||||
orig_conditioning = prompt_data.get("orig_neg_cond", None)
|
# For nodes with separate pos_conditioning and neg_conditioning outputs
|
||||||
if orig_conditioning is not None:
|
# like TSC_EfficientLoader and existing ControlNet-style metadata.
|
||||||
# Recursively find the prompt text for the original conditioning
|
if (
|
||||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=False)
|
is_positive
|
||||||
|
and id(prompt_data.get("positive_encoded")) == conditioning_id
|
||||||
return ""
|
):
|
||||||
|
if prompt_data.get("positive_text"):
|
||||||
|
extend_unique(prompt_texts, [prompt_data["positive_text"]])
|
||||||
|
else:
|
||||||
|
extend_unique(
|
||||||
|
prompt_texts,
|
||||||
|
find_prompt_texts_for_conditioning(
|
||||||
|
prompt_data.get("orig_pos_cond"),
|
||||||
|
is_positive=True,
|
||||||
|
visited=visited,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not is_positive
|
||||||
|
and id(prompt_data.get("negative_encoded")) == conditioning_id
|
||||||
|
):
|
||||||
|
if prompt_data.get("negative_text"):
|
||||||
|
extend_unique(prompt_texts, [prompt_data["negative_text"]])
|
||||||
|
else:
|
||||||
|
extend_unique(
|
||||||
|
prompt_texts,
|
||||||
|
find_prompt_texts_for_conditioning(
|
||||||
|
prompt_data.get("orig_neg_cond"),
|
||||||
|
is_positive=False,
|
||||||
|
visited=visited,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return prompt_texts
|
||||||
|
|
||||||
# Find prompt texts using the helper function
|
# Find prompt texts using the helper function
|
||||||
result["prompt"] = find_prompt_text_for_conditioning(pos_conditioning, is_positive=True)
|
result["prompt"] = ", ".join(
|
||||||
result["negative_prompt"] = find_prompt_text_for_conditioning(neg_conditioning, is_positive=False)
|
find_prompt_texts_for_conditioning(pos_conditioning, is_positive=True)
|
||||||
|
)
|
||||||
|
result["negative_prompt"] = ", ".join(
|
||||||
|
find_prompt_texts_for_conditioning(neg_conditioning, is_positive=False)
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -509,8 +560,14 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
params["loras"] = " ".join(lora_parts)
|
params["loras"] = " ".join(lora_parts)
|
||||||
|
|
||||||
# Set default clip_skip value
|
# Extract clip_skip from any SAMPLING node that provides it
|
||||||
params["clip_skip"] = "1" # Common default
|
for sampler_info in metadata.get(SAMPLING, {}).values():
|
||||||
|
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
|
||||||
|
if clip_skip is not None:
|
||||||
|
params["clip_skip"] = clip_skip
|
||||||
|
break
|
||||||
|
if params["clip_skip"] is None:
|
||||||
|
params["clip_skip"] = "1"
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
|||||||
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "ckpt_name" in inputs:
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
|
||||||
|
|
||||||
|
# Only extract from optional_lora_stack — skip the single lora_name to
|
||||||
|
# avoid double-counting LoRAs that come through the LORA_STACK path.
|
||||||
|
active_loras = []
|
||||||
|
optional_lora_stack = inputs.get("optional_lora_stack")
|
||||||
|
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
|
||||||
|
for item in optional_lora_stack:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lora_path = item[0]
|
||||||
|
model_strength = item[1]
|
||||||
|
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": model_strength
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
positive_text = inputs.get("positive", "")
|
||||||
|
negative_text = inputs.get("negative", "")
|
||||||
|
|
||||||
|
if positive_text or negative_text:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
metadata[PROMPTS][node_id]["positive_text"] = positive_text
|
||||||
|
metadata[PROMPTS][node_id]["negative_text"] = negative_text
|
||||||
|
|
||||||
|
if "clip_skip" in inputs:
|
||||||
|
clip_skip = inputs["clip_skip"]
|
||||||
|
if node_id not in metadata[SAMPLING]:
|
||||||
|
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
|
||||||
|
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
|
||||||
|
|
||||||
|
width = inputs.get("empty_latent_width")
|
||||||
|
height = inputs.get("empty_latent_height")
|
||||||
|
if width is not None and height is not None:
|
||||||
|
if SIZE not in metadata:
|
||||||
|
metadata[SIZE] = {}
|
||||||
|
metadata[SIZE][node_id] = {
|
||||||
|
"width": int(width),
|
||||||
|
"height": int(height),
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
# outputs: [(pipe_dict, model, vae), ...]
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return
|
||||||
|
first_output = outputs[0]
|
||||||
|
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||||
|
return
|
||||||
|
pipe = first_output[0]
|
||||||
|
if not isinstance(pipe, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
positive_conditioning = pipe.get("positive")
|
||||||
|
negative_conditioning = pipe.get("negative")
|
||||||
|
|
||||||
|
if positive_conditioning is not None or negative_conditioning is not None:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
if positive_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
|
if negative_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyPreSamplingExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
sampling_params = {}
|
||||||
|
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
|
||||||
|
if key in inputs:
|
||||||
|
sampling_params[key] = inputs[key]
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": sampling_params,
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EasySeedExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "seed" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": {"seed": inputs["seed"]},
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -163,6 +275,251 @@ class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
|||||||
conditioning = outputs[0][0]
|
conditioning = outputs[0][0]
|
||||||
metadata[PROMPTS][node_id]["conditioning"] = conditioning
|
metadata[PROMPTS][node_id]["conditioning"] = conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class MyOriginalWaifuTextExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extractor for ComfyUI-MyOriginalWaifu TextProvider nodes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
positive_text = inputs.get("positive", "")
|
||||||
|
negative_text = inputs.get("negative", "")
|
||||||
|
|
||||||
|
if positive_text or negative_text:
|
||||||
|
metadata[PROMPTS][node_id] = {
|
||||||
|
"positive_text": positive_text,
|
||||||
|
"negative_text": negative_text,
|
||||||
|
"node_id": node_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
output_tuple = _first_output_tuple(outputs)
|
||||||
|
if not output_tuple or len(output_tuple) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
prompt_metadata["positive_text"] = output_tuple[0]
|
||||||
|
prompt_metadata["negative_text"] = output_tuple[1]
|
||||||
|
|
||||||
|
|
||||||
|
class MyOriginalWaifuClipExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extractor for ComfyUI-MyOriginalWaifu ClipProvider nodes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
positive_text = inputs.get("positive", "")
|
||||||
|
negative_text = inputs.get("negative", "")
|
||||||
|
|
||||||
|
if positive_text or negative_text:
|
||||||
|
metadata[PROMPTS][node_id] = {
|
||||||
|
"positive_text": positive_text,
|
||||||
|
"negative_text": negative_text,
|
||||||
|
"node_id": node_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
output_tuple = _first_output_tuple(outputs)
|
||||||
|
if not output_tuple or len(output_tuple) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
prompt_metadata["positive_encoded"] = output_tuple[0]
|
||||||
|
prompt_metadata["negative_encoded"] = output_tuple[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_prompt_metadata(metadata, node_id):
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
return metadata[PROMPTS][node_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _first_output_tuple(outputs):
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return None
|
||||||
|
first_output = outputs[0]
|
||||||
|
if isinstance(first_output, tuple):
|
||||||
|
return first_output
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_conditioning_source(
|
||||||
|
metadata, node_id, output_conditioning, input_conditionings
|
||||||
|
):
|
||||||
|
if output_conditioning is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
conditioning for conditioning in input_conditionings if conditioning is not None
|
||||||
|
]
|
||||||
|
if not sources:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
prompt_metadata.setdefault("conditioning_sources", []).append(
|
||||||
|
{
|
||||||
|
"output": output_conditioning,
|
||||||
|
"inputs": sources,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_variable_name(inputs):
|
||||||
|
for key in ("key", "name", "variable_name", "tag", "text"):
|
||||||
|
value = inputs.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_node_variable_name(metadata, node_id, inputs):
|
||||||
|
variable_name = _get_variable_name(inputs)
|
||||||
|
if variable_name:
|
||||||
|
return variable_name
|
||||||
|
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
original_prompt = getattr(prompt, "original_prompt", None)
|
||||||
|
if not original_prompt or node_id not in original_prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
node_data = original_prompt[node_id]
|
||||||
|
variable_name = _get_variable_name(node_data.get("inputs", {}))
|
||||||
|
if variable_name:
|
||||||
|
return variable_name
|
||||||
|
|
||||||
|
widgets_values = node_data.get("widgets_values", [])
|
||||||
|
if widgets_values and isinstance(widgets_values[0], str):
|
||||||
|
return widgets_values[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ControlNetApplyAdvancedExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
if inputs.get("positive") is not None:
|
||||||
|
prompt_metadata["orig_pos_cond"] = inputs["positive"]
|
||||||
|
if inputs.get("negative") is not None:
|
||||||
|
prompt_metadata["orig_neg_cond"] = inputs["negative"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
output_tuple = _first_output_tuple(outputs)
|
||||||
|
if not output_tuple:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
positive_input = prompt_metadata.get("orig_pos_cond")
|
||||||
|
negative_input = prompt_metadata.get("orig_neg_cond")
|
||||||
|
|
||||||
|
if len(output_tuple) >= 1:
|
||||||
|
prompt_metadata["positive_encoded"] = output_tuple[0]
|
||||||
|
_record_conditioning_source(
|
||||||
|
metadata, node_id, output_tuple[0], [positive_input]
|
||||||
|
)
|
||||||
|
if len(output_tuple) >= 2:
|
||||||
|
prompt_metadata["negative_encoded"] = output_tuple[1]
|
||||||
|
_record_conditioning_source(
|
||||||
|
metadata, node_id, output_tuple[1], [negative_input]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConditioningCombineExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
input_conditionings = []
|
||||||
|
for input_name in inputs:
|
||||||
|
if (
|
||||||
|
input_name.startswith("conditioning")
|
||||||
|
and inputs[input_name] is not None
|
||||||
|
):
|
||||||
|
input_conditionings.append(inputs[input_name])
|
||||||
|
|
||||||
|
if input_conditionings:
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
prompt_metadata["orig_conditionings"] = input_conditionings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
output_tuple = _first_output_tuple(outputs)
|
||||||
|
if not output_tuple or len(output_tuple) < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
output_conditioning = output_tuple[0]
|
||||||
|
prompt_metadata["conditioning"] = output_conditioning
|
||||||
|
_record_conditioning_source(
|
||||||
|
metadata,
|
||||||
|
node_id,
|
||||||
|
output_conditioning,
|
||||||
|
prompt_metadata.get("orig_conditionings", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SetNodeExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
variable_name = _get_node_variable_name(metadata, node_id, inputs)
|
||||||
|
conditioning = inputs.get("CONDITIONING")
|
||||||
|
if conditioning is None:
|
||||||
|
conditioning = inputs.get("conditioning")
|
||||||
|
if conditioning is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
prompt_metadata["conditioning"] = conditioning
|
||||||
|
if variable_name:
|
||||||
|
prompt_metadata["variable_name"] = variable_name
|
||||||
|
metadata[PROMPTS].setdefault("__conditioning_variables__", {})[
|
||||||
|
variable_name
|
||||||
|
] = conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class GetNodeExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
variable_name = _get_node_variable_name(metadata, node_id, inputs or {})
|
||||||
|
if variable_name:
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
prompt_metadata["variable_name"] = variable_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
output_tuple = _first_output_tuple(outputs)
|
||||||
|
if not output_tuple or len(output_tuple) < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
|
output_conditioning = output_tuple[0]
|
||||||
|
prompt_metadata["conditioning"] = output_conditioning
|
||||||
|
|
||||||
|
variable_name = prompt_metadata.get("variable_name")
|
||||||
|
if not variable_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
input_conditioning = metadata[PROMPTS].get("__conditioning_variables__", {}).get(
|
||||||
|
variable_name
|
||||||
|
)
|
||||||
|
_record_conditioning_source(
|
||||||
|
metadata, node_id, output_conditioning, [input_conditioning]
|
||||||
|
)
|
||||||
|
|
||||||
# Base Sampler Extractor to reduce code redundancy
|
# Base Sampler Extractor to reduce code redundancy
|
||||||
class BaseSamplerExtractor(NodeMetadataExtractor):
|
class BaseSamplerExtractor(NodeMetadataExtractor):
|
||||||
"""Base extractor for sampler nodes with common functionality"""
|
"""Base extractor for sampler nodes with common functionality"""
|
||||||
@@ -768,9 +1125,12 @@ NODE_EXTRACTORS = {
|
|||||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||||
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||||
|
# ComfyUI-Easy-Use pre-sampling / seed
|
||||||
|
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
|
||||||
|
"easySeed": EasySeedExtractor, # easy seed
|
||||||
# Loaders
|
# Loaders
|
||||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
|
||||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||||
@@ -780,8 +1140,10 @@ NODE_EXTRACTORS = {
|
|||||||
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||||
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||||
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
|
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
|
||||||
|
"CheckpointLoaderLM": CheckpointLoaderExtractor, # LoRA Manager
|
||||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
|
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
||||||
"LoraLoader": LoraLoaderExtractor,
|
"LoraLoader": LoraLoaderExtractor,
|
||||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||||
@@ -796,6 +1158,12 @@ NODE_EXTRACTORS = {
|
|||||||
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
|
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
|
||||||
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
|
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
|
||||||
"PCTextEncode": CLIPTextEncodeExtractor, # From https://github.com/asagi4/comfyui-prompt-control
|
"PCTextEncode": CLIPTextEncodeExtractor, # From https://github.com/asagi4/comfyui-prompt-control
|
||||||
|
"TextProvider": MyOriginalWaifuTextExtractor, # ComfyUI-MyOriginalWaifu
|
||||||
|
"ClipProvider": MyOriginalWaifuClipExtractor, # ComfyUI-MyOriginalWaifu
|
||||||
|
"ControlNetApplyAdvanced": ControlNetApplyAdvancedExtractor,
|
||||||
|
"ConditioningCombine": ConditioningCombineExtractor,
|
||||||
|
"SetNode": SetNodeExtractor,
|
||||||
|
"GetNode": GetNodeExtractor,
|
||||||
# Latent
|
# Latent
|
||||||
"EmptyLatentImage": ImageSizeExtractor,
|
"EmptyLatentImage": ImageSizeExtractor,
|
||||||
# Flux
|
# Flux
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ..utils.utils import get_lora_info_absolute
|
|||||||
from .utils import (
|
from .utils import (
|
||||||
FlexibleOptionalInputType,
|
FlexibleOptionalInputType,
|
||||||
any_type,
|
any_type,
|
||||||
|
apply_lora_syntax_format,
|
||||||
detect_nunchaku_model_kind,
|
detect_nunchaku_model_kind,
|
||||||
extract_lora_name,
|
extract_lora_name,
|
||||||
get_loras_list,
|
get_loras_list,
|
||||||
@@ -52,7 +53,7 @@ def _collect_widget_entries(kwargs):
|
|||||||
for lora in get_loras_list(kwargs):
|
for lora in get_loras_list(kwargs):
|
||||||
if not lora.get("active", False):
|
if not lora.get("active", False):
|
||||||
continue
|
continue
|
||||||
lora_name = lora["name"]
|
lora_name = apply_lora_syntax_format(lora["name"])
|
||||||
model_strength = float(lora["strength"])
|
model_strength = float(lora["strength"])
|
||||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class LoraStackerLM:
|
|||||||
if not lora.get('active', False):
|
if not lora.get('active', False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_name = lora['name']
|
lora_name = apply_lora_syntax_format(lora['name'])
|
||||||
model_strength = float(lora['strength'])
|
model_strength = float(lora['strength'])
|
||||||
# Get clip strength - use model strength as default if not specified
|
# Get clip strength - use model strength as default if not specified
|
||||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..metadata_collector.metadata_processor import MetadataProcessor
|
from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||||
from ..metadata_collector import get_metadata
|
from ..metadata_collector import get_metadata
|
||||||
|
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
from ..utils.utils import calculate_recipe_fingerprint
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
import piexif
|
import piexif
|
||||||
import logging
|
import logging
|
||||||
@@ -86,6 +91,13 @@ class SaveImageLM:
|
|||||||
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.",
|
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
"save_as_recipe": (
|
||||||
|
"BOOLEAN",
|
||||||
|
{
|
||||||
|
"default": False,
|
||||||
|
"tooltip": "Also saves each generated image as a LoRA Manager recipe.",
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"id": "UNIQUE_ID",
|
"id": "UNIQUE_ID",
|
||||||
@@ -346,6 +358,203 @@ class SaveImageLM:
|
|||||||
|
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_cached_model_by_name(scanner, name):
|
||||||
|
cache = getattr(scanner, "_cache", None)
|
||||||
|
if cache is None or not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
name,
|
||||||
|
os.path.basename(name),
|
||||||
|
os.path.splitext(os.path.basename(name))[0],
|
||||||
|
]
|
||||||
|
for model in getattr(cache, "raw_data", []):
|
||||||
|
file_name = model.get("file_name")
|
||||||
|
if file_name in candidates:
|
||||||
|
return model
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_recipe_loras(self, recipe_scanner, lora_stack):
|
||||||
|
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", lora_stack or "")
|
||||||
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
|
loras_data = []
|
||||||
|
base_model_counts = {}
|
||||||
|
|
||||||
|
for name, strength in lora_matches:
|
||||||
|
lora_info = self._get_cached_model_by_name(lora_scanner, name)
|
||||||
|
civitai = (lora_info or {}).get("civitai") or {}
|
||||||
|
civitai_model = civitai.get("model") or {}
|
||||||
|
try:
|
||||||
|
parsed_strength = float(strength)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed_strength = 1.0
|
||||||
|
|
||||||
|
loras_data.append(
|
||||||
|
{
|
||||||
|
"file_name": name,
|
||||||
|
"strength": parsed_strength,
|
||||||
|
"hash": ((lora_info or {}).get("sha256") or "").lower(),
|
||||||
|
"modelVersionId": civitai.get("id", 0),
|
||||||
|
"modelName": civitai_model.get("name", name) if lora_info else "",
|
||||||
|
"modelVersionName": civitai.get("name", "") if lora_info else "",
|
||||||
|
"isDeleted": False,
|
||||||
|
"exclude": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
base_model = (lora_info or {}).get("base_model")
|
||||||
|
if base_model:
|
||||||
|
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||||
|
|
||||||
|
return lora_matches, loras_data, base_model_counts
|
||||||
|
|
||||||
|
def _build_recipe_checkpoint(self, recipe_scanner, checkpoint_raw):
|
||||||
|
if not isinstance(checkpoint_raw, str) or not checkpoint_raw.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
checkpoint_name = checkpoint_raw.strip()
|
||||||
|
file_name = os.path.splitext(os.path.basename(checkpoint_name))[0]
|
||||||
|
checkpoint_scanner = getattr(recipe_scanner, "_checkpoint_scanner", None)
|
||||||
|
checkpoint_info = self._get_cached_model_by_name(
|
||||||
|
checkpoint_scanner, checkpoint_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not checkpoint_info:
|
||||||
|
return {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"name": checkpoint_name,
|
||||||
|
"file_name": file_name,
|
||||||
|
"hash": self.get_checkpoint_hash(checkpoint_name) or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
civitai = checkpoint_info.get("civitai") or {}
|
||||||
|
civitai_model = civitai.get("model") or {}
|
||||||
|
file_path = checkpoint_info.get("file_path") or checkpoint_info.get("path") or ""
|
||||||
|
cached_file_name = (
|
||||||
|
checkpoint_info.get("file_name")
|
||||||
|
or (os.path.splitext(os.path.basename(file_path))[0] if file_path else "")
|
||||||
|
or file_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelId": civitai_model.get("id", 0),
|
||||||
|
"modelVersionId": civitai.get("id", 0),
|
||||||
|
"name": civitai_model.get("name")
|
||||||
|
or checkpoint_info.get("model_name")
|
||||||
|
or checkpoint_name,
|
||||||
|
"version": civitai.get("name", ""),
|
||||||
|
"hash": (
|
||||||
|
checkpoint_info.get("sha256") or checkpoint_info.get("hash") or ""
|
||||||
|
).lower(),
|
||||||
|
"file_name": cached_file_name,
|
||||||
|
"modelName": civitai_model.get("name", ""),
|
||||||
|
"modelVersionName": civitai.get("name", ""),
|
||||||
|
"baseModel": checkpoint_info.get("base_model")
|
||||||
|
or civitai.get("baseModel", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _derive_recipe_name(lora_matches):
|
||||||
|
recipe_name_parts = [
|
||||||
|
f"{name.strip()}-{float(strength):.2f}" for name, strength in lora_matches[:3]
|
||||||
|
]
|
||||||
|
return "_".join(recipe_name_parts) or "recipe"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_recipe_cache(recipe_scanner, recipe_data, json_path):
|
||||||
|
cache = getattr(recipe_scanner, "_cache", None)
|
||||||
|
if cache is not None:
|
||||||
|
cache.raw_data.append(recipe_data)
|
||||||
|
cache.sorted_by_name = sorted(
|
||||||
|
cache.raw_data, key=lambda item: item.get("title", "").lower()
|
||||||
|
)
|
||||||
|
cache.sorted_by_date = sorted(
|
||||||
|
cache.raw_data,
|
||||||
|
key=lambda item: (
|
||||||
|
item.get("modified", item.get("created_date", 0)),
|
||||||
|
item.get("file_path", ""),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
recipe_scanner._update_folder_metadata(cache)
|
||||||
|
recipe_scanner._update_fts_index_for_recipe(recipe_data, "add")
|
||||||
|
|
||||||
|
recipe_id = str(recipe_data.get("id", ""))
|
||||||
|
if recipe_id:
|
||||||
|
recipe_scanner._json_path_map[recipe_id] = json_path
|
||||||
|
persistent_cache = getattr(recipe_scanner, "_persistent_cache", None)
|
||||||
|
if persistent_cache:
|
||||||
|
persistent_cache.update_recipe(recipe_data, json_path)
|
||||||
|
|
||||||
|
def _save_image_as_recipe(self, file_path, metadata_dict):
|
||||||
|
if not metadata_dict:
|
||||||
|
raise ValueError("No generation metadata found")
|
||||||
|
|
||||||
|
recipe_scanner = ServiceRegistry.get_service_sync("recipe_scanner")
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipes_dir = recipe_scanner.recipes_dir
|
||||||
|
if not recipes_dir:
|
||||||
|
raise RuntimeError("Recipes directory unavailable")
|
||||||
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = str(uuid.uuid4())
|
||||||
|
optimized_image, extension = ExifUtils.optimize_image(
|
||||||
|
image_data=file_path,
|
||||||
|
target_width=CARD_PREVIEW_WIDTH,
|
||||||
|
format="webp",
|
||||||
|
quality=85,
|
||||||
|
preserve_metadata=True,
|
||||||
|
)
|
||||||
|
image_path = os.path.normpath(os.path.join(recipes_dir, f"{recipe_id}{extension}"))
|
||||||
|
with open(image_path, "wb") as file_obj:
|
||||||
|
file_obj.write(optimized_image)
|
||||||
|
|
||||||
|
lora_stack = metadata_dict.get("loras", "")
|
||||||
|
lora_matches, loras_data, base_model_counts = self._build_recipe_loras(
|
||||||
|
recipe_scanner, lora_stack
|
||||||
|
)
|
||||||
|
checkpoint_entry = self._build_recipe_checkpoint(
|
||||||
|
recipe_scanner, metadata_dict.get("checkpoint")
|
||||||
|
)
|
||||||
|
most_common_base_model = (
|
||||||
|
max(base_model_counts.items(), key=lambda item: item[1])[0]
|
||||||
|
if base_model_counts
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
current_time = time.time()
|
||||||
|
recipe_data = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": image_path,
|
||||||
|
"title": self._derive_recipe_name(lora_matches),
|
||||||
|
"modified": current_time,
|
||||||
|
"created_date": current_time,
|
||||||
|
"base_model": most_common_base_model
|
||||||
|
or (checkpoint_entry or {}).get("baseModel", ""),
|
||||||
|
"loras": loras_data,
|
||||||
|
"gen_params": {
|
||||||
|
key: value
|
||||||
|
for key, value in metadata_dict.items()
|
||||||
|
if key not in ["checkpoint", "loras"]
|
||||||
|
},
|
||||||
|
"loras_stack": lora_stack,
|
||||||
|
"fingerprint": calculate_recipe_fingerprint(loras_data),
|
||||||
|
}
|
||||||
|
if checkpoint_entry:
|
||||||
|
recipe_data["checkpoint"] = checkpoint_entry
|
||||||
|
|
||||||
|
json_path = os.path.normpath(
|
||||||
|
os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
|
)
|
||||||
|
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||||
|
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||||
|
self._sync_recipe_cache(recipe_scanner, recipe_data, json_path)
|
||||||
|
|
||||||
def save_images(
|
def save_images(
|
||||||
self,
|
self,
|
||||||
images,
|
images,
|
||||||
@@ -359,6 +568,7 @@ class SaveImageLM:
|
|||||||
embed_workflow=False,
|
embed_workflow=False,
|
||||||
save_with_metadata=True,
|
save_with_metadata=True,
|
||||||
add_counter_to_filename=True,
|
add_counter_to_filename=True,
|
||||||
|
save_as_recipe=False,
|
||||||
):
|
):
|
||||||
"""Save images with metadata"""
|
"""Save images with metadata"""
|
||||||
results = []
|
results = []
|
||||||
@@ -477,6 +687,14 @@ class SaveImageLM:
|
|||||||
|
|
||||||
img.save(file_path, format="WEBP", **save_kwargs)
|
img.save(file_path, format="WEBP", **save_kwargs)
|
||||||
|
|
||||||
|
if save_as_recipe:
|
||||||
|
try:
|
||||||
|
self._save_image_as_recipe(file_path, metadata_dict)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to save image as recipe: %s", e, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||||
)
|
)
|
||||||
@@ -499,6 +717,7 @@ class SaveImageLM:
|
|||||||
embed_workflow=False,
|
embed_workflow=False,
|
||||||
save_with_metadata=True,
|
save_with_metadata=True,
|
||||||
add_counter_to_filename=True,
|
add_counter_to_filename=True,
|
||||||
|
save_as_recipe=False,
|
||||||
):
|
):
|
||||||
"""Process and save image with metadata"""
|
"""Process and save image with metadata"""
|
||||||
# Make sure the output directory exists
|
# Make sure the output directory exists
|
||||||
@@ -527,6 +746,7 @@ class SaveImageLM:
|
|||||||
embed_workflow,
|
embed_workflow,
|
||||||
save_with_metadata,
|
save_with_metadata,
|
||||||
add_counter_to_filename,
|
add_counter_to_filename,
|
||||||
|
save_as_recipe,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -44,11 +44,29 @@ import folder_paths # type: ignore
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_lora_syntax_format():
|
||||||
|
try:
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
return get_settings_manager().get("lora_syntax_format", "legacy")
|
||||||
|
except Exception:
|
||||||
|
return "legacy"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_lora_syntax_format(name):
|
||||||
|
fmt = get_lora_syntax_format()
|
||||||
|
if fmt == "legacy":
|
||||||
|
return name.replace("\\", "/").rstrip("/").split("/")[-1]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
def extract_lora_name(lora_path):
|
def extract_lora_name(lora_path):
|
||||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
normalized = lora_path.replace("\\", "/")
|
||||||
# Get the basename without extension
|
basename = os.path.basename(normalized)
|
||||||
basename = os.path.basename(lora_path)
|
name_no_ext = os.path.splitext(basename)[0]
|
||||||
return os.path.splitext(basename)[0]
|
dirname = os.path.dirname(normalized)
|
||||||
|
if dirname and dirname not in (".", "/") and not normalized.startswith("/"):
|
||||||
|
return apply_lora_syntax_format(f"{dirname}/{name_no_ext}")
|
||||||
|
return apply_lora_syntax_format(name_no_ext)
|
||||||
|
|
||||||
|
|
||||||
def get_loras_list(kwargs):
|
def get_loras_list(kwargs):
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
class WanVideoLoraSelectLM:
|
class WanVideoLoraSelectLM:
|
||||||
NAME = "WanVideo Lora Select (LoraManager)"
|
NAME = "WanVideo Lora Select (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/stackers"
|
CATEGORY = "Lora Manager/stackers"
|
||||||
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
|
|||||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||||
|
|
||||||
# Get lora path and trigger words
|
# Get lora path and trigger words
|
||||||
lora_path, trigger_words = get_lora_info(lora_name)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
|
||||||
# Create lora item for WanVideo format
|
# Create lora item for WanVideo format
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import any_type
|
from .utils import any_type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 初始化日志记录器
|
# 初始化日志记录器
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
# 定义新节点的类
|
# 定义新节点的类
|
||||||
class WanVideoLoraTextSelectLM:
|
class WanVideoLoraTextSelectLM:
|
||||||
# 节点在UI中显示的名称
|
# 节点在UI中显示的名称
|
||||||
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
|
||||||
|
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
from typing import Dict, List, Any, Optional, Tuple
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,9 +58,52 @@ class RecipeMetadataParser(ABC):
|
|||||||
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
|
|
||||||
if not civitai_info or error_msg == "Model not found":
|
if not civitai_info or error_msg == "Model not found":
|
||||||
# Model not found or deleted
|
# CivitAI may fail to resolve a hash that is still being
|
||||||
lora_entry['isDeleted'] = True
|
# computed (known CivitAI issue). Before marking as deleted,
|
||||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
# try to reconcile with a local model that has the same
|
||||||
|
# filename and matching AutoV3 hash.
|
||||||
|
reconciled = False
|
||||||
|
file_name = lora_entry.get("file_name")
|
||||||
|
if file_name and recipe_scanner and hash_value:
|
||||||
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
|
if lora_scanner:
|
||||||
|
try:
|
||||||
|
# Local import to avoid circular dependency:
|
||||||
|
# base.py → file_utils → settings_manager → ...
|
||||||
|
# → recipe_scanner → enrichment → base.py
|
||||||
|
from ..utils.file_utils import calculate_autov3 # fmt: skip
|
||||||
|
cache = await lora_scanner.get_cached_data()
|
||||||
|
for item in getattr(cache, "raw_data", []):
|
||||||
|
if item.get("file_name") == file_name:
|
||||||
|
local_path = item.get("file_path")
|
||||||
|
if local_path and os.path.exists(local_path):
|
||||||
|
local_autov3 = calculate_autov3(local_path)
|
||||||
|
if local_autov3 and local_autov3 == hash_value:
|
||||||
|
lora_entry["existsLocally"] = True
|
||||||
|
lora_entry["localPath"] = local_path
|
||||||
|
lora_entry["hash"] = item.get("sha256", hash_value)
|
||||||
|
if "preview_url" in item:
|
||||||
|
lora_entry["thumbnailUrl"] = config.get_preview_static_url(item["preview_url"])
|
||||||
|
civ = item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
if civ.get("id") is not None:
|
||||||
|
lora_entry["id"] = civ["id"]
|
||||||
|
if civ.get("modelId") is not None:
|
||||||
|
lora_entry["modelId"] = civ["modelId"]
|
||||||
|
if civ.get("name"):
|
||||||
|
lora_entry["version"] = civ["name"]
|
||||||
|
# model_name is the CivitAI model display
|
||||||
|
# name stored directly in the cache column.
|
||||||
|
cached_model_name = item.get("model_name")
|
||||||
|
if cached_model_name:
|
||||||
|
lora_entry["name"] = cached_model_name
|
||||||
|
reconciled = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not reconciled:
|
||||||
|
lora_entry['isDeleted'] = True
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
return lora_entry
|
return lora_entry
|
||||||
|
|
||||||
# Get model type and validate
|
# Get model type and validate
|
||||||
@@ -173,6 +216,20 @@ class RecipeMetadataParser(ABC):
|
|||||||
checkpoint['isDeleted'] = True
|
checkpoint['isDeleted'] = True
|
||||||
return checkpoint
|
return checkpoint
|
||||||
|
|
||||||
|
# Validate that the model type is actually a checkpoint.
|
||||||
|
# Unlike populate_lora_from_civitai which has this check,
|
||||||
|
# this function was missing type validation — allowing LoRA
|
||||||
|
# version data to be saved as the recipe's checkpoint when the
|
||||||
|
# wrong version ID was passed downstream (fixed in v2.7+).
|
||||||
|
model_type = civitai_data.get('model', {}).get('type', '').lower()
|
||||||
|
if model_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||||
|
logger.warning(
|
||||||
|
f"Cannot populate checkpoint: model version {civitai_data.get('id')} "
|
||||||
|
f"has type '{model_type}', expected one of {VALID_CHECKPOINT_SUB_TYPES}. "
|
||||||
|
f"Skipping checkpoint enrichment."
|
||||||
|
)
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
checkpoint['name'] = civitai_data['model']['name']
|
checkpoint['name'] = civitai_data['model']['name']
|
||||||
|
|
||||||
|
|||||||
@@ -16,55 +16,65 @@ class RecipeEnricher:
|
|||||||
async def enrich_recipe(
|
async def enrich_recipe(
|
||||||
recipe: Dict[str, Any],
|
recipe: Dict[str, Any],
|
||||||
civitai_client: Any,
|
civitai_client: Any,
|
||||||
request_params: Optional[Dict[str, Any]] = None
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_model_version_id: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||||
civitai_client: Authenticated Civitai client instance.
|
civitai_client: Authenticated Civitai client instance.
|
||||||
request_params: (Optional) Parameters from a user request (e.g. import).
|
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||||
|
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
|
||||||
|
get_image_info, avoiding a duplicate API call.
|
||||||
|
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the recipe was modified, False otherwise.
|
bool: True if the recipe was modified, False otherwise.
|
||||||
"""
|
"""
|
||||||
updated = False
|
updated = False
|
||||||
gen_params = recipe.get("gen_params", {})
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
|
||||||
# 1. Fetch Civitai Info if available
|
# 1. Obtain Civitai metadata
|
||||||
civitai_meta = None
|
civitai_meta = None
|
||||||
model_version_id = None
|
model_version_id = prefetched_model_version_id
|
||||||
|
|
||||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
source_path = recipe.get("source_path", "")
|
||||||
|
|
||||||
# Check if it's a Civitai image URL
|
if prefetched_civitai_meta_raw is not None:
|
||||||
image_id = extract_civitai_image_id(str(source_url))
|
raw_meta = prefetched_civitai_meta_raw
|
||||||
if image_id:
|
if isinstance(raw_meta, dict):
|
||||||
try:
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
image_info = await civitai_client.get_image_info(
|
civitai_meta = raw_meta["meta"]
|
||||||
image_id, source_url=str(source_url)
|
else:
|
||||||
)
|
civitai_meta = raw_meta
|
||||||
if image_info:
|
else:
|
||||||
# Handle nested meta often found in Civitai API responses
|
image_id = extract_civitai_image_id(str(source_path))
|
||||||
raw_meta = image_info.get("meta")
|
if image_id:
|
||||||
if isinstance(raw_meta, dict):
|
try:
|
||||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
image_info = await civitai_client.get_image_info(
|
||||||
civitai_meta = raw_meta["meta"]
|
image_id, source_url=str(source_path)
|
||||||
else:
|
)
|
||||||
civitai_meta = raw_meta
|
if image_info:
|
||||||
|
raw_meta = image_info.get("meta")
|
||||||
model_version_id = image_info.get("modelVersionId")
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
# If not at top level, check resources in meta
|
civitai_meta = raw_meta["meta"]
|
||||||
if not model_version_id and civitai_meta:
|
else:
|
||||||
resources = civitai_meta.get("civitaiResources", [])
|
civitai_meta = raw_meta
|
||||||
for res in resources:
|
|
||||||
if res.get("type") == "checkpoint":
|
model_version_id = image_info.get("modelVersionId")
|
||||||
model_version_id = res.get("modelVersionId")
|
except Exception as e:
|
||||||
break
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
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
|
||||||
|
|
||||||
# 2. Merge Parameters
|
# 2. Merge Parameters
|
||||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
@@ -180,27 +190,42 @@ class RecipeEnricher:
|
|||||||
existing_cp = recipe.get("checkpoint")
|
existing_cp = recipe.get("checkpoint")
|
||||||
if existing_cp is None:
|
if existing_cp is None:
|
||||||
existing_cp = {}
|
existing_cp = {}
|
||||||
|
|
||||||
|
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
|
||||||
|
# (populate may reject non-checkpoint types and lose this data)
|
||||||
|
base_model_from_civitai: str = ""
|
||||||
|
if isinstance(civitai_info, dict):
|
||||||
|
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
|
||||||
|
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
|
||||||
|
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
|
||||||
|
|
||||||
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
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
|
|
||||||
|
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
|
||||||
current_base_model = recipe.get("base_model")
|
current_base_model = recipe.get("base_model")
|
||||||
resolved_base_model = checkpoint_data.get("baseModel")
|
resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
|
||||||
if resolved_base_model:
|
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"]
|
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:
|
if is_generic and resolved_base_model != current_base_model:
|
||||||
recipe["base_model"] = resolved_base_model
|
recipe["base_model"] = resolved_base_model
|
||||||
|
|
||||||
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
# 2. Only format and save checkpoint if it has real data (not just type after type rejection)
|
||||||
formatted_checkpoint = {
|
has_checkpoint_data = any([
|
||||||
"type": "checkpoint",
|
checkpoint_data.get("modelId"),
|
||||||
"modelId": checkpoint_data.get("modelId"),
|
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
checkpoint_data.get("name"),
|
||||||
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
checkpoint_data.get("version"),
|
||||||
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
])
|
||||||
}
|
if has_checkpoint_data:
|
||||||
# Remove None values
|
formatted_checkpoint = {
|
||||||
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
"type": "checkpoint",
|
||||||
|
"modelId": checkpoint_data.get("modelId"),
|
||||||
|
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
|
"modelName": checkpoint_data.get("name"),
|
||||||
|
"modelVersionName": checkpoint_data.get("version"),
|
||||||
|
}
|
||||||
|
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Fallback to name extraction if we don't already have one
|
# Fallback to name extraction if we don't already have one
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Dict, Any, Union
|
|||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
from ...config import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,7 +74,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def parse_metadata( # type: ignore[override]
|
async def parse_metadata( # type: ignore[override]
|
||||||
self, user_comment, recipe_scanner=None, civitai_client=None
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
||||||
|
local_cache: dict[str, Any] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Civitai image format
|
"""Parse metadata from Civitai image format
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
user_comment: The metadata from the image (dict)
|
user_comment: The metadata from the image (dict)
|
||||||
recipe_scanner: Optional recipe scanner service
|
recipe_scanner: Optional recipe scanner service
|
||||||
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
||||||
|
local_cache: Optional dict mapping sha256/autov3 hash → scanner cache item.
|
||||||
|
When provided, matching models skip CivitAI API calls.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing parsed recipe data
|
Dict containing parsed recipe data
|
||||||
@@ -185,8 +189,77 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Process standard resources array
|
# Process standard resources array
|
||||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||||
for resource in metadata["resources"]:
|
for resource in metadata["resources"]:
|
||||||
|
resource_type = resource.get("type", "lora")
|
||||||
|
|
||||||
|
# Track resources with type "model" — these are checkpoint models.
|
||||||
|
# The resources array is the most reliable source for checkpoint
|
||||||
|
# identification because it has an explicit type field and hash,
|
||||||
|
# unlike modelVersionIds which is a flat list with no type info.
|
||||||
|
if resource_type == "model":
|
||||||
|
checkpoint_entry = {
|
||||||
|
"id": 0,
|
||||||
|
"modelId": 0,
|
||||||
|
"name": resource.get("name", "Unknown Model"),
|
||||||
|
"version": "",
|
||||||
|
"type": resource.get("type", "model"),
|
||||||
|
"existsLocally": False,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": resource.get("name", ""),
|
||||||
|
"hash": resource.get("hash", "") or "",
|
||||||
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||||
|
"baseModel": "",
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to look up base model from the checkpoint hash
|
||||||
|
cp_hash = checkpoint_entry.get("hash")
|
||||||
|
if cp_hash and metadata_provider:
|
||||||
|
local_cached = local_cache.get(cp_hash) if local_cache else None
|
||||||
|
if local_cached:
|
||||||
|
self._populate_entry_from_cache(
|
||||||
|
checkpoint_entry, local_cached
|
||||||
|
)
|
||||||
|
bm = checkpoint_entry.get("baseModel", "")
|
||||||
|
if bm and not result["base_model"]:
|
||||||
|
result["base_model"] = bm
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
civitai_info = (
|
||||||
|
await metadata_provider.get_model_by_hash(
|
||||||
|
cp_hash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
civitai_data, error_msg = (
|
||||||
|
(civitai_info, None)
|
||||||
|
if not isinstance(civitai_info, tuple)
|
||||||
|
else civitai_info
|
||||||
|
)
|
||||||
|
if civitai_data and error_msg != "Model not found":
|
||||||
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
|
checkpoint_entry['name'] = civitai_data['model']['name']
|
||||||
|
checkpoint_entry['id'] = civitai_data.get('id', 0)
|
||||||
|
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
|
||||||
|
if 'name' in civitai_data:
|
||||||
|
checkpoint_entry['version'] = civitai_data['name']
|
||||||
|
base_model = civitai_data.get('baseModel', '')
|
||||||
|
if base_model:
|
||||||
|
checkpoint_entry['baseModel'] = base_model
|
||||||
|
if not result['base_model']:
|
||||||
|
result['base_model'] = base_model
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching checkpoint info for hash "
|
||||||
|
f"{cp_hash}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["model"] is None:
|
||||||
|
result["model"] = checkpoint_entry
|
||||||
|
continue
|
||||||
|
|
||||||
# Modified to process resources without a type field as potential LoRAs
|
# Modified to process resources without a type field as potential LoRAs
|
||||||
if resource.get("type", "lora") == "lora":
|
if resource_type == "lora":
|
||||||
lora_hash = resource.get("hash", "")
|
lora_hash = resource.get("hash", "")
|
||||||
|
|
||||||
# Try to get hash from the hashes field if not present in resource
|
# Try to get hash from the hashes field if not present in resource
|
||||||
@@ -220,34 +293,45 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_entry["hash"] and metadata_provider:
|
if lora_hash and metadata_provider:
|
||||||
try:
|
local_cached = local_cache.get(lora_hash) if local_cache else None
|
||||||
civitai_info = (
|
if local_cached:
|
||||||
await metadata_provider.get_model_by_hash(lora_hash)
|
self._populate_entry_from_cache(
|
||||||
|
lora_entry, local_cached
|
||||||
)
|
)
|
||||||
|
# Track by version ID for deduplication
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
if lora_entry.get("id"):
|
||||||
lora_entry,
|
|
||||||
civitai_info,
|
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts,
|
|
||||||
lora_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
if populated_entry is None:
|
|
||||||
continue # Skip invalid LoRA types
|
|
||||||
|
|
||||||
lora_entry = populated_entry
|
|
||||||
|
|
||||||
# If we have a version ID from Civitai, track it for deduplication
|
|
||||||
if "id" in lora_entry and lora_entry["id"]:
|
|
||||||
added_loras[str(lora_entry["id"])] = len(
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
result["loras"]
|
result["loras"]
|
||||||
)
|
)
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(
|
try:
|
||||||
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
civitai_info = (
|
||||||
)
|
await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
lora_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
|
||||||
|
# If we have a version ID from Civitai, track it for deduplication
|
||||||
|
if "id" in lora_entry and lora_entry["id"]:
|
||||||
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
|
result["loras"]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Track by hash if we have it
|
# Track by hash if we have it
|
||||||
if lora_hash:
|
if lora_hash:
|
||||||
@@ -625,3 +709,41 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
||||||
return {"error": str(e), "loras": []}
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _populate_entry_from_cache(
|
||||||
|
entry: dict[str, Any],
|
||||||
|
cache_item: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Fill a lora/checkpoint entry from a scanner cache item.
|
||||||
|
|
||||||
|
Avoids CivitAI API calls for models that exist locally.
|
||||||
|
Mirrors the population logic in
|
||||||
|
``RecipeMetadataParser.populate_lora_from_civitai()`` but operates
|
||||||
|
entirely on cached data.
|
||||||
|
"""
|
||||||
|
civ = cache_item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
if civ.get("id") is not None:
|
||||||
|
entry["id"] = civ["id"]
|
||||||
|
if civ.get("modelId") is not None:
|
||||||
|
entry["modelId"] = civ["modelId"]
|
||||||
|
if civ.get("name"):
|
||||||
|
entry["version"] = civ["name"]
|
||||||
|
cached_name = cache_item.get("model_name")
|
||||||
|
if cached_name:
|
||||||
|
entry["name"] = cached_name
|
||||||
|
entry["existsLocally"] = True
|
||||||
|
local_path = cache_item.get("file_path")
|
||||||
|
if local_path:
|
||||||
|
entry["localPath"] = local_path
|
||||||
|
sha256 = cache_item.get("sha256")
|
||||||
|
if sha256:
|
||||||
|
entry["hash"] = sha256
|
||||||
|
if "preview_url" in cache_item:
|
||||||
|
entry["thumbnailUrl"] = config.get_preview_static_url(
|
||||||
|
cache_item["preview_url"]
|
||||||
|
)
|
||||||
|
base_model = cache_item.get("base_model", "")
|
||||||
|
if base_model:
|
||||||
|
entry["baseModel"] = base_model
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
|
|||||||
|
|
||||||
def _find_model_file(self, files):
|
def _find_model_file(self, files):
|
||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||||
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None)
|
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
|
||||||
|
|
||||||
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
||||||
"""Expose handlers for subclasses or tests."""
|
"""Expose handlers for subclasses or tests."""
|
||||||
|
|||||||
@@ -33,15 +33,18 @@ from ...services.metadata_service import (
|
|||||||
update_metadata_providers,
|
update_metadata_providers,
|
||||||
)
|
)
|
||||||
from ...services.service_registry import ServiceRegistry
|
from ...services.service_registry import ServiceRegistry
|
||||||
|
from ...services.model_lifecycle_service import delete_model_artifacts
|
||||||
from ...services.settings_manager import get_settings_manager
|
from ...services.settings_manager import get_settings_manager
|
||||||
from ...services.websocket_manager import ws_manager
|
from ...services.websocket_manager import ws_manager
|
||||||
from ...services.downloader import get_downloader
|
from ...services.downloader import get_downloader
|
||||||
from ...services.errors import ResourceNotFoundError
|
from ...services.errors import ResourceNotFoundError
|
||||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
|
from ...utils.models import BaseModelMetadata
|
||||||
from ...utils.constants import (
|
from ...utils.constants import (
|
||||||
CIVITAI_USER_MODEL_TYPES,
|
CIVITAI_USER_MODEL_TYPES,
|
||||||
DEFAULT_NODE_COLOR,
|
DEFAULT_NODE_COLOR,
|
||||||
NODE_TYPES,
|
NODE_TYPES,
|
||||||
|
PREVIEW_EXTENSIONS,
|
||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
@@ -617,6 +620,7 @@ class DoctorHandler:
|
|||||||
diagnostics = [
|
diagnostics = [
|
||||||
await self._check_civitai_api_key(),
|
await self._check_civitai_api_key(),
|
||||||
await self._check_cache_health(),
|
await self._check_cache_health(),
|
||||||
|
await self._check_filename_conflicts(),
|
||||||
self._check_ui_version(client_version, app_version),
|
self._check_ui_version(client_version, app_version),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -681,6 +685,148 @@ class DoctorHandler:
|
|||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return web.json_response({"success": True, "renamed": [], "count": 0})
|
||||||
|
|
||||||
|
renamed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is None:
|
||||||
|
continue
|
||||||
|
duplicates = {
|
||||||
|
filename: list(paths)
|
||||||
|
for filename, paths in hash_index.get_duplicate_filenames().items()
|
||||||
|
}
|
||||||
|
if not duplicates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
path_to_model = {m["file_path"]: m for m in cache.raw_data}
|
||||||
|
|
||||||
|
used_basenames: set[str] = set()
|
||||||
|
for paths in duplicates.values():
|
||||||
|
if paths:
|
||||||
|
used_basenames.add(
|
||||||
|
os.path.splitext(os.path.basename(paths[0]))[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
for idx, path in enumerate(paths):
|
||||||
|
if idx == 0:
|
||||||
|
continue
|
||||||
|
dirname = os.path.dirname(path)
|
||||||
|
base_name = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
ext = os.path.splitext(path)[1]
|
||||||
|
if not ext:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_data = path_to_model.get(path)
|
||||||
|
sha256 = (
|
||||||
|
model_data.get("sha256", "") if model_data else ""
|
||||||
|
)
|
||||||
|
hash_provider = (
|
||||||
|
lambda s=sha256: s if s else "0000"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_filename = (
|
||||||
|
BaseModelMetadata.generate_unique_filename(
|
||||||
|
dirname,
|
||||||
|
base_name,
|
||||||
|
ext,
|
||||||
|
hash_provider=hash_provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_base = os.path.splitext(new_filename)[0]
|
||||||
|
counter = 1
|
||||||
|
original_base = candidate_base
|
||||||
|
while candidate_base in used_basenames:
|
||||||
|
candidate_base = f"{original_base}-{counter}"
|
||||||
|
new_filename = f"{candidate_base}{ext}"
|
||||||
|
counter += 1
|
||||||
|
used_basenames.add(candidate_base)
|
||||||
|
|
||||||
|
new_path = os.path.join(dirname, new_filename)
|
||||||
|
|
||||||
|
if new_filename == os.path.basename(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_base_no_ext = os.path.splitext(path)[0]
|
||||||
|
new_base_no_ext = (
|
||||||
|
os.path.splitext(new_path)[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
os.rename(path, new_path)
|
||||||
|
|
||||||
|
for suffix in (".metadata.json", ".civitai.info"):
|
||||||
|
old_sidecar = old_base_no_ext + suffix
|
||||||
|
new_sidecar = new_base_no_ext + suffix
|
||||||
|
if os.path.exists(old_sidecar):
|
||||||
|
os.rename(old_sidecar, new_sidecar)
|
||||||
|
|
||||||
|
for preview_ext in PREVIEW_EXTENSIONS:
|
||||||
|
old_preview = old_base_no_ext + preview_ext
|
||||||
|
new_preview = new_base_no_ext + preview_ext
|
||||||
|
if os.path.exists(old_preview):
|
||||||
|
os.rename(old_preview, new_preview)
|
||||||
|
|
||||||
|
entry = path_to_model.get(path)
|
||||||
|
if entry:
|
||||||
|
entry = dict(entry)
|
||||||
|
entry["file_name"] = os.path.splitext(new_filename)[0]
|
||||||
|
if entry.get("preview_url"):
|
||||||
|
old_preview_url = entry["preview_url"].replace("\\", "/")
|
||||||
|
preview_ext = os.path.splitext(old_preview_url)[1]
|
||||||
|
if preview_ext:
|
||||||
|
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
|
||||||
|
await scanner.update_single_model_cache(
|
||||||
|
path, new_path, entry
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Resolved duplicate filename '%s': "
|
||||||
|
"renamed '%s' to '%s'",
|
||||||
|
filename,
|
||||||
|
path,
|
||||||
|
new_path,
|
||||||
|
)
|
||||||
|
renamed.append({
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"old_path": path,
|
||||||
|
"new_path": new_path,
|
||||||
|
"new_filename": new_filename,
|
||||||
|
})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error(
|
||||||
|
"Failed to resolve filename conflicts for %s: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"renamed": renamed,
|
||||||
|
"count": len(renamed),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error resolving filename conflicts: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
@@ -846,6 +992,111 @@ class DoctorHandler:
|
|||||||
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
||||||
|
# When full path syntax is active, duplicate filenames across subfolders
|
||||||
|
# are not ambiguous (<lora:subfolder/name:strength>), so skip the check.
|
||||||
|
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "Full path syntax is active — duplicate filenames across folders are not ambiguous.",
|
||||||
|
"details": [],
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
all_conflicts: list[dict[str, Any]] = []
|
||||||
|
total_conflict_groups = 0
|
||||||
|
total_conflict_files = 0
|
||||||
|
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
# Duplicate filename detection targets LoRAs which use basename-only
|
||||||
|
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||||
|
# models via relative paths with extensions, so conflicts there would
|
||||||
|
# be false positives.
|
||||||
|
if model_type != "lora":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is None:
|
||||||
|
continue
|
||||||
|
duplicates = hash_index.get_duplicate_filenames()
|
||||||
|
if not duplicates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_conflict_groups += len(duplicates)
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
total_conflict_files += len(paths)
|
||||||
|
all_conflicts.append({
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"paths": paths,
|
||||||
|
})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error(
|
||||||
|
"Doctor filename conflict check failed for %s: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all_conflicts:
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "No duplicate filenames found across model directories.",
|
||||||
|
"details": [],
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"{total_conflict_groups} filename(s) shared by "
|
||||||
|
f"{total_conflict_files} files across your library. "
|
||||||
|
f"This causes ambiguity when loading LoRAs by name."
|
||||||
|
)
|
||||||
|
details: list[str | dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"conflict_groups": total_conflict_groups,
|
||||||
|
"total_conflict_files": total_conflict_files,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Show at most 5 conflict groups inline; note any remainder.
|
||||||
|
MAX_VISIBLE_CONFLICTS = 5
|
||||||
|
visible_conflicts = all_conflicts[:MAX_VISIBLE_CONFLICTS]
|
||||||
|
for conflict in visible_conflicts:
|
||||||
|
details.append(
|
||||||
|
f"'{conflict['filename']}' "
|
||||||
|
f"found in {len(conflict['paths'])} locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
hidden_count = len(all_conflicts) - MAX_VISIBLE_CONFLICTS
|
||||||
|
if hidden_count > 0:
|
||||||
|
details.append(
|
||||||
|
f"...and {hidden_count} more duplicate filename group(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": summary,
|
||||||
|
"details": details,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "resolve-filename-conflicts",
|
||||||
|
"label": "Resolve Conflicts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "open-settings-syntax-format",
|
||||||
|
"label": "Switch to Full Path Syntax",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
||||||
if client_version and client_version != app_version:
|
if client_version and client_version != app_version:
|
||||||
return {
|
return {
|
||||||
@@ -1576,29 +1827,33 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"exists": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
has_been_downloaded = False
|
has_been_downloaded = False
|
||||||
history_type = model_type
|
history_type = None
|
||||||
if history_type:
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
has_been_downloaded = await history_service.has_been_downloaded(
|
if await history_service.has_been_downloaded(
|
||||||
history_type,
|
candidate_type,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
)
|
):
|
||||||
else:
|
has_been_downloaded = True
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
history_type = candidate_type
|
||||||
if await history_service.has_been_downloaded(
|
break
|
||||||
candidate_type,
|
|
||||||
model_version_id,
|
|
||||||
):
|
|
||||||
has_been_downloaded = True
|
|
||||||
history_type = candidate_type
|
|
||||||
break
|
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"exists": exists,
|
"exists": False,
|
||||||
"modelType": model_type if exists else history_type,
|
"modelType": history_type,
|
||||||
"hasBeenDownloaded": has_been_downloaded,
|
"hasBeenDownloaded": has_been_downloaded,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1618,40 +1873,46 @@ class ModelLibraryHandler:
|
|||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
downloaded_version_ids = []
|
downloaded_version_ids = []
|
||||||
history_service = await self._get_download_history_service()
|
|
||||||
if lora_versions:
|
if lora_versions:
|
||||||
model_type = "lora"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(lora_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "lora",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif checkpoint_versions:
|
if checkpoint_versions:
|
||||||
model_type = "checkpoint"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(checkpoint_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "checkpoint",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif embedding_versions:
|
if embedding_versions:
|
||||||
model_type = "embedding"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(embedding_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "embedding",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
history_service = await self._get_download_history_service()
|
||||||
candidate_downloaded_version_ids = (
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
await history_service.get_downloaded_version_ids(
|
candidate_downloaded_version_ids = (
|
||||||
candidate_type,
|
await history_service.get_downloaded_version_ids(
|
||||||
model_id,
|
candidate_type,
|
||||||
)
|
model_id,
|
||||||
)
|
)
|
||||||
if candidate_downloaded_version_ids:
|
)
|
||||||
model_type = candidate_type
|
if candidate_downloaded_version_ids:
|
||||||
downloaded_version_ids = candidate_downloaded_version_ids
|
model_type = candidate_type
|
||||||
break
|
downloaded_version_ids = candidate_downloaded_version_ids
|
||||||
|
break
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1665,6 +1926,86 @@ class ModelLibraryHandler:
|
|||||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def check_models_exist(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_ids_raw = request.query.get("modelIds", "")
|
||||||
|
if not model_ids_raw:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_ids = model_ids_raw.split(",")
|
||||||
|
seen: set[int] = set()
|
||||||
|
model_ids: list[int] = []
|
||||||
|
for raw in raw_ids:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mid = int(stripped)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if mid not in seen:
|
||||||
|
seen.add(mid)
|
||||||
|
model_ids.append(mid)
|
||||||
|
|
||||||
|
if not model_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for model_id in model_ids:
|
||||||
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if lora_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "lora",
|
||||||
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if checkpoint_scanner:
|
||||||
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if checkpoint_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if embedding_scanner:
|
||||||
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if embedding_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "embedding",
|
||||||
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": None,
|
||||||
|
"versions": [],
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": results}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to check models existence: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_version_download_status(
|
async def get_model_version_download_status(
|
||||||
self, request: web.Request
|
self, request: web.Request
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
@@ -1759,7 +2100,7 @@ class ModelLibraryHandler:
|
|||||||
file_path=file_path if isinstance(file_path, str) else None,
|
file_path=file_path if isinstance(file_path, str) else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await history_service.mark_not_downloaded(model_type, model_version_id)
|
await history_service.mark_as_deleted(model_type, model_version_id)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1777,6 +2118,89 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def delete_model_version(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_version_id_str = request.query.get("modelVersionId")
|
||||||
|
if not model_version_id_str:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelVersionId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_version_id = int(model_version_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
found_type = None
|
||||||
|
file_path = None
|
||||||
|
found_cache = None
|
||||||
|
|
||||||
|
for model_type, scanner in (
|
||||||
|
("lora", lora_scanner),
|
||||||
|
("checkpoint", checkpoint_scanner),
|
||||||
|
("embedding", embedding_scanner),
|
||||||
|
):
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache and model_version_id in cache.version_index:
|
||||||
|
found_type = model_type
|
||||||
|
found_cache = cache
|
||||||
|
entry = cache.version_index[model_version_id]
|
||||||
|
file_path = entry.get("file_path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
base_name = os.path.basename(file_path)
|
||||||
|
file_name, extension = os.path.splitext(base_name)
|
||||||
|
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||||
|
|
||||||
|
if found_cache:
|
||||||
|
found_cache.raw_data = [
|
||||||
|
item
|
||||||
|
for item in found_cache.raw_data
|
||||||
|
if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
await found_cache.resort()
|
||||||
|
|
||||||
|
scanner_map = {
|
||||||
|
"lora": lora_scanner,
|
||||||
|
"checkpoint": checkpoint_scanner,
|
||||||
|
"embedding": embedding_scanner,
|
||||||
|
}
|
||||||
|
scanner = scanner_map.get(found_type)
|
||||||
|
if scanner:
|
||||||
|
persist = getattr(scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist):
|
||||||
|
await persist()
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
await history_service.mark_as_deleted(found_type, model_version_id)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": found_type,
|
||||||
|
"modelVersionId": model_version_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete model version: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
@@ -2796,6 +3220,7 @@ class MiscHandlerSet:
|
|||||||
"update_settings": self.settings.update_settings,
|
"update_settings": self.settings.update_settings,
|
||||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||||
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
||||||
|
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
|
||||||
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
||||||
"get_priority_tags": self.settings.get_priority_tags,
|
"get_priority_tags": self.settings.get_priority_tags,
|
||||||
"get_settings_libraries": self.settings.get_libraries,
|
"get_settings_libraries": self.settings.get_libraries,
|
||||||
@@ -2809,8 +3234,10 @@ class MiscHandlerSet:
|
|||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
|
"check_models_exist": self.model_library.check_models_exist,
|
||||||
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
||||||
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
||||||
|
"delete_model_version": self.model_library.delete_model_version,
|
||||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||||
|
|||||||
@@ -301,6 +301,15 @@ class ModelListingHandler:
|
|||||||
for tag in exclude_tags:
|
for tag in exclude_tags:
|
||||||
if tag:
|
if tag:
|
||||||
tag_filters[tag] = "exclude"
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
auto_tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("auto_tag_include", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("auto_tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
@@ -367,6 +376,7 @@ class ModelListingHandler:
|
|||||||
"fuzzy_search": fuzzy_search,
|
"fuzzy_search": fuzzy_search,
|
||||||
"base_models": base_models,
|
"base_models": base_models,
|
||||||
"tags": tag_filters,
|
"tags": tag_filters,
|
||||||
|
"auto_tags": auto_tag_filters,
|
||||||
"tag_logic": tag_logic,
|
"tag_logic": tag_logic,
|
||||||
"search_options": search_options,
|
"search_options": search_options,
|
||||||
"hash_filters": hash_filters,
|
"hash_filters": hash_filters,
|
||||||
@@ -778,7 +788,7 @@ class ModelManagementHandler:
|
|||||||
|
|
||||||
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
|
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
|
||||||
|
|
||||||
await self._metadata_sync.save_metadata_updates(
|
updated_metadata = await self._metadata_sync.save_metadata_updates(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
updates=metadata_updates,
|
updates=metadata_updates,
|
||||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||||
@@ -789,7 +799,12 @@ class ModelManagementHandler:
|
|||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
|
|
||||||
return web.json_response({"success": True})
|
from ...services.auto_tag_service import extract_auto_tags
|
||||||
|
auto_tags = extract_auto_tags(updated_metadata)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "auto_tags": auto_tags}
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
@@ -806,14 +821,16 @@ class ModelManagementHandler:
|
|||||||
if not isinstance(new_tags, list):
|
if not isinstance(new_tags, list):
|
||||||
return web.Response(text="Tags must be a list", status=400)
|
return web.Response(text="Tags must be a list", status=400)
|
||||||
|
|
||||||
tags = await self._tag_update_service.add_tags(
|
tags, auto_tags = await self._tag_update_service.add_tags(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
new_tags=new_tags,
|
new_tags=new_tags,
|
||||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||||
update_cache=self._service.scanner.update_single_model_cache,
|
update_cache=self._service.scanner.update_single_model_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.json_response({"success": True, "tags": tags})
|
return web.json_response(
|
||||||
|
{"success": True, "tags": tags, "auto_tags": auto_tags}
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
@@ -1160,6 +1177,12 @@ class ModelQueryHandler:
|
|||||||
|
|
||||||
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
|
settings = get_settings_manager()
|
||||||
|
if settings.get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "conflicts": [], "count": 0}
|
||||||
|
)
|
||||||
|
|
||||||
duplicates = self._service.find_duplicate_filenames()
|
duplicates = self._service.find_duplicate_filenames()
|
||||||
result = []
|
result = []
|
||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
@@ -1449,6 +1472,21 @@ class ModelDownloadHandler:
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text=str(exc))
|
return web.Response(status=500, text=str(exc))
|
||||||
|
|
||||||
|
async def skip_download_get(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
download_id = request.query.get("download_id")
|
||||||
|
if not download_id:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Download ID is required"}, status=400
|
||||||
|
)
|
||||||
|
result = await self._download_coordinator.skip_download(download_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error skipping download via GET: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def cancel_download_get(self, request: web.Request) -> web.Response:
|
async def cancel_download_get(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
download_id = request.query.get("download_id")
|
download_id = request.query.get("download_id")
|
||||||
@@ -1937,6 +1975,10 @@ class ModelUpdateHandler:
|
|||||||
if target_model_ids:
|
if target_model_ids:
|
||||||
target_model_ids = sorted(set(target_model_ids))
|
target_model_ids = sorted(set(target_model_ids))
|
||||||
|
|
||||||
|
folder_path: Optional[str] = payload.get("folder_path")
|
||||||
|
if folder_path is not None and not isinstance(folder_path, str):
|
||||||
|
folder_path = None
|
||||||
|
|
||||||
provider = await self._get_civitai_provider()
|
provider = await self._get_civitai_provider()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1951,6 +1993,7 @@ class ModelUpdateHandler:
|
|||||||
provider,
|
provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
target_model_ids=target_model_ids or None,
|
target_model_ids=target_model_ids or None,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1973,10 +2016,21 @@ class ModelUpdateHandler:
|
|||||||
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
hide_early_access = False
|
||||||
|
if self._settings is not None:
|
||||||
|
try:
|
||||||
|
hide_early_access = bool(
|
||||||
|
self._settings.get("hide_early_access_updates", False)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
serialized_records = []
|
serialized_records = []
|
||||||
for record in records.values():
|
for record in records.values():
|
||||||
has_update_fn = getattr(record, "has_update", None)
|
has_update_fn = getattr(record, "has_update", None)
|
||||||
if callable(has_update_fn) and has_update_fn():
|
if callable(has_update_fn) and has_update_fn(
|
||||||
|
hide_early_access=hide_early_access
|
||||||
|
):
|
||||||
serialized_records.append(self._serialize_record(record))
|
serialized_records.append(self._serialize_record(record))
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2423,6 +2477,7 @@ class ModelUpdateHandler:
|
|||||||
"shouldIgnore": version.should_ignore,
|
"shouldIgnore": version.should_ignore,
|
||||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||||
"isEarlyAccess": is_early_access,
|
"isEarlyAccess": is_early_access,
|
||||||
|
"usageControl": version.usage_control,
|
||||||
"filePath": context.get("file_path"),
|
"filePath": context.get("file_path"),
|
||||||
"fileName": context.get("file_name"),
|
"fileName": context.get("file_name"),
|
||||||
}
|
}
|
||||||
@@ -2537,6 +2592,7 @@ class ModelHandlerSet:
|
|||||||
"download_model": self.download.download_model,
|
"download_model": self.download.download_model,
|
||||||
"download_model_get": self.download.download_model_get,
|
"download_model_get": self.download.download_model_get,
|
||||||
"cancel_download_get": self.download.cancel_download_get,
|
"cancel_download_get": self.download.cancel_download_get,
|
||||||
|
"skip_download_get": self.download.skip_download_get,
|
||||||
"pause_download_get": self.download.pause_download_get,
|
"pause_download_get": self.download.pause_download_get,
|
||||||
"resume_download_get": self.download.resume_download_get,
|
"resume_download_get": self.download.resume_download_get,
|
||||||
"get_download_progress": self.download.get_download_progress,
|
"get_download_progress": self.download.get_download_progress,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ from ...config import config as global_config
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CHUNK_SIZE = 256 * 1024 # 256 KB
|
||||||
|
|
||||||
|
# Video file extensions that bypass native sendfile on Windows
|
||||||
|
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
||||||
|
_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"})
|
||||||
|
|
||||||
|
|
||||||
class PreviewHandler:
|
class PreviewHandler:
|
||||||
"""Serve preview assets for the active library at request time."""
|
"""Serve preview assets for the active library at request time."""
|
||||||
@@ -48,8 +55,51 @@ class PreviewHandler:
|
|||||||
logger.debug("Preview file not found at %s", str(resolved))
|
logger.debug("Preview file not found at %s", str(resolved))
|
||||||
raise web.HTTPNotFound(text="Preview file not found")
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
|
# Video files: stream manually to avoid Windows native sendfile crash.
|
||||||
|
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
|
||||||
|
# which breaks when the client disconnects mid-transfer — this happens
|
||||||
|
# constantly when users scroll through a gallery of animated previews.
|
||||||
|
suffix = resolved.suffix.lower()
|
||||||
|
if suffix in _VIDEO_EXTENSIONS:
|
||||||
|
return await self._stream_file(request, resolved)
|
||||||
|
|
||||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||||
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||||
|
|
||||||
|
async def _stream_file(
|
||||||
|
self, request: web.Request, path: Path
|
||||||
|
) -> web.StreamResponse:
|
||||||
|
"""Stream a file chunk-by-chunk, bypassing native sendfile.
|
||||||
|
|
||||||
|
This avoids the Windows IOCP ``_sendfile_native`` crash that occurs
|
||||||
|
when the client disconnects during a large file transfer.
|
||||||
|
"""
|
||||||
|
content_type, _ = mimetypes.guess_type(str(path))
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
file_size = path.stat().st_size
|
||||||
|
resp = web.StreamResponse()
|
||||||
|
resp.content_type = content_type
|
||||||
|
resp.content_length = file_size
|
||||||
|
|
||||||
|
await resp.prepare(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
await resp.write(chunk)
|
||||||
|
except (ConnectionResetError, ConnectionAbortedError):
|
||||||
|
# Client disconnected during streaming — expected when scrolling
|
||||||
|
# rapidly through a library with animated previews.
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug("I/O error streaming preview %s: %s", path, exc)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["PreviewHandler"]
|
__all__ = ["PreviewHandler"]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from aiohttp import web
|
|||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
from ...services.server_i18n import server_i18n as default_server_i18n
|
from ...services.server_i18n import server_i18n as default_server_i18n
|
||||||
from ...services.settings_manager import SettingsManager
|
from ...services.settings_manager import SettingsManager, get_settings_manager
|
||||||
from ...services.recipes import (
|
from ...services.recipes import (
|
||||||
RecipeAnalysisService,
|
RecipeAnalysisService,
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
@@ -26,7 +26,12 @@ from ...services.recipes import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
from ...utils.civitai_utils import (
|
||||||
|
build_civitai_image_page_url,
|
||||||
|
extract_civitai_image_id,
|
||||||
|
extract_civitai_image_id_from_cdn_url,
|
||||||
|
rewrite_preview_url,
|
||||||
|
)
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
from ...recipes.enrichment import RecipeEnricher
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
@@ -87,12 +92,16 @@ class RecipeHandlerSet:
|
|||||||
"repair_recipes": self.management.repair_recipes,
|
"repair_recipes": self.management.repair_recipes,
|
||||||
"cancel_repair": self.management.cancel_repair,
|
"cancel_repair": self.management.cancel_repair,
|
||||||
"repair_recipe": self.management.repair_recipe,
|
"repair_recipe": self.management.repair_recipe,
|
||||||
|
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
||||||
"get_repair_progress": self.management.get_repair_progress,
|
"get_repair_progress": self.management.get_repair_progress,
|
||||||
"start_batch_import": self.batch_import.start_batch_import,
|
"start_batch_import": self.batch_import.start_batch_import,
|
||||||
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
||||||
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
||||||
"start_directory_import": self.batch_import.start_directory_import,
|
"start_directory_import": self.batch_import.start_directory_import,
|
||||||
"browse_directory": self.batch_import.browse_directory,
|
"browse_directory": self.batch_import.browse_directory,
|
||||||
|
"check_image_exists": self.management.check_image_exists,
|
||||||
|
"import_from_url": self.management.import_from_url,
|
||||||
|
"create_from_example": self.management.create_from_example,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -458,7 +467,11 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
self._logger.info("Manually triggering recipe cache rebuild")
|
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
|
||||||
|
self._logger.info(
|
||||||
|
"Manually triggering recipe cache %s",
|
||||||
|
"full rebuild" if full_rebuild else "refresh",
|
||||||
|
)
|
||||||
await recipe_scanner.get_cached_data(force_refresh=True)
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "message": "Recipe cache refreshed successfully"}
|
{"success": True, "message": "Recipe cache refreshed successfully"}
|
||||||
@@ -541,7 +554,7 @@ class RecipeQueryHandler:
|
|||||||
)
|
)
|
||||||
response_data.append(
|
response_data.append(
|
||||||
{
|
{
|
||||||
"type": "source_url",
|
"type": "source_path",
|
||||||
"fingerprint": url,
|
"fingerprint": url,
|
||||||
"count": len(recipes),
|
"count": len(recipes),
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
@@ -607,6 +620,7 @@ class RecipeManagementHandler:
|
|||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
self._ws_manager = ws_manager
|
self._ws_manager = ws_manager
|
||||||
|
self._import_semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -703,6 +717,69 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
"""Bulk repair metadata for multiple recipes by their IDs.
|
||||||
|
|
||||||
|
Accepts a JSON body with a "recipe_ids" array and iterates
|
||||||
|
repair_recipe_by_id over each entry, collecting statistics.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids", [])
|
||||||
|
if not recipe_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_ids are required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(recipe_ids)
|
||||||
|
repaired = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
recipes = []
|
||||||
|
|
||||||
|
for recipe_id in recipe_ids:
|
||||||
|
try:
|
||||||
|
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||||
|
if result.get("success"):
|
||||||
|
repaired += result.get("repaired", 0)
|
||||||
|
skipped += result.get("skipped", 0)
|
||||||
|
if result.get("recipe"):
|
||||||
|
recipes.append(result["recipe"])
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
except RecipeNotFoundError:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error repairing recipe %s: %s", recipe_id, exc
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"total": total,
|
||||||
|
"repaired": repaired,
|
||||||
|
"skipped": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"recipes": recipes,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error performing bulk 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:
|
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -760,125 +837,28 @@ class RecipeManagementHandler:
|
|||||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
self._logger.info(
|
self._logger.info(
|
||||||
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
"Remote recipe import received: url=%s, lora_count=%d",
|
||||||
image_url,
|
image_url,
|
||||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
|
||||||
len(lora_entries),
|
len(lora_entries),
|
||||||
|
)
|
||||||
|
self._logger.debug(
|
||||||
|
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||||
|
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Initial Metadata Construction
|
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||||
metadata: Dict[str, Any] = {
|
async with self._import_semaphore:
|
||||||
"base_model": params.get("base_model", "") or "",
|
return await self._do_import_remote_recipe(
|
||||||
"loras": lora_entries,
|
image_url=image_url,
|
||||||
"gen_params": gen_params_request or {},
|
name=name,
|
||||||
"source_url": image_url,
|
lora_entries=lora_entries,
|
||||||
}
|
checkpoint_entry=checkpoint_entry,
|
||||||
|
gen_params_request=gen_params_request,
|
||||||
source_path = params.get("source_path")
|
tags=self._parse_tags(params.get("tags")),
|
||||||
if source_path:
|
base_model=params.get("base_model", "") or "",
|
||||||
metadata["source_path"] = source_path
|
source_path=params.get("source_path") or image_url,
|
||||||
|
|
||||||
# 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:
|
except RecipeValidationError as exc:
|
||||||
return web.json_response({"error": str(exc)}, status=400)
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
except RecipeDownloadError as exc:
|
except RecipeDownloadError as exc:
|
||||||
@@ -889,6 +869,155 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_remote_recipe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
image_url: str,
|
||||||
|
name: str,
|
||||||
|
lora_entries: list,
|
||||||
|
checkpoint_entry: dict,
|
||||||
|
gen_params_request: dict,
|
||||||
|
tags: list,
|
||||||
|
base_model: str,
|
||||||
|
source_path: str,
|
||||||
|
) -> web.Response:
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": base_model,
|
||||||
|
"loras": lora_entries,
|
||||||
|
"gen_params": gen_params_request or {},
|
||||||
|
"source_path": source_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkpoint_entry:
|
||||||
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
|
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
|
||||||
|
|
||||||
|
# Download image
|
||||||
|
(
|
||||||
|
image_bytes,
|
||||||
|
extension,
|
||||||
|
civitai_meta_raw,
|
||||||
|
model_version_id,
|
||||||
|
) = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
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 = await asyncio.to_thread(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse CivitAI API meta to discover all resources from modelVersionIds
|
||||||
|
# (modelVersionIds is injected at root level by _download_remote_media).
|
||||||
|
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
|
||||||
|
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
|
||||||
|
civitai_parsed = None
|
||||||
|
if civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
# modelVersionIds lives at outer meta level; propagate after unwrap
|
||||||
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||||
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||||
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
|
# Merge: API gen_params override EXIF at field level,
|
||||||
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
embedded_gen_params = {
|
||||||
|
**(embedded_gen_params or {}),
|
||||||
|
**civitai_parsed["gen_params"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if embedded_gen_params:
|
||||||
|
metadata["gen_params"] = embedded_gen_params
|
||||||
|
|
||||||
|
# Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
|
||||||
|
if civitai_parsed:
|
||||||
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = civitai_loras
|
||||||
|
civitai_model = civitai_parsed.get("model")
|
||||||
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = civitai_model
|
||||||
|
civitai_base_model = civitai_parsed.get("base_model")
|
||||||
|
if civitai_base_model and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = civitai_base_model
|
||||||
|
elif parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=gen_params_request,
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -1190,7 +1319,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -1238,10 +1367,38 @@ class RecipeManagementHandler:
|
|||||||
extension = ".webp" # Default to webp if unknown
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
|
model_ver_id = None
|
||||||
|
civitai_meta_raw = (
|
||||||
|
image_info.get("meta") if civitai_image_id and image_info else None
|
||||||
|
)
|
||||||
|
if civitai_image_id and image_info:
|
||||||
|
# modelVersionId (singular) — the primary version for this
|
||||||
|
# image on CivitAI. May be absent, or may *not* be the
|
||||||
|
# checkpoint (e.g. when the image was generated with a LoRA
|
||||||
|
# as the primary subject). When absent, DO NOT fall back to
|
||||||
|
# modelVersionIds[0] — that array mixes checkpoints, LoRAs,
|
||||||
|
# and other model version IDs without ordering guarantees.
|
||||||
|
# The downstream enrichment flow will find the real
|
||||||
|
# checkpoint via meta.resources (type:"model" hash) or
|
||||||
|
# meta.civitaiResources (type:"checkpoint" version ID), so
|
||||||
|
# leaving model_ver_id as None is safe and avoids the bug
|
||||||
|
# where a LoRA version ID was treated as the checkpoint.
|
||||||
|
model_ver_id = image_info.get("modelVersionId")
|
||||||
|
|
||||||
|
# Inject root-level modelVersionIds into meta so downstream
|
||||||
|
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
||||||
|
# (checkpoint + LoRAs), not just the first model version ID.
|
||||||
|
# CivitAI API returns modelVersionIds at the root level of
|
||||||
|
# the image response, NOT inside the meta object.
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if mvids and isinstance(civitai_meta_raw, dict):
|
||||||
|
civitai_meta_raw["modelVersionIds"] = mvids
|
||||||
|
|
||||||
return (
|
return (
|
||||||
file_obj.read(),
|
file_obj.read(),
|
||||||
extension,
|
extension,
|
||||||
image_info.get("meta") if civitai_image_id and image_info else None,
|
civitai_meta_raw,
|
||||||
|
model_ver_id,
|
||||||
)
|
)
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
@@ -1289,6 +1446,500 @@ class RecipeManagementHandler:
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def check_image_exists(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")
|
||||||
|
|
||||||
|
image_ids_raw = request.query.get("image_ids", "")
|
||||||
|
if not image_ids_raw:
|
||||||
|
return web.json_response({"success": True, "results": {}})
|
||||||
|
|
||||||
|
requested_ids = set()
|
||||||
|
for raw in image_ids_raw.split(","):
|
||||||
|
stripped = raw.strip()
|
||||||
|
if stripped and stripped.isdigit():
|
||||||
|
requested_ids.add(stripped)
|
||||||
|
|
||||||
|
if not requested_ids:
|
||||||
|
return web.json_response({"success": True, "results": {}})
|
||||||
|
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Build lookup: image_id -> recipe_id from stored source_path
|
||||||
|
image_to_recipe = {}
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
source = recipe.get("source_path")
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
image_id = extract_civitai_image_id(source)
|
||||||
|
if image_id and image_id not in image_to_recipe:
|
||||||
|
image_to_recipe[image_id] = recipe.get("id")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for img_id in requested_ids:
|
||||||
|
recipe_id = image_to_recipe.get(img_id)
|
||||||
|
results[img_id] = {
|
||||||
|
"in_library": recipe_id is not None,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error checking image existence: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def import_from_url(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")
|
||||||
|
|
||||||
|
image_url = request.query.get("image_url")
|
||||||
|
if not image_url:
|
||||||
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
|
|
||||||
|
force = request.query.get("force", "false").lower() == "true"
|
||||||
|
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for duplicate (fast, before acquiring semaphore), unless force
|
||||||
|
if not force:
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
source = recipe.get("source_path")
|
||||||
|
if source:
|
||||||
|
existing_id = extract_civitai_image_id(source)
|
||||||
|
if existing_id == image_id:
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe.get("id"),
|
||||||
|
"name": recipe.get("title", ""),
|
||||||
|
"already_exists": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
async with self._import_semaphore:
|
||||||
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||||
|
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 URL: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_from_url(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
recipe_scanner: Any,
|
||||||
|
) -> web.Response:
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
image_bytes, extension, civitai_meta_raw, model_version_id = (
|
||||||
|
await self._download_remote_media(image_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
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 = await asyncio.to_thread(
|
||||||
|
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"]
|
||||||
|
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: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse CivitAI API meta to discover all resources from modelVersionIds.
|
||||||
|
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
|
||||||
|
# LoRAs (modelVersionIds is NOT in the image EXIF).
|
||||||
|
civitai_parsed = None
|
||||||
|
if civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
# Propagate modelVersionIds into unwrapped meta — it lives
|
||||||
|
# at the outer meta level in the CivitAI API response.
|
||||||
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||||
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||||
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
|
# Merge: API gen_params override EXIF at field level,
|
||||||
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
embedded_gen_params = {
|
||||||
|
**(embedded_gen_params or {}),
|
||||||
|
**civitai_parsed["gen_params"],
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": "",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": embedded_gen_params or {},
|
||||||
|
"source_path": image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if civitai_parsed:
|
||||||
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = civitai_loras
|
||||||
|
civitai_model = civitai_parsed.get("model")
|
||||||
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = civitai_model
|
||||||
|
civitai_base_model = civitai_parsed.get("base_model")
|
||||||
|
if civitai_base_model and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = civitai_base_model
|
||||||
|
elif parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params={},
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
metadata.get("gen_params", {}).get("prompt")
|
||||||
|
or metadata.get("gen_params", {}).get("positivePrompt")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if prompt:
|
||||||
|
name = " ".join(str(prompt).split()[:10])
|
||||||
|
else:
|
||||||
|
name = f"Civitai Image {image_id}"
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
async def create_from_example(self, request: web.Request) -> web.Response:
|
||||||
|
"""Create a recipe from a model's example image using cached metadata.
|
||||||
|
|
||||||
|
Uses the image's meta data (already cached in .metadata.json from the
|
||||||
|
CivitAI model-versions API) to create a recipe without additional
|
||||||
|
CivitAI API calls.
|
||||||
|
|
||||||
|
If the image metadata doesn't contain any resources of the parent
|
||||||
|
model's type (LoRA-type or Checkpoint), the parent model is
|
||||||
|
auto-populated as a fallback.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
image_data (dict): The full image object from model-versions API
|
||||||
|
(includes meta, additionalResources, url, etc.)
|
||||||
|
model_hash (str): SHA256 hash of the parent model
|
||||||
|
model_name (str): Filename of the parent model
|
||||||
|
model_type (str): Page type (``"loras"``, ``"checkpoints"``, etc.)
|
||||||
|
local_image_path (str, optional): Local filesystem path to read
|
||||||
|
the image bytes for the recipe preview
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
image_data = data.get("image_data")
|
||||||
|
model_hash = data.get("model_hash")
|
||||||
|
model_name = data.get("model_name")
|
||||||
|
model_type = data.get("model_type", "")
|
||||||
|
|
||||||
|
if not image_data or not model_hash or not model_name:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Missing required fields: image_data, model_hash, model_name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge nested meta into top level so the parser finds everything.
|
||||||
|
# CivitaiApiMetadataParser expects prompt, seed, resources, etc.
|
||||||
|
# at the top level or wrapped under a "meta" key.
|
||||||
|
inner_meta = image_data.get("meta") or {}
|
||||||
|
parsed_input = {**image_data, **inner_meta}
|
||||||
|
parsed_input.pop("meta", None)
|
||||||
|
|
||||||
|
# Build a local cache of {hash → cache_item} so the parser can
|
||||||
|
# skip CivitAI API calls for models that exist on disk.
|
||||||
|
local_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
|
if lora_scanner and model_hash:
|
||||||
|
try:
|
||||||
|
parent_cache_data = await lora_scanner.get_cached_data()
|
||||||
|
for item in getattr(parent_cache_data, "raw_data", []):
|
||||||
|
if item.get("sha256", "").lower() == model_hash.lower():
|
||||||
|
local_cache[model_hash.lower()] = item
|
||||||
|
# Compute AutoV3 so the parser can also match on
|
||||||
|
# that hash type (CivitAI metadata resources use
|
||||||
|
# AutoV3).
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
from ...utils.file_utils import (
|
||||||
|
calculate_autov3,
|
||||||
|
)
|
||||||
|
autov3 = calculate_autov3(file_path)
|
||||||
|
if autov3:
|
||||||
|
local_cache[autov3.lower()] = item
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
parsed_input
|
||||||
|
)
|
||||||
|
if not parser:
|
||||||
|
raise RecipeValidationError("Unable to parse image metadata")
|
||||||
|
|
||||||
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
|
parsed = await parser.parse_metadata(
|
||||||
|
parsed_input,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed = await parser.parse_metadata(
|
||||||
|
parsed_input, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
loras = list(parsed.get("loras") or [])
|
||||||
|
checkpoint = parsed.get("model")
|
||||||
|
is_lora_type = model_type.startswith("lora")
|
||||||
|
is_ckpt_type = model_type.startswith("checkpoint")
|
||||||
|
|
||||||
|
# Extract parent model metadata from local_cache (used below to
|
||||||
|
# reconcile isDeleted entries and enrich auto-populated ones).
|
||||||
|
parent_civitai_id: int | None = None
|
||||||
|
parent_model_id: int | None = None
|
||||||
|
parent_version_name: str | None = None
|
||||||
|
parent_model_name: str | None = None
|
||||||
|
# Prefer sha256 key; fall back to any cached entry.
|
||||||
|
parent_item = local_cache.get(model_hash.lower()) if model_hash else None
|
||||||
|
if parent_item is None and local_cache:
|
||||||
|
parent_item = next(iter(local_cache.values()))
|
||||||
|
if parent_item:
|
||||||
|
civ = parent_item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
parent_civitai_id = civ.get("id")
|
||||||
|
parent_model_id = civ.get("modelId")
|
||||||
|
parent_version_name = civ.get("name")
|
||||||
|
parent_model_name = parent_item.get("model_name")
|
||||||
|
|
||||||
|
# Reconcile isDeleted entries against the parent model.
|
||||||
|
# When the CivitAI hash lookup fails (known issue — hashes not
|
||||||
|
# yet computed), the parser marks the entry isDeleted even though
|
||||||
|
# the model exists locally.
|
||||||
|
if is_lora_type:
|
||||||
|
for lora in loras:
|
||||||
|
if lora.get("isDeleted") and lora.get("file_name") == model_name:
|
||||||
|
lora["isDeleted"] = False
|
||||||
|
lora["existsLocally"] = True
|
||||||
|
lora["hash"] = model_hash
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
lora["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
lora["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
lora["version"] = parent_version_name
|
||||||
|
if parent_model_name is not None:
|
||||||
|
lora["name"] = parent_model_name
|
||||||
|
elif is_ckpt_type and checkpoint and checkpoint.get("isDeleted"):
|
||||||
|
if checkpoint.get("file_name") == model_name:
|
||||||
|
checkpoint["isDeleted"] = False
|
||||||
|
checkpoint["existsLocally"] = True
|
||||||
|
checkpoint["hash"] = model_hash
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
checkpoint["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
checkpoint["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
checkpoint["version"] = parent_version_name
|
||||||
|
|
||||||
|
# Auto-populate parent model only when the image metadata didn't
|
||||||
|
# contain any resources of that type.
|
||||||
|
if is_lora_type and not loras:
|
||||||
|
lora_entry = {
|
||||||
|
"name": model_name,
|
||||||
|
"type": "lora",
|
||||||
|
"weight": 1.0,
|
||||||
|
"hash": model_hash,
|
||||||
|
"existsLocally": True,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": model_name,
|
||||||
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||||
|
"baseModel": parsed.get("base_model", ""),
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
lora_entry["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
lora_entry["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
lora_entry["version"] = parent_version_name
|
||||||
|
if parent_model_name is not None:
|
||||||
|
lora_entry["name"] = parent_model_name
|
||||||
|
loras.insert(0, lora_entry)
|
||||||
|
elif is_ckpt_type and not checkpoint:
|
||||||
|
checkpoint = {
|
||||||
|
"name": model_name,
|
||||||
|
"type": "checkpoint",
|
||||||
|
"hash": model_hash,
|
||||||
|
"file_name": model_name,
|
||||||
|
"existsLocally": True,
|
||||||
|
"baseModel": parsed.get("base_model", ""),
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
checkpoint["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
checkpoint["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
checkpoint["version"] = parent_version_name
|
||||||
|
if parent_model_name is not None:
|
||||||
|
checkpoint["name"] = parent_model_name
|
||||||
|
|
||||||
|
image_url = image_data.get("url") or ""
|
||||||
|
image_id = extract_civitai_image_id_from_cdn_url(image_url)
|
||||||
|
settings_mgr = get_settings_manager()
|
||||||
|
civitai_host = settings_mgr.get("civitai_host") if settings_mgr else None
|
||||||
|
page_url = build_civitai_image_page_url(image_id, host=civitai_host) or image_url
|
||||||
|
|
||||||
|
recipe_metadata: dict[str, Any] = {
|
||||||
|
"base_model": parsed.get("base_model") or "",
|
||||||
|
"loras": loras,
|
||||||
|
"gen_params": parsed.get("gen_params") or {},
|
||||||
|
"source_path": page_url,
|
||||||
|
}
|
||||||
|
nsfw_level = image_data.get("nsfwLevel")
|
||||||
|
if isinstance(nsfw_level, int):
|
||||||
|
recipe_metadata["preview_nsfw_level"] = nsfw_level
|
||||||
|
if checkpoint:
|
||||||
|
recipe_metadata["checkpoint"] = checkpoint
|
||||||
|
|
||||||
|
image_bytes: bytes | None = None
|
||||||
|
extension: str | None = None
|
||||||
|
local_image_path = data.get("local_image_path")
|
||||||
|
if local_image_path and os.path.exists(local_image_path):
|
||||||
|
with open(local_image_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
ext = os.path.splitext(local_image_path)[1].lower()
|
||||||
|
if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||||
|
extension = ext
|
||||||
|
elif image_data.get("url"):
|
||||||
|
try:
|
||||||
|
downloader = await self._downloader_factory()
|
||||||
|
url = image_data["url"]
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False)
|
||||||
|
tmp.close()
|
||||||
|
success, result = await downloader.download_file(
|
||||||
|
url, tmp.name, use_auth=False
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
with open(tmp.name, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
url_path = url.split("?")[0].split("#")[0]
|
||||||
|
ext = os.path.splitext(url_path)[1].lower()
|
||||||
|
if ext:
|
||||||
|
extension = ext
|
||||||
|
if os.path.exists(tmp.name):
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to download image for recipe: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
(parsed.get("gen_params") or {}).get("prompt") or ""
|
||||||
|
)
|
||||||
|
if prompt:
|
||||||
|
name = " ".join(str(prompt).split()[:10])
|
||||||
|
else:
|
||||||
|
name = f"Recipe from {model_name}"
|
||||||
|
|
||||||
|
save_result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=[],
|
||||||
|
metadata=recipe_metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(save_result.payload, status=save_result.status)
|
||||||
|
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error creating recipe from example: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class RecipeAnalysisHandler:
|
class RecipeAnalysisHandler:
|
||||||
"""Analyze images to extract recipe metadata."""
|
"""Analyze images to extract recipe metadata."""
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||||
|
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||||
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
|
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET",
|
"GET",
|
||||||
"/api/lm/model-version-download-status",
|
"/api/lm/model-version-download-status",
|
||||||
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
@@ -70,6 +71,13 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
||||||
),
|
),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
|
||||||
|
),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from ..utils.settings_paths import ensure_settings_file
|
from ..utils.settings_paths import ensure_settings_file
|
||||||
from ..services.downloader import get_downloader
|
from ..services.downloader import get_downloader
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -212,8 +213,19 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
zip_path = tmp_zip_path
|
zip_path = tmp_zip_path
|
||||||
|
|
||||||
# Skip both settings.json, civitai and model cache folder
|
# Close the downloaded-versions SQLite connection before cleaning,
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
# so that shutil.rmtree() does not fail on Windows (the process
|
||||||
|
# cannot delete a file with an outstanding open handle).
|
||||||
|
try:
|
||||||
|
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
|
||||||
|
if history_svc is not None:
|
||||||
|
history_svc.close()
|
||||||
|
logger.info("Closed downloaded-version history database connection")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||||
|
|
||||||
|
# Skip settings.json, civitai, model cache and runtime cache folders
|
||||||
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Extract ZIP to temp dir
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -222,16 +234,17 @@ class UpdateRoutes:
|
|||||||
# Find extracted folder (GitHub ZIP contains a root folder)
|
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping settings.json and civitai folder
|
# Copy files, skipping user data that should be preserved
|
||||||
|
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
if item == 'settings.json' or item == 'civitai':
|
if item in skip_items:
|
||||||
continue
|
continue
|
||||||
src = os.path.join(extracted_root, item)
|
src = os.path.join(extracted_root, item)
|
||||||
dst = os.path.join(plugin_root, item)
|
dst = os.path.join(plugin_root, item)
|
||||||
if os.path.isdir(src):
|
if os.path.isdir(src):
|
||||||
if os.path.exists(dst):
|
if os.path.exists(dst):
|
||||||
shutil.rmtree(dst)
|
shutil.rmtree(dst)
|
||||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai'))
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
|
||||||
else:
|
else:
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
@@ -239,15 +252,17 @@ class UpdateRoutes:
|
|||||||
# for ComfyUI Manager to work properly
|
# for ComfyUI Manager to work properly
|
||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
|
skip_tracked = {'civitai', 'wildcards', 'backups'}
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
# Skip civitai folder and its contents
|
# Skip user data directories and their contents
|
||||||
rel_root = os.path.relpath(root, extracted_root)
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep):
|
top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
|
||||||
|
if top_dir in skip_tracked:
|
||||||
continue
|
continue
|
||||||
for file in files:
|
for file in files:
|
||||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||||
# Skip settings.json and any file under civitai
|
# Skip settings.json and any file under user data dirs
|
||||||
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
|
if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
|
||||||
continue
|
continue
|
||||||
tracking_files.append(rel_path.replace("\\", "/"))
|
tracking_files.append(rel_path.replace("\\", "/"))
|
||||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||||
|
|||||||
@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from .downloader import DownloadProgress, get_downloader
|
from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _try_certifi_ca_path() -> str | None:
|
||||||
|
"""Return the certifi CA bundle path if available, else None."""
|
||||||
|
try:
|
||||||
|
import certifi # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
path = certifi.where()
|
||||||
|
if os.path.isfile(path):
|
||||||
|
logger.debug(
|
||||||
|
"aria2 --ca-certificate: using certifi CA bundle at %s", path
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug("aria2 --ca-certificate: certifi not available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||||
"https://civitai.com/api/download/",
|
"https://civitai.com/api/download/",
|
||||||
"https://civitai.red/api/download/",
|
"https://civitai.red/api/download/",
|
||||||
@@ -39,7 +57,7 @@ class Aria2Transfer:
|
|||||||
|
|
||||||
|
|
||||||
class Aria2Downloader:
|
class Aria2Downloader:
|
||||||
"""Manage an aria2 RPC daemon for experimental model downloads."""
|
"""Manage an aria2 RPC daemon for recommended model downloads."""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_lock = asyncio.Lock()
|
_lock = asyncio.Lock()
|
||||||
@@ -391,6 +409,15 @@ class Aria2Downloader:
|
|||||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||||
)
|
)
|
||||||
except aiohttp.ClientError as exc:
|
except aiohttp.ClientError as exc:
|
||||||
|
if is_ssl_cert_verify_error(exc):
|
||||||
|
logger.error(
|
||||||
|
"SSL certificate verification failed during Civitai redirect "
|
||||||
|
"resolution for %s. This is usually caused by an outdated CA "
|
||||||
|
"certificate bundle. Recommended fixes:\n"
|
||||||
|
" 1. pip install --upgrade certifi\n"
|
||||||
|
" 2. pip install pip-system-certs",
|
||||||
|
url,
|
||||||
|
)
|
||||||
raise Aria2Error(
|
raise Aria2Error(
|
||||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -414,6 +441,11 @@ class Aria2Downloader:
|
|||||||
f"--rpc-listen-port={self._rpc_port}",
|
f"--rpc-listen-port={self._rpc_port}",
|
||||||
f"--rpc-secret={self._rpc_secret}",
|
f"--rpc-secret={self._rpc_secret}",
|
||||||
"--check-certificate=true",
|
"--check-certificate=true",
|
||||||
|
# Point aria2 at certifi's CA bundle when available so it uses
|
||||||
|
# the same certificate store as Python downloads.
|
||||||
|
*((
|
||||||
|
f"--ca-certificate={ca_cert}",
|
||||||
|
) if (ca_cert := _try_certifi_ca_path()) else ()),
|
||||||
"--allow-overwrite=true",
|
"--allow-overwrite=true",
|
||||||
"--auto-file-renaming=false",
|
"--auto-file-renaming=false",
|
||||||
"--file-allocation=none",
|
"--file-allocation=none",
|
||||||
|
|||||||
139
py/services/auto_tag_service.py
Normal file
139
py/services/auto_tag_service.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Auto-tag extraction service for model cards.
|
||||||
|
|
||||||
|
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
|
||||||
|
from filename, base_model, and CivitAI version name — no manual tagging required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Set
|
||||||
|
|
||||||
|
# ── Tag category definitions ──────────────────────────────────────────
|
||||||
|
# Each category maps a display label to a regex pattern.
|
||||||
|
# Patterns are case-insensitive and matched against filename, base_model,
|
||||||
|
# and civitai version name.
|
||||||
|
|
||||||
|
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
|
||||||
|
# Python's \b treats underscore as a word character, so \bHIGH\b
|
||||||
|
# won't match '_HIGH_' in filenames.
|
||||||
|
_B = r"(?<![a-zA-Z0-9])" # left boundary
|
||||||
|
_E = r"(?![a-zA-Z0-9])" # right boundary
|
||||||
|
|
||||||
|
AUTO_TAG_CATEGORIES: Dict[str, str] = {
|
||||||
|
"HIGH": _B + r"HIGH" + _E,
|
||||||
|
"LOW": _B + r"(?<!F)LOW" + _E,
|
||||||
|
"I2V": _B + r"I2V" + _E,
|
||||||
|
"T2V": _B + r"T2V" + _E,
|
||||||
|
"TI2V": _B + r"TI2V" + _E,
|
||||||
|
"Lightning": _B + r"Lightning" + _E,
|
||||||
|
"Turbo": _B + r"Turbo" + _E,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tags that belong to the "mode" group (HIGH/LOW)
|
||||||
|
MODE_TAGS = {"HIGH", "LOW"}
|
||||||
|
|
||||||
|
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
|
||||||
|
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
|
||||||
|
|
||||||
|
# Tags that belong to the "speed/optimization" group
|
||||||
|
SPEED_TAGS = {"Lightning", "Turbo"}
|
||||||
|
|
||||||
|
# ── Display category groups (for settings UI) ─────────────────────────
|
||||||
|
|
||||||
|
AUTO_TAG_GROUPS = {
|
||||||
|
"mode": {"HIGH", "LOW"},
|
||||||
|
"video": {"I2V", "T2V", "TI2V"},
|
||||||
|
"speed": {"Lightning", "Turbo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default enabled categories
|
||||||
|
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_sources(model_data: Dict) -> List[str]:
|
||||||
|
"""Collect all text sources from model data for tag matching."""
|
||||||
|
sources: List[str] = []
|
||||||
|
|
||||||
|
file_name = model_data.get("file_name", "")
|
||||||
|
if file_name:
|
||||||
|
sources.append(file_name)
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
if base_model:
|
||||||
|
sources.append(base_model)
|
||||||
|
|
||||||
|
civitai = model_data.get("civitai", {})
|
||||||
|
if isinstance(civitai, dict):
|
||||||
|
version_name = civitai.get("name", "")
|
||||||
|
if version_name:
|
||||||
|
sources.append(version_name)
|
||||||
|
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def extract_auto_tags(model_data: Dict) -> List[str]:
|
||||||
|
"""Extract auto-detected tags from model metadata.
|
||||||
|
|
||||||
|
Uses a two-layer approach:
|
||||||
|
Layer 1 — Regex-based detection against filename, base_model, and
|
||||||
|
CivitAI version name.
|
||||||
|
Layer 2 — Merge in any user-defined tags that overlap with known
|
||||||
|
auto-tag categories. This provides a manual fallback when
|
||||||
|
auto-detection fails (e.g. "I2V HN" or unlabeled models).
|
||||||
|
|
||||||
|
HIGH/LOW tags are only returned when the base_model indicates a Wan
|
||||||
|
family model — no other model architecture uses this distinction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_data: Model metadata dict with keys:
|
||||||
|
file_name, base_model, civitai (with optional 'name' field),
|
||||||
|
tags (user-defined tag list, used as fallback).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||||
|
"""
|
||||||
|
sources = _collect_sources(model_data)
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
is_wan = "wan" in base_model.lower()
|
||||||
|
|
||||||
|
found: Set[str] = set()
|
||||||
|
|
||||||
|
# ── Layer 1: regex-based detection ────────────────────────────
|
||||||
|
if sources:
|
||||||
|
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||||
|
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||||
|
if label in ("HIGH", "LOW"):
|
||||||
|
if not is_wan:
|
||||||
|
continue
|
||||||
|
# Use case-insensitive character class + case-sensitive boundary,
|
||||||
|
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||||
|
# Boundary: not followed by lowercase letter (= word has ended).
|
||||||
|
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||||
|
if label == "LOW":
|
||||||
|
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||||
|
else:
|
||||||
|
regex = re.compile(ci + r"(?![a-z])")
|
||||||
|
else:
|
||||||
|
regex = re.compile(pattern, re.IGNORECASE)
|
||||||
|
for source in sources:
|
||||||
|
if regex.search(source):
|
||||||
|
found.add(label)
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Layer 2: user-defined tags as manual fallback ─────────────
|
||||||
|
# When auto-detection fails (abbreviated names like "Hi"/"Lo",
|
||||||
|
# "I2V HN", or unlabeled models), users can add canonical tags
|
||||||
|
# (HIGH, LOW, I2V, etc.) to the model's regular tags for correct
|
||||||
|
# badge display and filtering. Matching is case-insensitive so
|
||||||
|
# "high"/"High"/"HIGH" all resolve to the canonical label.
|
||||||
|
user_tags = model_data.get("tags")
|
||||||
|
if user_tags:
|
||||||
|
label_map = {label.lower(): label for label in AUTO_TAG_CATEGORIES}
|
||||||
|
for t in user_tags:
|
||||||
|
canonical = label_map.get(t.lower())
|
||||||
|
if canonical:
|
||||||
|
found.add(canonical)
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
@@ -77,6 +77,7 @@ class BaseModelService(ABC):
|
|||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
hash_filters: dict = None,
|
hash_filters: dict = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
|
|||||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||||
else:
|
else:
|
||||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
|
# Pre-compute auto_tags for every item — needed for both filtering
|
||||||
|
# and display. Computation is cheap (string regex on 2-3 fields).
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
for item in sorted_data:
|
||||||
|
item["auto_tags"] = extract_auto_tags(item)
|
||||||
fetch_duration = time.perf_counter() - t0
|
fetch_duration = time.perf_counter() - t0
|
||||||
initial_count = len(sorted_data)
|
initial_count = len(sorted_data)
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
|
|||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
tag_logic=tag_logic,
|
tag_logic=tag_logic,
|
||||||
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
|
|||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
tag_logic: str = "any",
|
tag_logic: str = "any",
|
||||||
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
|
|||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=normalized_options,
|
search_options=normalized_options,
|
||||||
tag_logic=tag_logic,
|
tag_logic=tag_logic,
|
||||||
@@ -861,22 +870,75 @@ class BaseModelService(ABC):
|
|||||||
"""Get the static preview URL for a model file"""
|
"""Get the static preview URL for a model file"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
name_normalized = model_name.replace("\\", "/")
|
||||||
|
name_no_ext = name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if name_no_ext.lower().endswith(ext):
|
||||||
|
name_no_ext = name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in name_no_ext
|
||||||
|
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model["file_name"] == model_name:
|
file_name = model.get("file_name", "")
|
||||||
|
folder = model.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||||
preview_url = model.get("preview_url")
|
preview_url = model.get("preview_url")
|
||||||
if preview_url:
|
if preview_url:
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
return config.get_preview_static_url(preview_url)
|
return config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = model
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = model
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
preview_url = best_fallback.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]]:
|
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||||
"""Get the Civitai URL for a model file"""
|
"""Get the Civitai URL for a model file"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
name_normalized = model_name.replace("\\", "/")
|
||||||
|
name_no_ext = name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if name_no_ext.lower().endswith(ext):
|
||||||
|
name_no_ext = name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in name_no_ext
|
||||||
|
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model["file_name"] == model_name:
|
file_name = model.get("file_name", "")
|
||||||
|
folder = model.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||||
civitai_data = model.get("civitai", {})
|
civitai_data = model.get("civitai", {})
|
||||||
model_id = civitai_data.get("modelId")
|
model_id = civitai_data.get("modelId")
|
||||||
version_id = civitai_data.get("id")
|
version_id = civitai_data.get("id")
|
||||||
@@ -895,6 +957,27 @@ class BaseModelService(ABC):
|
|||||||
"version_id": str(version_id) if version_id else None,
|
"version_id": str(version_id) if version_id else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = model
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = model
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
civitai_data = best_fallback.get("civitai", {})
|
||||||
|
model_id = civitai_data.get("modelId")
|
||||||
|
if model_id:
|
||||||
|
version_id = civitai_data.get("id")
|
||||||
|
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||||
|
civitai_url = build_civitai_model_page_url(
|
||||||
|
model_id, version_id, host=civitai_host
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"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]:
|
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||||
@@ -908,6 +991,17 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
if should_skip or metadata is None:
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Prune stale example-image metadata entries whose files no longer
|
||||||
|
# exist on disk (e.g. a user deleted the files manually).
|
||||||
|
from ..utils.example_images_metadata import MetadataUpdater
|
||||||
|
|
||||||
|
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
|
||||||
|
if was_modified:
|
||||||
|
asyncio.create_task(
|
||||||
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class BatchImportService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
source_path = recipe.get("source_path") or recipe.get("source_url")
|
source_path = recipe.get("source_path")
|
||||||
if source_path and source_path == source:
|
if source_path and source_path == source:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -36,6 +37,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
file_extensions=file_extensions,
|
file_extensions=file_extensions,
|
||||||
hash_index=ModelHashIndex(),
|
hash_index=ModelHashIndex(),
|
||||||
)
|
)
|
||||||
|
if not hasattr(self, "_hash_calculation_lock"):
|
||||||
|
self._hash_calculation_lock = asyncio.Lock()
|
||||||
|
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
|
||||||
|
|
||||||
async def _create_default_metadata(
|
async def _create_default_metadata(
|
||||||
self, file_path: str
|
self, file_path: str
|
||||||
@@ -88,7 +92,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
||||||
"""Calculate hash for a checkpoint on-demand.
|
"""Calculate hash for a checkpoint on-demand with per-file singleflight.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the model file
|
file_path: Path to the model file
|
||||||
@@ -96,14 +100,65 @@ class CheckpointScanner(ModelScanner):
|
|||||||
Returns:
|
Returns:
|
||||||
SHA256 hash string, or None if calculation failed
|
SHA256 hash string, or None if calculation failed
|
||||||
"""
|
"""
|
||||||
from ..utils.file_utils import calculate_sha256
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
real_path = os.path.realpath(file_path)
|
real_path = os.path.realpath(file_path)
|
||||||
if not os.path.exists(real_path):
|
if not os.path.exists(real_path):
|
||||||
logger.error(f"File not found for hash calculation: {file_path}")
|
logger.error(f"File not found for hash calculation: {file_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
metadata is not None
|
||||||
|
and metadata.hash_status == "completed"
|
||||||
|
and metadata.sha256
|
||||||
|
):
|
||||||
|
return metadata.sha256
|
||||||
|
|
||||||
|
async with self._hash_calculation_lock:
|
||||||
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
metadata is not None
|
||||||
|
and metadata.hash_status == "completed"
|
||||||
|
and metadata.sha256
|
||||||
|
):
|
||||||
|
return metadata.sha256
|
||||||
|
|
||||||
|
task = self._hash_calculation_tasks.get(real_path)
|
||||||
|
if task is None:
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._run_hash_calculation_task(file_path, real_path)
|
||||||
|
)
|
||||||
|
self._hash_calculation_tasks[real_path] = task
|
||||||
|
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _run_hash_calculation_task(
|
||||||
|
self, file_path: str, real_path: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Run a hash calculation task and remove it from the in-flight map."""
|
||||||
|
try:
|
||||||
|
return await self._calculate_hash_for_model_uncached(file_path, real_path)
|
||||||
|
finally:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
async with self._hash_calculation_lock:
|
||||||
|
if self._hash_calculation_tasks.get(real_path) is task:
|
||||||
|
del self._hash_calculation_tasks[real_path]
|
||||||
|
|
||||||
|
async def _calculate_hash_for_model_uncached(
|
||||||
|
self, file_path: str, real_path: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Calculate hash for a checkpoint without checking in-flight tasks."""
|
||||||
|
from ..utils.file_utils import calculate_sha256
|
||||||
|
|
||||||
|
try:
|
||||||
# Load current metadata
|
# Load current metadata
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
file_path, self.model_class
|
file_path, self.model_class
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import CheckpointMetadata
|
from ..utils.models import CheckpointMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
|
|||||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
|||||||
if "metadata" in file_data:
|
if "metadata" in file_data:
|
||||||
transformed["metadata"] = file_data["metadata"]
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
# Infer metadata.format from filename extension
|
||||||
|
name = transformed.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
lower_name = name.lower()
|
||||||
|
if lower_name.endswith(".safetensors"):
|
||||||
|
inferred_format = "SafeTensor"
|
||||||
|
elif lower_name.endswith(".ckpt"):
|
||||||
|
inferred_format = "PickleTensor"
|
||||||
|
else:
|
||||||
|
inferred_format = None
|
||||||
|
if inferred_format:
|
||||||
|
if "metadata" not in transformed:
|
||||||
|
transformed["metadata"] = {}
|
||||||
|
if isinstance(transformed["metadata"], dict):
|
||||||
|
transformed["metadata"].setdefault("format", inferred_format)
|
||||||
|
|
||||||
if file_data.get("modelVersionId") is not None:
|
if file_data.get("modelVersionId") is not None:
|
||||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
elif file_data.get("model_version_id") is not None:
|
elif file_data.get("model_version_id") is not None:
|
||||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
|||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
|
def _sort_key(f: Dict) -> int:
|
||||||
|
fname = f.get("name") or ""
|
||||||
|
if isinstance(fname, str):
|
||||||
|
lower = fname.lower()
|
||||||
|
if lower.endswith(".safetensors"):
|
||||||
|
return 0
|
||||||
|
elif lower.endswith(".ckpt"):
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
transformed_files.sort(key=_sort_key)
|
||||||
return transformed_files
|
return transformed_files
|
||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
|
|||||||
"zimageturbo": "ZIT",
|
"zimageturbo": "ZIT",
|
||||||
"zimagebase": "ZIB",
|
"zimagebase": "ZIB",
|
||||||
"anima": "ANI",
|
"anima": "ANI",
|
||||||
|
"ernie": "ERNI",
|
||||||
|
"ernie turbo": "ETRB",
|
||||||
|
"nucleus": "NUCL",
|
||||||
"svd": "SVD",
|
"svd": "SVD",
|
||||||
"ltxv": "LTXV",
|
"ltxv": "LTXV",
|
||||||
"ltxv2": "LTV2",
|
"ltxv2": "LTV2",
|
||||||
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
|
|||||||
"Kolors",
|
"Kolors",
|
||||||
"NoobAI",
|
"NoobAI",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||||
from .connectivity_guard import (
|
from .connectivity_guard import (
|
||||||
OFFLINE_FRIENDLY_MESSAGE,
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
@@ -45,6 +46,14 @@ class CivitaiClient:
|
|||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
self.base_url = "https://civitai.red/api/v1"
|
self.base_url = "https://civitai.red/api/v1"
|
||||||
|
# In-memory cache to avoid redundant get_model_version_info calls
|
||||||
|
# within the same import/scan flow. Only successful results are cached.
|
||||||
|
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
|
||||||
|
# unbounded growth in long-running server processes.
|
||||||
|
self._version_info_cache: OrderedDict[
|
||||||
|
str, Tuple[Optional[Dict], Optional[str]]
|
||||||
|
] = OrderedDict()
|
||||||
|
self._MAX_CACHE_ENTRIES = 500
|
||||||
|
|
||||||
def _build_image_info_url(self, image_id: str) -> str:
|
def _build_image_info_url(self, image_id: str) -> str:
|
||||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||||
@@ -57,22 +66,57 @@ class CivitaiClient:
|
|||||||
use_auth: bool = False,
|
use_auth: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Tuple[bool, Dict | str]:
|
) -> Tuple[bool, Dict | str]:
|
||||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
"""Wrapper around downloader.make_request that surfaces rate limits,
|
||||||
|
with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
|
||||||
|
|
||||||
downloader = await get_downloader()
|
max_retries = 3
|
||||||
success, result = await downloader.make_request(
|
for attempt in range(max_retries):
|
||||||
method,
|
downloader = await get_downloader()
|
||||||
url,
|
success, result = await downloader.make_request(
|
||||||
use_auth=use_auth,
|
method,
|
||||||
**kwargs,
|
url,
|
||||||
)
|
use_auth=use_auth,
|
||||||
if not success and isinstance(result, RateLimitError):
|
**kwargs,
|
||||||
if result.provider is None:
|
)
|
||||||
result.provider = "civitai_api"
|
if success:
|
||||||
raise result
|
return True, result
|
||||||
if not success and is_offline_cooldown_error(result):
|
|
||||||
return False, OFFLINE_FRIENDLY_MESSAGE
|
if isinstance(result, RateLimitError):
|
||||||
return success, result
|
if result.provider is None:
|
||||||
|
result.provider = "civitai_api"
|
||||||
|
raise result
|
||||||
|
|
||||||
|
if is_offline_cooldown_error(result):
|
||||||
|
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
|
||||||
|
# Transient server error — retry with exponential backoff
|
||||||
|
if self._is_transient_server_error(str(result)):
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
wait = 2**attempt # 1s, 2s, 4s
|
||||||
|
logger.info(
|
||||||
|
"Transient error on %s %s, retrying in %ds "
|
||||||
|
"(attempt %d/%d): %s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
wait,
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
logger.warning(
|
||||||
|
"All %d retries exhausted for %s %s: %s",
|
||||||
|
max_retries,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
return False, "Unexpected error in _make_request"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||||
@@ -201,6 +245,29 @@ class CivitaiClient:
|
|||||||
|
|
||||||
return _from_value(payload)
|
return _from_value(payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_transient_server_error(message: str) -> bool:
|
||||||
|
"""Return True when the message indicates a transient upstream failure.
|
||||||
|
|
||||||
|
Recognises Cloudflare 524, generic 5xx, and connectivity-level flakiness
|
||||||
|
that should not be treated as a permanent failure.
|
||||||
|
"""
|
||||||
|
normalized = message.lower()
|
||||||
|
if "status 5" in normalized or "status 524" in normalized:
|
||||||
|
return True
|
||||||
|
if any(
|
||||||
|
keyword in normalized
|
||||||
|
for keyword in (
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"temporary failure",
|
||||||
|
"name resolution",
|
||||||
|
"connection closed",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
"""Get all versions of a model with local availability info"""
|
"""Get all versions of a model with local availability info"""
|
||||||
try:
|
try:
|
||||||
@@ -223,6 +290,13 @@ class CivitaiClient:
|
|||||||
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||||
return None
|
return None
|
||||||
if message:
|
if message:
|
||||||
|
if self._is_transient_server_error(message):
|
||||||
|
logger.info(
|
||||||
|
"Transient server error for model %s: %s",
|
||||||
|
model_id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
return None
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
return None
|
return None
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
@@ -257,7 +331,7 @@ class CivitaiClient:
|
|||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"ids": query},
|
params={"ids": query, "nsfw": "true"},
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
@@ -336,6 +410,25 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
target_version = self._select_target_version(model_data, model_id, version_id)
|
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||||
|
|
||||||
|
# If modelVersions is empty (e.g. CivitAI cache lag for newly published
|
||||||
|
# models) but a specific version_id is known, fall back to fetching the
|
||||||
|
# version directly via the individual model-versions endpoint, then
|
||||||
|
# enrich it with the model-level data we already have.
|
||||||
|
if target_version is None and version_id is not None:
|
||||||
|
logger.info(
|
||||||
|
"modelVersions empty for model %s; falling back to direct "
|
||||||
|
"version lookup for %s",
|
||||||
|
model_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
version = await self._fetch_version_by_id(version_id)
|
||||||
|
if version:
|
||||||
|
self._enrich_version_with_model_data(version, model_data)
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
return version
|
||||||
|
return None
|
||||||
|
|
||||||
if target_version is None:
|
if target_version is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -482,6 +575,14 @@ class CivitaiClient:
|
|||||||
- The model version data or None if not found
|
- The model version data or None if not found
|
||||||
- An error message if there was an error, or None on success
|
- An error message if there was an error, or None on success
|
||||||
"""
|
"""
|
||||||
|
# In-memory cache avoids redundant API calls within the same
|
||||||
|
# import/scan flow (e.g. _resolve_base_model_from_checkpoint
|
||||||
|
# followed by _resolve_and_populate_checkpoint with the same id).
|
||||||
|
if version_id in self._version_info_cache:
|
||||||
|
logger.debug("Cache hit for model version info: %s", version_id)
|
||||||
|
self._version_info_cache.move_to_end(version_id) # LRU bump
|
||||||
|
return self._version_info_cache[version_id]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/model-versions/{version_id}"
|
url = f"{self.base_url}/model-versions/{version_id}"
|
||||||
|
|
||||||
@@ -491,6 +592,11 @@ class CivitaiClient:
|
|||||||
if success:
|
if success:
|
||||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||||
self._remove_comfy_metadata(result)
|
self._remove_comfy_metadata(result)
|
||||||
|
self._version_info_cache[version_id] = (result, None)
|
||||||
|
self._version_info_cache.move_to_end(version_id)
|
||||||
|
# Evict oldest entry when over capacity
|
||||||
|
if len(self._version_info_cache) > self._MAX_CACHE_ENTRIES:
|
||||||
|
self._version_info_cache.popitem(last=False)
|
||||||
return result, None
|
return result, None
|
||||||
|
|
||||||
# Handle specific error cases
|
# Handle specific error cases
|
||||||
@@ -532,6 +638,13 @@ class CivitaiClient:
|
|||||||
if not success:
|
if not success:
|
||||||
if is_expected_offline_error(result):
|
if is_expected_offline_error(result):
|
||||||
return None
|
return None
|
||||||
|
if self._is_transient_server_error(str(result)):
|
||||||
|
logger.info(
|
||||||
|
"Transient server error fetching image info for ID %s: %s",
|
||||||
|
image_id,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return None
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||||
image_id,
|
image_id,
|
||||||
@@ -577,6 +690,59 @@ class CivitaiClient:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
|
||||||
|
|
||||||
|
Uses POST /api/v1/model-versions/by-hash which returns full version
|
||||||
|
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
|
||||||
|
not available from the model-level API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of version dicts or None on failure.
|
||||||
|
"""
|
||||||
|
if not hashes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
all_versions: List[Dict] = []
|
||||||
|
|
||||||
|
for start in range(0, len(hashes), BATCH_SIZE):
|
||||||
|
batch = hashes[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
success, result = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/model-versions/by-hash",
|
||||||
|
use_auth=True,
|
||||||
|
json=batch,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(
|
||||||
|
"Batch by-hash request failed for %d hashes: %s",
|
||||||
|
len(batch),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
all_versions.extend(result)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Unexpected by-hash response type: %s", type(result)
|
||||||
|
)
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error fetching model versions by hashes: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_versions if all_versions else None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
"""Fetch all models for a specific Civitai user."""
|
"""Fetch all models for a specific Civitai user."""
|
||||||
if not username:
|
if not username:
|
||||||
@@ -587,7 +753,7 @@ class CivitaiClient:
|
|||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"username": username},
|
params={"username": username, "nsfw": "true"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -110,6 +110,23 @@ class DownloadCoordinator:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def skip_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
|
"""Skip a download while preserving all partial files on disk."""
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
result = await download_manager.skip_download(download_id)
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(
|
||||||
|
download_id,
|
||||||
|
{
|
||||||
|
"status": "skipped",
|
||||||
|
"progress": 0,
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": "Download skipped by user (partial files preserved)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Pause an active download and notify listeners."""
|
"""Pause an active download and notify listeners."""
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ..utils.constants import (
|
|||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
|
from ..utils.file_utils import calculate_sha256
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
from ..utils.utils import sanitize_folder_name
|
from ..utils.utils import sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
@@ -1364,7 +1365,7 @@ class DownloadManager:
|
|||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary")
|
if f.get("primary")
|
||||||
and f.get("type") in ("Model", "Negative")
|
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1395,7 +1396,7 @@ class DownloadManager:
|
|||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary") and f.get("type") in ("Model", "Negative")
|
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -2239,8 +2240,11 @@ class DownloadManager:
|
|||||||
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
# Update size to actual downloaded file size
|
# Update size to actual downloaded file size
|
||||||
entry.size = os.path.getsize(file_path)
|
entry.size = os.path.getsize(file_path)
|
||||||
# Use SHA256 from API metadata (already set in from_civitai_info)
|
# Compute SHA256 locally when the API response didn't include it
|
||||||
# Do not recalculate to avoid blocking during ComfyUI execution
|
if not entry.sha256:
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
entry.sha256 = sha256.lower()
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
@@ -2400,6 +2404,89 @@ class DownloadManager:
|
|||||||
self._download_tasks.pop(download_id, None)
|
self._download_tasks.pop(download_id, None)
|
||||||
await self._aria2_state_store.remove(download_id)
|
await self._aria2_state_store.remove(download_id)
|
||||||
|
|
||||||
|
async def skip_download(self, download_id: str) -> Dict:
|
||||||
|
"""Skip a download while preserving all partial files on disk.
|
||||||
|
|
||||||
|
Removes all in-memory tracking (asyncio task, semaphore, active/pause
|
||||||
|
state) but keeps partial files (.part / .aria2) on disk so that a
|
||||||
|
subsequent download-model-get request for the same save path can
|
||||||
|
auto-resume from the preserved partial download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_id: The unique identifier of the download task
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Status of the skip operation
|
||||||
|
"""
|
||||||
|
await self._restore_persisted_downloads()
|
||||||
|
|
||||||
|
if download_id not in self._download_tasks and download_id not in self._active_downloads:
|
||||||
|
return {"success": False, "error": "Download task not found"}
|
||||||
|
|
||||||
|
download_info = self._active_downloads.get(download_id)
|
||||||
|
task = self._download_tasks.get(download_id)
|
||||||
|
active_statuses = {"queued", "waiting", "downloading", "paused", "cancelling"}
|
||||||
|
if task is None and (
|
||||||
|
not isinstance(download_info, dict)
|
||||||
|
or download_info.get("status") not in active_statuses
|
||||||
|
):
|
||||||
|
return {"success": False, "error": "Download task not found"}
|
||||||
|
|
||||||
|
backend = (
|
||||||
|
self._active_downloads.get(download_id, {}).get("transfer_backend")
|
||||||
|
or "python"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For aria2: pause the transfer rather than force-removing it, so
|
||||||
|
# the .aria2 control file stays on disk for future resume
|
||||||
|
if backend == "aria2":
|
||||||
|
try:
|
||||||
|
aria2_downloader = await get_aria2_downloader()
|
||||||
|
pause_result = await aria2_downloader.pause_download(download_id)
|
||||||
|
if not pause_result.get("success"):
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pause aria2 transfer for %s during skip: %s",
|
||||||
|
download_id,
|
||||||
|
pause_result.get("error"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pause aria2 transfer for %s during skip: %s",
|
||||||
|
download_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel the asyncio task so the semaphore slot is released
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Resume pause event so the task can exit cleanly
|
||||||
|
pause_control = self._pause_events.get(download_id)
|
||||||
|
if pause_control is not None:
|
||||||
|
pause_control.resume()
|
||||||
|
|
||||||
|
# Wait briefly for task to acknowledge cancellation
|
||||||
|
if task is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Download skipped for task {download_id} (partial files preserved)")
|
||||||
|
return {"success": True, "message": "Download skipped successfully"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error skipping download: {e}", exc_info=True)
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
# Clean up local in-memory tracking only - NO file deletion
|
||||||
|
self._pause_events.pop(download_id, None)
|
||||||
|
self._download_tasks.pop(download_id, None)
|
||||||
|
if download_id in self._active_downloads:
|
||||||
|
del self._active_downloads[download_id]
|
||||||
|
# Preserve aria2 state store entry so the partial download
|
||||||
|
# info survives restarts and can be resumed later
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict:
|
async def pause_download(self, download_id: str) -> Dict:
|
||||||
"""Pause an active download without losing progress."""
|
"""Pause an active download without losing progress."""
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
|
|||||||
self._db_path = db_path or _resolve_database_path()
|
self._db_path = db_path or _resolve_database_path()
|
||||||
self._settings = settings_manager or get_settings_manager()
|
self._settings = settings_manager or get_settings_manager()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
self._schema_initialized = False
|
self._schema_initialized = False
|
||||||
self._ensure_directory()
|
self._ensure_directory()
|
||||||
self._initialize_schema()
|
self._initialize_schema()
|
||||||
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
return self._conn
|
||||||
|
|
||||||
def _initialize_schema(self) -> None:
|
def _initialize_schema(self) -> None:
|
||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
@@ -89,6 +96,21 @@ class DownloadedVersionHistoryService:
|
|||||||
def get_database_path(self) -> str:
|
def get_database_path(self) -> str:
|
||||||
return self._db_path
|
return self._db_path
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the persistent SQLite connection, if open.
|
||||||
|
|
||||||
|
This is called before plugin update operations to release the
|
||||||
|
database file lock on Windows, allowing ``shutil.rmtree()`` to
|
||||||
|
succeed when the cache resides inside the plugin directory.
|
||||||
|
"""
|
||||||
|
if self._conn is not None:
|
||||||
|
try:
|
||||||
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def _get_active_library_name(self) -> str | None:
|
def _get_active_library_name(self) -> str | None:
|
||||||
try:
|
try:
|
||||||
value = self._settings.get_active_library_name()
|
value = self._settings.get_active_library_name()
|
||||||
@@ -116,33 +138,33 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 0
|
is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
normalized_type,
|
normalized_type,
|
||||||
normalized_version_id,
|
normalized_version_id,
|
||||||
normalized_model_id,
|
normalized_model_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
source,
|
source,
|
||||||
file_path,
|
file_path,
|
||||||
active_library_name,
|
active_library_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_downloaded_bulk(
|
async def mark_downloaded_bulk(
|
||||||
self,
|
self,
|
||||||
@@ -180,26 +202,26 @@ class DownloadedVersionHistoryService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 0
|
is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
normalized_version_id = _normalize_int(version_id)
|
normalized_version_id = _normalize_int(version_id)
|
||||||
if normalized_type is None or normalized_version_id is None:
|
if normalized_type is None or normalized_version_id is None:
|
||||||
@@ -208,28 +230,28 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 1
|
is_deleted_override = 1
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
normalized_type,
|
normalized_type,
|
||||||
normalized_version_id,
|
normalized_version_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
self._get_active_library_name(),
|
self._get_active_library_name(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
@@ -238,15 +260,15 @@ class DownloadedVersionHistoryService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT is_deleted_override
|
SELECT is_deleted_override
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ? AND version_id = ?
|
WHERE model_type = ? AND version_id = ?
|
||||||
""",
|
""",
|
||||||
(normalized_type, normalized_version_id),
|
(normalized_type, normalized_version_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return bool(row) and not bool(row["is_deleted_override"])
|
return bool(row) and not bool(row["is_deleted_override"])
|
||||||
|
|
||||||
async def get_downloaded_version_ids(
|
async def get_downloaded_version_ids(
|
||||||
@@ -258,16 +280,16 @@ class DownloadedVersionHistoryService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT version_id
|
SELECT version_id
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||||
ORDER BY version_id ASC
|
ORDER BY version_id ASC
|
||||||
""",
|
""",
|
||||||
(normalized_type, normalized_model_id),
|
(normalized_type, normalized_model_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [int(row["version_id"]) for row in rows]
|
return [int(row["version_id"]) for row in rows]
|
||||||
|
|
||||||
async def get_downloaded_version_ids_bulk(
|
async def get_downloaded_version_ids_bulk(
|
||||||
@@ -291,17 +313,17 @@ class DownloadedVersionHistoryService:
|
|||||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id
|
SELECT model_id, version_id
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ?
|
WHERE model_type = ?
|
||||||
AND model_id IN ({placeholders})
|
AND model_id IN ({placeholders})
|
||||||
AND is_deleted_override = 0
|
AND is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
result: dict[int, set[int]] = {}
|
result: dict[int, set[int]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This module provides a centralized download service with:
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ssl
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -31,6 +32,20 @@ from .errors import RateLimitError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
|
||||||
|
"""Check if an exception represents an SSL certificate verification failure.
|
||||||
|
|
||||||
|
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
|
||||||
|
(which wraps the former), and falls back to the standard OpenSSL error text.
|
||||||
|
"""
|
||||||
|
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||||
|
return True
|
||||||
|
cert_error = getattr(exc, "certificate_error", None)
|
||||||
|
if isinstance(cert_error, ssl.SSLCertVerificationError):
|
||||||
|
return True
|
||||||
|
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DownloadProgress:
|
class DownloadProgress:
|
||||||
"""Snapshot of a download transfer at a moment in time."""
|
"""Snapshot of a download transfer at a moment in time."""
|
||||||
@@ -265,9 +280,22 @@ class Downloader:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
||||||
)
|
)
|
||||||
|
# Build SSL context: prefer certifi's CA bundle for broader
|
||||||
|
# CA coverage across different Python environments (especially
|
||||||
|
# embedded/compatibility Python builds).
|
||||||
|
try:
|
||||||
|
import certifi # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
ca_path = certifi.where()
|
||||||
|
ssl_context = ssl.create_default_context(cafile=ca_path)
|
||||||
|
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
|
||||||
|
except (ImportError, FileNotFoundError, ValueError, OSError):
|
||||||
|
ssl_context = ssl.create_default_context()
|
||||||
|
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
||||||
|
|
||||||
# Optimize TCP connection parameters
|
# Optimize TCP connection parameters
|
||||||
connector = aiohttp.TCPConnector(
|
connector = aiohttp.TCPConnector(
|
||||||
ssl=True,
|
ssl=ssl_context,
|
||||||
limit=8, # Concurrent connections
|
limit=8, # Concurrent connections
|
||||||
ttl_dns_cache=300, # DNS cache timeout
|
ttl_dns_cache=300, # DNS cache timeout
|
||||||
force_close=False, # Keep connections for reuse
|
force_close=False, # Keep connections for reuse
|
||||||
@@ -736,6 +764,17 @@ class Downloader:
|
|||||||
DownloadRestartRequested,
|
DownloadRestartRequested,
|
||||||
) as e:
|
) as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
|
|
||||||
|
if is_ssl_cert_verify_error(e):
|
||||||
|
logger.error(
|
||||||
|
"SSL certificate verification failed when connecting to %s. "
|
||||||
|
"This is usually caused by an outdated CA certificate bundle "
|
||||||
|
"in the Python environment. Recommended fixes:\n"
|
||||||
|
" 1. pip install --upgrade certifi\n"
|
||||||
|
" 2. pip install pip-system-certs",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import EmbeddingMetadata
|
from ..utils.models import EmbeddingMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
|
|||||||
"exclude": bool(embedding_data.get("exclude", False)),
|
"exclude": bool(embedding_data.get("exclude", False)),
|
||||||
"update_available": bool(embedding_data.get("update_available", False)),
|
"update_available": bool(embedding_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .model_query import resolve_sub_type
|
from .model_query import resolve_sub_type
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import LoraMetadata
|
from ..utils.models import LoraMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(
|
"civitai": self.filter_civitai_data(
|
||||||
lora_data.get("civitai", {}), minimal=True
|
lora_data.get("civitai", {}), minimal=True
|
||||||
),
|
),
|
||||||
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
@@ -310,8 +312,23 @@ class LoraService(BaseModelService):
|
|||||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||||
|
|
||||||
|
fn_normalized = filename.replace("\\", "/")
|
||||||
|
fn_no_ext = fn_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if fn_no_ext.lower().endswith(ext):
|
||||||
|
fn_no_ext = fn_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
for lora in cache.raw_data if cache else []:
|
for lora in cache.raw_data if cache else []:
|
||||||
if lora.get("file_name") == filename:
|
file_name = lora.get("file_name", "")
|
||||||
|
folder = lora.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
if fn_no_ext in (file_name_no_ext, path_name):
|
||||||
return lora
|
return lora
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -399,7 +416,10 @@ class LoraService(BaseModelService):
|
|||||||
locked_loras = locked_loras[:target_count]
|
locked_loras = locked_loras[:target_count]
|
||||||
|
|
||||||
# Filter out locked LoRAs from available pool
|
# Filter out locked LoRAs from available pool
|
||||||
locked_names = {lora["name"] for lora in locked_loras}
|
locked_names = {
|
||||||
|
os.path.basename(lora["name"]) if "/" in str(lora.get("name", "")) else lora["name"]
|
||||||
|
for lora in locked_loras
|
||||||
|
}
|
||||||
available_pool = [
|
available_pool = [
|
||||||
l for l in available_loras if l["file_name"] not in locked_names
|
l for l in available_loras if l["file_name"] not in locked_names
|
||||||
]
|
]
|
||||||
@@ -454,7 +474,7 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
result_loras.append(
|
result_loras.append(
|
||||||
{
|
{
|
||||||
"name": lora["file_name"],
|
"name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||||
"strength": model_str,
|
"strength": model_str,
|
||||||
"clipStrength": clip_str,
|
"clipStrength": clip_str,
|
||||||
"active": True,
|
"active": True,
|
||||||
@@ -670,8 +690,9 @@ class LoraService(BaseModelService):
|
|||||||
# Return minimal data needed for cycling
|
# Return minimal data needed for cycling
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"file_name": lora["file_name"],
|
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||||
"model_name": lora.get("model_name", lora["file_name"]),
|
"model_name": lora.get("model_name", lora["file_name"]),
|
||||||
|
"folder": lora.get("folder", ""),
|
||||||
}
|
}
|
||||||
for lora in available_loras
|
for lora in available_loras
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ModelHashIndex:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._hash_to_path: Dict[str, str] = {}
|
self._hash_to_path: Dict[str, str] = {}
|
||||||
self._filename_to_hash: Dict[str, str] = {}
|
self._filename_to_hash: Dict[str, str] = {}
|
||||||
|
self._autov2_to_path: Dict[str, str] = {}
|
||||||
# New data structures for tracking duplicates
|
# New data structures for tracking duplicates
|
||||||
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||||
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||||
@@ -63,6 +64,9 @@ class ModelHashIndex:
|
|||||||
# Add new mappings
|
# Add new mappings
|
||||||
self._hash_to_path[sha256] = file_path
|
self._hash_to_path[sha256] = file_path
|
||||||
self._filename_to_hash[filename] = sha256
|
self._filename_to_hash[filename] = sha256
|
||||||
|
# AutoV2 = first 10 chars of SHA256
|
||||||
|
if len(sha256) >= 10:
|
||||||
|
self._autov2_to_path[sha256[:10]] = file_path
|
||||||
|
|
||||||
def _get_filename_from_path(self, file_path: str) -> str:
|
def _get_filename_from_path(self, file_path: str) -> str:
|
||||||
"""Extract filename without extension from path"""
|
"""Extract filename without extension from path"""
|
||||||
@@ -79,6 +83,12 @@ class ModelHashIndex:
|
|||||||
hash_val = h
|
hash_val = h
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if hash_val is None:
|
||||||
|
for h, paths in self._duplicate_hashes.items():
|
||||||
|
if file_path in paths:
|
||||||
|
hash_val = h
|
||||||
|
break
|
||||||
|
|
||||||
# If we didn't find a hash, nothing to do
|
# If we didn't find a hash, nothing to do
|
||||||
if not hash_val:
|
if not hash_val:
|
||||||
return
|
return
|
||||||
@@ -151,7 +161,12 @@ class ModelHashIndex:
|
|||||||
del self._duplicate_filenames[filename]
|
del self._duplicate_filenames[filename]
|
||||||
if filename in self._filename_to_hash:
|
if filename in self._filename_to_hash:
|
||||||
del self._filename_to_hash[filename]
|
del self._filename_to_hash[filename]
|
||||||
|
|
||||||
|
# Remove from AutoV2 index
|
||||||
|
autov2_keys_to_remove = [k for k, v in self._autov2_to_path.items() if v == file_path]
|
||||||
|
for k in autov2_keys_to_remove:
|
||||||
|
del self._autov2_to_path[k]
|
||||||
|
|
||||||
def remove_by_hash(self, sha256: str) -> None:
|
def remove_by_hash(self, sha256: str) -> None:
|
||||||
"""Remove entry by hash"""
|
"""Remove entry by hash"""
|
||||||
sha256 = sha256.lower()
|
sha256 = sha256.lower()
|
||||||
@@ -171,6 +186,10 @@ class ModelHashIndex:
|
|||||||
# Remove hash-to-path mapping
|
# Remove hash-to-path mapping
|
||||||
del self._hash_to_path[sha256]
|
del self._hash_to_path[sha256]
|
||||||
|
|
||||||
|
autov2_key = sha256[:10]
|
||||||
|
if autov2_key in self._autov2_to_path:
|
||||||
|
del self._autov2_to_path[autov2_key]
|
||||||
|
|
||||||
# Update filename-to-hash and duplicate filenames for all paths
|
# Update filename-to-hash and duplicate filenames for all paths
|
||||||
for path_to_remove in paths_to_remove:
|
for path_to_remove in paths_to_remove:
|
||||||
fname = self._get_filename_from_path(path_to_remove)
|
fname = self._get_filename_from_path(path_to_remove)
|
||||||
@@ -189,13 +208,24 @@ class ModelHashIndex:
|
|||||||
# If only one entry remains, it's no longer a duplicate
|
# If only one entry remains, it's no longer a duplicate
|
||||||
del self._duplicate_filenames[fname]
|
del self._duplicate_filenames[fname]
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, hash_value: str) -> bool:
|
||||||
"""Check if hash exists in index"""
|
"""Check if hash exists in index (SHA256 or AutoV2)"""
|
||||||
return sha256.lower() in self._hash_to_path
|
normalized = hash_value.lower()
|
||||||
|
if normalized in self._hash_to_path:
|
||||||
def get_path(self, sha256: str) -> Optional[str]:
|
return True
|
||||||
"""Get file path for a hash"""
|
if len(normalized) == 10:
|
||||||
return self._hash_to_path.get(sha256.lower())
|
return normalized in self._autov2_to_path
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_path(self, hash_value: str) -> Optional[str]:
|
||||||
|
"""Get file path for a hash (SHA256 or AutoV2)"""
|
||||||
|
normalized = hash_value.lower()
|
||||||
|
path = self._hash_to_path.get(normalized)
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
if len(normalized) == 10:
|
||||||
|
return self._autov2_to_path.get(normalized)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_hash(self, file_path: str) -> Optional[str]:
|
def get_hash(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a file path"""
|
"""Get hash for a file path"""
|
||||||
@@ -203,13 +233,16 @@ class ModelHashIndex:
|
|||||||
return self._filename_to_hash.get(filename)
|
return self._filename_to_hash.get(filename)
|
||||||
|
|
||||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||||
"""Get hash for a filename without extension"""
|
"""Get hash for a filename (bare basename or path-prefixed name)"""
|
||||||
|
if "/" in filename or "\\" in filename:
|
||||||
|
filename = os.path.splitext(os.path.basename(filename.replace("\\", "/")))[0]
|
||||||
return self._filename_to_hash.get(filename)
|
return self._filename_to_hash.get(filename)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all entries"""
|
"""Clear all entries"""
|
||||||
self._hash_to_path.clear()
|
self._hash_to_path.clear()
|
||||||
self._filename_to_hash.clear()
|
self._filename_to_hash.clear()
|
||||||
|
self._autov2_to_path.clear()
|
||||||
self._duplicate_hashes.clear()
|
self._duplicate_hashes.clear()
|
||||||
self._duplicate_filenames.clear()
|
self._duplicate_filenames.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ class ModelLifecycleService:
|
|||||||
self._scanner._hash_index.remove_by_path(file_path)
|
self._scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
await self._sync_update_for_model(model_id)
|
await self._sync_update_for_model(model_id)
|
||||||
|
|
||||||
|
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist_current_cache):
|
||||||
|
await persist_current_cache()
|
||||||
|
|
||||||
return {"success": True, "deleted_files": deleted_files}
|
return {"success": True, "deleted_files": deleted_files}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
||||||
from .downloader import get_downloader
|
from .downloader import get_downloader
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -108,6 +108,18 @@ class ModelMetadataProvider(ABC):
|
|||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict]]:
|
||||||
"""Fetch model versions for multiple model ids when supported."""
|
"""Fetch model versions for multiple model ids when supported."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch full version details for multiple SHA256 hashes.
|
||||||
|
|
||||||
|
Used specifically to retrieve ``usageControl`` which is only
|
||||||
|
available from the per-version / by-hash API, not from model-level
|
||||||
|
responses. Providers that cannot resolve hashes should let the
|
||||||
|
default ``NotImplementedError`` propagate.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
@@ -140,6 +152,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
self, model_ids: Sequence[int]
|
self, model_ids: Sequence[int]
|
||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict]]:
|
||||||
return await self.client.get_model_versions_bulk(model_ids)
|
return await self.client.get_model_versions_bulk(model_ids)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
return await self.client.get_model_versions_by_hashes(hashes)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
return await self.client.get_model_version(model_id, version_id)
|
return await self.client.get_model_version(model_id, version_id)
|
||||||
@@ -465,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
return None, "Model not found"
|
return None, "Model not found"
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
not_found_confirmed = False
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
@@ -475,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
|
if not_found_confirmed:
|
||||||
|
logger.debug(
|
||||||
|
"Suppressing rate limit from %s for model %s: "
|
||||||
|
"already confirmed as not found by another provider",
|
||||||
|
label,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
exc.provider = exc.provider or label
|
exc.provider = exc.provider or label
|
||||||
raise exc
|
raise exc
|
||||||
|
except ResourceNotFoundError:
|
||||||
|
not_found_confirmed = True
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s reports model %s as not found",
|
||||||
|
label,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -519,6 +553,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None, "No provider could retrieve the data"
|
return None, "No provider could retrieve the data"
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_versions_by_hashes,
|
||||||
|
hashes,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except NotImplementedError:
|
||||||
|
continue
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||||
|
label,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
@@ -593,6 +653,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
model_ids,
|
model_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_versions_by_hashes,
|
||||||
|
hashes,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
@@ -669,6 +738,17 @@ class ModelMetadataProviderManager:
|
|||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_version_info(version_id)
|
return await provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self,
|
||||||
|
hashes: List[str],
|
||||||
|
provider_name: str = None,
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
try:
|
||||||
|
return await provider.get_model_versions_by_hashes(hashes)
|
||||||
|
except NotImplementedError:
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
||||||
"""Fetch models owned by the specified user"""
|
"""Fetch models owned by the specified user"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class FilterCriteria:
|
|||||||
folder_exclude: Optional[Sequence[str]] = None
|
folder_exclude: Optional[Sequence[str]] = None
|
||||||
base_models: Optional[Sequence[str]] = None
|
base_models: Optional[Sequence[str]] = None
|
||||||
tags: Optional[Dict[str, str]] = None
|
tags: Optional[Dict[str, str]] = None
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None
|
||||||
favorites_only: bool = False
|
favorites_only: bool = False
|
||||||
search_options: Optional[Dict[str, Any]] = None
|
search_options: Optional[Dict[str, Any]] = None
|
||||||
model_types: Optional[Sequence[str]] = None
|
model_types: Optional[Sequence[str]] = None
|
||||||
@@ -359,10 +360,37 @@ class ModelFilterSet:
|
|||||||
]
|
]
|
||||||
model_types_duration = time.perf_counter() - t0
|
model_types_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
auto_tags_duration = 0
|
||||||
|
auto_tag_filters = criteria.auto_tags or {}
|
||||||
|
if auto_tag_filters:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
include_at = set()
|
||||||
|
exclude_at = set()
|
||||||
|
for tag, state in auto_tag_filters.items():
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
if state == "exclude":
|
||||||
|
exclude_at.add(tag)
|
||||||
|
else:
|
||||||
|
include_at.add(tag)
|
||||||
|
|
||||||
|
if include_at:
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if any(tag in include_at for tag in (item.get("auto_tags") or []))
|
||||||
|
]
|
||||||
|
|
||||||
|
if exclude_at:
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
|
||||||
|
]
|
||||||
|
auto_tags_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
duration = time.perf_counter() - overall_start
|
duration = time.perf_counter() - overall_start
|
||||||
if duration > 0.1: # Only log if it's potentially slow
|
if duration > 0.1: # Only log if it's potentially slow
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
|
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
|
||||||
"Count: %d -> %d",
|
"Count: %d -> %d",
|
||||||
duration,
|
duration,
|
||||||
sfw_duration,
|
sfw_duration,
|
||||||
@@ -371,6 +399,7 @@ class ModelFilterSet:
|
|||||||
base_models_duration,
|
base_models_duration,
|
||||||
tags_duration,
|
tags_duration,
|
||||||
model_types_duration,
|
model_types_duration,
|
||||||
|
auto_tags_duration,
|
||||||
initial_count,
|
initial_count,
|
||||||
len(items),
|
len(items),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set,
|
|||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.file_utils import find_preview_file, get_preview_extension
|
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.civitai_utils import resolve_license_info
|
from ..utils.civitai_utils import resolve_license_info
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
@@ -1067,19 +1067,24 @@ class ModelScanner:
|
|||||||
|
|
||||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||||
|
|
||||||
|
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
||||||
|
if not model_data.get('sha256') and file_path:
|
||||||
|
try:
|
||||||
|
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
model_data['sha256'] = sha256.lower()
|
||||||
|
if isinstance(metadata, BaseModelMetadata):
|
||||||
|
metadata.sha256 = sha256.lower()
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
|
||||||
|
|
||||||
# Skip excluded models
|
# Skip excluded models
|
||||||
if model_data.get('exclude', False):
|
if model_data.get('exclude', False):
|
||||||
excluded_models.append(model_data['file_path'])
|
excluded_models.append(model_data['file_path'])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for duplicate filename before adding to hash index
|
|
||||||
# filename = os.path.splitext(os.path.basename(file_path))[0]
|
|
||||||
# existing_hash = hash_index.get_hash_by_filename(filename)
|
|
||||||
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
|
|
||||||
# existing_path = hash_index.get_path(existing_hash)
|
|
||||||
# if existing_path and existing_path != file_path:
|
|
||||||
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
|
|
||||||
|
|
||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
||||||
@@ -1105,6 +1110,39 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
|
self._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
def _log_duplicate_filename_summary(self) -> None:
|
||||||
|
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||||
|
# Duplicate filename detection is only relevant for LoRAs, which use
|
||||||
|
# basename-only syntax (<lora:name:strength>). Checkpoints and embeddings
|
||||||
|
# use full relative paths for resolution, so conflicts are not ambiguous.
|
||||||
|
if self._hash_index is None or self.model_type != "lora":
|
||||||
|
return
|
||||||
|
|
||||||
|
# When full path syntax is active, duplicate filenames across subfolders
|
||||||
|
# are fully qualified, so there is no ambiguity — skip the warning.
|
||||||
|
if get_settings_manager().get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return
|
||||||
|
|
||||||
|
duplicates = self._hash_index.get_duplicate_filenames()
|
||||||
|
if not duplicates:
|
||||||
|
return
|
||||||
|
|
||||||
|
total_files = sum(len(paths) for paths in duplicates.values())
|
||||||
|
conflict_count = len(duplicates)
|
||||||
|
model_type_label = self.model_type or "model"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Duplicate filename conflict detected: %d %s filename(s) "
|
||||||
|
"are shared by %d files total, causing ambiguity in %s resolution. "
|
||||||
|
"Open the Doctor panel to resolve one-click.",
|
||||||
|
conflict_count,
|
||||||
|
model_type_label,
|
||||||
|
total_files,
|
||||||
|
model_type_label.capitalize(),
|
||||||
|
)
|
||||||
|
|
||||||
async def _sync_download_history(
|
async def _sync_download_history(
|
||||||
self,
|
self,
|
||||||
raw_data: List[Mapping[str, Any]],
|
raw_data: List[Mapping[str, Any]],
|
||||||
@@ -1456,6 +1494,15 @@ class ModelScanner:
|
|||||||
file_path_override=normalized_new_path,
|
file_path_override=normalized_new_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ensure sha256 is populated even when metadata doesn't have it
|
||||||
|
if not cache_entry.get('sha256') and normalized_new_path and os.path.exists(normalized_new_path):
|
||||||
|
try:
|
||||||
|
sha256 = await calculate_sha256(normalized_new_path)
|
||||||
|
if sha256:
|
||||||
|
cache_entry['sha256'] = sha256.lower()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to compute SHA256 for {normalized_new_path}: {e}")
|
||||||
|
|
||||||
if recalculate_type:
|
if recalculate_type:
|
||||||
cache_entry = self.adjust_cached_entry(cache_entry)
|
cache_entry = self.adjust_cached_entry(cache_entry)
|
||||||
|
|
||||||
@@ -1555,12 +1602,39 @@ class ModelScanner:
|
|||||||
"""Get model information by name"""
|
"""Get model information by name"""
|
||||||
try:
|
try:
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
name_normalized = name.replace("\\", "/")
|
||||||
|
name_no_ext = name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if name_no_ext.lower().endswith(ext):
|
||||||
|
name_no_ext = name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in name_no_ext
|
||||||
|
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model.get("file_name") == name:
|
file_name = model.get("file_name", "")
|
||||||
|
folder = model.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||||
return model
|
return model
|
||||||
|
|
||||||
return None
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = model
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = model
|
||||||
|
|
||||||
|
return best_fallback
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ModelVersionRecord:
|
|||||||
early_access_ends_at: Optional[str] = None
|
early_access_ends_at: Optional[str] = None
|
||||||
sort_index: int = 0
|
sort_index: int = 0
|
||||||
is_early_access: bool = False
|
is_early_access: bool = False
|
||||||
|
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
|
|||||||
|
|
||||||
return [version.version_id for version in self.versions if version.is_in_library]
|
return [version.version_id for version in self.versions if version.is_in_library]
|
||||||
|
|
||||||
def has_update(self, hide_early_access: bool = False) -> bool:
|
def has_update(
|
||||||
|
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
|
||||||
|
) -> bool:
|
||||||
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
|
|||||||
not version.is_in_library
|
not version.is_in_library
|
||||||
and not version.should_ignore
|
and not version.should_ignore
|
||||||
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
||||||
|
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
|
||||||
for version in self.versions
|
for version in self.versions
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
if version.version_id > max_in_library:
|
if version.version_id > max_in_library:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
|
|||||||
# Phase 1: Basic EA flag from bulk API
|
# Phase 1: Basic EA flag from bulk API
|
||||||
return version.is_early_access
|
return version.is_early_access
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_downloadable(version: ModelVersionRecord) -> bool:
|
||||||
|
if version.usage_control is None:
|
||||||
|
return True
|
||||||
|
return version.usage_control == "Download"
|
||||||
|
|
||||||
def has_update_for_base(
|
def has_update_for_base(
|
||||||
self,
|
self,
|
||||||
local_version_id: Optional[int],
|
local_version_id: Optional[int],
|
||||||
local_base_model: Optional[str],
|
local_base_model: Optional[str],
|
||||||
hide_early_access: bool = False,
|
hide_early_access: bool = False,
|
||||||
|
hide_non_downloadable: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when a newer remote version with the same base model exists.
|
"""Return True when a newer remote version with the same base model exists.
|
||||||
|
|
||||||
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
|
|||||||
local_version_id: The current local version id.
|
local_version_id: The current local version id.
|
||||||
local_base_model: The base model to filter by.
|
local_base_model: The base model to filter by.
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
version_base = _normalize_base_model(version.base_model)
|
version_base = _normalize_base_model(version.base_model)
|
||||||
if version_base != normalized_base:
|
if version_base != normalized_base:
|
||||||
continue
|
continue
|
||||||
@@ -209,6 +226,8 @@ class ModelUpdateRecord:
|
|||||||
class ModelUpdateService:
|
class ModelUpdateService:
|
||||||
"""Persist and query remote model version metadata."""
|
"""Persist and query remote model version metadata."""
|
||||||
|
|
||||||
|
_SQLITE_MAX_VARIABLES = 500
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
CREATE TABLE IF NOT EXISTS model_update_status (
|
CREATE TABLE IF NOT EXISTS model_update_status (
|
||||||
@@ -228,6 +247,7 @@ class ModelUpdateService:
|
|||||||
preview_url TEXT,
|
preview_url TEXT,
|
||||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_control TEXT,
|
||||||
PRIMARY KEY (model_id, version_id),
|
PRIMARY KEY (model_id, version_id),
|
||||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -463,6 +483,10 @@ class ModelUpdateService:
|
|||||||
"ALTER TABLE model_update_versions "
|
"ALTER TABLE model_update_versions "
|
||||||
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
||||||
),
|
),
|
||||||
|
"usage_control": (
|
||||||
|
"ALTER TABLE model_update_versions "
|
||||||
|
"ADD COLUMN usage_control TEXT"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, statement in migrations.items():
|
for column, statement in migrations.items():
|
||||||
@@ -665,6 +689,7 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, ModelUpdateRecord]:
|
) -> Dict[int, ModelUpdateRecord]:
|
||||||
"""Refresh update information for every model present in the cache."""
|
"""Refresh update information for every model present in the cache."""
|
||||||
scanner.reset_cancellation()
|
scanner.reset_cancellation()
|
||||||
@@ -679,6 +704,7 @@ class ModelUpdateService:
|
|||||||
local_versions = await self._collect_local_versions(
|
local_versions = await self._collect_local_versions(
|
||||||
scanner,
|
scanner,
|
||||||
target_model_ids=target_filter,
|
target_model_ids=target_filter,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
total_models = len(local_versions)
|
total_models = len(local_versions)
|
||||||
if total_models == 0:
|
if total_models == 0:
|
||||||
@@ -965,18 +991,22 @@ class ModelUpdateService:
|
|||||||
fallback_attempted = True
|
fallback_attempted = True
|
||||||
try:
|
try:
|
||||||
response = await metadata_provider.get_model_versions(model_id)
|
response = await metadata_provider.get_model_versions(model_id)
|
||||||
|
if response is not None:
|
||||||
|
await self._enrich_version_entries(
|
||||||
|
metadata_provider,
|
||||||
|
{model_id: response},
|
||||||
|
)
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
except ResourceNotFoundError as exc:
|
except ResourceNotFoundError as exc:
|
||||||
fallback_error_message = str(exc) or "resource not found"
|
fallback_error_message = str(exc) or "resource not found"
|
||||||
mark_model_as_ignored = True
|
mark_model_as_ignored = True
|
||||||
except Exception as exc: # pragma: no cover - defensive log
|
except Exception as exc: # pragma: no cover - defensive log
|
||||||
logger.error(
|
logger.warning(
|
||||||
"Failed to fetch versions for model %s (%s): %s",
|
"Failed to fetch versions for model %s (%s): %s",
|
||||||
model_id,
|
model_id,
|
||||||
model_type,
|
model_type,
|
||||||
exc,
|
exc,
|
||||||
exc_info=True,
|
|
||||||
)
|
)
|
||||||
fallback_error_message = str(exc)
|
fallback_error_message = str(exc)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
@@ -1059,6 +1089,136 @@ class ModelUpdateService:
|
|||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
async def _enrich_version_entries(
|
||||||
|
self,
|
||||||
|
metadata_provider,
|
||||||
|
responses_by_model_id: Dict[int, Mapping],
|
||||||
|
) -> None:
|
||||||
|
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
|
||||||
|
|
||||||
|
The model-level API does not include ``usageControl`` on version
|
||||||
|
entries. This method collects SHA256 hashes from every version's
|
||||||
|
primary model file, calls ``POST /api/v1/model-versions/by-hash``
|
||||||
|
(up to 100 hashes per request), and injects ``usageControl`` +
|
||||||
|
``earlyAccessEndsAt`` into each version entry dict in-place.
|
||||||
|
"""
|
||||||
|
if not metadata_provider or not responses_by_model_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
hashes_by_version: Dict[int, str] = {}
|
||||||
|
for response in responses_by_model_id.values():
|
||||||
|
hashes_by_version.update(
|
||||||
|
self._collect_hashes_from_response(response)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hashes_by_version:
|
||||||
|
return
|
||||||
|
|
||||||
|
version_ids_by_hash: Dict[str, List[int]] = {}
|
||||||
|
for version_id, sha256 in hashes_by_version.items():
|
||||||
|
version_ids_by_hash.setdefault(sha256, []).append(version_id)
|
||||||
|
|
||||||
|
all_hashes = list(version_ids_by_hash.keys())
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
|
||||||
|
enrichment: Dict[int, Dict] = {}
|
||||||
|
try:
|
||||||
|
for start in range(0, len(all_hashes), BATCH_SIZE):
|
||||||
|
batch = all_hashes[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
enriched = await metadata_provider.get_model_versions_by_hashes(
|
||||||
|
batch
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
return
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not enriched:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in enriched:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
version_id = entry.get("id")
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
enrichment[version_id] = {
|
||||||
|
"usageControl": _normalize_string(
|
||||||
|
entry.get("usageControl")
|
||||||
|
),
|
||||||
|
"earlyAccessEndsAt": _normalize_string(
|
||||||
|
entry.get("earlyAccessEndsAt")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not enrichment:
|
||||||
|
return
|
||||||
|
|
||||||
|
for response in responses_by_model_id.values():
|
||||||
|
versions = response.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
continue
|
||||||
|
for version in versions:
|
||||||
|
if not isinstance(version, dict):
|
||||||
|
continue
|
||||||
|
version_id = version.get("id")
|
||||||
|
if version_id not in enrichment:
|
||||||
|
continue
|
||||||
|
extra = enrichment[version_id]
|
||||||
|
if extra.get("usageControl") and not version.get("usageControl"):
|
||||||
|
version["usageControl"] = extra["usageControl"]
|
||||||
|
if extra.get("earlyAccessEndsAt") and not version.get(
|
||||||
|
"earlyAccessEndsAt"
|
||||||
|
):
|
||||||
|
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
|
||||||
|
"""Extract ``{version_id: sha256}`` from a model-level API response.
|
||||||
|
|
||||||
|
Returns an empty dict if the response structure is unexpected.
|
||||||
|
"""
|
||||||
|
result: Dict[int, str] = {}
|
||||||
|
versions = response.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
return result
|
||||||
|
for entry in versions:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
version_id = _normalize_int(entry.get("id"))
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
|
||||||
|
if sha256:
|
||||||
|
result[version_id] = sha256
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
|
||||||
|
"""Return the SHA256 hash from the primary model file of a version entry."""
|
||||||
|
files = entry.get("files")
|
||||||
|
if not isinstance(files, list):
|
||||||
|
return None
|
||||||
|
for file_info in files:
|
||||||
|
if not isinstance(file_info, dict):
|
||||||
|
continue
|
||||||
|
if file_info.get("type") != "Model":
|
||||||
|
continue
|
||||||
|
primary = file_info.get("primary")
|
||||||
|
if primary is not True and str(primary).strip().lower() != "true":
|
||||||
|
continue
|
||||||
|
hashes = file_info.get("hashes")
|
||||||
|
if isinstance(hashes, dict):
|
||||||
|
sha256 = hashes.get("SHA256")
|
||||||
|
if sha256:
|
||||||
|
return sha256
|
||||||
|
return None
|
||||||
|
|
||||||
async def _fetch_model_versions_bulk(
|
async def _fetch_model_versions_bulk(
|
||||||
self,
|
self,
|
||||||
metadata_provider,
|
metadata_provider,
|
||||||
@@ -1110,6 +1270,7 @@ class ModelUpdateService:
|
|||||||
len(aggregated),
|
len(aggregated),
|
||||||
provider_name,
|
provider_name,
|
||||||
)
|
)
|
||||||
|
await self._enrich_version_entries(metadata_provider, aggregated)
|
||||||
return aggregated
|
return aggregated
|
||||||
|
|
||||||
async def _collect_local_versions(
|
async def _collect_local_versions(
|
||||||
@@ -1117,6 +1278,7 @@ class ModelUpdateService:
|
|||||||
scanner,
|
scanner,
|
||||||
*,
|
*,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, List[int]]:
|
) -> Dict[int, List[int]]:
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
mapping: Dict[int, set[int]] = {}
|
mapping: Dict[int, set[int]] = {}
|
||||||
@@ -1129,7 +1291,19 @@ class ModelUpdateService:
|
|||||||
if not target_set:
|
if not target_set:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_folder = None
|
||||||
|
if folder_path is not None:
|
||||||
|
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
|
# Apply folder filter first (cheapest check)
|
||||||
|
if normalized_folder is not None:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
|
||||||
|
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
|
||||||
|
continue
|
||||||
|
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1237,6 +1411,7 @@ class ModelUpdateService:
|
|||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
is_early_access=remote_version.is_early_access,
|
is_early_access=remote_version.is_early_access,
|
||||||
|
usage_control=remote_version.usage_control,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1335,6 +1510,7 @@ class ModelUpdateService:
|
|||||||
# Check availability field from bulk API for basic EA detection
|
# Check availability field from bulk API for basic EA detection
|
||||||
availability = _normalize_string(entry.get("availability"))
|
availability = _normalize_string(entry.get("availability"))
|
||||||
is_early_access = availability == "EarlyAccess"
|
is_early_access = availability == "EarlyAccess"
|
||||||
|
usage_control = _normalize_string(entry.get("usageControl"))
|
||||||
|
|
||||||
return ModelVersionRecord(
|
return ModelVersionRecord(
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
@@ -1348,6 +1524,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=early_access_ends_at,
|
early_access_ends_at=early_access_ends_at,
|
||||||
sort_index=index,
|
sort_index=index,
|
||||||
is_early_access=is_early_access,
|
is_early_access=is_early_access,
|
||||||
|
usage_control=usage_control,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||||
@@ -1439,33 +1616,41 @@ class ModelUpdateService:
|
|||||||
if not model_ids:
|
if not model_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
params = tuple(model_ids)
|
ids = list(model_ids)
|
||||||
placeholders = ",".join("?" for _ in params)
|
status_rows: list = []
|
||||||
|
version_rows: list = []
|
||||||
|
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
status_rows = conn.execute(
|
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
|
||||||
f"""
|
chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES])
|
||||||
SELECT model_id, model_type, last_checked_at, should_ignore_model
|
placeholders = ",".join("?" for _ in chunk)
|
||||||
FROM model_update_status
|
|
||||||
WHERE model_id IN ({placeholders})
|
chunk_status = conn.execute(
|
||||||
""",
|
f"""
|
||||||
params,
|
SELECT model_id, model_type, last_checked_at, should_ignore_model
|
||||||
).fetchall()
|
FROM model_update_status
|
||||||
|
WHERE model_id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
chunk,
|
||||||
|
).fetchall()
|
||||||
|
status_rows.extend(chunk_status)
|
||||||
|
|
||||||
|
chunk_versions = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
|
is_early_access, usage_control
|
||||||
|
FROM model_update_versions
|
||||||
|
WHERE model_id IN ({placeholders})
|
||||||
|
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||||
|
""",
|
||||||
|
chunk,
|
||||||
|
).fetchall()
|
||||||
|
version_rows.extend(chunk_versions)
|
||||||
|
|
||||||
if not status_rows:
|
if not status_rows:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
version_rows = conn.execute(
|
|
||||||
f"""
|
|
||||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
|
||||||
is_early_access
|
|
||||||
FROM model_update_versions
|
|
||||||
WHERE model_id IN ({placeholders})
|
|
||||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
versions_by_model: Dict[int, List[ModelVersionRecord]] = {}
|
versions_by_model: Dict[int, List[ModelVersionRecord]] = {}
|
||||||
for row in version_rows:
|
for row in version_rows:
|
||||||
model_id = int(row["model_id"])
|
model_id = int(row["model_id"])
|
||||||
@@ -1482,6 +1667,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=row["early_access_ends_at"],
|
early_access_ends_at=row["early_access_ends_at"],
|
||||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||||
is_early_access=bool(row["is_early_access"]),
|
is_early_access=bool(row["is_early_access"]),
|
||||||
|
usage_control=row["usage_control"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1538,8 +1724,8 @@ class ModelUpdateService:
|
|||||||
INSERT INTO model_update_versions (
|
INSERT INTO model_update_versions (
|
||||||
version_id, model_id, sort_index, name, base_model, released_at,
|
version_id, model_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
version.version_id,
|
version.version_id,
|
||||||
@@ -1554,6 +1740,7 @@ class ModelUpdateService:
|
|||||||
1 if version.should_ignore else 0,
|
1 if version.should_ignore else 0,
|
||||||
version.early_access_ends_at,
|
version.early_access_ends_at,
|
||||||
1 if version.is_early_access else 0,
|
1 if version.is_early_access else 0,
|
||||||
|
version.usage_control,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class PersistentRecipeCache:
|
|||||||
"json_path",
|
"json_path",
|
||||||
"title",
|
"title",
|
||||||
"folder",
|
"folder",
|
||||||
|
"source_path",
|
||||||
"base_model",
|
"base_model",
|
||||||
"fingerprint",
|
"fingerprint",
|
||||||
"created_date",
|
"created_date",
|
||||||
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
|
|||||||
json_path TEXT,
|
json_path TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
folder TEXT,
|
folder TEXT,
|
||||||
|
source_path TEXT,
|
||||||
base_model TEXT,
|
base_model TEXT,
|
||||||
fingerprint TEXT,
|
fingerprint TEXT,
|
||||||
created_date REAL,
|
created_date REAL,
|
||||||
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Migration: add source_path column to existing databases
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
|
|||||||
json_path,
|
json_path,
|
||||||
recipe.get("title"),
|
recipe.get("title"),
|
||||||
recipe.get("folder"),
|
recipe.get("folder"),
|
||||||
|
recipe.get("source_path"),
|
||||||
recipe.get("base_model"),
|
recipe.get("base_model"),
|
||||||
recipe.get("fingerprint"),
|
recipe.get("fingerprint"),
|
||||||
float(recipe.get("created_date") or 0.0),
|
float(recipe.get("created_date") or 0.0),
|
||||||
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
|
|||||||
"file_path": row["file_path"] or "",
|
"file_path": row["file_path"] or "",
|
||||||
"title": row["title"] or "",
|
"title": row["title"] or "",
|
||||||
"folder": row["folder"] or "",
|
"folder": row["folder"] or "",
|
||||||
|
"source_path": row["source_path"] or "",
|
||||||
"base_model": row["base_model"] or "",
|
"base_model": row["base_model"] or "",
|
||||||
"fingerprint": row["fingerprint"] or "",
|
"fingerprint": row["fingerprint"] or "",
|
||||||
"created_date": row["created_date"] or 0.0,
|
"created_date": row["created_date"] or 0.0,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class RecipeScanner:
|
|||||||
cls._instance._civitai_client = None # Will be lazily initialized
|
cls._instance._civitai_client = None # Will be lazily initialized
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
REPAIR_VERSION = 3
|
REPAIR_VERSION = 4
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -292,6 +292,32 @@ class RecipeScanner:
|
|||||||
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
|
||||||
|
# A checkpoint whose modelVersionId also appears in a LoRA entry is
|
||||||
|
# definitely wrong — the CivitAI import code used to pick
|
||||||
|
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
|
||||||
|
# Clearing it lets the enrichment flow re-resolve the correct
|
||||||
|
# checkpoint from CivitAI image metadata.
|
||||||
|
cp = recipe.get("checkpoint")
|
||||||
|
lora_mvids = {
|
||||||
|
l.get("modelVersionId")
|
||||||
|
for l in recipe.get("loras", [])
|
||||||
|
if l.get("modelVersionId")
|
||||||
|
}
|
||||||
|
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
|
||||||
|
cp_mvid = cp["modelVersionId"]
|
||||||
|
logger.info(
|
||||||
|
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
|
||||||
|
"clearing corrupted checkpoint and removing matching LoRA entry",
|
||||||
|
recipe.get("id"),
|
||||||
|
cp_mvid,
|
||||||
|
)
|
||||||
|
recipe["checkpoint"] = None
|
||||||
|
recipe["loras"] = [
|
||||||
|
l for l in recipe.get("loras", [])
|
||||||
|
if l.get("modelVersionId") != cp_mvid
|
||||||
|
]
|
||||||
|
|
||||||
# 2. Identification: Is repair needed?
|
# 2. Identification: Is repair needed?
|
||||||
has_checkpoint = (
|
has_checkpoint = (
|
||||||
"checkpoint" in recipe
|
"checkpoint" in recipe
|
||||||
@@ -504,6 +530,9 @@ class RecipeScanner:
|
|||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
self._update_folder_metadata(self._cache)
|
self._update_folder_metadata(self._cache)
|
||||||
self._sort_cache_sync()
|
self._sort_cache_sync()
|
||||||
|
# Backfill source_path from JSON files if missing (schema migration)
|
||||||
|
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||||
|
self._persistent_cache.save_cache(recipes, json_paths)
|
||||||
return self._cache
|
return self._cache
|
||||||
else:
|
else:
|
||||||
# Partial update: some files changed
|
# Partial update: some files changed
|
||||||
@@ -514,6 +543,8 @@ class RecipeScanner:
|
|||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
self._update_folder_metadata(self._cache)
|
self._update_folder_metadata(self._cache)
|
||||||
self._sort_cache_sync()
|
self._sort_cache_sync()
|
||||||
|
# Backfill source_path from JSON files if missing (schema migration)
|
||||||
|
self._backfill_source_path_if_needed(recipes, json_paths)
|
||||||
# Persist updated cache
|
# Persist updated cache
|
||||||
self._persistent_cache.save_cache(recipes, json_paths)
|
self._persistent_cache.save_cache(recipes, json_paths)
|
||||||
return self._cache
|
return self._cache
|
||||||
@@ -642,6 +673,34 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return recipes, changed, json_paths
|
return recipes, changed, json_paths
|
||||||
|
|
||||||
|
def _backfill_source_path_if_needed(
|
||||||
|
self,
|
||||||
|
recipes: List[Dict],
|
||||||
|
json_paths: Dict[str, str],
|
||||||
|
) -> bool:
|
||||||
|
"""Backfill source_path from recipe JSON files if missing from cache.
|
||||||
|
|
||||||
|
Returns True if any recipes were updated (caller should persist cache).
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
for recipe in recipes:
|
||||||
|
if recipe.get("source_path"):
|
||||||
|
continue
|
||||||
|
recipe_id = str(recipe.get("id", ""))
|
||||||
|
json_path = json_paths.get(recipe_id)
|
||||||
|
if not json_path or not os.path.exists(json_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
file_source_path = json_data.get("source_path")
|
||||||
|
if file_source_path:
|
||||||
|
recipe["source_path"] = file_source_path
|
||||||
|
updated = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return updated
|
||||||
|
|
||||||
def _full_directory_scan_sync(
|
def _full_directory_scan_sync(
|
||||||
self, recipes_dir: str
|
self, recipes_dir: str
|
||||||
) -> Tuple[List[Dict], Dict[str, str]]:
|
) -> Tuple[List[Dict], Dict[str, str]]:
|
||||||
@@ -1815,6 +1874,15 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return await self._lora_scanner.get_model_info_by_name(name)
|
return await self._lora_scanner.get_model_info_by_name(name)
|
||||||
|
|
||||||
|
async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Lookup a local checkpoint model by name."""
|
||||||
|
|
||||||
|
checkpoint_scanner = getattr(self, "_checkpoint_scanner", None)
|
||||||
|
if not checkpoint_scanner or not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await checkpoint_scanner.get_model_info_by_name(name)
|
||||||
|
|
||||||
async def get_paginated_data(
|
async def get_paginated_data(
|
||||||
self,
|
self,
|
||||||
page: int,
|
page: int,
|
||||||
@@ -2475,6 +2543,7 @@ class RecipeScanner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
file_name = None
|
file_name = None
|
||||||
|
folder = ""
|
||||||
hash_value = (lora.get("hash") or "").lower()
|
hash_value = (lora.get("hash") or "").lower()
|
||||||
if (
|
if (
|
||||||
hash_value
|
hash_value
|
||||||
@@ -2484,6 +2553,11 @@ class RecipeScanner:
|
|||||||
file_path = self._lora_scanner._hash_index.get_path(hash_value)
|
file_path = self._lora_scanner._hash_index.get_path(hash_value)
|
||||||
if file_path:
|
if file_path:
|
||||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
if lora_cache is not None:
|
||||||
|
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||||
|
if cached_lora.get("file_path") == file_path:
|
||||||
|
folder = cached_lora.get("folder", "")
|
||||||
|
break
|
||||||
|
|
||||||
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
|
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
|
||||||
for cached_lora in getattr(lora_cache, "raw_data", []):
|
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||||
@@ -2498,13 +2572,16 @@ class RecipeScanner:
|
|||||||
file_name = os.path.splitext(os.path.basename(cached_path))[
|
file_name = os.path.splitext(os.path.basename(cached_path))[
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
|
folder = cached_lora.get("folder", "")
|
||||||
break
|
break
|
||||||
|
|
||||||
if not file_name:
|
if not file_name:
|
||||||
file_name = lora.get("file_name", "unknown-lora")
|
file_name = lora.get("file_name", "unknown-lora")
|
||||||
|
folder = lora.get("folder", "")
|
||||||
|
|
||||||
|
lora_name = f"{folder}/{file_name}" if folder else file_name
|
||||||
strength = lora.get("strength", 1.0)
|
strength = lora.get("strength", 1.0)
|
||||||
syntax_parts.append(f"<lora:{file_name}:{strength}>")
|
syntax_parts.append(f"<lora:{lora_name}:{strength}>")
|
||||||
|
|
||||||
return syntax_parts
|
return syntax_parts
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||||
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
|
|||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None and not is_video:
|
if metadata is None and not is_video:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, temp_path
|
||||||
|
)
|
||||||
|
|
||||||
return await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata or {},
|
metadata or {},
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
|
|||||||
is_video=is_video,
|
is_video=is_video,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||||
|
mvid = image_info.get("modelVersionId")
|
||||||
|
if not mvid:
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if isinstance(mvids, list) and mvids:
|
||||||
|
mvid = mvids[0]
|
||||||
|
|
||||||
|
recipe_for_enrich = {
|
||||||
|
"gen_params": result.payload.get("gen_params", {}),
|
||||||
|
"loras": result.payload.get("loras", []),
|
||||||
|
"base_model": result.payload.get("base_model", "") or "",
|
||||||
|
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
|
||||||
|
"source_path": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=recipe_for_enrich,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=None,
|
||||||
|
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||||
|
prefetched_model_version_id=mvid,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||||
|
if recipe_for_enrich.get("checkpoint"):
|
||||||
|
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
|
||||||
|
if recipe_for_enrich.get("base_model"):
|
||||||
|
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||||
|
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
self._safe_cleanup(temp_path)
|
self._safe_cleanup(temp_path)
|
||||||
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
|
|||||||
if not os.path.isfile(normalized_path):
|
if not os.path.isfile(normalized_path):
|
||||||
raise RecipeNotFoundError("File not found")
|
raise RecipeNotFoundError("File not found")
|
||||||
|
|
||||||
metadata = self._exif_utils.extract_image_metadata(normalized_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, normalized_path
|
||||||
|
)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return self._metadata_not_found_response(normalized_path)
|
return self._metadata_not_found_response(normalized_path)
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ class RecipePersistenceService:
|
|||||||
if metadata.get("source_path"):
|
if metadata.get("source_path"):
|
||||||
recipe_data["source_path"] = metadata.get("source_path")
|
recipe_data["source_path"] = metadata.get("source_path")
|
||||||
|
|
||||||
|
nsfw_level = metadata.get("preview_nsfw_level")
|
||||||
|
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||||
|
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||||
|
|
||||||
json_filename = f"{recipe_id}.recipe.json"
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
json_path = os.path.join(recipes_dir, json_filename)
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
json_path = os.path.normpath(json_path)
|
json_path = os.path.normpath(json_path)
|
||||||
@@ -508,6 +512,10 @@ class RecipePersistenceService:
|
|||||||
most_common_base_model = (
|
most_common_base_model = (
|
||||||
max(base_model_counts.items(), key=lambda item: item[1])[0] if base_model_counts else ""
|
max(base_model_counts.items(), key=lambda item: item[1])[0] if base_model_counts else ""
|
||||||
)
|
)
|
||||||
|
checkpoint_entry = await self._build_widget_checkpoint_entry(
|
||||||
|
recipe_scanner,
|
||||||
|
metadata.get("checkpoint"),
|
||||||
|
)
|
||||||
|
|
||||||
recipe_data = {
|
recipe_data = {
|
||||||
"id": recipe_id,
|
"id": recipe_id,
|
||||||
@@ -515,9 +523,8 @@ class RecipePersistenceService:
|
|||||||
"title": recipe_name,
|
"title": recipe_name,
|
||||||
"modified": time.time(),
|
"modified": time.time(),
|
||||||
"created_date": time.time(),
|
"created_date": time.time(),
|
||||||
"base_model": most_common_base_model,
|
"base_model": most_common_base_model or (checkpoint_entry or {}).get("baseModel", ""),
|
||||||
"loras": loras_data,
|
"loras": loras_data,
|
||||||
"checkpoint": self._sanitize_checkpoint_entry(metadata.get("checkpoint", "")),
|
|
||||||
"gen_params": {
|
"gen_params": {
|
||||||
key: value
|
key: value
|
||||||
for key, value in metadata.items()
|
for key, value in metadata.items()
|
||||||
@@ -525,6 +532,8 @@ class RecipePersistenceService:
|
|||||||
},
|
},
|
||||||
"loras_stack": lora_stack,
|
"loras_stack": lora_stack,
|
||||||
}
|
}
|
||||||
|
if checkpoint_entry:
|
||||||
|
recipe_data["checkpoint"] = checkpoint_entry
|
||||||
|
|
||||||
json_filename = f"{recipe_id}.recipe.json"
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
json_path = os.path.join(recipes_dir, json_filename)
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
@@ -546,6 +555,91 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
# Helper methods ---------------------------------------------------
|
# Helper methods ---------------------------------------------------
|
||||||
|
|
||||||
|
async def _build_widget_checkpoint_entry(
|
||||||
|
self,
|
||||||
|
recipe_scanner,
|
||||||
|
checkpoint_raw: Any,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Build recipe checkpoint metadata from widget generation metadata."""
|
||||||
|
|
||||||
|
if isinstance(checkpoint_raw, dict):
|
||||||
|
return self._sanitize_checkpoint_entry(checkpoint_raw)
|
||||||
|
|
||||||
|
if not isinstance(checkpoint_raw, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
checkpoint_name = checkpoint_raw.strip()
|
||||||
|
if not checkpoint_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_name = os.path.splitext(os.path.basename(checkpoint_name))[0]
|
||||||
|
checkpoint_info = await self._lookup_widget_checkpoint(
|
||||||
|
recipe_scanner,
|
||||||
|
checkpoint_name,
|
||||||
|
)
|
||||||
|
if not checkpoint_info:
|
||||||
|
return {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"name": checkpoint_name,
|
||||||
|
"file_name": file_name,
|
||||||
|
"hash": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
civitai = checkpoint_info.get("civitai") or {}
|
||||||
|
civitai_model = civitai.get("model") or {}
|
||||||
|
file_path = checkpoint_info.get("file_path") or checkpoint_info.get("path") or ""
|
||||||
|
cached_file_name = (
|
||||||
|
checkpoint_info.get("file_name")
|
||||||
|
or (os.path.splitext(os.path.basename(file_path))[0] if file_path else "")
|
||||||
|
or file_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelId": civitai_model.get("id", 0),
|
||||||
|
"modelVersionId": civitai.get("id", 0),
|
||||||
|
"name": civitai_model.get("name") or checkpoint_info.get("model_name") or checkpoint_name,
|
||||||
|
"version": civitai.get("name", ""),
|
||||||
|
"hash": (checkpoint_info.get("sha256") or checkpoint_info.get("hash") or "").lower(),
|
||||||
|
"file_name": cached_file_name,
|
||||||
|
"modelName": civitai_model.get("name", ""),
|
||||||
|
"modelVersionName": civitai.get("name", ""),
|
||||||
|
"baseModel": checkpoint_info.get("base_model") or civitai.get("baseModel", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _lookup_widget_checkpoint(
|
||||||
|
self,
|
||||||
|
recipe_scanner,
|
||||||
|
checkpoint_name: str,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
lookup = getattr(recipe_scanner, "get_local_checkpoint", None)
|
||||||
|
if not callable(lookup):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for candidate in (
|
||||||
|
checkpoint_name,
|
||||||
|
os.path.basename(checkpoint_name),
|
||||||
|
os.path.splitext(os.path.basename(checkpoint_name))[0],
|
||||||
|
):
|
||||||
|
if candidate and candidate not in candidates:
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
checkpoint_info = await lookup(candidate)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.debug(
|
||||||
|
"Failed to lookup checkpoint %s while saving widget recipe: %s",
|
||||||
|
candidate,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if checkpoint_info:
|
||||||
|
return checkpoint_info
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _extract_checkpoint_entry(self, metadata: dict[str, Any]) -> Optional[dict[str, Any]]:
|
def _extract_checkpoint_entry(self, metadata: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||||
"""Pull a checkpoint entry from various metadata locations."""
|
"""Pull a checkpoint entry from various metadata locations."""
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"folder_paths": {},
|
"folder_paths": {},
|
||||||
"extra_folder_paths": {},
|
"extra_folder_paths": {},
|
||||||
"example_images_path": "",
|
"example_images_path": "",
|
||||||
|
"example_images_open_mode": "system",
|
||||||
|
"example_images_local_root": "",
|
||||||
|
"example_images_open_uri_template": "",
|
||||||
"optimize_example_images": True,
|
"optimize_example_images": True,
|
||||||
"auto_download_example_images": False,
|
"auto_download_example_images": False,
|
||||||
"blur_mature_content": True,
|
"blur_mature_content": True,
|
||||||
@@ -93,7 +96,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"compact_mode": False,
|
"compact_mode": False,
|
||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
"model_name_display": "model_name",
|
"model_name_display": "model_name",
|
||||||
|
"lora_syntax_format": "legacy",
|
||||||
"model_card_footer_action": "replace_preview",
|
"model_card_footer_action": "replace_preview",
|
||||||
|
"show_version_on_card": True,
|
||||||
"update_flag_strategy": "same_base",
|
"update_flag_strategy": "same_base",
|
||||||
"auto_organize_exclusions": [],
|
"auto_organize_exclusions": [],
|
||||||
"metadata_refresh_skip_paths": [],
|
"metadata_refresh_skip_paths": [],
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from typing import Awaitable, Callable, Dict, List, Sequence
|
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||||
|
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
|
||||||
|
|
||||||
class TagUpdateService:
|
class TagUpdateService:
|
||||||
@@ -20,9 +22,8 @@ class TagUpdateService:
|
|||||||
new_tags: Sequence[str],
|
new_tags: Sequence[str],
|
||||||
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
||||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||||
) -> List[str]:
|
) -> Tuple[List[str], List[str]]:
|
||||||
"""Add tags to a metadata entry while keeping case-insensitive uniqueness."""
|
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||||
|
|
||||||
base, _ = os.path.splitext(file_path)
|
base, _ = os.path.splitext(file_path)
|
||||||
metadata_path = f"{base}.metadata.json"
|
metadata_path = f"{base}.metadata.json"
|
||||||
metadata = await metadata_loader(metadata_path)
|
metadata = await metadata_loader(metadata_path)
|
||||||
@@ -44,5 +45,6 @@ class TagUpdateService:
|
|||||||
await self._metadata_manager.save_metadata(file_path, metadata)
|
await self._metadata_manager.save_metadata(file_path, metadata)
|
||||||
await update_cache(file_path, file_path, metadata)
|
await update_cache(file_path, file_path, metadata)
|
||||||
|
|
||||||
return existing_tags
|
auto_tags = extract_auto_tags(metadata)
|
||||||
|
return existing_tags, auto_tags
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
|
|||||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
|
||||||
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||||
@@ -66,6 +66,46 @@ def build_civitai_model_page_url(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None:
|
||||||
|
"""Extract the numeric image ID from a Cloudflare CDN image URL.
|
||||||
|
|
||||||
|
CivitAI image CDN URLs follow the pattern::
|
||||||
|
|
||||||
|
https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext}
|
||||||
|
|
||||||
|
The image database ID is always the last path segment (minus extension)
|
||||||
|
because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly
|
||||||
|
in the model-versions REST API response.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
match = _RE_CDN_IMAGE_ID.search(url)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_civitai_image_page_url(
|
||||||
|
image_id: str | int | None,
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Build a Civitai image page URL.
|
||||||
|
|
||||||
|
Returns something like ``https://civitai.com/images/12345``.
|
||||||
|
The host is resolved through :func:`normalize_civitai_page_host` and
|
||||||
|
therefore respects the user's ``civitai_host`` setting.
|
||||||
|
"""
|
||||||
|
if not image_id:
|
||||||
|
return None
|
||||||
|
normalized_host = normalize_civitai_page_host(host)
|
||||||
|
normalized_id = str(image_id).strip()
|
||||||
|
if not normalized_id:
|
||||||
|
return None
|
||||||
|
return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
def _parse_supported_civitai_page_url(url: str | None):
|
def _parse_supported_civitai_page_url(url: str | None):
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
@@ -239,9 +279,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
|
|||||||
normalized_values.add(normalized)
|
normalized_values.add(normalized)
|
||||||
|
|
||||||
has_sell = "sell" in normalized_values
|
has_sell = "sell" in normalized_values
|
||||||
has_rent = has_sell or "rent" in normalized_values
|
has_rent = "rent" in normalized_values
|
||||||
has_rentcivit = has_rent or "rentcivit" in normalized_values
|
has_rentcivit = "rentcivit" in normalized_values
|
||||||
has_image = has_sell or "image" in normalized_values
|
has_image = "image" in normalized_values
|
||||||
|
|
||||||
commercial_bits = (
|
commercial_bits = (
|
||||||
(1 if has_sell else 0) << 3
|
(1 if has_sell else 0) << 3
|
||||||
@@ -328,8 +368,10 @@ def rewrite_preview_url(
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"build_civitai_image_page_url",
|
||||||
"build_license_flags",
|
"build_license_flags",
|
||||||
"extract_civitai_image_id",
|
"extract_civitai_image_id",
|
||||||
|
"extract_civitai_image_id_from_cdn_url",
|
||||||
"extract_civitai_page_host",
|
"extract_civitai_page_host",
|
||||||
"extract_civitai_model_url_parts",
|
"extract_civitai_model_url_parts",
|
||||||
"is_supported_civitai_page_host",
|
"is_supported_civitai_page_host",
|
||||||
|
|||||||
@@ -100,8 +100,35 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
[
|
[
|
||||||
"ZImageTurbo",
|
"Anima",
|
||||||
"ZImageBase",
|
# Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
|
||||||
|
"Flux.1 D",
|
||||||
|
"Flux.1 S",
|
||||||
|
"Flux.1 Krea",
|
||||||
|
"Flux.1 Kontext",
|
||||||
|
"Flux.2 D",
|
||||||
|
"Flux.2 Klein 9B",
|
||||||
|
"Flux.2 Klein 9B-base",
|
||||||
|
"Flux.2 Klein 4B",
|
||||||
|
"Flux.2 Klein 4B-base",
|
||||||
|
# Non-UNet / DiT image diffusion models
|
||||||
|
"AuraFlow",
|
||||||
|
"Chroma",
|
||||||
|
"HiDream",
|
||||||
|
"Hunyuan 1",
|
||||||
|
"Kolors",
|
||||||
|
"Lumina",
|
||||||
|
"PixArt a",
|
||||||
|
"PixArt E",
|
||||||
|
# Video diffusion models
|
||||||
|
"CogVideoX",
|
||||||
|
"Hunyuan Video",
|
||||||
|
"LTXV",
|
||||||
|
"LTXV2",
|
||||||
|
"LTXV 2.3",
|
||||||
|
"Mochi",
|
||||||
|
"SVD",
|
||||||
|
"Wan Video",
|
||||||
"Wan Video 1.3B t2v",
|
"Wan Video 1.3B t2v",
|
||||||
"Wan Video 14B t2v",
|
"Wan Video 14B t2v",
|
||||||
"Wan Video 14B i2v 480p",
|
"Wan Video 14B i2v 480p",
|
||||||
@@ -111,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.2 T2V-A14B",
|
"Wan Video 2.2 T2V-A14B",
|
||||||
"Wan Video 2.5 T2V",
|
"Wan Video 2.5 T2V",
|
||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"CogVideoX",
|
# Other diffusion models
|
||||||
"Mochi",
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
|
"ZImageBase",
|
||||||
|
"ZImageTurbo",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,5 +208,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"Hunyuan Video",
|
"Hunyuan Video",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -397,13 +397,12 @@ class DownloadManager:
|
|||||||
|
|
||||||
models_with_hash = len(all_models_with_hash)
|
models_with_hash = len(all_models_with_hash)
|
||||||
|
|
||||||
# Calculate pending count: check which models actually need processing
|
# Calculate pending count: check which models actually need processing.
|
||||||
# A model is pending if it has a hash, is not in processed_models,
|
# A model is pending if it has a hash, is not already processed or known-failed,
|
||||||
# and its folder doesn't exist or is empty
|
# and its folder doesn't exist or is empty.
|
||||||
pending_hashes = set()
|
pending_hashes = set()
|
||||||
for model_hash, model_name in all_models_with_hash:
|
for model_hash, model_name in all_models_with_hash:
|
||||||
if model_hash not in processed_models:
|
if model_hash not in processed_models and model_hash not in failed_models:
|
||||||
# Check if model folder exists with files
|
|
||||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||||
model_hash, active_library
|
model_hash, active_library
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,81 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..utils.example_images_paths import (
|
from ..utils.example_images_paths import (
|
||||||
get_model_folder,
|
get_model_folder,
|
||||||
get_model_relative_path,
|
|
||||||
)
|
)
|
||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:/")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_within_root(path: str, root: str) -> bool:
|
||||||
|
try:
|
||||||
|
return os.path.commonpath([os.path.abspath(path), os.path.abspath(root)]) == os.path.abspath(root)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _join_local_example_path(local_root: str, relative_path: str) -> str:
|
||||||
|
separator = "\\" if "\\" in local_root and "/" not in local_root else "/"
|
||||||
|
normalized_root = local_root.rstrip("\\/")
|
||||||
|
normalized_relative = relative_path.replace("/", separator)
|
||||||
|
if not normalized_root:
|
||||||
|
return normalized_relative
|
||||||
|
return f"{normalized_root}{separator}{normalized_relative}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_file_uri(path: str) -> str:
|
||||||
|
normalized = path.replace("\\", "/")
|
||||||
|
if _WINDOWS_DRIVE_PATTERN.match(normalized):
|
||||||
|
return f"file:///{quote(normalized, safe='/:')}"
|
||||||
|
if normalized.startswith("/"):
|
||||||
|
return f"file://{quote(normalized, safe='/:')}"
|
||||||
|
return f"file:///{quote(normalized.lstrip('/'), safe='/:')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_open_uri_template(template: str, local_path: str, relative_path: str) -> str:
|
||||||
|
file_uri = _build_file_uri(local_path)
|
||||||
|
replacements = {
|
||||||
|
"{{local_path}}": local_path,
|
||||||
|
"{{encoded_local_path}}": quote(local_path, safe=""),
|
||||||
|
"{{relative_path}}": relative_path,
|
||||||
|
"{{encoded_relative_path}}": quote(relative_path, safe=""),
|
||||||
|
"{{file_uri}}": file_uri,
|
||||||
|
"{{encoded_file_uri}}": quote(file_uri, safe=""),
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = template
|
||||||
|
for placeholder, value in replacements.items():
|
||||||
|
rendered = rendered.replace(placeholder, value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def _open_system_folder(model_folder: str) -> dict[str, object]:
|
||||||
|
if os.name == "nt": # Windows
|
||||||
|
os.startfile(model_folder)
|
||||||
|
elif os.name == "posix": # macOS and Linux
|
||||||
|
if sys.platform == "darwin": # macOS
|
||||||
|
subprocess.Popen(["open", model_folder])
|
||||||
|
else: # Linux
|
||||||
|
subprocess.Popen(["xdg-open", model_folder])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Opened example images folder for {model_folder}",
|
||||||
|
"path": model_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ExampleImagesFileManager:
|
class ExampleImagesFileManager:
|
||||||
"""Manages access and operations for example image files"""
|
"""Manages access and operations for example image files"""
|
||||||
|
|
||||||
@@ -54,7 +118,7 @@ class ExampleImagesFileManager:
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
# Path validation: ensure model_folder is under example_images_path
|
# Path validation: ensure model_folder is under example_images_path
|
||||||
if not model_folder.startswith(os.path.abspath(example_images_path)):
|
if not _is_within_root(model_folder, example_images_path):
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Invalid model folder path'
|
'error': 'Invalid model folder path'
|
||||||
@@ -66,20 +130,40 @@ class ExampleImagesFileManager:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': 'No example images found for this model. Download example images first.'
|
'error': 'No example images found for this model. Download example images first.'
|
||||||
}, status=404)
|
}, status=404)
|
||||||
|
|
||||||
# Open folder in file explorer
|
root_path = os.path.abspath(example_images_path)
|
||||||
if os.name == 'nt': # Windows
|
relative_path = os.path.relpath(model_folder, root_path).replace("\\", "/")
|
||||||
os.startfile(model_folder)
|
open_mode = settings_manager.get("example_images_open_mode") or "system"
|
||||||
elif os.name == 'posix': # macOS and Linux
|
|
||||||
if sys.platform == 'darwin': # macOS
|
if open_mode == "clipboard":
|
||||||
subprocess.Popen(['open', model_folder])
|
local_root = settings_manager.get("example_images_local_root") or root_path
|
||||||
else: # Linux
|
local_path = _join_local_example_path(local_root, relative_path)
|
||||||
subprocess.Popen(['xdg-open', model_folder])
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
return web.json_response({
|
'mode': 'clipboard',
|
||||||
'success': True,
|
'path': local_path,
|
||||||
'message': f'Opened example images folder for model {model_hash}'
|
'relative_path': relative_path,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if open_mode == "uri_template":
|
||||||
|
local_root = settings_manager.get("example_images_local_root") or root_path
|
||||||
|
uri_template = settings_manager.get("example_images_open_uri_template") or ""
|
||||||
|
if not uri_template.strip():
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'No example image open URI template configured.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
local_path = _join_local_example_path(local_root, relative_path)
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'mode': 'uri',
|
||||||
|
'path': local_path,
|
||||||
|
'relative_path': relative_path,
|
||||||
|
'uri': _render_open_uri_template(uri_template, local_path, relative_path),
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response(_open_system_folder(model_folder))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
|
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
|
||||||
@@ -143,7 +227,7 @@ class ExampleImagesFileManager:
|
|||||||
file_ext = os.path.splitext(file)[1].lower()
|
file_ext = os.path.splitext(file)[1].lower()
|
||||||
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
||||||
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
|
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
|
||||||
relative_path = get_model_relative_path(model_hash)
|
relative_path = os.path.relpath(model_folder, os.path.abspath(example_images_path)).replace("\\", "/")
|
||||||
files.append({
|
files.append({
|
||||||
'name': file,
|
'name': file,
|
||||||
'path': f'/example_images_static/{relative_path}/{file}',
|
'path': f'/example_images_static/{relative_path}/{file}',
|
||||||
@@ -227,4 +311,4 @@ class ExampleImagesFileManager:
|
|||||||
return web.json_response({
|
return web.json_response({
|
||||||
'has_images': False,
|
'has_images': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -452,3 +452,111 @@ class MetadataUpdater:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def prune_stale_example_images(metadata) -> bool:
|
||||||
|
"""Remove example-image metadata entries whose files no longer exist on disk.
|
||||||
|
|
||||||
|
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
|
||||||
|
that have an empty ``url`` (no remote fallback) against actual files in
|
||||||
|
the model's example-image folder. Stale entries are removed in-place so
|
||||||
|
the caller can persist the cleaned metadata afterwards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: A ``BaseModelMetadata`` instance (modified in place).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one entry was removed.
|
||||||
|
"""
|
||||||
|
from ..utils.example_images_paths import get_model_folder
|
||||||
|
|
||||||
|
model_hash = getattr(metadata, "sha256", None)
|
||||||
|
if not model_hash:
|
||||||
|
return False
|
||||||
|
|
||||||
|
model_folder = get_model_folder(model_hash)
|
||||||
|
if not model_folder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
civitai = getattr(metadata, "civitai", None)
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_changes = False
|
||||||
|
|
||||||
|
custom_images = civitai.get("customImages")
|
||||||
|
if isinstance(custom_images, list) and custom_images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(custom_images):
|
||||||
|
img_id = img.get("id", "")
|
||||||
|
if not img_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"custom_{img_id}"
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix) and os.path.isfile(
|
||||||
|
os.path.join(model_folder, fname)
|
||||||
|
):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
custom_images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale custom image(s) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
images = civitai.get("images")
|
||||||
|
if isinstance(images, list) and images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(images):
|
||||||
|
if img.get("url", ""):
|
||||||
|
# Has a remote fallback – keep it even if the local copy
|
||||||
|
# is gone.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"image_{idx}."
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale image entry(ies) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
return has_changes
|
||||||
|
|||||||
@@ -1,15 +1,142 @@
|
|||||||
import piexif
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
from io import BytesIO
|
|
||||||
import os
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import piexif
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ExifUtils:
|
class ExifUtils:
|
||||||
"""Utility functions for working with EXIF data in images"""
|
"""Utility functions for working with EXIF data in images"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_user_comment(user_comment: Any) -> Optional[str]:
|
||||||
|
if user_comment is None:
|
||||||
|
return None
|
||||||
|
if isinstance(user_comment, bytes):
|
||||||
|
if user_comment.startswith(b"UNICODE\0"):
|
||||||
|
return user_comment[8:].decode("utf-16be", errors="ignore")
|
||||||
|
return user_comment.decode("utf-8", errors="ignore")
|
||||||
|
if isinstance(user_comment, str):
|
||||||
|
return user_comment
|
||||||
|
return str(user_comment)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_exif_text(value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode("utf-8", errors="ignore")
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_structured_metadata(image_path: str) -> dict[str, Optional[str]]:
|
||||||
|
metadata = {
|
||||||
|
"parameters": None,
|
||||||
|
"prompt": None,
|
||||||
|
"workflow": None,
|
||||||
|
"comment": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
info = getattr(img, "info", {}) or {}
|
||||||
|
|
||||||
|
if "parameters" in info:
|
||||||
|
metadata["parameters"] = info["parameters"]
|
||||||
|
if "prompt" in info:
|
||||||
|
metadata["prompt"] = info["prompt"]
|
||||||
|
if "workflow" in info:
|
||||||
|
metadata["workflow"] = info["workflow"]
|
||||||
|
|
||||||
|
if img.format not in ["JPEG", "TIFF", "WEBP"]:
|
||||||
|
exif = img.getexif()
|
||||||
|
if exif and piexif.ExifIFD.UserComment in exif:
|
||||||
|
metadata["comment"] = ExifUtils._decode_user_comment(
|
||||||
|
exif[piexif.ExifIFD.UserComment]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
exif_dict = piexif.load(image_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error loading EXIF data: {e}")
|
||||||
|
exif_dict = {}
|
||||||
|
|
||||||
|
if piexif.ExifIFD.UserComment in exif_dict.get("Exif", {}):
|
||||||
|
metadata["comment"] = ExifUtils._decode_user_comment(
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.UserComment]
|
||||||
|
)
|
||||||
|
|
||||||
|
image_description = ExifUtils._decode_exif_text(
|
||||||
|
exif_dict.get("0th", {}).get(piexif.ImageIFD.ImageDescription)
|
||||||
|
)
|
||||||
|
if image_description:
|
||||||
|
if image_description.startswith("Workflow:"):
|
||||||
|
metadata["workflow"] = image_description[len("Workflow:") :]
|
||||||
|
elif not metadata["prompt"]:
|
||||||
|
metadata["prompt"] = image_description
|
||||||
|
|
||||||
|
if not metadata["parameters"] and metadata["comment"]:
|
||||||
|
metadata["parameters"] = metadata["comment"]
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_pnginfo(img: Image.Image, metadata_fields: dict[str, Optional[str]]) -> PngImagePlugin.PngInfo:
|
||||||
|
png_info = PngImagePlugin.PngInfo()
|
||||||
|
existing_info = getattr(img, "info", {}) or {}
|
||||||
|
managed_keys = {"parameters", "prompt", "workflow"}
|
||||||
|
|
||||||
|
for key, value in existing_info.items():
|
||||||
|
if key in {"exif", "dpi", "transparency", "gamma", "aspect"}:
|
||||||
|
continue
|
||||||
|
if key in managed_keys:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
png_info.add_text(key, value)
|
||||||
|
|
||||||
|
for key in managed_keys:
|
||||||
|
value = metadata_fields.get(key)
|
||||||
|
if value:
|
||||||
|
png_info.add_text(key, value)
|
||||||
|
|
||||||
|
return png_info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_exif_bytes(
|
||||||
|
metadata_fields: dict[str, Optional[str]], existing_exif: bytes | None = None
|
||||||
|
) -> bytes:
|
||||||
|
try:
|
||||||
|
exif_dict = piexif.load(existing_exif or b"")
|
||||||
|
except Exception:
|
||||||
|
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}}
|
||||||
|
|
||||||
|
exif_dict.setdefault("0th", {})
|
||||||
|
exif_dict.setdefault("Exif", {})
|
||||||
|
|
||||||
|
parameters = metadata_fields.get("parameters")
|
||||||
|
workflow = metadata_fields.get("workflow")
|
||||||
|
prompt = metadata_fields.get("prompt")
|
||||||
|
|
||||||
|
if parameters:
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.UserComment] = (
|
||||||
|
b"UNICODE\0" + parameters.encode("utf-16be")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exif_dict["Exif"].pop(piexif.ExifIFD.UserComment, None)
|
||||||
|
|
||||||
|
if workflow:
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = f"Workflow:{workflow}"
|
||||||
|
elif prompt:
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = prompt
|
||||||
|
else:
|
||||||
|
exif_dict["0th"].pop(piexif.ImageIFD.ImageDescription, None)
|
||||||
|
|
||||||
|
return piexif.dump(exif_dict)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_image_metadata(image_path: str) -> Optional[str]:
|
def extract_image_metadata(image_path: str) -> Optional[str]:
|
||||||
@@ -28,48 +155,12 @@ class ExifUtils:
|
|||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm']:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# First try to open the image
|
metadata = ExifUtils._load_structured_metadata(image_path)
|
||||||
with Image.open(image_path) as img:
|
return (
|
||||||
# Method 1: Check for parameters in image info
|
metadata.get("parameters")
|
||||||
if hasattr(img, 'info') and 'parameters' in img.info:
|
or metadata.get("prompt")
|
||||||
return img.info['parameters']
|
or metadata.get("workflow")
|
||||||
|
)
|
||||||
# Method 2: Check EXIF UserComment field
|
|
||||||
if img.format not in ['JPEG', 'TIFF', 'WEBP']:
|
|
||||||
# For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
|
|
||||||
exif = img.getexif()
|
|
||||||
if exif and piexif.ExifIFD.UserComment in exif:
|
|
||||||
user_comment = exif[piexif.ExifIFD.UserComment]
|
|
||||||
if isinstance(user_comment, bytes):
|
|
||||||
if user_comment.startswith(b'UNICODE\0'):
|
|
||||||
return user_comment[8:].decode('utf-16be')
|
|
||||||
return user_comment.decode('utf-8', errors='ignore')
|
|
||||||
return user_comment
|
|
||||||
|
|
||||||
# For JPEG/TIFF/WEBP, use piexif
|
|
||||||
try:
|
|
||||||
exif_dict = piexif.load(image_path)
|
|
||||||
|
|
||||||
if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}):
|
|
||||||
user_comment = exif_dict['Exif'][piexif.ExifIFD.UserComment]
|
|
||||||
if isinstance(user_comment, bytes):
|
|
||||||
if user_comment.startswith(b'UNICODE\0'):
|
|
||||||
user_comment = user_comment[8:].decode('utf-16be')
|
|
||||||
else:
|
|
||||||
user_comment = user_comment.decode('utf-8', errors='ignore')
|
|
||||||
return user_comment
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error loading EXIF data: {e}")
|
|
||||||
|
|
||||||
# Method 3: Check PNG metadata for workflow info (for ComfyUI images)
|
|
||||||
if img.format == 'PNG':
|
|
||||||
# Look for workflow or prompt metadata in PNG chunks
|
|
||||||
for key in img.info:
|
|
||||||
if key in ['workflow', 'prompt', 'parameters']:
|
|
||||||
return img.info[key]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error extracting image metadata: {e}", exc_info=True)
|
logger.error(f"Error extracting image metadata: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -92,50 +183,26 @@ class ExifUtils:
|
|||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm']:
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
# Load the image and check its format
|
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||||
|
metadata_fields["parameters"] = metadata
|
||||||
|
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
img_format = img.format
|
img_format = img.format
|
||||||
|
|
||||||
# For PNG, try to update parameters directly
|
if img_format == "PNG":
|
||||||
if img_format == 'PNG':
|
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
|
||||||
# Use PngInfo instead of plain dictionary
|
img.save(image_path, format="PNG", pnginfo=png_info)
|
||||||
png_info = PngImagePlugin.PngInfo()
|
|
||||||
png_info.add_text("parameters", metadata)
|
|
||||||
img.save(image_path, format='PNG', pnginfo=png_info)
|
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
# For WebP format, use PIL's exif parameter directly
|
exif_bytes = ExifUtils._build_exif_bytes(
|
||||||
elif img_format == 'WEBP':
|
metadata_fields, img.info.get("exif")
|
||||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
)
|
||||||
exif_bytes = piexif.dump(exif_dict)
|
save_kwargs = {"exif": exif_bytes}
|
||||||
|
if img_format == "WEBP":
|
||||||
# Save with the exif data
|
save_kwargs["quality"] = 85
|
||||||
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
|
|
||||||
return image_path
|
img.save(image_path, format=img_format, **save_kwargs)
|
||||||
|
|
||||||
# For other formats, use standard EXIF approach
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
exif_dict = piexif.load(img.info.get('exif', b''))
|
|
||||||
except:
|
|
||||||
exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}}
|
|
||||||
|
|
||||||
# If no Exif dictionary exists, create one
|
|
||||||
if 'Exif' not in exif_dict:
|
|
||||||
exif_dict['Exif'] = {}
|
|
||||||
|
|
||||||
# Update the UserComment field - use UNICODE format
|
|
||||||
unicode_bytes = metadata.encode('utf-16be')
|
|
||||||
metadata_bytes = b'UNICODE\0' + unicode_bytes
|
|
||||||
|
|
||||||
exif_dict['Exif'][piexif.ExifIFD.UserComment] = metadata_bytes
|
|
||||||
|
|
||||||
# Convert EXIF dict back to bytes
|
|
||||||
exif_bytes = piexif.dump(exif_dict)
|
|
||||||
|
|
||||||
# Save the image with updated EXIF data
|
|
||||||
img.save(image_path, exif=exif_bytes)
|
|
||||||
|
|
||||||
return image_path
|
return image_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating metadata in {image_path}: {e}")
|
logger.error(f"Error updating metadata in {image_path}: {e}")
|
||||||
@@ -297,12 +364,12 @@ class ExifUtils:
|
|||||||
raise ValueError(f"Cannot process corrupt image data: {e}")
|
raise ValueError(f"Cannot process corrupt image data: {e}")
|
||||||
|
|
||||||
# Extract metadata if needed and valid
|
# Extract metadata if needed and valid
|
||||||
metadata = None
|
metadata_fields = None
|
||||||
if preserve_metadata:
|
if preserve_metadata:
|
||||||
try:
|
try:
|
||||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||||
# For file path, extract directly
|
# For file path, extract directly
|
||||||
metadata = ExifUtils.extract_image_metadata(image_data)
|
metadata_fields = ExifUtils._load_structured_metadata(image_data)
|
||||||
else:
|
else:
|
||||||
# For binary data, save to temp file first
|
# For binary data, save to temp file first
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -310,7 +377,7 @@ class ExifUtils:
|
|||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
temp_file.write(image_data)
|
temp_file.write(image_data)
|
||||||
try:
|
try:
|
||||||
metadata = ExifUtils.extract_image_metadata(temp_path)
|
metadata_fields = ExifUtils._load_structured_metadata(temp_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to extract metadata from temp file: {e}")
|
logger.warning(f"Failed to extract metadata from temp file: {e}")
|
||||||
finally:
|
finally:
|
||||||
@@ -363,14 +430,13 @@ class ExifUtils:
|
|||||||
optimized_data = output.getvalue()
|
optimized_data = output.getvalue()
|
||||||
|
|
||||||
# Handle metadata preservation if requested and available
|
# Handle metadata preservation if requested and available
|
||||||
if preserve_metadata and metadata:
|
if preserve_metadata and metadata_fields:
|
||||||
try:
|
try:
|
||||||
if save_format == 'WEBP':
|
if save_format == 'WEBP':
|
||||||
# For WebP format, directly save with metadata
|
# For WebP format, directly save with metadata
|
||||||
try:
|
try:
|
||||||
output_with_metadata = BytesIO()
|
output_with_metadata = BytesIO()
|
||||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
|
||||||
exif_bytes = piexif.dump(exif_dict)
|
|
||||||
resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality)
|
resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality)
|
||||||
optimized_data = output_with_metadata.getvalue()
|
optimized_data = output_with_metadata.getvalue()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -383,8 +449,9 @@ class ExifUtils:
|
|||||||
temp_file.write(optimized_data)
|
temp_file.write(optimized_data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Add metadata
|
ExifUtils.update_image_metadata(
|
||||||
ExifUtils.update_image_metadata(temp_path, metadata)
|
temp_path, metadata_fields.get("parameters") or ""
|
||||||
|
)
|
||||||
# Read back the file
|
# Read back the file
|
||||||
with open(temp_path, 'rb') as f:
|
with open(temp_path, 'rb') as f:
|
||||||
optimized_data = f.read()
|
optimized_data = f.read()
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
@@ -31,7 +34,7 @@ def _get_hash_chunk_size_bytes() -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def calculate_sha256(file_path: str) -> str:
|
async def calculate_sha256(file_path: str) -> str:
|
||||||
"""Calculate SHA256 hash of a file"""
|
"""Calculate SHA256 hash of a file (full file content)."""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
chunk_size = _get_hash_chunk_size_bytes()
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
@@ -39,6 +42,79 @@ async def calculate_sha256(file_path: str) -> str:
|
|||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_autov2(file_path: str) -> str:
|
||||||
|
"""Calculate CivitAI AutoV2 hash.
|
||||||
|
|
||||||
|
AutoV2 is the first 10 characters of the full file SHA256.
|
||||||
|
Used by CivitAI as a shortened file identifier.
|
||||||
|
|
||||||
|
Reference: https://developer.civitai.com/site/reference/model-versions
|
||||||
|
"""
|
||||||
|
full_hash = hashlib.sha256()
|
||||||
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||||
|
full_hash.update(byte_block)
|
||||||
|
return full_hash.hexdigest()[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
|
||||||
|
"""Read the ``__metadata__`` dict from a safetensors file header.
|
||||||
|
|
||||||
|
Safetensors file format:
|
||||||
|
- 8 bytes: header length (little-endian 64-bit)
|
||||||
|
- N bytes: UTF-8 JSON header
|
||||||
|
- The header JSON contains a ``__metadata__`` key holding arbitrary metadata.
|
||||||
|
|
||||||
|
Returns an empty dict if the file is not a valid safetensors file or has no
|
||||||
|
metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
header_len_bytes = f.read(8)
|
||||||
|
if len(header_len_bytes) < 8:
|
||||||
|
return {}
|
||||||
|
header_len = struct.unpack("<Q", header_len_bytes)[0]
|
||||||
|
header_bytes = f.read(header_len)
|
||||||
|
if len(header_bytes) < header_len:
|
||||||
|
return {}
|
||||||
|
header = json.loads(header_bytes.decode("utf-8"))
|
||||||
|
return header.get("__metadata__", {})
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_autov3(file_path: str) -> str | None:
|
||||||
|
"""Calculate CivitAI AutoV3 hash from a safetensors file.
|
||||||
|
|
||||||
|
AutoV3 is extracted from the safetensors file's embedded metadata, not
|
||||||
|
computed from the file bytes directly. The orchestrator reads the
|
||||||
|
``sshs_model_hash`` (kohya-ss format) or ``modelspec.hash_sha256`` field
|
||||||
|
from the safetensors header and stores the first 12 characters.
|
||||||
|
|
||||||
|
The embedded hash itself is the SHA256 of the file after skipping the
|
||||||
|
8-byte header length + JSON header (a.k.a. the addnet hash / tensor-only
|
||||||
|
hash).
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
- CivitAI DB trigger: ``SUBSTRING(NEW.hash FROM 1 FOR 12)``
|
||||||
|
- https://developer.civitai.com/site/reference/model-versions
|
||||||
|
|
||||||
|
Returns ``None`` when no AutoV3 hash can be determined (e.g. the file is
|
||||||
|
not safetensors, or the metadata doesn't contain a recognised hash field).
|
||||||
|
"""
|
||||||
|
metadata = read_safetensors_metadata(file_path)
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
embedded_hash = metadata.get("sshs_model_hash") or metadata.get("modelspec.hash_sha256")
|
||||||
|
if embedded_hash and isinstance(embedded_hash, str) and len(embedded_hash) >= 12:
|
||||||
|
return embedded_hash[:12]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_preview_file(base_name: str, dir_path: str) -> str:
|
def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||||
"""Find preview file for given base name in directory.
|
"""Find preview file for given base name in directory.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
|
|||||||
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||||
|
|
||||||
|
|
||||||
|
_KEEP_LOG_COUNT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_logs(log_dir: str) -> None:
|
||||||
|
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
|
||||||
|
try:
|
||||||
|
files = [
|
||||||
|
os.path.join(log_dir, name)
|
||||||
|
for name in os.listdir(log_dir)
|
||||||
|
if name.startswith("standalone-session-") and name.endswith(".log")
|
||||||
|
]
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
files.sort(key=os.path.getmtime, reverse=True)
|
||||||
|
for path in files[_KEEP_LOG_COUNT:]:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||||
global _session_state
|
global _session_state
|
||||||
|
|
||||||
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
|
|||||||
file_handler.set_name(_FILE_HANDLER_NAME)
|
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
|
_prune_old_logs(os.path.dirname(log_file_path))
|
||||||
|
|
||||||
_session_state = StandaloneSessionLogState(
|
_session_state = StandaloneSessionLogState(
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
|
|||||||
@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
|
|||||||
scanner = await ServiceRegistry.get_lora_scanner()
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
lora_name_normalized = lora_name.replace("\\", "/")
|
||||||
|
lora_name_no_ext = lora_name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if lora_name_no_ext.lower().endswith(ext):
|
||||||
|
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in lora_name_no_ext
|
||||||
|
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("file_name") == lora_name:
|
file_name = item.get("file_name", "")
|
||||||
file_path = item.get("file_path")
|
folder = item.get("folder", "")
|
||||||
if file_path:
|
file_name_no_ext = file_name
|
||||||
# Check all lora roots including extra paths
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
all_roots = list(config.loras_roots or []) + list(
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
config.extra_loras_roots or []
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if lora_name_no_ext not in (file_name_no_ext, path_name):
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = item
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = item
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_roots = list(config.loras_roots or []) + list(
|
||||||
|
config.extra_loras_roots or []
|
||||||
|
)
|
||||||
|
for root in all_roots:
|
||||||
|
root = root.replace(os.sep, "/")
|
||||||
|
if file_path.startswith(root):
|
||||||
|
relative_path = os.path.relpath(file_path, root).replace(
|
||||||
|
os.sep, "/"
|
||||||
)
|
)
|
||||||
for root in all_roots:
|
|
||||||
root = root.replace(os.sep, "/")
|
|
||||||
if file_path.startswith(root):
|
|
||||||
relative_path = os.path.relpath(file_path, root).replace(
|
|
||||||
os.sep, "/"
|
|
||||||
)
|
|
||||||
# Get trigger words from civitai metadata
|
|
||||||
civitai = item.get("civitai", {})
|
|
||||||
trigger_words = (
|
|
||||||
civitai.get("trainedWords", []) if civitai else []
|
|
||||||
)
|
|
||||||
return relative_path, trigger_words
|
|
||||||
# If not found in any root, return path with trigger words from cache
|
|
||||||
civitai = item.get("civitai", {})
|
civitai = item.get("civitai", {})
|
||||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
trigger_words = (
|
||||||
return file_path, trigger_words
|
civitai.get("trainedWords", []) if civitai else []
|
||||||
|
)
|
||||||
|
return relative_path, trigger_words
|
||||||
|
civitai = item.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
file_path = best_fallback.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
civitai = best_fallback.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
return lora_name, []
|
return lora_name, []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
|
|||||||
scanner = await ServiceRegistry.get_lora_scanner()
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
lora_name_normalized = lora_name.replace("\\", "/")
|
||||||
|
lora_name_no_ext = lora_name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if lora_name_no_ext.lower().endswith(ext):
|
||||||
|
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in lora_name_no_ext
|
||||||
|
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("file_name") == lora_name:
|
file_name = item.get("file_name", "")
|
||||||
|
folder = item.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if lora_name_no_ext == file_name_no_ext:
|
||||||
file_path = item.get("file_path")
|
file_path = item.get("file_path")
|
||||||
if file_path:
|
if file_path:
|
||||||
# Return absolute path directly
|
|
||||||
# Get trigger words from civitai metadata
|
|
||||||
civitai = item.get("civitai", {})
|
civitai = item.get("civitai", {})
|
||||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
return file_path, trigger_words
|
return file_path, trigger_words
|
||||||
|
|
||||||
|
if lora_name_no_ext == path_name:
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
civitai = item.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = item
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = item
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
file_path = best_fallback.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
civitai = best_fallback.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
return lora_name, []
|
return lora_name, []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.0.5"
|
version = "1.0.11"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
353
scripts/migrate_legacy_metadata.py
Normal file
353
scripts/migrate_legacy_metadata.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
|
||||||
|
|
||||||
|
This script automatically discovers model folders from LoRA Manager's settings.json,
|
||||||
|
finds JSON files with the same basename as model files (e.g., `model.json` for
|
||||||
|
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
|
||||||
|
|
||||||
|
Fields migrated:
|
||||||
|
- "activation text" → civitai.trainedWords (array of trigger words)
|
||||||
|
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
|
||||||
|
- "notes" → notes (user-defined notes)
|
||||||
|
|
||||||
|
Supported model types: LoRA, Checkpoint
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Read settings.json to find all configured model folders
|
||||||
|
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
|
||||||
|
3. Find corresponding legacy metadata JSON files
|
||||||
|
4. Migrate data to .metadata.json files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.error(f"Invalid JSON in {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(f"Cannot read {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(expand_path(value))
|
||||||
|
return {key: dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
model_files = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
model_files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return model_files
|
||||||
|
|
||||||
|
|
||||||
|
def find_legacy_metadata(model_path: Path) -> Path | None:
|
||||||
|
base_name = model_path.stem
|
||||||
|
legacy_path = model_path.with_name(f"{base_name}.json")
|
||||||
|
if legacy_path.exists() and legacy_path.is_file():
|
||||||
|
return legacy_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
with open(legacy_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata(metadata_path: Path) -> dict[str, Any]:
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading metadata file {metadata_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
|
||||||
|
return True
|
||||||
|
temp_path = metadata_path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(temp_path, metadata_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving metadata to {metadata_path}: {e}")
|
||||||
|
if temp_path.exists():
|
||||||
|
try:
|
||||||
|
temp_path.unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_metadata(
|
||||||
|
legacy_data: dict[str, Any],
|
||||||
|
existing_metadata: dict[str, Any],
|
||||||
|
model_type: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
metadata = existing_metadata.copy()
|
||||||
|
changes_made = False
|
||||||
|
if "civitai" not in metadata:
|
||||||
|
metadata["civitai"] = {}
|
||||||
|
activation_text = legacy_data.get("activation text")
|
||||||
|
if activation_text and isinstance(activation_text, str):
|
||||||
|
trigger_words = [
|
||||||
|
word.strip()
|
||||||
|
for word in activation_text.replace("\n", ",").split(",")
|
||||||
|
if word.strip()
|
||||||
|
]
|
||||||
|
if trigger_words:
|
||||||
|
existing_trained = metadata["civitai"].get("trainedWords", [])
|
||||||
|
if not isinstance(existing_trained, list):
|
||||||
|
existing_trained = []
|
||||||
|
merged = list(dict.fromkeys(existing_trained + trigger_words))
|
||||||
|
if merged != existing_trained:
|
||||||
|
metadata["civitai"]["trainedWords"] = merged
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated activation text: {trigger_words}")
|
||||||
|
if model_type == "lora":
|
||||||
|
preferred_weight = legacy_data.get("preferred weight")
|
||||||
|
if preferred_weight is not None:
|
||||||
|
try:
|
||||||
|
weight_value = float(preferred_weight)
|
||||||
|
usage_tips_str = metadata.get("usage_tips", "{}")
|
||||||
|
if isinstance(usage_tips_str, str):
|
||||||
|
try:
|
||||||
|
usage_tips = json.loads(usage_tips_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
usage_tips = {}
|
||||||
|
elif isinstance(usage_tips_str, dict):
|
||||||
|
usage_tips = usage_tips_str
|
||||||
|
else:
|
||||||
|
usage_tips = {}
|
||||||
|
if "strength" not in usage_tips:
|
||||||
|
usage_tips["strength"] = weight_value
|
||||||
|
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated preferred weight: {weight_value}")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
|
||||||
|
else:
|
||||||
|
if legacy_data.get("preferred weight") is not None:
|
||||||
|
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
|
||||||
|
notes = legacy_data.get("notes")
|
||||||
|
if notes and isinstance(notes, str) and notes.strip():
|
||||||
|
existing_notes = metadata.get("notes", "")
|
||||||
|
if not existing_notes:
|
||||||
|
metadata["notes"] = notes.strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Migrated notes")
|
||||||
|
elif notes.strip() not in existing_notes:
|
||||||
|
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Appended notes")
|
||||||
|
return metadata if changes_made else None
|
||||||
|
|
||||||
|
|
||||||
|
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
return True
|
||||||
|
logger.info(f"Processing: {model_path.name} ({model_type})")
|
||||||
|
logger.info(f" Found legacy metadata: {legacy_path.name}")
|
||||||
|
legacy_data = load_legacy_metadata(legacy_path)
|
||||||
|
if legacy_data is None:
|
||||||
|
return False
|
||||||
|
metadata_path = model_path.with_suffix(".metadata.json")
|
||||||
|
existing_metadata = load_metadata(metadata_path)
|
||||||
|
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
|
||||||
|
if migrated is None:
|
||||||
|
logger.info(" No changes needed (fields already exist or no migratable data)")
|
||||||
|
return True
|
||||||
|
if save_metadata(metadata_path, migrated, dry_run):
|
||||||
|
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(" ✗ Failed to save metadata")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python scripts/migrate_legacy_metadata.py
|
||||||
|
python scripts/migrate_legacy_metadata.py --dry-run
|
||||||
|
python scripts/migrate_legacy_metadata.py --verbose
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Preview changes without modifying any files"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose output"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info(f"Using settings: {settings_path}")
|
||||||
|
settings = load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
|
||||||
|
return 1
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders configured in settings.json.")
|
||||||
|
return 1
|
||||||
|
lora_roots = roots.get("loras", [])
|
||||||
|
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
|
||||||
|
all_roots = []
|
||||||
|
for root_list in [lora_roots, checkpoint_roots]:
|
||||||
|
for root in root_list:
|
||||||
|
path = Path(root)
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
|
||||||
|
if not all_roots:
|
||||||
|
logger.error("No valid model folders found.")
|
||||||
|
return 1
|
||||||
|
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
|
||||||
|
processed = 0
|
||||||
|
migrated = 0
|
||||||
|
errors = 0
|
||||||
|
skipped = 0
|
||||||
|
lora_count = 0
|
||||||
|
checkpoint_count = 0
|
||||||
|
for root_path, model_type in all_roots:
|
||||||
|
logger.info(f"Scanning: {root_path} ({model_type})")
|
||||||
|
model_files = find_model_files(root_path)
|
||||||
|
logger.debug(f" Found {len(model_files)} model files")
|
||||||
|
for model_path in model_files:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
processed += 1
|
||||||
|
if process_model(model_path, model_type, dry_run=args.dry_run):
|
||||||
|
migrated += 1
|
||||||
|
if model_type == "lora":
|
||||||
|
lora_count += 1
|
||||||
|
else:
|
||||||
|
checkpoint_count += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
logger.info("\n" + "=" * 50)
|
||||||
|
logger.info("Migration Summary:")
|
||||||
|
logger.info(f" Models with legacy metadata: {processed}")
|
||||||
|
logger.info(f" Successfully migrated: {migrated}")
|
||||||
|
logger.info(f" - LoRA models: {lora_count}")
|
||||||
|
logger.info(f" - Checkpoint models: {checkpoint_count}")
|
||||||
|
logger.info(f" Errors: {errors}")
|
||||||
|
logger.info(f" Skipped (no legacy file): {skipped}")
|
||||||
|
if args.dry_run:
|
||||||
|
logger.info("\n [DRY RUN MODE - No files were modified]")
|
||||||
|
return 0 if errors == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
403
scripts/restore_suffixed_filenames.py
Normal file
403
scripts/restore_suffixed_filenames.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Restore original filenames by removing leftover 4-char hash suffixes.
|
||||||
|
|
||||||
|
When LoRA Manager's old duplicate filename resolver ran, it appended
|
||||||
|
``-{first4ofSHA256}`` to duplicate filenames, e.g.::
|
||||||
|
|
||||||
|
my_lora.safetensors → my_lora-a3f7.safetensors
|
||||||
|
|
||||||
|
With full-path LoRA syntax now available (``<lora:subfolder/name:1.0>``),
|
||||||
|
these suffixes are unnecessary. This script detects such files and, with
|
||||||
|
your confirmation, restores their original names.
|
||||||
|
|
||||||
|
The same suffix pattern is also used by the download conflict handler
|
||||||
|
(``{name}-{hash}.{ext}``). To avoid false positives, this script skips
|
||||||
|
any file whose original name already exists in the same directory — those
|
||||||
|
were likely added by a download conflict, not the old resolver.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Detect only (dry-run, default)
|
||||||
|
python scripts/restore_suffixed_filenames.py
|
||||||
|
|
||||||
|
# Detect + restore (with confirmation prompt)
|
||||||
|
python scripts/restore_suffixed_filenames.py --apply
|
||||||
|
|
||||||
|
After restoring filenames, run **Rebuild Cache** in the LoRA Manager
|
||||||
|
Doctor panel to refresh the model cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
PREVIEW_EXTENSIONS = {
|
||||||
|
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
|
||||||
|
".mp4", ".webm", ".mov",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Matches filenames like "my_lora-a3f7.safetensors"
|
||||||
|
# Groups: (base_name, 4-char-hex, extension)
|
||||||
|
_SUFFIX_RE = re.compile(r"^(.+)-([0-9a-f]{4})(\.[^.]+)$")
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers (copied from migrate_legacy_metadata.py for consistency) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = _load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [_expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
"""Extract model folder roots from LoRA Manager settings.
|
||||||
|
|
||||||
|
Returns ``{model_type: [path, ...]}`` where *model_type* is one of
|
||||||
|
``loras``, ``checkpoints``, ``embeddings``, ``unet``, etc.
|
||||||
|
|
||||||
|
Both primary (``folder_paths``) and extra (``extra_folder_paths``)
|
||||||
|
paths are included. Extra paths can be configured via the UI at
|
||||||
|
Settings → Model Libraries → Extra Folder Paths.
|
||||||
|
"""
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
# Primary folder paths.
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(_normalize_path_list(value))
|
||||||
|
# Extra folder paths (Settings → Model Libraries → Extra Folder Paths).
|
||||||
|
extra_folder_paths = source.get("extra_folder_paths")
|
||||||
|
if isinstance(extra_folder_paths, dict):
|
||||||
|
for key, value in extra_folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(_normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(_expand_path(value))
|
||||||
|
return {key: _dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
"""Recursively find all model files in *directory*."""
|
||||||
|
files: list[Path] = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
# ── core detection logic ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def check_file(path: Path) -> tuple[str, str, str] | None:
|
||||||
|
"""If *path* matches the suffix pattern, return ``(base_name, hex, ext)``.
|
||||||
|
|
||||||
|
Returns ``None`` when:
|
||||||
|
* The filename does not match the pattern, or
|
||||||
|
* The original name (without the suffix) already exists in the same
|
||||||
|
directory (likely a download-conflict rename, not a doctor rename).
|
||||||
|
"""
|
||||||
|
match = _SUFFIX_RE.match(path.name)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_name = match.group(1)
|
||||||
|
hex_part = match.group(2)
|
||||||
|
extension = match.group(3)
|
||||||
|
orig_name = base_name + extension
|
||||||
|
orig_path = path.with_name(orig_name)
|
||||||
|
|
||||||
|
# Safety: skip if the original name already exists.
|
||||||
|
if orig_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return base_name, hex_part, extension
|
||||||
|
|
||||||
|
|
||||||
|
def scan_roots(
|
||||||
|
roots: dict[str, list[str]],
|
||||||
|
) -> dict[str, list[tuple[Path, str, str, str]]]:
|
||||||
|
"""Scan all model roots and return detected files grouped by model type.
|
||||||
|
|
||||||
|
Returns ``{model_type: [(full_path, base_name, hex, ext), ...]}``.
|
||||||
|
"""
|
||||||
|
results: dict[str, list[tuple[Path, str, str, str]]] = {}
|
||||||
|
|
||||||
|
for model_type, root_list in roots.items():
|
||||||
|
type_results: list[tuple[Path, str, str, str]] = []
|
||||||
|
for root in root_list:
|
||||||
|
root_path = Path(root)
|
||||||
|
if not root_path.is_dir():
|
||||||
|
continue
|
||||||
|
for model_file in find_model_files(root_path):
|
||||||
|
match = check_file(model_file)
|
||||||
|
if match:
|
||||||
|
type_results.append((model_file, *match))
|
||||||
|
if type_results:
|
||||||
|
results[model_type] = type_results
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def rename_file(
|
||||||
|
path: Path, base_name: str, extension: str, dry_run: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Rename *path* to ``{base_name}{extension}``.
|
||||||
|
|
||||||
|
Also renames sidecar files (``.metadata.json``, ``.civitai.info``) and
|
||||||
|
preview images. Returns ``True`` on success.
|
||||||
|
"""
|
||||||
|
new_path = path.with_name(base_name + extension)
|
||||||
|
old_stem = path.with_suffix("") # /dir/base_name-hex (no ext)
|
||||||
|
new_stem = new_path.with_suffix("") # /dir/base_name (no ext)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(" would rename: %s", path.name)
|
||||||
|
logger.info(" -> %s", new_path.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(path, new_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(" FAILED to rename %s: %s", path.name, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Rename sidecar metadata files.
|
||||||
|
for suffix in (".metadata.json", ".civitai.info"):
|
||||||
|
old_sidecar = old_stem.with_name(old_stem.name + suffix)
|
||||||
|
new_sidecar = new_stem.with_name(new_stem.name + suffix)
|
||||||
|
if old_sidecar.exists():
|
||||||
|
try:
|
||||||
|
os.rename(old_sidecar, new_sidecar)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(" could not rename sidecar %s: %s", old_sidecar.name, exc)
|
||||||
|
|
||||||
|
# Rename preview images.
|
||||||
|
for preview_ext in PREVIEW_EXTENSIONS:
|
||||||
|
old_preview = old_stem.with_name(old_stem.name + preview_ext)
|
||||||
|
new_preview = new_stem.with_name(new_stem.name + preview_ext)
|
||||||
|
if old_preview.exists():
|
||||||
|
try:
|
||||||
|
os.rename(old_preview, new_preview)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(" could not rename preview %s: %s", old_preview.name, exc)
|
||||||
|
|
||||||
|
logger.info(" renamed: %s -> %s", path.name, new_path.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── report helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(results: dict[str, list[tuple[Path, str, str, str]]]) -> int:
|
||||||
|
"""Print a human-readable report of detected files. Returns total count."""
|
||||||
|
if not results:
|
||||||
|
logger.info("No leftover suffixed filenames detected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for model_type in sorted(results):
|
||||||
|
entries = results[model_type]
|
||||||
|
total += len(entries)
|
||||||
|
label = model_type.capitalize()
|
||||||
|
logger.info("")
|
||||||
|
logger.info("─" * 50)
|
||||||
|
logger.info(" %s (%d file(s))", label, len(entries))
|
||||||
|
logger.info("─" * 50)
|
||||||
|
for path, base_name, hex_part, ext in sorted(entries):
|
||||||
|
logger.info(" %s → %s%s", path.name, base_name, ext)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" Total: %d file(s) with leftover suffixes.", total)
|
||||||
|
logger.info("=" * 50)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_user(count: int) -> bool:
|
||||||
|
"""Ask the user whether to proceed with the rename."""
|
||||||
|
try:
|
||||||
|
answer = input(
|
||||||
|
f"\nRestore {count} file(s) to their original names? [y/N] "
|
||||||
|
).strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
return False
|
||||||
|
return answer in ("y", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Detect and restore model filenames that have leftover "
|
||||||
|
"4-character hash suffixes from the old conflict resolver."
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"Examples:\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py --apply\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py --apply --yes\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="Actually rename files (with confirmation prompt unless --yes is given)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes", "-y",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip confirmation prompt (implies --apply)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Detect only — show what would be renamed without making changes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable debug-level logging",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Resolve settings.
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info("Settings: %s", settings_path)
|
||||||
|
settings = _load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Is LoRA Manager configured?")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders found in settings.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Log which roots are being scanned.
|
||||||
|
for model_type, root_list in roots.items():
|
||||||
|
for root in root_list:
|
||||||
|
logger.info("Scanning %s: %s", model_type, root)
|
||||||
|
|
||||||
|
# Detect.
|
||||||
|
results = scan_roots(roots)
|
||||||
|
total = print_report(results)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Determine mode.
|
||||||
|
dry_run = not args.apply and not args.yes
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("\n[Dry-run mode — no files modified]")
|
||||||
|
logger.info("Run with --apply to restore filenames.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Confirm unless --yes.
|
||||||
|
if not args.yes:
|
||||||
|
if not prompt_user(total):
|
||||||
|
logger.info("Aborted.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Rename.
|
||||||
|
logger.info("")
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
for model_type in sorted(results):
|
||||||
|
entries = results[model_type]
|
||||||
|
logger.info("")
|
||||||
|
logger.info("─" * 50)
|
||||||
|
logger.info(" Restoring %s (%d file(s))", model_type, len(entries))
|
||||||
|
logger.info("─" * 50)
|
||||||
|
for path, base_name, hex_part, ext in sorted(entries):
|
||||||
|
ok = rename_file(path, base_name, ext, dry_run=False)
|
||||||
|
if ok:
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" Done: %d restored, %d failed.", success, fail)
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info("")
|
||||||
|
logger.info(" ⚠ Please run Rebuild Cache in the LoRA Manager")
|
||||||
|
logger.info(" Doctor panel to refresh the model cache.")
|
||||||
|
|
||||||
|
return 0 if fail == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
"C:/path/to/your/checkpoints_folder",
|
"C:/path/to/your/checkpoints_folder",
|
||||||
"C:/path/to/another/checkpoints_folder"
|
"C:/path/to/another/checkpoints_folder"
|
||||||
],
|
],
|
||||||
|
"unet": [
|
||||||
|
"C:/path/to/your/diffusion_models_folder",
|
||||||
|
"C:/path/to/another/diffusion_models_folder"
|
||||||
|
],
|
||||||
"embeddings": [
|
"embeddings": [
|
||||||
"C:/path/to/your/embeddings_folder",
|
"C:/path/to/your/embeddings_folder",
|
||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
|
@import 'tokens/index.css';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Disable default scrolling */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Firefox */
|
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border-color) transparent;
|
scrollbar-color: var(--border-base) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Webkit browsers (Chrome, Safari等) */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: var(--scrollbar-width, 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -24,116 +23,128 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--border-color);
|
background-color: var(--border-base);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #ffffff;
|
|
||||||
--text-color: #333333;
|
|
||||||
--text-muted: #6c757d;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--border-color: #e0e0e0;
|
|
||||||
--header-height: 48px;
|
--header-height: 48px;
|
||||||
|
|
||||||
/* Color Components */
|
|
||||||
--lora-accent-l: 68%;
|
|
||||||
--lora-accent-c: 0.28;
|
|
||||||
--lora-accent-h: 256;
|
|
||||||
--lora-warning-l: 75%;
|
|
||||||
--lora-warning-c: 0.25;
|
|
||||||
--lora-warning-h: 80;
|
|
||||||
--lora-success-l: 70%;
|
|
||||||
--lora-success-c: 0.2;
|
|
||||||
--lora-success-h: 140;
|
|
||||||
|
|
||||||
/* Composed Colors */
|
|
||||||
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
|
||||||
--lora-surface: oklch(97% 0 0 / 0.95);
|
|
||||||
--lora-border: oklch(72% 0.03 256 / 0.45);
|
|
||||||
--lora-text: oklch(95% 0.02 256);
|
|
||||||
--lora-error: oklch(75% 0.32 29);
|
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 20%, transparent);
|
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 50%, transparent);
|
|
||||||
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
|
||||||
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h));
|
|
||||||
--badge-update-bg: oklch(72% 0.2 220);
|
|
||||||
--badge-update-text: oklch(28% 0.03 220);
|
|
||||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
|
||||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
|
||||||
--badge-skip-refresh-text: oklch(35% 0.02 45);
|
|
||||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
|
||||||
|
|
||||||
/* Spacing Scale */
|
|
||||||
--space-1: calc(8px * 1);
|
|
||||||
--space-2: calc(8px * 2);
|
|
||||||
--space-3: calc(8px * 3);
|
|
||||||
--space-4: calc(8px * 4);
|
|
||||||
|
|
||||||
/* Z-index Scale */
|
|
||||||
--z-base: 10;
|
|
||||||
--z-header: 100;
|
|
||||||
--z-modal: 1000;
|
|
||||||
--z-overlay: 2000;
|
|
||||||
|
|
||||||
/* Border Radius */
|
|
||||||
--border-radius-base: 12px;
|
|
||||||
--border-radius-md: 12px;
|
|
||||||
--border-radius-sm: 8px;
|
|
||||||
--border-radius-xs: 4px;
|
|
||||||
|
|
||||||
--scrollbar-width: 8px;
|
--scrollbar-width: 8px;
|
||||||
/* 添加滚动条宽度变量 */
|
|
||||||
|
|
||||||
/* Shortcut styles */
|
--shortcut-bg: var(--color-accent-subtle);
|
||||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
--shortcut-border: var(--color-accent-border);
|
||||||
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
--shortcut-text: var(--text-primary);
|
||||||
--shortcut-text: var(--text-color);
|
|
||||||
|
--lora-accent-transparent: var(--color-accent-transparent);
|
||||||
|
|
||||||
|
/* Legacy spacing aliases: 8px base grid to match existing component usage */
|
||||||
|
--space-1: 8px;
|
||||||
|
--space-2: 16px;
|
||||||
|
--space-3: 24px;
|
||||||
|
--space-4: 32px;
|
||||||
|
|
||||||
|
/* Legacy border-radius aliases to match existing component usage */
|
||||||
|
--border-radius-xs: 4px;
|
||||||
|
--border-radius-sm: 6px;
|
||||||
|
--border-radius-base: 8px;
|
||||||
|
--border-radius-md: 12px;
|
||||||
|
--border-radius-lg: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-color: var(--bg-base);
|
||||||
|
--text-color: var(--text-primary);
|
||||||
|
--text-muted: var(--text-secondary);
|
||||||
|
--card-bg: var(--surface-base);
|
||||||
|
--border-color: var(--border-base);
|
||||||
|
|
||||||
|
--lora-accent: var(--color-accent);
|
||||||
|
--lora-surface: var(--bg-elevated);
|
||||||
|
--lora-border: var(--border-subtle);
|
||||||
|
--lora-text: var(--text-primary);
|
||||||
|
--lora-error: var(--color-error);
|
||||||
|
--lora-error-bg: var(--color-error-bg);
|
||||||
|
--lora-error-border: var(--color-error-border);
|
||||||
|
--lora-warning: var(--color-warning);
|
||||||
|
--lora-success: var(--color-success);
|
||||||
|
|
||||||
|
--badge-update-bg: var(--color-info-bg);
|
||||||
|
--badge-update-text: var(--color-info-text);
|
||||||
|
--badge-update-glow: var(--color-info-glow);
|
||||||
|
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||||
|
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||||
|
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-color: var(--bg-base);
|
||||||
|
--text-color: var(--text-primary);
|
||||||
|
--text-muted: var(--text-secondary);
|
||||||
|
--card-bg: var(--surface-base);
|
||||||
|
--border-color: var(--border-base);
|
||||||
|
|
||||||
|
--lora-accent: var(--color-accent);
|
||||||
|
--lora-surface: var(--bg-elevated);
|
||||||
|
--lora-border: var(--border-subtle);
|
||||||
|
--lora-text: var(--text-primary);
|
||||||
|
--lora-error: var(--color-error);
|
||||||
|
--lora-error-bg: var(--color-error-bg);
|
||||||
|
--lora-error-border: var(--color-error-border);
|
||||||
|
--lora-warning: var(--color-warning);
|
||||||
|
--lora-success: var(--color-success);
|
||||||
|
|
||||||
|
--badge-update-bg: var(--color-info-bg);
|
||||||
|
--badge-update-text: var(--color-info-text);
|
||||||
|
--badge-update-glow: var(--color-info-glow);
|
||||||
|
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||||
|
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||||
|
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
background-color: #1a1a1a !important;
|
background-color: var(--bg-base) !important;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] {
|
html[data-theme="light"] {
|
||||||
background-color: #ffffff !important;
|
background-color: var(--bg-base) !important;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bg-color: #1a1a1a;
|
|
||||||
--text-color: #e0e0e0;
|
|
||||||
--text-muted: #a0a0a0;
|
|
||||||
--card-bg: #2d2d2d;
|
|
||||||
--border-color: #404040;
|
|
||||||
|
|
||||||
--lora-accent: oklch(68% 0.28 256);
|
|
||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
|
||||||
--lora-text: oklch(98% 0.02 256);
|
|
||||||
--lora-warning: oklch(75% 0.25 80);
|
|
||||||
/* Modified to be used with oklch() */
|
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
|
||||||
--badge-update-bg: oklch(62% 0.18 220);
|
|
||||||
--badge-update-text: oklch(98% 0.02 240);
|
|
||||||
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
|
|
||||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
|
||||||
--badge-skip-refresh-text: oklch(98% 0.02 45);
|
|
||||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', sans-serif;
|
font-family: var(--font-body);
|
||||||
background: var(--bg-color);
|
background: var(--bg-base);
|
||||||
color: var(--text-color);
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
/* Remove the padding-top */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible),
|
||||||
|
input:focus:not(:focus-visible),
|
||||||
|
select:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-side);
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-side);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-alphabet-bar:hover {
|
.toggle-alphabet-bar:hover {
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.letter-chip.active {
|
.letter-chip.active {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tertiary Action Button */
|
/* Tertiary Action Button */
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
||||||
content: '\f00c';
|
content: '\f00c';
|
||||||
font-family: 'Font Awesome 6 Free';
|
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn:hover {
|
.back-btn:hover {
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* 卡片网格布局 */
|
/* Card grid layout */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||||
gap: 12px; /* Consistent gap for both row and column spacing */
|
gap: 12px; /* Consistent gap for both row and column spacing */
|
||||||
row-gap: 20px; /* Increase vertical spacing between rows */
|
row-gap: 20px; /* Increase vertical spacing between rows */
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
padding-top: 4px;
|
||||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
padding-bottom: 4px;
|
||||||
width: 100%; /* Ensure it takes full width of container */
|
width: 100%; /* Ensure it takes full width of container */
|
||||||
max-width: 1400px; /* Base container width */
|
max-width: 1400px; /* Base container width */
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
transition: transform 160ms ease-out;
|
transition: transform var(--transition-fast) ease-out, box-shadow var(--transition-fast) ease-out, border-color var(--transition-fast) ease-out;
|
||||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||||
max-width: 260px; /* Base size */
|
max-width: 260px; /* Base size */
|
||||||
|
min-width: 200px; /* Prevent cards from becoming too narrow */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
|
|
||||||
.model-card:hover {
|
.model-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
background: oklch(100% 0 0 / 0.6);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card:focus-visible {
|
.model-card:focus-visible {
|
||||||
@@ -328,7 +330,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions i {
|
.card-actions i {
|
||||||
margin-left: var(--space-1);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
color: white;
|
||||||
transition: opacity 0.2s, transform 0.15s ease;
|
transition: opacity 0.2s, transform 0.15s ease;
|
||||||
@@ -353,24 +354,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1); /* Use gap instead of margin for spacing between icons */
|
gap: var(--space-1);
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions i:hover {
|
.card-actions i:hover,
|
||||||
|
.card-actions i:focus-visible {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for active favorites */
|
|
||||||
.favorite-active {
|
.favorite-active {
|
||||||
color: #ffc107 !important; /* Gold color for favorites */
|
color: var(--favorite-color) !important;
|
||||||
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
text-shadow: 0 0 5px var(--favorite-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
@media (max-width: 1200px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
max-width: 240px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
||||||
@@ -378,17 +393,10 @@
|
|||||||
|
|
||||||
.model-card {
|
.model-card {
|
||||||
max-width: 100%; /* Allow cards to fill available space on mobile */
|
max-width: 100%; /* Allow cards to fill available space on mobile */
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
flex-shrink: 0; /* Prevent actions from shrinking */
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-1);
|
|
||||||
align-items: flex-end; /* 将图标靠下对齐 */
|
|
||||||
align-self: flex-end; /* 将整个actions容器靠下对齐 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-link {
|
.model-link {
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
@@ -401,9 +409,13 @@
|
|||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-link a:hover {
|
.model-link a:hover,
|
||||||
|
.model-link a:focus-visible {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Updated model name to fix text cutoff issues */
|
/* Updated model name to fix text cutoff issues */
|
||||||
@@ -428,7 +440,7 @@
|
|||||||
|
|
||||||
.base-model {
|
.base-model {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #f0f0f0;
|
background: var(--surface-hover, oklch(95% 0 0));
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
@@ -497,11 +509,91 @@
|
|||||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Version row — flex container for badges + version names */
|
||||||
|
.version-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge + version-name binding: they wrap as a single unit */
|
||||||
|
.badge-version-unit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Medium density adjustments for version name */
|
/* Medium density adjustments for version name */
|
||||||
.medium-density .version-name {
|
.medium-density .version-name {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medium-density .badge-version-unit .version-name {
|
||||||
|
max-width: 90px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact density adjustments for version name */
|
||||||
|
.compact-density .version-name {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-density .badge-version-unit .version-name {
|
||||||
|
max-width: 70px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .version-row {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HIGH / LOW badges — shown inline before version name in card footer */
|
||||||
|
.hl-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-badge--high {
|
||||||
|
color: oklch(75% 0.12 230);
|
||||||
|
background: oklch(55% 0.15 240 / 0.25);
|
||||||
|
border-color: oklch(60% 0.18 250 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-badge--low {
|
||||||
|
color: oklch(78% 0.10 185);
|
||||||
|
background: oklch(50% 0.10 190 / 0.25);
|
||||||
|
border-color: oklch(55% 0.12 195 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .hl-badge {
|
||||||
|
font-size: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-density .hl-badge {
|
||||||
|
font-size: 0.62em;
|
||||||
|
padding: 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide version-related elements when setting is disabled */
|
||||||
|
body.hide-card-version .civitai-version,
|
||||||
|
body.hide-card-version .hl-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact density adjustments for version name */
|
/* Compact density adjustments for version name */
|
||||||
.compact-density .version-name {
|
.compact-density .version-name {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
@@ -558,8 +650,13 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
margin: 0;
|
||||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow cards to grow beyond 260px in virtual scroll mode */
|
||||||
|
.virtual-scroll-item.model-card {
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-item:hover {
|
.virtual-scroll-item:hover {
|
||||||
@@ -571,11 +668,11 @@
|
|||||||
.card-grid.virtual-scroll {
|
.card-grid.virtual-scroll {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 auto;
|
margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
|
||||||
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1400px; /* Keep the max-width from original grid */
|
max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
|
||||||
box-sizing: border-box; /* Include padding in width calculation */
|
box-sizing: border-box; /* Include padding in width calculation */
|
||||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
|
box-shadow: var(--shadow-lg); /* Stronger shadow */
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition-slow);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button.btn-exit-mode:hover {
|
.duplicates-banner button.btn-exit-mode:hover {
|
||||||
@@ -86,16 +86,16 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button:hover {
|
.duplicates-banner button:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button.btn-exit {
|
.duplicates-banner button.btn-exit {
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); /* Add subtle shadow to groups */
|
box-shadow: var(--shadow-md); /* Add subtle shadow to groups */
|
||||||
/* Add responsive width settings to match banner */
|
/* Add responsive width settings to match banner */
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -173,9 +173,9 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: var(--shadow-xs);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-group-container {
|
.card-group-container {
|
||||||
@@ -230,20 +230,20 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-toggle-btn:hover {
|
.group-toggle-btn:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Duplicate card styling */
|
/* Duplicate card styling */
|
||||||
.model-card.duplicate {
|
.model-card.duplicate {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card.duplicate:hover {
|
.model-card.duplicate:hover {
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
|
|
||||||
.model-card.duplicate-selected {
|
.model-card.duplicate-selected {
|
||||||
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card .selector-checkbox {
|
.model-card .selector-checkbox {
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-verify-hashes:hover {
|
.btn-verify-hashes:hover {
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8px; /* Moved closer to button */
|
top: -8px; /* Moved closer to button */
|
||||||
right: -8px; /* Moved closer to button */
|
right: -8px; /* Moved closer to button */
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); /* Softer shadow */
|
box-shadow: var(--shadow-sm); /* Softer shadow */
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-icon:hover {
|
.help-icon:hover {
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-elevated);
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -572,16 +572,16 @@
|
|||||||
|
|
||||||
/* In dark mode, add additional distinction */
|
/* In dark mode, add additional distinction */
|
||||||
html[data-theme="dark"] .duplicates-banner {
|
html[data-theme="dark"] .duplicates-banner {
|
||||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* Stronger shadow in dark mode */
|
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
|
||||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .duplicate-group {
|
html[data-theme="dark"] .duplicate-group {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); /* Stronger shadow in dark mode */
|
box-shadow: var(--shadow-lg); /* Stronger shadow in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .help-tooltip {
|
html[data-theme="dark"] .help-tooltip {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles for disabled controls during duplicates mode */
|
/* Styles for disabled controls during duplicates mode */
|
||||||
|
|||||||
@@ -7,22 +7,22 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group .filter-active:hover {
|
.control-group .filter-active:hover {
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group .filter-active:active {
|
.control-group .filter-active:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group .filter-active i.fa-filter {
|
.control-group .filter-active i.fa-filter {
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
|
|
||||||
/* Animation for filter indicator */
|
/* Animation for filter indicator */
|
||||||
@keyframes filterPulse {
|
@keyframes filterPulse {
|
||||||
0% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
0% { transform: scale(1); box-shadow: var(--shadow-sm); }
|
||||||
50% { transform: scale(1.03); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); }
|
50% { transform: scale(1.03); box-shadow: var(--shadow-lg); }
|
||||||
100% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
100% { transform: scale(1); box-shadow: var(--shadow-sm); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-active.animate {
|
.filter-active.animate {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
/* Reduced height */
|
/* Reduced height */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
/* Slightly stronger shadow */
|
/* Slightly stronger shadow */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,22 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left section: Logo + Navigation */
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right section: Controls */
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive header container for larger screens */
|
/* Responsive header container for larger screens */
|
||||||
@media (min-width: 2150px) {
|
@media (min-width: 2150px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -77,6 +93,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover,
|
.nav-item:hover,
|
||||||
@@ -97,13 +114,101 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header search */
|
/* Header search - Centered with VS Code command palette style */
|
||||||
.header-search {
|
.header-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VS Code command palette style search container */
|
||||||
|
.header-search .search-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg, var(--card-bg));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||||
|
box-shadow: var(--shadow-header);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container:focus-within {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: var(--shadow-header), 0 0 0 1px var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
padding-left: 2.25rem !important;
|
||||||
|
padding-right: 5rem !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle,
|
||||||
|
.header-search .search-filter-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle {
|
||||||
|
right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:focus-visible {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .filter-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Disabled state for header search */
|
/* Disabled state for header search */
|
||||||
.header-search.disabled {
|
.header-search.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -166,7 +271,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +343,7 @@
|
|||||||
background-color: var(--lora-error);
|
background-color: var(--lora-error);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid var(--card-bg);
|
border: 2px solid var(--card-bg);
|
||||||
transition: all 0.2s ease;
|
transition: opacity var(--transition-base);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -247,44 +352,216 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Hamburger menu button - hidden by default */
|
||||||
@media (max-width: 768px) {
|
.hamburger-menu-btn {
|
||||||
.app-title {
|
display: none;
|
||||||
display: none;
|
width: 32px;
|
||||||
/* Hide text title on mobile */
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn:hover,
|
||||||
|
.hamburger-menu-btn:focus-visible {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item:hover,
|
||||||
|
.hamburger-dropdown .dropdown-item:focus-visible {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger dropdown menu */
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
box-shadow: var(--shadow-toast);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: var(--z-dropdown, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item i {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header-container {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls>div {
|
.header-controls > div {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Hide nav icons at 1100px to save space */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nav-item {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.app-title {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0 0.5rem;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
margin-right: 0.5rem;
|
gap: 0.25rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For very small screens */
|
/* Responsive: Compact mode at 768px */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-search input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
padding-left: 2rem !important;
|
||||||
|
padding-right: 4.5rem !important;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container {
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For very small screens - switch nav to icons only */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: none;
|
display: flex;
|
||||||
/* Hide navigation on very small screens */
|
gap: 0.15rem;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.nav-item {
|
||||||
flex: 1;
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Position relative for hamburger menu positioning */
|
||||||
|
.header-right {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
@@ -757,7 +757,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
background: rgba(var(--lora-accent), 0.05);
|
background: rgba(var(--lora-accent), 0.05);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-header {
|
.tips-header {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-nav-hint i {
|
.keyboard-nav-hint i {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
transform: translateY(-15%); /* Vertically center */
|
transform: translateY(-15%); /* Vertically center */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -92,5 +92,5 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
width: min(400px, 90vw); /* 固定最大宽度,但保持响应式 */
|
width: min(400px, 90vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
.loading-status {
|
.loading-status {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--text-color); /* 使用主题文本颜色 */
|
color: var(--text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
width: 280px; /* 固定进度条宽度 */
|
width: 280px;
|
||||||
background-color: var(--lora-border); /* 使用主题边框颜色 */
|
background-color: var(--lora-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 auto; /* 居中显示 */
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.model-description-content code {
|
.model-description-content code {
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
padding: 0.1em 0.3em;
|
padding: 0.1em 0.3em;
|
||||||
|
|||||||
@@ -105,14 +105,14 @@
|
|||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 调整深色主题下的样式 */
|
/* Dark theme info item styles */
|
||||||
[data-theme="dark"] .info-item {
|
[data-theme="dark"] .info-item {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,18 +140,70 @@
|
|||||||
|
|
||||||
/* Add specific styles for notes content */
|
/* Add specific styles for notes content */
|
||||||
.info-item.notes .editable-field [contenteditable] {
|
.info-item.notes .editable-field [contenteditable] {
|
||||||
height: 60px; /* Keep initial modal layout stable regardless of note length */
|
min-height: 60px;
|
||||||
min-height: 60px; /* Increase height for multiple lines */
|
white-space: pre-wrap;
|
||||||
max-height: 420px; /* Limit maximum height */
|
line-height: 1.5;
|
||||||
overflow: auto; /* Enable scrolling and resize handle for long content */
|
padding: 8px 12px;
|
||||||
resize: vertical; /* Allow manual vertical resizing */
|
}
|
||||||
white-space: pre-wrap; /* Preserve line breaks */
|
|
||||||
line-height: 1.5; /* Improve readability */
|
/* Notes expand/collapse — collapsed by default; only applies when JS detects long content */
|
||||||
padding: 8px 12px; /* Slightly increase padding */
|
.info-item.notes .editable-field {
|
||||||
|
position: relative;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item.notes .editable-field.collapsed {
|
||||||
|
max-height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient fade overlay hint when collapsed */
|
||||||
|
.info-item.notes .editable-field.collapsed::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 28px;
|
||||||
|
background: linear-gradient(transparent, var(--bg-color));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes header row — label left, toggle button right */
|
||||||
|
.notes-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button — icon only, inline with the label */
|
||||||
|
.notes-toggle-btn {
|
||||||
|
display: none; /* shown by JS when content exceeds threshold */
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toggle-btn:hover {
|
||||||
|
background: rgba(66, 153, 225, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toggle-btn i {
|
||||||
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-path {
|
.file-path {
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +271,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
|
/* Back-to-top button pinned inside modal */
|
||||||
.modal-content .back-to-top {
|
.modal-content .back-to-top {
|
||||||
position: sticky; /* 改用 sticky 定位 */
|
position: sticky;
|
||||||
float: right; /* 使用 float 确保按钮在右侧 */
|
float: right;
|
||||||
bottom: 20px; /* 距离底部的距离 */
|
bottom: 20px;
|
||||||
margin-right: 20px; /* 右侧间距 */
|
margin-right: 20px;
|
||||||
margin-top: -56px; /* 负边距确保不占用额外空间 */
|
margin-top: -56px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -239,7 +291,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
transition: all 0.3s ease;
|
transition: opacity var(--transition-slow), visibility var(--transition-slow), transform var(--transition-slow);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,35 +307,39 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File name copy styles */
|
/* Editable inline field styles (file name, version name, etc.) */
|
||||||
.file-name-wrapper {
|
.file-name-wrapper,
|
||||||
|
.version-name-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px;
|
padding: 4px 0;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-content {
|
.file-name-content,
|
||||||
padding: 2px 4px;
|
.version-name-content {
|
||||||
|
padding: 2px 4px 2px 0;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-wrapper.editing .file-name-content {
|
.file-name-wrapper.editing .file-name-content,
|
||||||
|
.version-name-wrapper.editing .version-name-content {
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 合并编辑按钮样式 */
|
/* Consolidated edit button styles */
|
||||||
.edit-model-name-btn,
|
.edit-model-name-btn,
|
||||||
.edit-file-name-btn,
|
.edit-file-name-btn,
|
||||||
.edit-base-model-btn,
|
.edit-base-model-btn,
|
||||||
.edit-model-description-btn {
|
.edit-model-description-btn,
|
||||||
|
.edit-version-name-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -291,7 +347,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: all 0.2s ease;
|
transition: opacity var(--transition-base), background-color var(--transition-base);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,9 +355,11 @@
|
|||||||
.edit-file-name-btn.visible,
|
.edit-file-name-btn.visible,
|
||||||
.edit-base-model-btn.visible,
|
.edit-base-model-btn.visible,
|
||||||
.edit-model-description-btn.visible,
|
.edit-model-description-btn.visible,
|
||||||
|
.edit-version-name-btn.visible,
|
||||||
.model-name-header:hover .edit-model-name-btn,
|
.model-name-header:hover .edit-model-name-btn,
|
||||||
.file-name-wrapper:hover .edit-file-name-btn,
|
.file-name-wrapper:hover .edit-file-name-btn,
|
||||||
.base-model-display:hover .edit-base-model-btn,
|
.base-model-display:hover .edit-base-model-btn,
|
||||||
|
.version-name-wrapper:hover .edit-version-name-btn,
|
||||||
.model-name-header:hover .edit-model-description-btn {
|
.model-name-header:hover .edit-model-description-btn {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@@ -309,14 +367,16 @@
|
|||||||
.edit-model-name-btn:hover,
|
.edit-model-name-btn:hover,
|
||||||
.edit-file-name-btn:hover,
|
.edit-file-name-btn:hover,
|
||||||
.edit-base-model-btn:hover,
|
.edit-base-model-btn:hover,
|
||||||
.edit-model-description-btn:hover {
|
.edit-model-description-btn:hover,
|
||||||
opacity: 0.8 !important;
|
.edit-version-name-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .edit-model-name-btn:hover,
|
[data-theme="dark"] .edit-model-name-btn:hover,
|
||||||
[data-theme="dark"] .edit-file-name-btn:hover,
|
[data-theme="dark"] .edit-file-name-btn:hover,
|
||||||
[data-theme="dark"] .edit-base-model-btn:hover {
|
[data-theme="dark"] .edit-base-model-btn:hover,
|
||||||
|
[data-theme="dark"] .edit-version-name-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +387,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-wrapper {
|
.base-wrapper {
|
||||||
flex: 2; /* 分配更多空间给base model */
|
flex: 2; /* Allocate more space to base model */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base model display and editing styles */
|
/* Base model display and editing styles */
|
||||||
@@ -338,7 +398,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-model-content {
|
.base-model-content {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px 2px 0;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -370,7 +430,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.size-wrapper span {
|
.size-wrapper span {
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
@@ -387,7 +447,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
font-size: 1.5em !important;
|
font-size: 1.5em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -423,7 +483,7 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -828,18 +888,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
max-width: fit-content;
|
max-width: fit-content;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .creator-info,
|
[data-theme="dark"] .creator-info,
|
||||||
[data-theme="dark"] .civitai-view,
|
[data-theme="dark"] .civitai-view,
|
||||||
[data-theme="dark"] .modal-send-btn {
|
[data-theme="dark"] .modal-send-btn {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,14 +958,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.civitai-view i {
|
.civitai-view i {
|
||||||
@@ -921,18 +981,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .modal-send-btn {
|
[data-theme="dark"] .modal-send-btn {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: calc(var(--space-1) * 0.5) var(--space-1);
|
padding: calc(var(--space-1) * 0.5) var(--space-1);
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tag span {
|
.preset-tag span {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tag:hover {
|
.preset-tag:hover {
|
||||||
|
|||||||
@@ -111,8 +111,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
|
|
||||||
.media-control-btn:hover {
|
.media-control-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-control-btn.set-preview-btn:hover {
|
.media-control-btn.set-preview-btn:hover {
|
||||||
@@ -141,8 +141,9 @@
|
|||||||
border-color: var(--lora-error);
|
border-color: var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disabled state for delete button */
|
/* Disabled state for delete and create-recipe buttons */
|
||||||
.media-control-btn.example-delete-btn.disabled {
|
.media-control-btn.example-delete-btn.disabled,
|
||||||
|
.media-control-btn.create-recipe-btn.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -204,7 +205,7 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
max-height: 50%; /* Reduced to take less space */
|
max-height: 50%; /* Reduced to take less space */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-inset-top);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@
|
|||||||
/* Adjust to dark theme */
|
/* Adjust to dark theme */
|
||||||
[data-theme="dark"] .image-metadata-panel {
|
[data-theme="dark"] .image-metadata-panel {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-inset-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-content {
|
.metadata-content {
|
||||||
@@ -296,7 +297,7 @@
|
|||||||
|
|
||||||
.metadata-prompt {
|
.metadata-prompt {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -311,7 +312,7 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-prompt-btn:hover {
|
.copy-prompt-btn:hover {
|
||||||
@@ -408,7 +409,7 @@
|
|||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition-slow);
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -454,9 +455,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-formats {
|
.import-formats {
|
||||||
font-size: 0.8em !important;
|
font-size: 0.8em;
|
||||||
opacity: 0.6 !important;
|
opacity: 0.6;
|
||||||
margin-top: var(--space-2) !important;
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-files-btn {
|
.select-files-btn {
|
||||||
@@ -470,7 +471,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-files-btn:hover {
|
.select-files-btn:hover {
|
||||||
@@ -480,7 +481,7 @@
|
|||||||
|
|
||||||
/* For dark theme */
|
/* For dark theme */
|
||||||
[data-theme="dark"] .import-container {
|
[data-theme="dark"] .import-container {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Setup Guidance State - When example images path is not configured */
|
/* Setup Guidance State - When example images path is not configured */
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.model-tag-compact {
|
.model-tag-compact {
|
||||||
/* Updated styles to match info-item appearance */
|
/* Updated styles to match info-item appearance */
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
/* Adjust dark theme tag styles */
|
/* Adjust dark theme tag styles */
|
||||||
[data-theme="dark"] .model-tag-compact {
|
[data-theme="dark"] .model-tag-compact {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,14 +73,14 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
.tooltip-tag {
|
.tooltip-tag {
|
||||||
/* Updated styles to match info-item appearance */
|
/* Updated styles to match info-item appearance */
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-hover);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
/* Adjust dark theme tooltip tag styles */
|
/* Adjust dark theme tooltip tag styles */
|
||||||
[data-theme="dark"] .tooltip-tag {
|
[data-theme="dark"] .tooltip-tag {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-hover);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/* Update Trigger Words styles */
|
/* Update Trigger Words styles */
|
||||||
.info-item.trigger-words {
|
.info-item.trigger-words {
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 调整 trigger words 样式 */
|
/* Trigger words styles */
|
||||||
[data-theme="dark"] .info-item.trigger-words {
|
[data-theme="dark"] .info-item.trigger-words {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
|
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
box-shadow: var(--shadow-xs);
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
|
|
||||||
.model-version-row:hover {
|
.model-version-row:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-version-row.is-clickable {
|
.model-version-row.is-clickable {
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
height: 88px;
|
height: 88px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-hover);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -374,11 +374,23 @@
|
|||||||
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.version-action:disabled {
|
.version-action:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.versions-loading-state,
|
.versions-loading-state,
|
||||||
.versions-empty,
|
.versions-empty,
|
||||||
.versions-error {
|
.versions-error {
|
||||||
|
|||||||
124
static/css/components/media-viewer.css
Normal file
124
static/css/components/media-viewer.css
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
.media-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active .media-viewer-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-content-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-media {
|
||||||
|
display: block;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow-dark-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-video {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-counter {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-title {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active .media-viewer-nav {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-prev {
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-next {
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user