mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -03:00
Compare commits
60 Commits
761108bfd1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a1dff6dd47 | ||
|
|
7fa40023b0 | ||
|
|
3c8acdb65e | ||
|
|
1e9a7812d6 | ||
|
|
37f0e8f213 | ||
|
|
ecf7ea21e4 | ||
|
|
79dd9a1b29 | ||
|
|
ef4923fd94 | ||
|
|
1eeba666f5 | ||
|
|
89e26d9292 | ||
|
|
fc19a145ff | ||
|
|
34f03d6495 | ||
|
|
9443175abc | ||
|
|
dc5072628f | ||
|
|
ff4b8ec849 | ||
|
|
7ab271c752 | ||
|
|
5a7f4dc88b |
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())
|
||||||
@@ -12,33 +12,39 @@
|
|||||||
"2018cfh",
|
"2018cfh",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"wackop",
|
"wackop",
|
||||||
"Takkan",
|
"Phil",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
|
"Arlecchino Shion",
|
||||||
|
"stone9k",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
|
"Gingko Biloba",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
"stone9k",
|
"Takkan",
|
||||||
|
"Charles Blakemore",
|
||||||
|
"Rob Williams",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
|
"Tobi_Swagg",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
"Gooohokrbe",
|
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"OldBones",
|
|
||||||
"VantAI",
|
"VantAI",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
|
"Illrigger",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
|
"Jorge Hussni",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Birdy",
|
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
"Jorge Hussni",
|
"Birdy",
|
||||||
"Kiba",
|
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
|
"Kiba",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
|
"Mozzel",
|
||||||
"sig",
|
"sig",
|
||||||
"Christian Byrne",
|
"Christian Byrne",
|
||||||
"DM",
|
"DM",
|
||||||
@@ -46,39 +52,41 @@
|
|||||||
"Estragon",
|
"Estragon",
|
||||||
"J\\B/ 8r0wns0n",
|
"J\\B/ 8r0wns0n",
|
||||||
"Snaggwort",
|
"Snaggwort",
|
||||||
"Arlecchino Shion",
|
|
||||||
"Charles Blakemore",
|
|
||||||
"Rob Williams",
|
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
|
"Jonathan Ross",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
|
"Nazono_hito",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"Tobi_Swagg",
|
"contrite831",
|
||||||
"SG",
|
"SG",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
"jmack",
|
||||||
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
"Cosmosis",
|
"Anthony Rizzo",
|
||||||
"iamresist",
|
"iamresist",
|
||||||
|
"Gooohokrbe",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"Wolffen",
|
"Wolffen",
|
||||||
"FloPro4Sho",
|
|
||||||
"James Todd",
|
"James Todd",
|
||||||
|
"OldBones",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"Tim",
|
"Tim",
|
||||||
|
"Timmy",
|
||||||
|
"Johnny",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"Michael Wong",
|
"Michael Wong",
|
||||||
"Illrigger",
|
"whudunit",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
|
"dl0901dm",
|
||||||
"JackieWang",
|
"JackieWang",
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
"Julian V",
|
|
||||||
"Steven Owens",
|
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
"Echo",
|
"Echo",
|
||||||
@@ -86,147 +94,137 @@
|
|||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
"PM",
|
"PM",
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Mozzel",
|
"Briton Heilbrun",
|
||||||
"Gingko Biloba",
|
"Aleksander Wujczyk",
|
||||||
"Sterilized",
|
|
||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
|
"Sterilized",
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"quarz",
|
"quarz",
|
||||||
"Greg",
|
|
||||||
"Penfore",
|
"Penfore",
|
||||||
|
"Greg",
|
||||||
"JSST",
|
"JSST",
|
||||||
"esthe",
|
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
"IamAyam",
|
"zounic",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
"Baekdoosixt",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
|
||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"Nazono_hito",
|
|
||||||
"Melville Parrish",
|
"Melville Parrish",
|
||||||
"daniel dove",
|
"daniel dove",
|
||||||
"Lustre",
|
"Lustre",
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"contrite831",
|
|
||||||
"Alex",
|
"Alex",
|
||||||
"bh",
|
"bh",
|
||||||
"confiscated Zyra",
|
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
"Adam Shaw",
|
"Cosmosis",
|
||||||
"Tee Gee",
|
|
||||||
"Anthony Rizzo",
|
|
||||||
"tarek helmi",
|
|
||||||
"M Postkasse",
|
"M Postkasse",
|
||||||
|
"FloPro4Sho",
|
||||||
"ASLPro3D",
|
"ASLPro3D",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Timmy",
|
"Lex Song",
|
||||||
"Johnny",
|
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Tak",
|
"Tak",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"Jimmy Ledbetter",
|
||||||
"Luc Job",
|
"Luc Job",
|
||||||
"dl0901dm",
|
|
||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"corde",
|
"corde",
|
||||||
"Nick Walker",
|
"Nick Walker",
|
||||||
"lh qwe",
|
"Julian V",
|
||||||
|
"Steven Owens",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"conner",
|
|
||||||
"aai",
|
"aai",
|
||||||
"Briton Heilbrun",
|
|
||||||
"Tori",
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
"Princess Bright Eyes",
|
|
||||||
"AbstractAss",
|
|
||||||
"Felipe dos Santos",
|
|
||||||
"ViperC",
|
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
"Aleksander Wujczyk",
|
|
||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"Markus",
|
"ViperC",
|
||||||
"S Sang",
|
"Ran C",
|
||||||
|
"Sangheili460",
|
||||||
|
"MagnaInsomnia",
|
||||||
"Karl P.",
|
"Karl P.",
|
||||||
"Akira_HentAI",
|
"Akira_HentAI",
|
||||||
"MagnaInsomnia",
|
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"yuxz69",
|
"yuxz69",
|
||||||
"Douglas Gaspar",
|
"esthe",
|
||||||
"AlexDuKaNa",
|
|
||||||
"George",
|
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
"dw",
|
|
||||||
"N/A",
|
"N/A",
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"Phil",
|
|
||||||
"graysock",
|
"graysock",
|
||||||
|
"Pozadine1",
|
||||||
"Greenmoustache",
|
"Greenmoustache",
|
||||||
"zounic",
|
|
||||||
"fancypants",
|
"fancypants",
|
||||||
|
"IamAyam",
|
||||||
|
"Eldithor",
|
||||||
|
"Joboshy",
|
||||||
"Digital",
|
"Digital",
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
"奚明 刘",
|
"Bohemian Corporal",
|
||||||
|
"Dan",
|
||||||
|
"confiscated Zyra",
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Bro Xie",
|
"Bro Xie",
|
||||||
"준희 김",
|
"yer fey",
|
||||||
"batblue",
|
"batblue",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"Olive",
|
"Olive",
|
||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
|
"Tee Gee",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
|
"jinxedx",
|
||||||
|
"tarek helmi",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
"Tomohiro Baba",
|
|
||||||
"David Ortega",
|
|
||||||
"AELOX",
|
"AELOX",
|
||||||
|
"Dankin",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
"Noora",
|
|
||||||
"wamekukyouzin",
|
"wamekukyouzin",
|
||||||
"drum matthieu",
|
"drum matthieu",
|
||||||
"Dogmaster",
|
"Dogmaster",
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Mattssn",
|
"Frank Nitty",
|
||||||
"Lex Song",
|
"Pronredn",
|
||||||
"John Saveas",
|
|
||||||
"Christopher Michel",
|
"Christopher Michel",
|
||||||
"Serge Bekenkamp",
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"DougPeterson",
|
||||||
"LeoZero",
|
"LeoZero",
|
||||||
"Antonio Pontes",
|
"Antonio Pontes",
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
"nahinahi9",
|
"nahinahi9",
|
||||||
|
"lh qwe",
|
||||||
|
"Kevin John Duck",
|
||||||
|
"conner",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
"dan",
|
"dan",
|
||||||
"Yaboi",
|
"Blackfish95",
|
||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
"Steam Steam",
|
"Princess Bright Eyes",
|
||||||
"Damon Cunliffe",
|
"Paul Kroll",
|
||||||
"CryptoTraderJK",
|
"AbstractAss",
|
||||||
"Davaitamin",
|
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"Ran C",
|
"Felipe dos Santos",
|
||||||
"tedcor",
|
"Bas Imagineer",
|
||||||
"Fotek Design",
|
"Markus",
|
||||||
|
"MiraiKuriyamaSy",
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
|
"Douglas Gaspar",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
"MadSpin",
|
"AlexDuKaNa",
|
||||||
"Pozadine1",
|
"George",
|
||||||
|
"dw",
|
||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
"inbijiburu",
|
|
||||||
"Luc",
|
"Luc",
|
||||||
"ProtonPrince",
|
"ProtonPrince",
|
||||||
"DiffDuck",
|
"DiffDuck",
|
||||||
"elu3199",
|
"elu3199",
|
||||||
"Nick “Loadstone” D",
|
|
||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
"Ubivis",
|
"Ubivis",
|
||||||
@@ -234,54 +232,45 @@
|
|||||||
"thesoftwaredruid",
|
"thesoftwaredruid",
|
||||||
"wundershark",
|
"wundershark",
|
||||||
"mr_dinosaur",
|
"mr_dinosaur",
|
||||||
|
"Tyrswood",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
"Gamalonia",
|
|
||||||
"Vir",
|
|
||||||
"Pkrsky",
|
"Pkrsky",
|
||||||
"Joboshy",
|
"奚明 刘",
|
||||||
"Bohemian Corporal",
|
|
||||||
"Dan",
|
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Seth Christensen",
|
"Nerezza",
|
||||||
"Griffin Dahlberg",
|
"Griffin Dahlberg",
|
||||||
"Draven T",
|
"준희 김",
|
||||||
"yer fey",
|
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"Geolog",
|
"Geolog",
|
||||||
"jinxedx",
|
|
||||||
"Neco28",
|
"Neco28",
|
||||||
"Aquatic Coffee",
|
"Tomohiro Baba",
|
||||||
"Dankin",
|
"David Ortega",
|
||||||
"ethanfel",
|
"Noora",
|
||||||
"Cristian Vazquez",
|
"Cristian Vazquez",
|
||||||
"Frank Nitty",
|
"Mattssn",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Focuschannel",
|
|
||||||
"DougPeterson",
|
|
||||||
"Jeff",
|
"Jeff",
|
||||||
"Bruce",
|
"Bruce",
|
||||||
"Kevin John Duck",
|
|
||||||
"Anthony Faxlandez",
|
|
||||||
"Kevin Christopher",
|
"Kevin Christopher",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
"Blackfish95",
|
"Chad Idk",
|
||||||
|
"Yaboi",
|
||||||
"dd",
|
"dd",
|
||||||
"Paul Kroll",
|
"Steam Steam",
|
||||||
"MiraiKuriyamaSy",
|
"CryptoTraderJK",
|
||||||
"semicolon drainpipe",
|
"Davaitamin",
|
||||||
"Thesharingbrother",
|
"Dušan Ryban",
|
||||||
"Bas Imagineer",
|
"tedcor",
|
||||||
"Pat Hen",
|
"Fotek Design",
|
||||||
|
"sjon kreutz",
|
||||||
"John Statham",
|
"John Statham",
|
||||||
"ResidentDeviant",
|
"MadSpin",
|
||||||
"Nihongasuki",
|
"Metryman55",
|
||||||
"JC",
|
"inbijiburu",
|
||||||
"Prompt Pirate",
|
|
||||||
"uwutismxd",
|
|
||||||
"decoy",
|
"decoy",
|
||||||
"Tyrswood",
|
"Nick “Loadstone” D",
|
||||||
"Ray Wing",
|
"Ray Wing",
|
||||||
"Ranzitho",
|
"Ranzitho",
|
||||||
"Gus",
|
"Gus",
|
||||||
@@ -290,6 +279,7 @@
|
|||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
@@ -302,77 +292,60 @@
|
|||||||
"Moon Knight",
|
"Moon Knight",
|
||||||
"몽타주",
|
"몽타주",
|
||||||
"Kland",
|
"Kland",
|
||||||
"zenobeus",
|
"Hailshem",
|
||||||
"Jackthemind",
|
"kudari",
|
||||||
"ryoma",
|
"Naomi Hale Danchi",
|
||||||
"Stryker",
|
"dc7431",
|
||||||
"raf8osz",
|
"Vir",
|
||||||
"ElitaSSJ4",
|
|
||||||
"blikkies",
|
|
||||||
"Chris",
|
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"Nerezza",
|
|
||||||
"sanborondon",
|
"sanborondon",
|
||||||
|
"Seth Christensen",
|
||||||
|
"Draven T",
|
||||||
"Taylor Funk",
|
"Taylor Funk",
|
||||||
"aezin",
|
"aezin",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"jcay015",
|
"jcay015",
|
||||||
"Kevin Picco",
|
"Kevin Picco",
|
||||||
"Erik Lopez",
|
"Erik Lopez",
|
||||||
"Shock Shockor",
|
|
||||||
"Mateo Curić",
|
"Mateo Curić",
|
||||||
"Goldwaters",
|
"Aquatic Coffee",
|
||||||
"Zude",
|
|
||||||
"Eris3D",
|
"Eris3D",
|
||||||
"m",
|
"m",
|
||||||
|
"ethanfel",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Kyler",
|
"Focuschannel",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
"aRtFuL_DodGeR",
|
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
"CrimsonDX",
|
|
||||||
"Martial",
|
"Martial",
|
||||||
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Chad Idk",
|
|
||||||
"DarkSunset",
|
|
||||||
"Billy Gladky",
|
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Probis",
|
"Pat Hen",
|
||||||
"Dušan Ryban",
|
"semicolon drainpipe",
|
||||||
"ItsGeneralButtNaked",
|
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
|
"Thesharingbrother",
|
||||||
"Sam",
|
"Sam",
|
||||||
"sjon kreutz",
|
|
||||||
"Nimess",
|
|
||||||
"SRDB",
|
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"g unit",
|
"ResidentDeviant",
|
||||||
"Youguang",
|
"Nihongasuki",
|
||||||
"Metryman55",
|
"JC",
|
||||||
"andrewzpong",
|
"Prompt Pirate",
|
||||||
"FrxzenSnxw",
|
"uwutismxd",
|
||||||
"BossGame",
|
|
||||||
"lrdchs",
|
|
||||||
"momokai",
|
"momokai",
|
||||||
"Hailshem",
|
"zenobeus",
|
||||||
"kudari",
|
|
||||||
"Naomi Hale Danchi",
|
|
||||||
"dc7431",
|
|
||||||
"ken",
|
"ken",
|
||||||
"Inversity",
|
|
||||||
"AIVORY3D",
|
|
||||||
"epicgamer0020690",
|
"epicgamer0020690",
|
||||||
"Joshua Porrata",
|
"Joshua Porrata",
|
||||||
"keemun",
|
"keemun",
|
||||||
"SuBu",
|
"SuBu",
|
||||||
"RedPIXel",
|
"RedPIXel",
|
||||||
"Kevinj",
|
|
||||||
"Wind",
|
"Wind",
|
||||||
|
"Jackthemind",
|
||||||
"Nexus",
|
"Nexus",
|
||||||
"Ramneek“Guy”Ashok",
|
"Ramneek“Guy”Ashok",
|
||||||
"squid_actually",
|
"squid_actually",
|
||||||
@@ -385,80 +358,81 @@
|
|||||||
"emyth",
|
"emyth",
|
||||||
"chriphost",
|
"chriphost",
|
||||||
"KitKatM",
|
"KitKatM",
|
||||||
|
"ryoma",
|
||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
"ResidentDeviant",
|
"OrganicArtifact",
|
||||||
|
"Stryker",
|
||||||
|
"MudkipMedkitz",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Welkor",
|
"raf8osz",
|
||||||
"John Martin",
|
"ElitaSSJ4",
|
||||||
"Richard",
|
"Richard",
|
||||||
|
"blikkies",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
|
"Chris",
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"Littlehuggy",
|
"Littlehuggy",
|
||||||
"moranqianlong",
|
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"mrjuan",
|
"mrjuan",
|
||||||
"Brian Buie",
|
"Brian Buie",
|
||||||
|
"Shock Shockor",
|
||||||
"Sadlip",
|
"Sadlip",
|
||||||
"Haru Yotu",
|
"Goldwaters",
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
"Joey Callahan",
|
||||||
|
"Zude",
|
||||||
"Ivan Tadic",
|
"Ivan Tadic",
|
||||||
"Mike Simone",
|
"Mike Simone",
|
||||||
|
"John J Linehan",
|
||||||
|
"Kyler",
|
||||||
|
"Elliot E",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
"Kyron Mahan",
|
"Theerat Jiramate",
|
||||||
"Matura Arbeit",
|
"aRtFuL_DodGeR",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
"Jacob McDaniel",
|
||||||
"X",
|
"X",
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
"TBitz33",
|
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
|
||||||
"Temikus",
|
"Temikus",
|
||||||
"Artokun",
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
"SendingRavens",
|
|
||||||
"Derek Baker",
|
"Derek Baker",
|
||||||
|
"CrimsonDX",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
|
"DarkSunset",
|
||||||
"Atilla Berke Pekduyar",
|
"Atilla Berke Pekduyar",
|
||||||
"Michael Docherty",
|
|
||||||
"Nathan",
|
"Nathan",
|
||||||
|
"Billy Gladky",
|
||||||
|
"NICHOLAS BAXLEY",
|
||||||
"Decx _",
|
"Decx _",
|
||||||
"Paul Hartsuyker",
|
"Probis",
|
||||||
"elitassj",
|
"Ed Wang",
|
||||||
"Jacob Winter",
|
"ItsGeneralButtNaked",
|
||||||
|
"Nimess",
|
||||||
|
"SRDB",
|
||||||
|
"g unit",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
"David",
|
"Youguang",
|
||||||
"Meilo",
|
|
||||||
"Pen Bouryoung",
|
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"shinonomeiro",
|
"Saya",
|
||||||
"Snille",
|
"andrewzpong",
|
||||||
"MaartenAlbers",
|
"FrxzenSnxw",
|
||||||
"khanh duy",
|
"BossGame",
|
||||||
"xybrightsummer",
|
"lrdchs",
|
||||||
"jreedatchison",
|
|
||||||
"PhilW",
|
|
||||||
"Tree Tagger",
|
"Tree Tagger",
|
||||||
"Janik",
|
"Inversity",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"Cruel",
|
"AIVORY3D",
|
||||||
"MRBlack",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"Kiyoe",
|
|
||||||
"humptynutz",
|
|
||||||
"michael.isaza",
|
|
||||||
"Kalnei",
|
|
||||||
"Whitepinetrader",
|
"Whitepinetrader",
|
||||||
"OrganicArtifact",
|
"ResidentDeviant",
|
||||||
"Scott",
|
|
||||||
"MudkipMedkitz",
|
|
||||||
"deanbrian",
|
"deanbrian",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"Alex Wortman",
|
"Alex Wortman",
|
||||||
"Cody",
|
"Cody",
|
||||||
"Raku",
|
"Raku",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"emadsultan",
|
|
||||||
"InformedViewz",
|
"InformedViewz",
|
||||||
"CHKeeho80",
|
"CHKeeho80",
|
||||||
"Bubbafett",
|
"Bubbafett",
|
||||||
@@ -466,76 +440,152 @@
|
|||||||
"Menard",
|
"Menard",
|
||||||
"Skyfire83",
|
"Skyfire83",
|
||||||
"Adam Rinehart",
|
"Adam Rinehart",
|
||||||
"D",
|
|
||||||
"Pitpe11",
|
"Pitpe11",
|
||||||
"TheD1rtyD03",
|
"TheD1rtyD03",
|
||||||
"moonpetal",
|
"moonpetal",
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"g9p0o",
|
"g9p0o",
|
||||||
"nanana",
|
|
||||||
"TheHolySheep",
|
"TheHolySheep",
|
||||||
"Monte Won",
|
"Monte Won",
|
||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"Nathen+Choi",
|
||||||
|
"T",
|
||||||
|
"LarsesFPC",
|
||||||
|
"cocona",
|
||||||
|
"sfasdfasfdsa",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"4IXplr0r3r",
|
"Welkor",
|
||||||
"dfklsjfkljslfjd",
|
"David Schenck",
|
||||||
"hayden",
|
"John Martin",
|
||||||
"ahoystan",
|
|
||||||
"Leland Saunders",
|
|
||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"Ink Temptation",
|
"Ink Temptation",
|
||||||
"Bob Barker",
|
"moranqianlong",
|
||||||
"edk",
|
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
"Aeternyx",
|
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"YOU SINWOO",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"ja s",
|
"Haru Yotu",
|
||||||
"Doug Mason",
|
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"Jeremy Townsend",
|
|
||||||
"EpicElric",
|
"EpicElric",
|
||||||
"Sean voets",
|
"Kyron Mahan",
|
||||||
"Owen Gwosdz",
|
|
||||||
"John J Linehan",
|
|
||||||
"Elliot E",
|
|
||||||
"Thomas Wanner",
|
|
||||||
"Theerat Jiramate",
|
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"Devil Lude",
|
"Matura Arbeit",
|
||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
"kevin stoddard",
|
"TBitz33",
|
||||||
"Jack Dole",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"Vane Holzer",
|
"Vane Holzer",
|
||||||
"psytrax",
|
"psytrax",
|
||||||
|
"Cyrus Fett",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
|
"SendingRavens",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"CptNeo",
|
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Maso",
|
"Michael Docherty",
|
||||||
"Eric Ketchum",
|
|
||||||
"NICHOLAS BAXLEY",
|
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Kevin Wallace",
|
"Paul Hartsuyker",
|
||||||
"Matheus Couto",
|
"elitassj",
|
||||||
"Saya",
|
"Jacob Winter",
|
||||||
"ChicRic",
|
|
||||||
"mercur",
|
|
||||||
"J C",
|
|
||||||
"Ed Wang",
|
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
|
"Lyavph",
|
||||||
|
"David",
|
||||||
|
"Meilo",
|
||||||
|
"Filippo Ferrari",
|
||||||
|
"Pen Bouryoung",
|
||||||
|
"shinonomeiro",
|
||||||
|
"Snille",
|
||||||
|
"MaartenAlbers",
|
||||||
|
"khanh duy",
|
||||||
|
"xybrightsummer",
|
||||||
|
"jreedatchison",
|
||||||
|
"PhilW",
|
||||||
|
"Janik",
|
||||||
|
"Cruel",
|
||||||
|
"MRBlack",
|
||||||
|
"Kiyoe",
|
||||||
|
"humptynutz",
|
||||||
|
"michael.isaza",
|
||||||
|
"Kalnei",
|
||||||
|
"Scott",
|
||||||
|
"Muratoraccio",
|
||||||
|
"Ginnie",
|
||||||
|
"emadsultan",
|
||||||
|
"D",
|
||||||
|
"nanana",
|
||||||
|
"Fthehappy",
|
||||||
|
"rsamerica",
|
||||||
|
"Alan+Cano",
|
||||||
|
"FeralOpticsAI",
|
||||||
|
"Pavlaki",
|
||||||
|
"generic404",
|
||||||
|
"Doug+Rintoul",
|
||||||
|
"Noor",
|
||||||
|
"Yorunai",
|
||||||
|
"quantenmecha",
|
||||||
|
"abattoirblues",
|
||||||
|
"Jason+Nash",
|
||||||
|
"BillyBoy84",
|
||||||
|
"zounik",
|
||||||
|
"DarkRoast",
|
||||||
|
"letzte",
|
||||||
|
"Nasty+Hobbit",
|
||||||
|
"Sora+Yori",
|
||||||
|
"lrdchs2",
|
||||||
|
"Duk3+Rand0m",
|
||||||
|
"4IXplr0r3r",
|
||||||
|
"hayden",
|
||||||
|
"ahoystan",
|
||||||
|
"Leland Saunders",
|
||||||
|
"Bob Barker",
|
||||||
|
"edk",
|
||||||
|
"JBsuede",
|
||||||
|
"Time Valentine",
|
||||||
|
"Aeternyx",
|
||||||
|
"YOU SINWOO",
|
||||||
|
"りん あめ",
|
||||||
|
"ja s",
|
||||||
|
"Михал Михалыч",
|
||||||
|
"Matt",
|
||||||
|
"Doug Mason",
|
||||||
|
"Jeremy Townsend",
|
||||||
|
"Frogmilk",
|
||||||
|
"Sean voets",
|
||||||
|
"Owen Gwosdz",
|
||||||
|
"SPJ",
|
||||||
|
"Thomas Wanner",
|
||||||
|
"Bryan Rutkowski",
|
||||||
|
"Devil Lude",
|
||||||
|
"David Murcko",
|
||||||
|
"kevin stoddard",
|
||||||
|
"Jack Dole",
|
||||||
|
"max blo",
|
||||||
|
"Xenon Xue",
|
||||||
|
"CptNeo",
|
||||||
|
"JackJohnnyJim",
|
||||||
|
"Dmitry Ryzhov",
|
||||||
|
"Maso",
|
||||||
|
"Edward Ten Eyck",
|
||||||
|
"Eric Ketchum",
|
||||||
|
"Kevin Wallace",
|
||||||
|
"Matheus Couto",
|
||||||
|
"ChicRic",
|
||||||
|
"Henrique Faiolli",
|
||||||
|
"mercur",
|
||||||
|
"Solixer",
|
||||||
|
"J C",
|
||||||
|
"jinksta187",
|
||||||
|
"Andrew Wilkinson",
|
||||||
|
"Manu Thetug",
|
||||||
|
"Karlanx",
|
||||||
"Yves Poezevara",
|
"Yves Poezevara",
|
||||||
|
"operationancut",
|
||||||
"Teriak47",
|
"Teriak47",
|
||||||
"Just me",
|
"Just me",
|
||||||
"Raf Stahelin",
|
"Raf Stahelin",
|
||||||
"Вячеслав Маринин",
|
"Вячеслав Маринин",
|
||||||
"Lyavph",
|
|
||||||
"Filippo Ferrari",
|
|
||||||
"Cola Matthew",
|
"Cola Matthew",
|
||||||
"OniNoKen",
|
"OniNoKen",
|
||||||
"Iain Wisely",
|
"Iain Wisely",
|
||||||
@@ -576,98 +626,121 @@
|
|||||||
"dg",
|
"dg",
|
||||||
"Maarten Harms",
|
"Maarten Harms",
|
||||||
"Israel",
|
"Israel",
|
||||||
"Muratoraccio",
|
|
||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"Ginnie",
|
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
"Alan+Cano",
|
"lighthawke",
|
||||||
"FeralOpticsAI",
|
"Terraformer",
|
||||||
"Pavlaki",
|
"GDS+DEV",
|
||||||
"generic404",
|
"4rt+r3d",
|
||||||
|
"low9",
|
||||||
|
"Winged",
|
||||||
|
"you+halo9",
|
||||||
|
"YassineKhaled",
|
||||||
|
"YK12",
|
||||||
|
"MatteKey",
|
||||||
|
"Flob",
|
||||||
|
"ShiroSenpai",
|
||||||
|
"Somebody",
|
||||||
|
"Inkognito",
|
||||||
|
"Somebody",
|
||||||
|
"Gramer+Gumbyte",
|
||||||
|
"Crescent~San",
|
||||||
|
"Tan+Huynh",
|
||||||
|
"AiGirlTS",
|
||||||
|
"D",
|
||||||
|
"datasl4ve",
|
||||||
|
"Somebody",
|
||||||
|
"Dark_Pest",
|
||||||
|
"Aza",
|
||||||
|
"Jacky+Ho",
|
||||||
|
"koopa990",
|
||||||
|
"Karru",
|
||||||
|
"ChaChanoKo",
|
||||||
|
"null",
|
||||||
|
"bo",
|
||||||
|
"The+Forgetful+Dev",
|
||||||
|
"redcarrot",
|
||||||
|
"powerbot99",
|
||||||
"Mateusz+Kosela",
|
"Mateusz+Kosela",
|
||||||
"Doug+Rintoul",
|
|
||||||
"Noor",
|
|
||||||
"Yorunai",
|
|
||||||
"Bula",
|
"Bula",
|
||||||
"quantenmecha",
|
|
||||||
"abattoirblues",
|
|
||||||
"Jason+Nash",
|
|
||||||
"BillyBoy84",
|
|
||||||
"DarkRoast",
|
|
||||||
"zounik",
|
|
||||||
"letzte",
|
|
||||||
"Nasty+Hobbit",
|
|
||||||
"SgtFluffles",
|
|
||||||
"lrdchs2",
|
|
||||||
"Duk3+Rand0m",
|
|
||||||
"KUJYAKU",
|
"KUJYAKU",
|
||||||
"NathenChoi",
|
|
||||||
"Thomas+Reck",
|
|
||||||
"Larses",
|
|
||||||
"cocona",
|
|
||||||
"Coeur+de+cochon",
|
"Coeur+de+cochon",
|
||||||
"David Schenck",
|
|
||||||
"han b",
|
"han b",
|
||||||
"Nico",
|
"Nico",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"JBsuede",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"beersandbacon",
|
"beersandbacon",
|
||||||
"Maximilian Pyko",
|
"Maximilian Pyko",
|
||||||
"Invis",
|
"Invis",
|
||||||
"Justin Houston",
|
"Bob barker",
|
||||||
"Time Valentine",
|
"Ben D",
|
||||||
|
"Garrett Wood",
|
||||||
|
"Ronan Delevacq",
|
||||||
"james",
|
"james",
|
||||||
|
"Christian Schäfer",
|
||||||
"OrochiNights",
|
"OrochiNights",
|
||||||
"Michael Zhu",
|
"Michael Zhu",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
|
||||||
"gonzalo",
|
"gonzalo",
|
||||||
"Seraphy",
|
"Seraphy",
|
||||||
"Михал Михалыч",
|
|
||||||
"雨の心 落",
|
"雨の心 落",
|
||||||
"Matt",
|
|
||||||
"AllTimeNoobie",
|
"AllTimeNoobie",
|
||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
|
"Dave Abraham",
|
||||||
|
"Joaquin Hierrezuelo",
|
||||||
"Dismem",
|
"Dismem",
|
||||||
"Frogmilk",
|
"Locrospiel",
|
||||||
"SPJ",
|
"Jairus Knudsen",
|
||||||
|
"Jarrid Lee",
|
||||||
"Xan Dionysus",
|
"Xan Dionysus",
|
||||||
"Nathan lee",
|
"Nathan lee",
|
||||||
|
"Kor",
|
||||||
|
"Joseph Hanson",
|
||||||
"Mewtora",
|
"Mewtora",
|
||||||
"Middo",
|
"Middo",
|
||||||
"Forbidden Atelier",
|
"Forbidden Atelier",
|
||||||
"Bryan Rutkowski",
|
"John Rednoulf",
|
||||||
|
"Spire",
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
|
"Boba Smith",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"Cyrus Fett",
|
"MR.Bear",
|
||||||
|
"dsffsdfsdfsdfsdfsdf",
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
"Kurt",
|
"Kurt",
|
||||||
"max blo",
|
"ivistorm",
|
||||||
"Xenon Xue",
|
"Sauv",
|
||||||
"JackJohnnyJim",
|
"Steven",
|
||||||
"Edward Ten Eyck",
|
"TenaciousD",
|
||||||
|
"Khánh Đặng",
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
|
"Ted Cart",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
"Goober719",
|
"Goober719",
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
|
"Person Y",
|
||||||
|
"David Spearing",
|
||||||
"James Ming",
|
"James Ming",
|
||||||
"vanditking",
|
"vanditking",
|
||||||
"kripitonga",
|
"kripitonga",
|
||||||
"Rizzi",
|
"Rizzi",
|
||||||
"nimin",
|
"nimin",
|
||||||
"OMAR LUCIANO",
|
"OMAR LUCIANO",
|
||||||
|
"Ken+Suzuki",
|
||||||
"hannibal",
|
"hannibal",
|
||||||
"Jo+Example",
|
"Jo+Example",
|
||||||
"BrentBertram",
|
"BrentBertram",
|
||||||
|
"Tigon",
|
||||||
"eumelzocker",
|
"eumelzocker",
|
||||||
"dxjaymz",
|
"dxjaymz",
|
||||||
"L C",
|
"L C",
|
||||||
"Dude"
|
"Dude",
|
||||||
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 666
|
"totalCount": 739
|
||||||
}
|
}
|
||||||
@@ -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!**
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"close": "Schließen"
|
"close": "Schließen",
|
||||||
|
"menu": "Menü"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
@@ -276,6 +277,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 +429,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 +542,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",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"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",
|
||||||
"clear": "Auswahl löschen",
|
"clear": "Auswahl löschen",
|
||||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "Bearbeitung abbrechen",
|
"cancel": "Bearbeitung abbrechen",
|
||||||
"save": "Änderungen speichern",
|
"save": "Änderungen speichern",
|
||||||
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
||||||
|
"editWord": "Trigger Word bearbeiten",
|
||||||
|
"editPlaceholder": "Trigger Word bearbeiten",
|
||||||
"copyWord": "Trigger Word kopieren",
|
"copyWord": "Trigger Word kopieren",
|
||||||
"deleteWord": "Trigger Word löschen",
|
"deleteWord": "Trigger Word löschen",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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",
|
||||||
@@ -1440,6 +1467,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.",
|
||||||
@@ -1671,6 +1702,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",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Konnte trainierte Wörter nicht laden",
|
"loadFailed": "Konnte trainierte Wörter nicht laden",
|
||||||
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten",
|
"tooLong": "Trigger Word sollte 500 Wörter nicht überschreiten",
|
||||||
"tooMany": "Maximal 30 Trigger Words erlaubt",
|
"tooMany": "Maximal 100 Trigger Words erlaubt",
|
||||||
"alreadyExists": "Dieses Trigger Word existiert bereits",
|
"alreadyExists": "Dieses Trigger Word existiert bereits",
|
||||||
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
|
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
|
||||||
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -276,6 +277,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 +429,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 +542,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",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"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",
|
||||||
"clear": "Clear Selection",
|
"clear": "Clear Selection",
|
||||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "Cancel editing",
|
"cancel": "Cancel editing",
|
||||||
"save": "Save changes",
|
"save": "Save changes",
|
||||||
"addPlaceholder": "Type to add or click suggestions below",
|
"addPlaceholder": "Type to add or click suggestions below",
|
||||||
|
"editWord": "Edit trigger word",
|
||||||
|
"editPlaceholder": "Edit trigger word",
|
||||||
"copyWord": "Copy trigger word",
|
"copyWord": "Copy trigger word",
|
||||||
"deleteWord": "Delete trigger word",
|
"deleteWord": "Delete trigger word",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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",
|
||||||
@@ -1440,6 +1467,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.",
|
||||||
@@ -1671,6 +1702,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)",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Could not load trained words",
|
"loadFailed": "Could not load trained words",
|
||||||
"tooLong": "Trigger word should not exceed 100 words",
|
"tooLong": "Trigger word should not exceed 500 words",
|
||||||
"tooMany": "Maximum 30 trigger words allowed",
|
"tooMany": "Maximum 100 trigger words allowed",
|
||||||
"alreadyExists": "This trigger word already exists",
|
"alreadyExists": "This trigger word already exists",
|
||||||
"updateSuccess": "Trigger words updated successfully",
|
"updateSuccess": "Trigger words updated successfully",
|
||||||
"updateFailed": "Failed to update trigger words",
|
"updateFailed": "Failed to update trigger words",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"close": "Cerrar"
|
"close": "Cerrar",
|
||||||
|
"menu": "Menú"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -276,6 +277,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 +429,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 +542,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",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"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",
|
||||||
"clear": "Limpiar selección",
|
"clear": "Limpiar selección",
|
||||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "Cancelar edición",
|
"cancel": "Cancelar edición",
|
||||||
"save": "Guardar cambios",
|
"save": "Guardar cambios",
|
||||||
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
||||||
|
"editWord": "Editar palabra de activación",
|
||||||
|
"editPlaceholder": "Editar palabra de activación",
|
||||||
"copyWord": "Copiar palabra clave",
|
"copyWord": "Copiar palabra clave",
|
||||||
"deleteWord": "Eliminar palabra clave",
|
"deleteWord": "Eliminar palabra clave",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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",
|
||||||
@@ -1440,6 +1467,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.",
|
||||||
@@ -1671,6 +1702,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",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "No se pudieron cargar palabras entrenadas",
|
"loadFailed": "No se pudieron cargar palabras entrenadas",
|
||||||
"tooLong": "La palabra clave no debe exceder 100 palabras",
|
"tooLong": "La palabra clave no debe exceder 500 palabras",
|
||||||
"tooMany": "Máximo 30 palabras clave permitidas",
|
"tooMany": "Máximo 100 palabras clave permitidas",
|
||||||
"alreadyExists": "Esta palabra clave ya existe",
|
"alreadyExists": "Esta palabra clave ya existe",
|
||||||
"updateSuccess": "Palabras clave actualizadas exitosamente",
|
"updateSuccess": "Palabras clave actualizadas exitosamente",
|
||||||
"updateFailed": "Error al actualizar palabras clave",
|
"updateFailed": "Error al actualizar palabras clave",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"close": "Fermer"
|
"close": "Fermer",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -276,6 +277,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 +429,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 +542,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",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"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",
|
||||||
"clear": "Effacer la sélection",
|
"clear": "Effacer la sélection",
|
||||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "Annuler la modification",
|
"cancel": "Annuler la modification",
|
||||||
"save": "Sauvegarder les modifications",
|
"save": "Sauvegarder les modifications",
|
||||||
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
||||||
|
"editWord": "Modifier le mot déclencheur",
|
||||||
|
"editPlaceholder": "Modifier le mot déclencheur",
|
||||||
"copyWord": "Copier le mot-clé",
|
"copyWord": "Copier le mot-clé",
|
||||||
"deleteWord": "Supprimer le mot-clé",
|
"deleteWord": "Supprimer le mot-clé",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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",
|
||||||
@@ -1440,6 +1467,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.",
|
||||||
@@ -1671,6 +1702,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",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Impossible de charger les mots entraînés",
|
"loadFailed": "Impossible de charger les mots entraînés",
|
||||||
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots",
|
"tooLong": "Le mot-clé ne doit pas dépasser 500 mots",
|
||||||
"tooMany": "Maximum 30 mots-clés autorisés",
|
"tooMany": "Maximum 100 mots-clés autorisés",
|
||||||
"alreadyExists": "Ce mot-clé existe déjà",
|
"alreadyExists": "Ce mot-clé existe déjà",
|
||||||
"updateSuccess": "Mots-clés mis à jour avec succès",
|
"updateSuccess": "Mots-clés mis à jour avec succès",
|
||||||
"updateFailed": "Échec de la mise à jour des mots-clés",
|
"updateFailed": "Échec de la mise à jour des mots-clés",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה",
|
"add": "הוספה",
|
||||||
"close": "סגור"
|
"close": "סגור",
|
||||||
|
"menu": "תפריט"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -276,6 +277,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 +429,8 @@
|
|||||||
"hover": "חשוף בריחוף"
|
"hover": "חשוף בריחוף"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||||
|
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||||
|
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "פתח תמונות דוגמה",
|
"exampleImages": "פתח תמונות דוגמה",
|
||||||
@@ -538,6 +542,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": "הורד",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||||
"deleteAll": "מחק את כל המודלים",
|
"setFavorite": "הגדר כמועדף",
|
||||||
|
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
|
||||||
|
"unfavorite": "הסר ממועדפים",
|
||||||
|
"deleteAll": "מחק נבחרים",
|
||||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||||
"clear": "נקה בחירה",
|
"clear": "נקה בחירה",
|
||||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "בטל עריכה",
|
"cancel": "בטל עריכה",
|
||||||
"save": "שמור שינויים",
|
"save": "שמור שינויים",
|
||||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||||
|
"editWord": "עריכת מילת טריגר",
|
||||||
|
"editPlaceholder": "עריכת מילת טריגר",
|
||||||
"copyWord": "העתק מילת טריגר",
|
"copyWord": "העתק מילת טריגר",
|
||||||
"deleteWord": "מחק מילת טריגר",
|
"deleteWord": "מחק מילת טריגר",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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": "התעלם",
|
||||||
@@ -1440,6 +1467,10 @@
|
|||||||
"opened": "תיקיית תמונות הדוגמה נפתחה",
|
"opened": "תיקיית תמונות הדוגמה נפתחה",
|
||||||
"openingFolder": "פותח תיקיית תמונות דוגמה",
|
"openingFolder": "פותח תיקיית תמונות דוגמה",
|
||||||
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
|
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
|
||||||
|
"copiedPath": "הנתיב הועתק ללוח: {{path}}",
|
||||||
|
"clipboardFallback": "נתיב: {{path}}",
|
||||||
|
"copiedUri": "הקישור הועתק ללוח: {{uri}}",
|
||||||
|
"uriClipboardFallback": "קישור: {{uri}}",
|
||||||
"setupRequired": "אחסון תמונות דוגמה",
|
"setupRequired": "אחסון תמונות דוגמה",
|
||||||
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
|
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
|
||||||
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
|
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
|
||||||
@@ -1671,6 +1702,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} שנבחרו",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "לא ניתן היה לטעון מילים מאומנות",
|
"loadFailed": "לא ניתן היה לטעון מילים מאומנות",
|
||||||
"tooLong": "מילת טריגר לא תעלה על 100 מילים",
|
"tooLong": "מילת טריגר לא תעלה על 500 מילים",
|
||||||
"tooMany": "מותרות עד 30 מילות טריגר",
|
"tooMany": "מותרות עד 100 מילות טריגר",
|
||||||
"alreadyExists": "מילת טריגר זו כבר קיימת",
|
"alreadyExists": "מילת טריגר זו כבר קיימת",
|
||||||
"updateSuccess": "מילות הטריגר עודכנו בהצלחה",
|
"updateSuccess": "מילות הטריגר עודכנו בהצלחה",
|
||||||
"updateFailed": "עדכון מילות הטריגר נכשל",
|
"updateFailed": "עדכון מילות הטריגר נכשל",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
|
||||||
|
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
|
||||||
|
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"menu": "メニュー"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -276,6 +277,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 +429,8 @@
|
|||||||
"hover": "ホバー時に表示"
|
"hover": "ホバー時に表示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||||
|
"showVersionOnCard": "カードにバージョンを表示",
|
||||||
|
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "例画像を開く",
|
"exampleImages": "例画像を開く",
|
||||||
@@ -538,6 +542,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": "ダウンロード",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"autoOrganize": "自動整理を実行",
|
"autoOrganize": "自動整理を実行",
|
||||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||||
"deleteAll": "すべてのモデルを削除",
|
"setFavorite": "お気に入りに設定",
|
||||||
|
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
|
||||||
|
"unfavorite": "お気に入りから削除",
|
||||||
|
"deleteAll": "選択したものを削除",
|
||||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||||
"clear": "選択をクリア",
|
"clear": "選択をクリア",
|
||||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "編集をキャンセル",
|
"cancel": "編集をキャンセル",
|
||||||
"save": "変更を保存",
|
"save": "変更を保存",
|
||||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||||
|
"editWord": "トリガーワードを編集",
|
||||||
|
"editPlaceholder": "トリガーワードを編集",
|
||||||
"copyWord": "トリガーワードをコピー",
|
"copyWord": "トリガーワードをコピー",
|
||||||
"deleteWord": "トリガーワードを削除",
|
"deleteWord": "トリガーワードを削除",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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": "無視",
|
||||||
@@ -1440,6 +1467,10 @@
|
|||||||
"opened": "例画像フォルダが開かれました",
|
"opened": "例画像フォルダが開かれました",
|
||||||
"openingFolder": "例画像フォルダを開いています",
|
"openingFolder": "例画像フォルダを開いています",
|
||||||
"failedToOpen": "例画像フォルダを開くのに失敗しました",
|
"failedToOpen": "例画像フォルダを開くのに失敗しました",
|
||||||
|
"copiedPath": "パスをクリップボードにコピーしました: {{path}}",
|
||||||
|
"clipboardFallback": "パス: {{path}}",
|
||||||
|
"copiedUri": "リンクをクリップボードにコピーしました: {{uri}}",
|
||||||
|
"uriClipboardFallback": "リンク: {{uri}}",
|
||||||
"setupRequired": "例画像ストレージ",
|
"setupRequired": "例画像ストレージ",
|
||||||
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
|
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
|
||||||
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
|
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
|
||||||
@@ -1671,6 +1702,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}には更新が見つかりませんでした",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "学習済みワードを読み込めませんでした",
|
"loadFailed": "学習済みワードを読み込めませんでした",
|
||||||
"tooLong": "トリガーワードは100ワードを超えてはいけません",
|
"tooLong": "トリガーワードは500ワードを超えてはいけません",
|
||||||
"tooMany": "最大30トリガーワードまで許可されています",
|
"tooMany": "最大100トリガーワードまで許可されています",
|
||||||
"alreadyExists": "このトリガーワードは既に存在します",
|
"alreadyExists": "このトリガーワードは既に存在します",
|
||||||
"updateSuccess": "トリガーワードが正常に更新されました",
|
"updateSuccess": "トリガーワードが正常に更新されました",
|
||||||
"updateFailed": "トリガーワードの更新に失敗しました",
|
"updateFailed": "トリガーワードの更新に失敗しました",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
|
||||||
|
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
|
||||||
|
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"close": "닫기"
|
"close": "닫기",
|
||||||
|
"menu": "메뉴"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -276,6 +277,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 +429,8 @@
|
|||||||
"hover": "호버 시 표시"
|
"hover": "호버 시 표시"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||||
|
"showVersionOnCard": "카드에 버전 표시",
|
||||||
|
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "예시 이미지 열기",
|
"exampleImages": "예시 이미지 열기",
|
||||||
@@ -538,6 +542,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": "다운로드",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"autoOrganize": "자동 정리 선택",
|
"autoOrganize": "자동 정리 선택",
|
||||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||||
"deleteAll": "모든 모델 삭제",
|
"setFavorite": "즐겨찾기로 설정",
|
||||||
|
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
|
||||||
|
"unfavorite": "즐겨찾기 해제",
|
||||||
|
"deleteAll": "선택된 항목 삭제",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
"clear": "선택 지우기",
|
"clear": "선택 지우기",
|
||||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "편집 취소",
|
"cancel": "편집 취소",
|
||||||
"save": "변경사항 저장",
|
"save": "변경사항 저장",
|
||||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||||
|
"editWord": "트리거 단어 편집",
|
||||||
|
"editPlaceholder": "트리거 단어 편집",
|
||||||
"copyWord": "트리거 단어 복사",
|
"copyWord": "트리거 단어 복사",
|
||||||
"deleteWord": "트리거 단어 삭제",
|
"deleteWord": "트리거 단어 삭제",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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": "무시",
|
||||||
@@ -1440,6 +1467,10 @@
|
|||||||
"opened": "예시 이미지 폴더가 열렸습니다",
|
"opened": "예시 이미지 폴더가 열렸습니다",
|
||||||
"openingFolder": "예시 이미지 폴더를 여는 중",
|
"openingFolder": "예시 이미지 폴더를 여는 중",
|
||||||
"failedToOpen": "예시 이미지 폴더 열기 실패",
|
"failedToOpen": "예시 이미지 폴더 열기 실패",
|
||||||
|
"copiedPath": "경로를 클립보드에 복사했습니다: {{path}}",
|
||||||
|
"clipboardFallback": "경로: {{path}}",
|
||||||
|
"copiedUri": "링크를 클립보드에 복사했습니다: {{uri}}",
|
||||||
|
"uriClipboardFallback": "링크: {{uri}}",
|
||||||
"setupRequired": "예시 이미지 저장소",
|
"setupRequired": "예시 이미지 저장소",
|
||||||
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
|
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
|
||||||
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
|
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
|
||||||
@@ -1671,6 +1702,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}에 대한 업데이트가 없습니다",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
|
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
|
||||||
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다",
|
"tooLong": "트리거 단어는 500단어를 초과할 수 없습니다",
|
||||||
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
|
"tooMany": "최대 100개의 트리거 단어만 허용됩니다",
|
||||||
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
|
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
|
||||||
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
|
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
|
||||||
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||||
"repairFailed": "캐시 재구성 실패: {message}",
|
"repairFailed": "캐시 재구성 실패: {message}",
|
||||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
"exportFailed": "진단 번들 내보내기 실패: {message}",
|
||||||
|
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
|
||||||
|
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"close": "Закрыть"
|
"close": "Закрыть",
|
||||||
|
"menu": "Меню"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -276,6 +277,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 +429,8 @@
|
|||||||
"hover": "Показать при наведении"
|
"hover": "Показать при наведении"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||||
|
"showVersionOnCard": "Показывать версию на карточке",
|
||||||
|
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Открыть примеры изображений",
|
"exampleImages": "Открыть примеры изображений",
|
||||||
@@ -538,6 +542,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": "Загрузить",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"autoOrganize": "Автоматически организовать выбранные",
|
"autoOrganize": "Автоматически организовать выбранные",
|
||||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||||
"deleteAll": "Удалить все модели",
|
"setFavorite": "Добавить в избранное",
|
||||||
|
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
|
||||||
|
"unfavorite": "Удалить из избранного",
|
||||||
|
"deleteAll": "Удалить выбранные",
|
||||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
"clear": "Очистить выбор",
|
"clear": "Очистить выбор",
|
||||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "Отменить редактирование",
|
"cancel": "Отменить редактирование",
|
||||||
"save": "Сохранить изменения",
|
"save": "Сохранить изменения",
|
||||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||||
|
"editWord": "Редактировать триггерное слово",
|
||||||
|
"editPlaceholder": "Редактировать триггерное слово",
|
||||||
"copyWord": "Копировать триггерное слово",
|
"copyWord": "Копировать триггерное слово",
|
||||||
"deleteWord": "Удалить триггерное слово",
|
"deleteWord": "Удалить триггерное слово",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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": "Игнорировать",
|
||||||
@@ -1440,6 +1467,10 @@
|
|||||||
"opened": "Папка с примерами изображений открыта",
|
"opened": "Папка с примерами изображений открыта",
|
||||||
"openingFolder": "Открытие папки с примерами изображений",
|
"openingFolder": "Открытие папки с примерами изображений",
|
||||||
"failedToOpen": "Не удалось открыть папку с примерами изображений",
|
"failedToOpen": "Не удалось открыть папку с примерами изображений",
|
||||||
|
"copiedPath": "Путь скопирован в буфер обмена: {{path}}",
|
||||||
|
"clipboardFallback": "Путь: {{path}}",
|
||||||
|
"copiedUri": "Ссылка скопирована в буфер обмена: {{uri}}",
|
||||||
|
"uriClipboardFallback": "Ссылка: {{uri}}",
|
||||||
"setupRequired": "Хранилище примеров изображений",
|
"setupRequired": "Хранилище примеров изображений",
|
||||||
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
|
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
|
||||||
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
|
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
|
||||||
@@ -1671,6 +1702,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} не найдены",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Не удалось загрузить обученные слова",
|
"loadFailed": "Не удалось загрузить обученные слова",
|
||||||
"tooLong": "Триггерное слово не должно превышать 100 слов",
|
"tooLong": "Триггерное слово не должно превышать 500 слов",
|
||||||
"tooMany": "Максимум 30 триггерных слов разрешено",
|
"tooMany": "Максимум 100 триггерных слов разрешено",
|
||||||
"alreadyExists": "Это триггерное слово уже существует",
|
"alreadyExists": "Это триггерное слово уже существует",
|
||||||
"updateSuccess": "Триггерные слова успешно обновлены",
|
"updateSuccess": "Триггерные слова успешно обновлены",
|
||||||
"updateFailed": "Не удалось обновить триггерные слова",
|
"updateFailed": "Не удалось обновить триггерные слова",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"repairSuccess": "Перестройка кэша завершена.",
|
"repairSuccess": "Перестройка кэша завершена.",
|
||||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
|
||||||
|
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
|
||||||
|
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"menu": "菜单"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -276,6 +277,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 +429,8 @@
|
|||||||
"hover": "悬停时显示"
|
"hover": "悬停时显示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||||
|
"showVersionOnCard": "在卡片上显示版本",
|
||||||
|
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||||
"modelCardFooterAction": "模型卡片按钮操作",
|
"modelCardFooterAction": "模型卡片按钮操作",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "打开示例图片",
|
"exampleImages": "打开示例图片",
|
||||||
@@ -538,6 +542,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": "下载",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"autoOrganize": "自动整理所选模型",
|
"autoOrganize": "自动整理所选模型",
|
||||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||||
"deleteAll": "删除选中模型",
|
"setFavorite": "设为收藏",
|
||||||
|
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
|
"deleteAll": "删除已选",
|
||||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "取消编辑",
|
"cancel": "取消编辑",
|
||||||
"save": "保存更改",
|
"save": "保存更改",
|
||||||
"addPlaceholder": "输入或点击下方建议添加",
|
"addPlaceholder": "输入或点击下方建议添加",
|
||||||
|
"editWord": "编辑触发词",
|
||||||
|
"editPlaceholder": "编辑触发词",
|
||||||
"copyWord": "复制触发词",
|
"copyWord": "复制触发词",
|
||||||
"deleteWord": "删除触发词",
|
"deleteWord": "删除触发词",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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": "忽略",
|
||||||
@@ -1440,6 +1467,10 @@
|
|||||||
"opened": "示例图片文件夹已打开",
|
"opened": "示例图片文件夹已打开",
|
||||||
"openingFolder": "正在打开示例图片文件夹",
|
"openingFolder": "正在打开示例图片文件夹",
|
||||||
"failedToOpen": "打开示例图片文件夹失败",
|
"failedToOpen": "打开示例图片文件夹失败",
|
||||||
|
"copiedPath": "路径已复制到剪贴板:{{path}}",
|
||||||
|
"clipboardFallback": "路径:{{path}}",
|
||||||
|
"copiedUri": "链接已复制到剪贴板:{{uri}}",
|
||||||
|
"uriClipboardFallback": "链接:{{uri}}",
|
||||||
"setupRequired": "示例图片存储",
|
"setupRequired": "示例图片存储",
|
||||||
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
||||||
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
||||||
@@ -1671,6 +1702,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} 未发现更新",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "无法加载训练词",
|
"loadFailed": "无法加载训练词",
|
||||||
"tooLong": "触发词不能超过100个词",
|
"tooLong": "触发词不能超过500个词",
|
||||||
"tooMany": "最多允许30个触发词",
|
"tooMany": "最多允许100个触发词",
|
||||||
"alreadyExists": "该触发词已存在",
|
"alreadyExists": "该触发词已存在",
|
||||||
"updateSuccess": "触发词更新成功",
|
"updateSuccess": "触发词更新成功",
|
||||||
"updateFailed": "触发词更新失败",
|
"updateFailed": "触发词更新失败",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"repairSuccess": "缓存重建完成。",
|
"repairSuccess": "缓存重建完成。",
|
||||||
"repairFailed": "缓存重建失败:{message}",
|
"repairFailed": "缓存重建失败:{message}",
|
||||||
"exportSuccess": "诊断包已导出。",
|
"exportSuccess": "诊断包已导出。",
|
||||||
"exportFailed": "导出诊断包失败:{message}"
|
"exportFailed": "导出诊断包失败:{message}",
|
||||||
|
"conflictsResolved": "已解决 {count} 个文件名冲突。",
|
||||||
|
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"close": "關閉"
|
"close": "關閉",
|
||||||
|
"menu": "選單"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -276,6 +277,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 +429,8 @@
|
|||||||
"hover": "滑鼠懸停顯示"
|
"hover": "滑鼠懸停顯示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||||
|
"showVersionOnCard": "在卡片上顯示版本",
|
||||||
|
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "開啟範例圖片",
|
"exampleImages": "開啟範例圖片",
|
||||||
@@ -538,6 +542,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": "下載",
|
||||||
@@ -668,7 +687,10 @@
|
|||||||
"autoOrganize": "自動整理所選模型",
|
"autoOrganize": "自動整理所選模型",
|
||||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||||
"deleteAll": "刪除全部模型",
|
"setFavorite": "設為收藏",
|
||||||
|
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
|
"deleteAll": "刪除所選",
|
||||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||||
"clear": "清除選取",
|
"clear": "清除選取",
|
||||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
"cancel": "取消編輯",
|
"cancel": "取消編輯",
|
||||||
"save": "儲存變更",
|
"save": "儲存變更",
|
||||||
"addPlaceholder": "輸入或點擊下方建議",
|
"addPlaceholder": "輸入或點擊下方建議",
|
||||||
|
"editWord": "編輯觸發詞",
|
||||||
|
"editPlaceholder": "編輯觸發詞",
|
||||||
"copyWord": "複製觸發詞",
|
"copyWord": "複製觸發詞",
|
||||||
"deleteWord": "刪除觸發詞",
|
"deleteWord": "刪除觸發詞",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
@@ -1272,12 +1296,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": "忽略",
|
||||||
@@ -1440,6 +1467,10 @@
|
|||||||
"opened": "範例圖片資料夾已開啟",
|
"opened": "範例圖片資料夾已開啟",
|
||||||
"openingFolder": "正在開啟範例圖片資料夾",
|
"openingFolder": "正在開啟範例圖片資料夾",
|
||||||
"failedToOpen": "開啟範例圖片資料夾失敗",
|
"failedToOpen": "開啟範例圖片資料夾失敗",
|
||||||
|
"copiedPath": "路徑已複製到剪貼簿:{{path}}",
|
||||||
|
"clipboardFallback": "路徑:{{path}}",
|
||||||
|
"copiedUri": "連結已複製到剪貼簿:{{uri}}",
|
||||||
|
"uriClipboardFallback": "連結:{{uri}}",
|
||||||
"setupRequired": "範例圖片儲存",
|
"setupRequired": "範例圖片儲存",
|
||||||
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
|
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
|
||||||
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
|
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
|
||||||
@@ -1671,6 +1702,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} 未找到更新",
|
||||||
@@ -1761,8 +1797,8 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "無法載入訓練詞",
|
"loadFailed": "無法載入訓練詞",
|
||||||
"tooLong": "觸發詞不可超過 100 個字",
|
"tooLong": "觸發詞不可超過 500 個字",
|
||||||
"tooMany": "最多允許 30 個觸發詞",
|
"tooMany": "最多允許 100 個觸發詞",
|
||||||
"alreadyExists": "此觸發詞已存在",
|
"alreadyExists": "此觸發詞已存在",
|
||||||
"updateSuccess": "觸發詞已更新",
|
"updateSuccess": "觸發詞已更新",
|
||||||
"updateFailed": "更新觸發詞失敗",
|
"updateFailed": "更新觸發詞失敗",
|
||||||
@@ -1882,7 +1918,9 @@
|
|||||||
"repairSuccess": "快取重建完成。",
|
"repairSuccess": "快取重建完成。",
|
||||||
"repairFailed": "快取重建失敗:{message}",
|
"repairFailed": "快取重建失敗:{message}",
|
||||||
"exportSuccess": "診斷套件已匯出。",
|
"exportSuccess": "診斷套件已匯出。",
|
||||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
"exportFailed": "匯出診斷套件失敗:{message}",
|
||||||
|
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
|
||||||
|
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
90
py/config.py
90
py/config.py
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import posixpath
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
@@ -25,21 +26,57 @@ standalone_mode = (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_root_identity(path: str) -> str:
|
||||||
|
"""Normalize a root path for comparisons across slash styles."""
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
|
||||||
|
if len(normalized) >= 2 and normalized[1] == ":":
|
||||||
|
return normalized.lower()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _resolve_valid_default_root(
|
def _resolve_valid_default_root(
|
||||||
current: str, primary_paths: List[str], name: str
|
current: str, primary_paths: List[str], allowed_paths: List[str], name: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return a valid default root from the current primary path set."""
|
"""Return a valid default root from the current primary/extra path set."""
|
||||||
|
|
||||||
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
|
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
|
||||||
if not valid_paths:
|
fallback_paths: List[str] = []
|
||||||
return ""
|
seen: Set[str] = set()
|
||||||
|
for path in allowed_paths:
|
||||||
|
if not isinstance(path, str):
|
||||||
|
continue
|
||||||
|
stripped = path.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
identity = _normalize_root_identity(stripped)
|
||||||
|
if identity in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identity)
|
||||||
|
fallback_paths.append(stripped)
|
||||||
|
|
||||||
if current in valid_paths:
|
allowed = {_normalize_root_identity(path) for path in fallback_paths}
|
||||||
|
|
||||||
|
if current and _normalize_root_identity(current) in allowed:
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
if not valid_paths:
|
||||||
|
if not fallback_paths:
|
||||||
|
return ""
|
||||||
|
if current:
|
||||||
|
logger.info(
|
||||||
|
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
|
||||||
|
name,
|
||||||
|
current,
|
||||||
|
fallback_paths[0],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Auto-setting %s to '%s'", name, fallback_paths[0])
|
||||||
|
return fallback_paths[0]
|
||||||
|
|
||||||
if current:
|
if current:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Repaired stale %s from '%s' to '%s'",
|
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
|
||||||
name,
|
name,
|
||||||
current,
|
current,
|
||||||
valid_paths[0],
|
valid_paths[0],
|
||||||
@@ -226,39 +263,76 @@ class Config:
|
|||||||
default_lora_root = _resolve_valid_default_root(
|
default_lora_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_lora_root", ""),
|
comfy_library.get("default_lora_root", ""),
|
||||||
list(self.loras_roots or []),
|
list(self.loras_roots or []),
|
||||||
|
list(self.loras_roots or [])
|
||||||
|
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
|
||||||
"default_lora_root",
|
"default_lora_root",
|
||||||
)
|
)
|
||||||
|
|
||||||
default_checkpoint_root = _resolve_valid_default_root(
|
default_checkpoint_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_checkpoint_root", ""),
|
comfy_library.get("default_checkpoint_root", ""),
|
||||||
list(self.checkpoints_roots or []),
|
list(self.checkpoints_roots or []),
|
||||||
|
list(self.checkpoints_roots or [])
|
||||||
|
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
|
||||||
"default_checkpoint_root",
|
"default_checkpoint_root",
|
||||||
)
|
)
|
||||||
|
|
||||||
default_embedding_root = _resolve_valid_default_root(
|
default_embedding_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_embedding_root", ""),
|
comfy_library.get("default_embedding_root", ""),
|
||||||
list(self.embeddings_roots or []),
|
list(self.embeddings_roots or []),
|
||||||
|
list(self.embeddings_roots or [])
|
||||||
|
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
|
||||||
"default_embedding_root",
|
"default_embedding_root",
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata = dict(comfy_library.get("metadata", {}))
|
metadata = dict(comfy_library.get("metadata", {}))
|
||||||
metadata.setdefault("display_name", "ComfyUI")
|
metadata.setdefault("display_name", "ComfyUI")
|
||||||
metadata["source"] = "comfyui"
|
metadata["source"] = "comfyui"
|
||||||
|
extra_folder_paths = {}
|
||||||
|
if isinstance(comfy_library, Mapping):
|
||||||
|
existing_extra_paths = comfy_library.get("extra_folder_paths", {})
|
||||||
|
if isinstance(existing_extra_paths, Mapping):
|
||||||
|
extra_folder_paths = {
|
||||||
|
key: list(value) if isinstance(value, list) else []
|
||||||
|
for key, value in existing_extra_paths.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
active_library_name = settings_service.get_active_library_name()
|
||||||
|
should_activate = (
|
||||||
|
active_library_name == "comfyui"
|
||||||
|
or self._should_activate_comfy_library(libraries, libraries_changed)
|
||||||
|
)
|
||||||
|
|
||||||
settings_service.upsert_library(
|
settings_service.upsert_library(
|
||||||
"comfyui",
|
"comfyui",
|
||||||
folder_paths=target_folder_paths,
|
folder_paths=target_folder_paths,
|
||||||
|
extra_folder_paths=extra_folder_paths,
|
||||||
default_lora_root=default_lora_root,
|
default_lora_root=default_lora_root,
|
||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=True,
|
activate=should_activate,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Updated 'comfyui' library with current folder paths")
|
if should_activate:
|
||||||
|
logger.info("Updated 'comfyui' library with current folder paths")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Updated 'comfyui' library with current folder paths without activating it"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to save folder paths: {e}")
|
logger.warning(f"Failed to save folder paths: {e}")
|
||||||
|
|
||||||
|
def _should_activate_comfy_library(
|
||||||
|
self, libraries: Mapping[str, Any], libraries_changed: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether startup sync should make the ComfyUI library active."""
|
||||||
|
|
||||||
|
if libraries_changed:
|
||||||
|
return True
|
||||||
|
if not libraries:
|
||||||
|
return True
|
||||||
|
return "comfyui" in libraries and len(libraries) == 1
|
||||||
|
|
||||||
def _is_link(self, path: str) -> bool:
|
def _is_link(self, path: str) -> bool:
|
||||||
try:
|
try:
|
||||||
if os.path.islink(path):
|
if os.path.islink(path):
|
||||||
|
|||||||
@@ -353,49 +353,100 @@ 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 nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader)
|
# For CLIP text nodes with a single conditioning output.
|
||||||
if is_positive and "positive_encoded" in prompt_data:
|
if id(prompt_data.get("conditioning")) == conditioning_id:
|
||||||
if id(prompt_data["positive_encoded"]) == id(conditioning_obj):
|
text = prompt_data.get("text", "")
|
||||||
if "positive_text" in prompt_data:
|
if text:
|
||||||
return prompt_data["positive_text"]
|
extend_unique(prompt_texts, [text])
|
||||||
else:
|
|
||||||
orig_conditioning = prompt_data.get("orig_pos_cond", None)
|
|
||||||
if orig_conditioning is not None:
|
|
||||||
# Recursively find the prompt text for the original conditioning
|
|
||||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=True)
|
|
||||||
|
|
||||||
if not is_positive and "negative_encoded" in prompt_data:
|
# Generic provenance for passthrough/transform/combine nodes.
|
||||||
if id(prompt_data["negative_encoded"]) == id(conditioning_obj):
|
for source in prompt_data.get("conditioning_sources", []):
|
||||||
if "negative_text" in prompt_data:
|
if id(source.get("output")) != conditioning_id:
|
||||||
return prompt_data["negative_text"]
|
continue
|
||||||
else:
|
for input_conditioning in source.get("inputs", []):
|
||||||
orig_conditioning = prompt_data.get("orig_neg_cond", None)
|
extend_unique(
|
||||||
if orig_conditioning is not None:
|
prompt_texts,
|
||||||
# Recursively find the prompt text for the original conditioning
|
find_prompt_texts_for_conditioning(
|
||||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=False)
|
input_conditioning, is_positive, visited
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return ""
|
# For nodes with separate pos_conditioning and neg_conditioning outputs
|
||||||
|
# like TSC_EfficientLoader and existing ControlNet-style metadata.
|
||||||
|
if (
|
||||||
|
is_positive
|
||||||
|
and id(prompt_data.get("positive_encoded")) == conditioning_id
|
||||||
|
):
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ class TriggerWordToggleLM:
|
|||||||
# Filter out empty strings and return as set
|
# Filter out empty strings and return as set
|
||||||
return set(word for word in words if word)
|
return set(word for word in words if word)
|
||||||
|
|
||||||
|
def _group_has_child_items(self, item):
|
||||||
|
return isinstance(item, dict) and isinstance(item.get("items"), list)
|
||||||
|
|
||||||
def process_trigger_words(
|
def process_trigger_words(
|
||||||
self,
|
self,
|
||||||
id,
|
id,
|
||||||
@@ -112,7 +115,11 @@ class TriggerWordToggleLM:
|
|||||||
|
|
||||||
if isinstance(trigger_data, list):
|
if isinstance(trigger_data, list):
|
||||||
if group_mode:
|
if group_mode:
|
||||||
if allow_strength_adjustment:
|
if any(self._group_has_child_items(item) for item in trigger_data):
|
||||||
|
filtered_groups = self._process_group_items(
|
||||||
|
trigger_data, allow_strength_adjustment
|
||||||
|
)
|
||||||
|
elif allow_strength_adjustment:
|
||||||
parsed_items = [
|
parsed_items = [
|
||||||
self._parse_trigger_item(
|
self._parse_trigger_item(
|
||||||
item, allow_strength_adjustment
|
item, allow_strength_adjustment
|
||||||
@@ -174,6 +181,41 @@ class TriggerWordToggleLM:
|
|||||||
|
|
||||||
return (filtered_triggers,)
|
return (filtered_triggers,)
|
||||||
|
|
||||||
|
def _process_group_items(self, trigger_data, allow_strength_adjustment):
|
||||||
|
filtered_groups = []
|
||||||
|
|
||||||
|
for item in trigger_data:
|
||||||
|
group = self._parse_trigger_item(item, allow_strength_adjustment)
|
||||||
|
if not group["text"] or not group["active"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_items = item.get("items") if isinstance(item, dict) else None
|
||||||
|
if isinstance(raw_items, list):
|
||||||
|
active_items = []
|
||||||
|
for raw_item in raw_items:
|
||||||
|
child = self._parse_trigger_item(
|
||||||
|
raw_item, allow_strength_adjustment=False
|
||||||
|
)
|
||||||
|
if child["text"] and child["active"]:
|
||||||
|
active_items.append(child["text"])
|
||||||
|
|
||||||
|
if not active_items:
|
||||||
|
continue
|
||||||
|
|
||||||
|
group_text = ", ".join(active_items)
|
||||||
|
else:
|
||||||
|
group_text = group["text"]
|
||||||
|
|
||||||
|
filtered_groups.append(
|
||||||
|
self._format_word_output(
|
||||||
|
group_text,
|
||||||
|
group["strength"],
|
||||||
|
allow_strength_adjustment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return filtered_groups
|
||||||
|
|
||||||
def _parse_trigger_item(self, item, allow_strength_adjustment):
|
def _parse_trigger_item(self, item, allow_strength_adjustment):
|
||||||
text = (item.get("text") or "").strip()
|
text = (item.get("text") or "").strip()
|
||||||
active = bool(item.get("active", False))
|
active = bool(item.get("active", False))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,145 @@ class DoctorHandler:
|
|||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
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 +989,79 @@ 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]:
|
||||||
|
all_conflicts: list[dict[str, Any]] = []
|
||||||
|
total_conflict_groups = 0
|
||||||
|
total_conflict_files = 0
|
||||||
|
|
||||||
|
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 = 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,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for conflict in all_conflicts:
|
||||||
|
details.append(
|
||||||
|
f"[{conflict['label']}] '{conflict['filename']}' "
|
||||||
|
f"found in {len(conflict['paths'])} locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": summary,
|
||||||
|
"details": details,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "resolve-filename-conflicts",
|
||||||
|
"label": "Resolve Conflicts",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
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 +1792,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 +1838,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 +1891,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:
|
||||||
@@ -1777,6 +2083,78 @@ 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()
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
await history_service.mark_not_downloaded(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 +3174,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 +3188,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,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import jinja2
|
|||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
from ...services.download_coordinator import DownloadCoordinator
|
from ...services.download_coordinator import DownloadCoordinator
|
||||||
|
from ...services.connectivity_guard import (
|
||||||
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
|
is_expected_offline_error,
|
||||||
|
)
|
||||||
from ...services.metadata_sync_service import MetadataSyncService
|
from ...services.metadata_sync_service import MetadataSyncService
|
||||||
from ...services.model_file_service import ModelMoveService
|
from ...services.model_file_service import ModelMoveService
|
||||||
from ...services.preview_asset_service import PreviewAssetService
|
from ...services.preview_asset_service import PreviewAssetService
|
||||||
@@ -504,6 +508,11 @@ class ModelManagementHandler:
|
|||||||
formatted_metadata = await self._service.format_response(model_data)
|
formatted_metadata = await self._service.format_response(model_data)
|
||||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
return web.json_response({"success": True, "metadata": formatted_metadata})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if is_expected_offline_error(str(exc)):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
self._logger.error("Error fetching from CivitAI: %s", exc, exc_info=True)
|
self._logger.error("Error fetching from CivitAI: %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)
|
||||||
|
|
||||||
@@ -550,6 +559,11 @@ class ModelManagementHandler:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if is_expected_offline_error(str(exc)):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
self._logger.error("Error re-linking to CivitAI: %s", exc, exc_info=True)
|
self._logger.error("Error re-linking to CivitAI: %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)
|
||||||
|
|
||||||
@@ -1858,6 +1872,11 @@ class ModelUpdateHandler:
|
|||||||
status=429,
|
status=429,
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive log
|
except Exception as exc: # pragma: no cover - defensive log
|
||||||
|
if is_expected_offline_error(str(exc)):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
|
self._logger.error("Failed to fetch license info: %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)
|
||||||
|
|
||||||
@@ -1946,9 +1965,12 @@ class ModelUpdateHandler:
|
|||||||
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
self._logger.error(
|
if is_expected_offline_error(str(exc)):
|
||||||
"Failed to refresh model updates: %s", exc, exc_info=True
|
return web.json_response(
|
||||||
)
|
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
serialized_records = []
|
serialized_records = []
|
||||||
@@ -2401,6 +2423,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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import copy
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||||
|
from .connectivity_guard import (
|
||||||
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
|
is_expected_offline_error,
|
||||||
|
is_offline_cooldown_error,
|
||||||
|
)
|
||||||
from .model_metadata_provider import (
|
from .model_metadata_provider import (
|
||||||
CivitaiModelMetadataProvider,
|
CivitaiModelMetadataProvider,
|
||||||
ModelMetadataProviderManager,
|
ModelMetadataProviderManager,
|
||||||
@@ -65,6 +70,8 @@ class CivitaiClient:
|
|||||||
if result.provider is None:
|
if result.provider is None:
|
||||||
result.provider = "civitai_api"
|
result.provider = "civitai_api"
|
||||||
raise result
|
raise result
|
||||||
|
if not success and is_offline_cooldown_error(result):
|
||||||
|
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||||
return success, result
|
return success, result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -124,6 +131,8 @@ class CivitaiClient:
|
|||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
message = str(version)
|
message = str(version)
|
||||||
|
if is_expected_offline_error(message):
|
||||||
|
return None, OFFLINE_FRIENDLY_MESSAGE
|
||||||
if "not found" in message.lower():
|
if "not found" in message.lower():
|
||||||
return None, "Model not found"
|
return None, "Model not found"
|
||||||
|
|
||||||
@@ -164,6 +173,9 @@ class CivitaiClient:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if is_expected_offline_error(str(e)):
|
||||||
|
logger.debug("Preview download skipped due to offline state.")
|
||||||
|
return False
|
||||||
logger.error(f"Download Error: {str(e)}")
|
logger.error(f"Download Error: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -207,6 +219,9 @@ class CivitaiClient:
|
|||||||
message = self._extract_error_message(result)
|
message = self._extract_error_message(result)
|
||||||
if message and "not found" in message.lower():
|
if message and "not found" in message.lower():
|
||||||
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
|
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
|
||||||
|
if is_expected_offline_error(message):
|
||||||
|
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||||
|
return None
|
||||||
if message:
|
if message:
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
return None
|
return None
|
||||||
@@ -357,6 +372,8 @@ class CivitaiClient:
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return data
|
return data
|
||||||
|
if is_expected_offline_error(data):
|
||||||
|
return None
|
||||||
logger.warning(f"Failed to fetch model data for model {model_id}")
|
logger.warning(f"Failed to fetch model data for model {model_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -371,6 +388,8 @@ class CivitaiClient:
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return version
|
return version
|
||||||
|
if is_expected_offline_error(version):
|
||||||
|
return None
|
||||||
|
|
||||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||||
return None
|
return None
|
||||||
@@ -386,6 +405,8 @@ class CivitaiClient:
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return version
|
return version
|
||||||
|
if is_expected_offline_error(version):
|
||||||
|
return None
|
||||||
|
|
||||||
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
||||||
return None
|
return None
|
||||||
@@ -473,6 +494,8 @@ class CivitaiClient:
|
|||||||
return result, None
|
return result, None
|
||||||
|
|
||||||
# Handle specific error cases
|
# Handle specific error cases
|
||||||
|
if is_expected_offline_error(result):
|
||||||
|
return None, OFFLINE_FRIENDLY_MESSAGE
|
||||||
if "not found" in str(result):
|
if "not found" in str(result):
|
||||||
error_msg = f"Model not found"
|
error_msg = f"Model not found"
|
||||||
logger.warning(f"Model version not found: {version_id} - {error_msg}")
|
logger.warning(f"Model version not found: {version_id} - {error_msg}")
|
||||||
@@ -507,6 +530,8 @@ class CivitaiClient:
|
|||||||
success, result = await self._make_request("GET", url, use_auth=True)
|
success, result = await self._make_request("GET", url, use_auth=True)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
if is_expected_offline_error(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,
|
||||||
@@ -552,6 +577,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:
|
||||||
@@ -566,6 +644,9 @@ class CivitaiClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
if is_expected_offline_error(result):
|
||||||
|
logger.info("User model fetch skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||||
|
return None
|
||||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
204
py/services/connectivity_guard.py
Normal file
204
py/services/connectivity_guard.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""In-memory connectivity guard to suppress repeated network retries when offline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import errno
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OFFLINE_COOLDOWN_ERROR = "offline_cooldown"
|
||||||
|
OFFLINE_FRIENDLY_MESSAGE = "Network offline, will retry automatically later"
|
||||||
|
|
||||||
|
|
||||||
|
def is_offline_cooldown_error(value: Any) -> bool:
|
||||||
|
"""Return True when a response payload represents guard short-circuit."""
|
||||||
|
return isinstance(value, str) and value == OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
def is_expected_offline_error(value: Any) -> bool:
|
||||||
|
"""Return True when payload is an expected offline-related result."""
|
||||||
|
if is_offline_cooldown_error(value):
|
||||||
|
return True
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return False
|
||||||
|
normalized = value.lower()
|
||||||
|
return "network offline" in normalized or "offline" in normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectivityGuard:
|
||||||
|
"""Tracks network failures and gates outbound requests during cooldown."""
|
||||||
|
|
||||||
|
_instance: "ConnectivityGuard | None" = None
|
||||||
|
_instance_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_instance(cls) -> "ConnectivityGuard":
|
||||||
|
async with cls._instance_lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if hasattr(self, "_initialized"):
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
self._default_destination = "__global__"
|
||||||
|
self._destination_states: dict[str, _DestinationState] = {
|
||||||
|
self._default_destination: _DestinationState()
|
||||||
|
}
|
||||||
|
self.base_backoff_seconds = 30
|
||||||
|
self.max_backoff_seconds = 300
|
||||||
|
self.failure_threshold = 3
|
||||||
|
|
||||||
|
@property
|
||||||
|
def online(self) -> bool:
|
||||||
|
return self._state_for_destination(None).online
|
||||||
|
|
||||||
|
@online.setter
|
||||||
|
def online(self, value: bool) -> None:
|
||||||
|
self._state_for_destination(None).online = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failure_count(self) -> int:
|
||||||
|
return self._state_for_destination(None).failure_count
|
||||||
|
|
||||||
|
@failure_count.setter
|
||||||
|
def failure_count(self, value: int) -> None:
|
||||||
|
self._state_for_destination(None).failure_count = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cooldown_until(self) -> datetime | None:
|
||||||
|
return self._state_for_destination(None).cooldown_until
|
||||||
|
|
||||||
|
@cooldown_until.setter
|
||||||
|
def cooldown_until(self, value: datetime | None) -> None:
|
||||||
|
self._state_for_destination(None).cooldown_until = value
|
||||||
|
|
||||||
|
def _now(self) -> datetime:
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
def _normalize_destination(self, destination: str | None) -> str:
|
||||||
|
if destination is None or not destination.strip():
|
||||||
|
return self._default_destination
|
||||||
|
return destination.lower().strip()
|
||||||
|
|
||||||
|
def _state_for_destination(self, destination: str | None) -> "_DestinationState":
|
||||||
|
destination_key = self._normalize_destination(destination)
|
||||||
|
if destination_key not in self._destination_states:
|
||||||
|
self._destination_states[destination_key] = _DestinationState()
|
||||||
|
return self._destination_states[destination_key]
|
||||||
|
|
||||||
|
def in_cooldown(self, destination: str | None = None) -> bool:
|
||||||
|
state = self._state_for_destination(destination)
|
||||||
|
if state.cooldown_until is None:
|
||||||
|
return False
|
||||||
|
return self._now() < state.cooldown_until
|
||||||
|
|
||||||
|
def cooldown_remaining_seconds(self, destination: str | None = None) -> float:
|
||||||
|
state = self._state_for_destination(destination)
|
||||||
|
if state.cooldown_until is None:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, (state.cooldown_until - self._now()).total_seconds())
|
||||||
|
|
||||||
|
def should_block_request(self, destination: str | None = None) -> bool:
|
||||||
|
return self.in_cooldown(destination)
|
||||||
|
|
||||||
|
def register_success(self, destination: str | None = None) -> None:
|
||||||
|
destination_key = self._normalize_destination(destination)
|
||||||
|
state = self._state_for_destination(destination_key)
|
||||||
|
was_offline = (not state.online) or state.cooldown_until is not None
|
||||||
|
state.online = True
|
||||||
|
state.failure_count = 0
|
||||||
|
state.cooldown_until = None
|
||||||
|
if was_offline:
|
||||||
|
logger.info(
|
||||||
|
"Connectivity restored for destination '%s'; requests resumed.",
|
||||||
|
destination_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_network_failure(
|
||||||
|
self, exc: Exception, destination: str | None = None
|
||||||
|
) -> None:
|
||||||
|
destination_key = self._normalize_destination(destination)
|
||||||
|
state = self._state_for_destination(destination_key)
|
||||||
|
state.online = False
|
||||||
|
state.failure_count += 1
|
||||||
|
|
||||||
|
if state.failure_count < self.failure_threshold:
|
||||||
|
logger.debug(
|
||||||
|
"Network failure tracked for destination '%s' (%d/%d): %s",
|
||||||
|
destination_key,
|
||||||
|
state.failure_count,
|
||||||
|
self.failure_threshold,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
retry_step = state.failure_count - self.failure_threshold
|
||||||
|
backoff = min(
|
||||||
|
self.max_backoff_seconds,
|
||||||
|
self.base_backoff_seconds * (2**retry_step),
|
||||||
|
)
|
||||||
|
should_log_warning = not self.in_cooldown(destination_key)
|
||||||
|
state.cooldown_until = self._now() + timedelta(seconds=backoff)
|
||||||
|
|
||||||
|
if should_log_warning:
|
||||||
|
logger.warning(
|
||||||
|
"Connectivity offline for destination '%s'; enter cooldown for %ss after %d network failures.",
|
||||||
|
destination_key,
|
||||||
|
int(backoff),
|
||||||
|
state.failure_count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Cooldown still active for destination '%s'; failure_count=%d, backoff=%ss.",
|
||||||
|
destination_key,
|
||||||
|
state.failure_count,
|
||||||
|
int(backoff),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_network_unreachable_error(exc: Exception) -> bool:
|
||||||
|
"""Return whether the exception should count as connectivity failure."""
|
||||||
|
if isinstance(exc, asyncio.CancelledError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(
|
||||||
|
exc,
|
||||||
|
(
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
TimeoutError,
|
||||||
|
ConnectionRefusedError,
|
||||||
|
socket.gaierror,
|
||||||
|
aiohttp.ServerTimeoutError,
|
||||||
|
aiohttp.ConnectionTimeoutError,
|
||||||
|
aiohttp.ClientConnectorError,
|
||||||
|
aiohttp.ClientConnectionError,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(exc, OSError) and exc.errno in {
|
||||||
|
errno.ENETUNREACH,
|
||||||
|
errno.EHOSTUNREACH,
|
||||||
|
errno.ETIMEDOUT,
|
||||||
|
errno.ECONNREFUSED,
|
||||||
|
}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _DestinationState:
|
||||||
|
online: bool = True
|
||||||
|
failure_count: int = 0
|
||||||
|
cooldown_until: datetime | None = None
|
||||||
@@ -75,6 +75,65 @@ class DownloadManager:
|
|||||||
backend = (get_settings_manager().get("download_backend") or "python").strip()
|
backend = (get_settings_manager().get("download_backend") or "python").strip()
|
||||||
return backend.lower() or "python"
|
return backend.lower() or "python"
|
||||||
|
|
||||||
|
async def _schedule_auto_example_images_download(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
metadata,
|
||||||
|
model_type: str,
|
||||||
|
) -> None:
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
if not settings_manager.get("auto_download_example_images", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not settings_manager.get("example_images_path"):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping automatic example images download; example_images_path is not configured"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_hash = getattr(metadata, "sha256", "") or ""
|
||||||
|
model_hash = str(raw_hash).strip().lower()
|
||||||
|
if not model_hash:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping automatic example images download for %s; missing sha256",
|
||||||
|
getattr(metadata, "file_path", ""),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
optimize = bool(settings_manager.get("optimize_example_images", True))
|
||||||
|
|
||||||
|
async def _run_auto_example_images_download() -> None:
|
||||||
|
try:
|
||||||
|
from ..utils.example_images_download_manager import (
|
||||||
|
DownloadInProgressError,
|
||||||
|
get_default_download_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_manager = await ServiceRegistry.get_websocket_manager()
|
||||||
|
example_images_manager = get_default_download_manager(ws_manager)
|
||||||
|
await example_images_manager.start_force_download(
|
||||||
|
{
|
||||||
|
"model_hashes": [model_hash],
|
||||||
|
"optimize": optimize,
|
||||||
|
"model_types": [model_type],
|
||||||
|
"delay": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except DownloadInProgressError:
|
||||||
|
logger.info(
|
||||||
|
"Skipping automatic example images download for %s; another example images download is already running",
|
||||||
|
model_hash,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Automatic example images download failed for %s: %s",
|
||||||
|
model_hash,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(_run_auto_example_images_download())
|
||||||
|
|
||||||
async def _download_model_file(
|
async def _download_model_file(
|
||||||
self,
|
self,
|
||||||
download_url: str,
|
download_url: str,
|
||||||
@@ -1305,7 +1364,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,
|
||||||
)
|
)
|
||||||
@@ -1336,7 +1395,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,
|
||||||
)
|
)
|
||||||
@@ -1458,6 +1517,10 @@ class DownloadManager:
|
|||||||
version_info,
|
version_info,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
)
|
)
|
||||||
|
await self._schedule_auto_example_images_download(
|
||||||
|
metadata=metadata,
|
||||||
|
model_type=model_type,
|
||||||
|
)
|
||||||
|
|
||||||
# If early_access_msg exists and download failed, replace error message
|
# If early_access_msg exists and download failed, replace error message
|
||||||
if "early_access_msg" in locals() and not result.get("success", False):
|
if "early_access_msg" in locals() and not result.get("success", False):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -116,33 +123,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,24 +187,24 @@ 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_not_downloaded(self, model_type: str, version_id: int) -> None:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
@@ -208,28 +215,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 +245,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 +265,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 +298,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:
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ from collections import deque
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from .connectivity_guard import (
|
||||||
|
OFFLINE_COOLDOWN_ERROR,
|
||||||
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
|
ConnectivityGuard,
|
||||||
|
)
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -797,6 +803,11 @@ class Downloader:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
||||||
"""
|
"""
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
destination = self._guard_destination(url)
|
||||||
|
if guard.should_block_request(destination):
|
||||||
|
return False, OFFLINE_FRIENDLY_MESSAGE, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await self.session
|
session = await self.session
|
||||||
# Debug log for proxy mode at request time
|
# Debug log for proxy mode at request time
|
||||||
@@ -819,6 +830,7 @@ class Downloader:
|
|||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
content = await response.read()
|
content = await response.read()
|
||||||
|
guard.register_success(destination)
|
||||||
if return_headers:
|
if return_headers:
|
||||||
return True, content, dict(response.headers)
|
return True, content, dict(response.headers)
|
||||||
else:
|
else:
|
||||||
@@ -837,6 +849,12 @@ class Downloader:
|
|||||||
return False, error_msg, None
|
return False, error_msg, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if guard.is_network_unreachable_error(e):
|
||||||
|
guard.register_network_failure(e, destination)
|
||||||
|
if guard.should_block_request(destination):
|
||||||
|
return False, OFFLINE_FRIENDLY_MESSAGE, None
|
||||||
|
logger.debug("Network unavailable during memory download: %s", e)
|
||||||
|
return False, str(e), None
|
||||||
logger.error(f"Error downloading to memory from {url}: {e}")
|
logger.error(f"Error downloading to memory from {url}: {e}")
|
||||||
return False, str(e), None
|
return False, str(e), None
|
||||||
|
|
||||||
@@ -857,6 +875,11 @@ class Downloader:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message)
|
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message)
|
||||||
"""
|
"""
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
destination = self._guard_destination(url)
|
||||||
|
if guard.should_block_request(destination):
|
||||||
|
return False, OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await self.session
|
session = await self.session
|
||||||
# Debug log for proxy mode at request time
|
# Debug log for proxy mode at request time
|
||||||
@@ -878,11 +901,18 @@ class Downloader:
|
|||||||
url, headers=headers, proxy=self.proxy_url
|
url, headers=headers, proxy=self.proxy_url
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
|
guard.register_success(destination)
|
||||||
return True, dict(response.headers)
|
return True, dict(response.headers)
|
||||||
else:
|
else:
|
||||||
return False, f"Head request failed with status {response.status}"
|
return False, f"Head request failed with status {response.status}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if guard.is_network_unreachable_error(e):
|
||||||
|
guard.register_network_failure(e, destination)
|
||||||
|
if guard.should_block_request(destination):
|
||||||
|
return False, OFFLINE_COOLDOWN_ERROR
|
||||||
|
logger.debug("Network unavailable during header probe: %s", e)
|
||||||
|
return False, str(e)
|
||||||
logger.error(f"Error getting headers from {url}: {e}")
|
logger.error(f"Error getting headers from {url}: {e}")
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -907,6 +937,11 @@ class Downloader:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
|
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
|
||||||
"""
|
"""
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
destination = self._guard_destination(url)
|
||||||
|
if guard.should_block_request(destination):
|
||||||
|
return False, OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await self.session
|
session = await self.session
|
||||||
# Debug log for proxy mode at request time
|
# Debug log for proxy mode at request time
|
||||||
@@ -930,6 +965,7 @@ class Downloader:
|
|||||||
method, url, headers=headers, **kwargs
|
method, url, headers=headers, **kwargs
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
|
guard.register_success(destination)
|
||||||
# Try to parse as JSON, fall back to text
|
# Try to parse as JSON, fall back to text
|
||||||
try:
|
try:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
@@ -960,6 +996,12 @@ class Downloader:
|
|||||||
return False, f"Request failed with status {response.status}"
|
return False, f"Request failed with status {response.status}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if guard.is_network_unreachable_error(e):
|
||||||
|
guard.register_network_failure(e, destination)
|
||||||
|
if guard.should_block_request(destination):
|
||||||
|
return False, OFFLINE_COOLDOWN_ERROR
|
||||||
|
logger.debug("Network unavailable for %s %s: %s", method, url, e)
|
||||||
|
return False, str(e)
|
||||||
logger.error(f"Error making {method} request to {url}: {e}")
|
logger.error(f"Error making {method} request to {url}: {e}")
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -1010,6 +1052,14 @@ class Downloader:
|
|||||||
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
|
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
|
||||||
return max(0.0, delta.total_seconds())
|
return max(0.0, delta.total_seconds())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _guard_destination(url: str) -> str:
|
||||||
|
"""Build per-destination connectivity guard scope from request URL."""
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
if parsed_url.hostname:
|
||||||
|
return parsed_url.hostname.lower()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
# Global instance accessor
|
# Global instance accessor
|
||||||
async def get_downloader() -> Downloader:
|
async def get_downloader() -> Downloader:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
|||||||
from ..services.settings_manager import SettingsManager
|
from ..services.settings_manager import SettingsManager
|
||||||
from ..utils.civitai_utils import resolve_license_payload
|
from ..utils.civitai_utils import resolve_license_payload
|
||||||
from ..utils.model_utils import determine_base_model
|
from ..utils.model_utils import determine_base_model
|
||||||
|
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -274,11 +275,18 @@ class MetadataSyncService:
|
|||||||
else "No provider returned metadata"
|
else "No provider returned metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resolved_error = last_error or default_error
|
||||||
|
if is_expected_offline_error(resolved_error):
|
||||||
|
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
|
||||||
error_msg = (
|
error_msg = (
|
||||||
f"Error fetching metadata: {last_error or default_error} "
|
f"Error fetching metadata: {resolved_error} "
|
||||||
f"(model_name={model_data.get('model_name', '')})"
|
f"(model_name={model_data.get('model_name', '')})"
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
if is_expected_offline_error(resolved_error):
|
||||||
|
logger.info(error_msg)
|
||||||
|
else:
|
||||||
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
model_data["from_civitai"] = True
|
model_data["from_civitai"] = True
|
||||||
@@ -347,6 +355,9 @@ class MetadataSyncService:
|
|||||||
return False, error_msg
|
return False, error_msg
|
||||||
except Exception as exc: # pragma: no cover - error path
|
except Exception as exc: # pragma: no cover - error path
|
||||||
error_msg = f"Error fetching metadata: {exc}"
|
error_msg = f"Error fetching metadata: {exc}"
|
||||||
|
if is_expected_offline_error(str(exc)):
|
||||||
|
logger.info(OFFLINE_FRIENDLY_MESSAGE)
|
||||||
|
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,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
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ class ModelMetadataProvider(ABC):
|
|||||||
"""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]:
|
||||||
"""Get specific model version with additional metadata"""
|
"""Get specific model version with additional metadata"""
|
||||||
@@ -141,6 +153,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
) -> 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)
|
||||||
|
|
||||||
@@ -519,6 +536,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 +636,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 +721,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)
|
||||||
|
|||||||
@@ -1072,14 +1072,6 @@ class ModelScanner:
|
|||||||
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 +1097,31 @@ 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."""
|
||||||
|
if self._hash_index is None:
|
||||||
|
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]],
|
||||||
|
|||||||
@@ -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():
|
||||||
@@ -965,6 +989,11 @@ 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:
|
||||||
@@ -1059,6 +1088,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 +1269,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(
|
||||||
@@ -1237,6 +1397,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 +1496,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 +1510,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 +1602,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 +1653,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 +1710,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 +1726,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()
|
||||||
|
|||||||
@@ -1815,6 +1815,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,
|
||||||
|
|||||||
@@ -508,6 +508,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 +519,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 +528,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 +551,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."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
@@ -80,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,6 +97,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
"model_name_display": "model_name",
|
"model_name_display": "model_name",
|
||||||
"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": [],
|
||||||
@@ -103,6 +108,15 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_root_identity(path: str) -> str:
|
||||||
|
"""Normalize a root path for equality checks across slash styles."""
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
|
||||||
|
if len(normalized) >= 2 and normalized[1] == ":":
|
||||||
|
return normalized.lower()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
class SettingsManager:
|
class SettingsManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.settings_file = ensure_settings_file(logger)
|
self.settings_file = ensure_settings_file(logger)
|
||||||
@@ -763,34 +777,29 @@ class SettingsManager:
|
|||||||
if self._preserve_disk_template:
|
if self._preserve_disk_template:
|
||||||
return
|
return
|
||||||
|
|
||||||
folder_paths = self.settings.get("folder_paths", {})
|
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
def _check_and_auto_set(key: str, setting_key: str) -> bool:
|
def _check_and_auto_set(key: str, setting_key: str) -> bool:
|
||||||
"""Repair default roots when empty or no longer present."""
|
"""Repair default roots when empty or no longer present."""
|
||||||
current = self.settings.get(setting_key, "")
|
current = self.settings.get(setting_key, "")
|
||||||
candidates = folder_paths.get(key, [])
|
primary_candidates = self._get_valid_root_candidates(key)
|
||||||
if not isinstance(candidates, list) or not candidates:
|
if not primary_candidates:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Filter valid path strings
|
allowed_roots = self._get_allowed_roots(key)
|
||||||
valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()]
|
if current and _normalize_root_identity(current) in allowed_roots:
|
||||||
if not valid_paths:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if current in valid_paths:
|
self.settings[setting_key] = primary_candidates[0]
|
||||||
return False
|
|
||||||
|
|
||||||
self.settings[setting_key] = valid_paths[0]
|
|
||||||
if current:
|
if current:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Repaired stale %s from '%s' to '%s'",
|
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
|
||||||
setting_key,
|
setting_key,
|
||||||
current,
|
current,
|
||||||
valid_paths[0],
|
primary_candidates[0],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Auto-set %s to '%s'", setting_key, valid_paths[0])
|
logger.info("Auto-set %s to '%s'", setting_key, primary_candidates[0])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Process all model types
|
# Process all model types
|
||||||
@@ -813,6 +822,36 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
|
def _get_valid_root_candidates(self, key: str) -> List[str]:
|
||||||
|
"""Return stable root candidates, preferring primary roots over extra roots."""
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for mapping_key in ("folder_paths", "extra_folder_paths"):
|
||||||
|
raw_paths = self.settings.get(mapping_key, {})
|
||||||
|
if not isinstance(raw_paths, Mapping):
|
||||||
|
continue
|
||||||
|
values = raw_paths.get(key, [])
|
||||||
|
if not isinstance(values, list):
|
||||||
|
continue
|
||||||
|
for value in values:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
identity = _normalize_root_identity(normalized)
|
||||||
|
if identity in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identity)
|
||||||
|
candidates.append(normalized)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _get_allowed_roots(self, key: str) -> set[str]:
|
||||||
|
"""Return all valid roots for a model type, including extra roots."""
|
||||||
|
|
||||||
|
return {_normalize_root_identity(path) for path in self._get_valid_root_candidates(key)}
|
||||||
|
|
||||||
def _check_environment_variables(self) -> None:
|
def _check_environment_variables(self) -> None:
|
||||||
"""Check for environment variables and update settings if needed"""
|
"""Check for environment variables and update settings if needed"""
|
||||||
env_api_key = os.environ.get("CIVITAI_API_KEY")
|
env_api_key = os.environ.get("CIVITAI_API_KEY")
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ 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(
|
||||||
[
|
[
|
||||||
|
"Anima",
|
||||||
"ZImageTurbo",
|
"ZImageTurbo",
|
||||||
"ZImageBase",
|
"ZImageBase",
|
||||||
"Wan Video 1.3B t2v",
|
"Wan Video 1.3B t2v",
|
||||||
@@ -177,5 +178,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",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -67,19 +131,39 @@ class ExampleImagesFileManager:
|
|||||||
'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
|
|
||||||
subprocess.Popen(['open', model_folder])
|
|
||||||
else: # Linux
|
|
||||||
subprocess.Popen(['xdg-open', model_folder])
|
|
||||||
|
|
||||||
return web.json_response({
|
if open_mode == "clipboard":
|
||||||
'success': True,
|
local_root = settings_manager.get("example_images_local_root") or root_path
|
||||||
'message': f'Opened example images folder for model {model_hash}'
|
local_path = _join_local_example_path(local_root, relative_path)
|
||||||
})
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'mode': 'clipboard',
|
||||||
|
'path': local_path,
|
||||||
|
'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}',
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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__)
|
||||||
@@ -11,6 +12,132 @@ 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]:
|
||||||
"""Extract metadata from image including UserComment or parameters field
|
"""Extract metadata from image including UserComment or parameters field
|
||||||
@@ -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,49 +183,25 @@ 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_kwargs["quality"] = 85
|
||||||
|
|
||||||
# Save with the exif data
|
img.save(image_path, format=img_format, **save_kwargs)
|
||||||
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
|
|
||||||
return image_path
|
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -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()
|
||||||
|
|||||||
354
scripts/migrate_legacy_metadata.py
Normal file
354
scripts/migrate_legacy_metadata.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 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())
|
||||||
@@ -15,5 +15,8 @@
|
|||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"example_images_open_mode": "system",
|
||||||
|
"example_images_local_root": "",
|
||||||
|
"example_images_open_uri_template": "",
|
||||||
"auto_organize_exclusions": []
|
"auto_organize_exclusions": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms 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;
|
||||||
@@ -328,7 +329,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;
|
||||||
@@ -370,7 +370,16 @@
|
|||||||
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
@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,6 +387,7 @@
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +517,11 @@
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide civitai version name when setting is disabled */
|
||||||
|
body.hide-card-version .civitai-version {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Prevent text selection on cards and interactive elements */
|
/* Prevent text selection on cards and interactive elements */
|
||||||
.model-card,
|
.model-card,
|
||||||
.model-card *,
|
.model-card *,
|
||||||
@@ -558,8 +573,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 +591,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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,99 @@
|
|||||||
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: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container:focus-within {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 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: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle {
|
||||||
|
right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
@@ -247,44 +350,207 @@
|
|||||||
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: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
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: all 0.2s ease;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,9 +140,11 @@
|
|||||||
|
|
||||||
/* 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; /* Increase height for multiple lines */
|
min-height: 60px; /* Increase height for multiple lines */
|
||||||
max-height: 150px; /* Limit maximum height */
|
max-height: 420px; /* Limit maximum height */
|
||||||
overflow-y: auto; /* Add scrolling for long content */
|
overflow: auto; /* Enable scrolling and resize handle for long content */
|
||||||
|
resize: vertical; /* Allow manual vertical resizing */
|
||||||
white-space: pre-wrap; /* Preserve line breaks */
|
white-space: pre-wrap; /* Preserve line breaks */
|
||||||
line-height: 1.5; /* Improve readability */
|
line-height: 1.5; /* Improve readability */
|
||||||
padding: 8px 12px; /* Slightly increase padding */
|
padding: 8px 12px; /* Slightly increase padding */
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trigger-word-tag:not(.is-editing) {
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.trigger-word-content {
|
.trigger-word-content {
|
||||||
color: var(--lora-accent) !important;
|
color: var(--lora-accent) !important;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@@ -65,6 +69,38 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trigger-words.edit-mode .trigger-word-tag {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-word-tag.is-editing {
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 1 min(var(--trigger-word-edit-width, 48ch), 100%);
|
||||||
|
width: min(var(--trigger-word-edit-width, 48ch), 100%);
|
||||||
|
height: var(--trigger-word-edit-height, auto);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-word-edit-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1px 2px;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
overflow: auto;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.trigger-word-copy {
|
.trigger-word-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: 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 {
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
|
|
||||||
.early-access-info {
|
.early-access-info {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -97,7 +96,6 @@
|
|||||||
|
|
||||||
.local-path {
|
.local-path {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@@ -271,11 +271,16 @@
|
|||||||
|
|
||||||
/* Enhanced Sidebar Breadcrumb Styles */
|
/* Enhanced Sidebar Breadcrumb Styles */
|
||||||
.sidebar-breadcrumb-container {
|
.sidebar-breadcrumb-container {
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
|
/* Sticky positioning to stick below header when scrolling
|
||||||
|
top: 0 means stick at the top of the scroll container (page-content)
|
||||||
|
which is at header height (48px) from the viewport */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: calc(var(--z-header) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-breadcrumb-nav {
|
.sidebar-breadcrumb-nav {
|
||||||
@@ -284,7 +289,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
padding: 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-breadcrumb-item {
|
.sidebar-breadcrumb-item {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
top: -54px;
|
top: -54px;
|
||||||
z-index: calc(var(--z-header) - 1);
|
z-index: calc(var(--z-header) - 1);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
padding: var(--space-2) 0;
|
padding: var(--space-1) 0;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +371,14 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
|
||||||
|
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
|
||||||
|
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
|
||||||
|
the sticky breadcrumb. */
|
||||||
|
.controls:has(.dropdown-group.active) {
|
||||||
|
z-index: var(--z-header);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 6px 15px;
|
padding: 6px 15px;
|
||||||
@@ -397,6 +405,33 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce button sizes to fit better */
|
||||||
|
.control-group button {
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -74,6 +74,34 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
if (setContentRatingItem) {
|
if (setContentRatingItem) {
|
||||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
|
||||||
|
|
||||||
|
if (setFavoriteItem && config.setFavorite) {
|
||||||
|
setFavoriteItem.style.display = 'flex';
|
||||||
|
|
||||||
|
const total = state.selectedModels.size;
|
||||||
|
const favoritedCount = this.countFavoritedInSelection();
|
||||||
|
const allFavorited = total > 0 && favoritedCount === total;
|
||||||
|
|
||||||
|
const icon = setFavoriteItem.querySelector('i');
|
||||||
|
const label = setFavoriteItem.querySelector('span');
|
||||||
|
|
||||||
|
if (allFavorited) {
|
||||||
|
if (icon) { icon.className = 'far fa-star'; }
|
||||||
|
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
|
||||||
|
} else {
|
||||||
|
if (icon) { icon.className = 'fas fa-star'; }
|
||||||
|
if (label) {
|
||||||
|
label.textContent = favoritedCount > 0
|
||||||
|
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
|
||||||
|
: translate('loras.bulkOperations.setFavorite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (setFavoriteItem) {
|
||||||
|
setFavoriteItem.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadMissingLorasItem) {
|
if (downloadMissingLorasItem) {
|
||||||
// Only show for recipes page
|
// Only show for recipes page
|
||||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||||
@@ -138,6 +166,20 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countFavoritedInSelection() {
|
||||||
|
let count = 0;
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||||
|
? window.CSS.escape(filePath)
|
||||||
|
: filePath.replace(/["\\]/g, '\\$&');
|
||||||
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
|
if (card && card.dataset.favorite === 'true') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
this.updateMenuItemsForModelType();
|
this.updateMenuItemsForModelType();
|
||||||
this.updateSelectedCountHeader();
|
this.updateSelectedCountHeader();
|
||||||
@@ -185,6 +227,11 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
|
case 'set-favorite': {
|
||||||
|
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
||||||
|
bulkManager.setBulkFavorites(!allFavorited);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'download-missing-loras':
|
case 'download-missing-loras':
|
||||||
this.handleDownloadMissingLoras();
|
this.handleDownloadMissingLoras();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -129,6 +129,126 @@ export class HeaderManager {
|
|||||||
|
|
||||||
// Hide search functionality on Statistics page
|
// Hide search functionality on Statistics page
|
||||||
this.updateHeaderForPage();
|
this.updateHeaderForPage();
|
||||||
|
|
||||||
|
// Initialize hamburger menu for mobile
|
||||||
|
this.initializeHamburgerMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeHamburgerMenu() {
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
||||||
|
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
|
||||||
|
|
||||||
|
if (!hamburgerBtn || !hamburgerDropdown) return;
|
||||||
|
|
||||||
|
// Toggle dropdown on hamburger button click
|
||||||
|
hamburgerBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hamburgerDropdown.classList.toggle('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
if (hamburgerDropdown.classList.contains('active')) {
|
||||||
|
icon.classList.remove('fa-bars');
|
||||||
|
icon.classList.add('fa-times');
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle dropdown item clicks
|
||||||
|
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
|
||||||
|
dropdownItems.forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
const action = item.dataset.action;
|
||||||
|
this.handleHamburgerAction(action);
|
||||||
|
hamburgerDropdown.classList.remove('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
|
||||||
|
hamburgerDropdown.classList.remove('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme icon in hamburger menu based on current theme
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHamburgerAction(action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'theme':
|
||||||
|
if (typeof toggleTheme === 'function') {
|
||||||
|
const newTheme = toggleTheme();
|
||||||
|
// Update theme toggle in header if it exists
|
||||||
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||||
|
themeToggle.classList.add(`theme-${newTheme}`);
|
||||||
|
this.updateThemeTooltip(themeToggle, newTheme);
|
||||||
|
}
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
if (window.settingsManager) {
|
||||||
|
window.settingsManager.toggleSettings();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
const helpToggle = document.getElementById('helpToggleBtn');
|
||||||
|
if (helpToggle) {
|
||||||
|
helpToggle.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'notifications':
|
||||||
|
updateService.toggleUpdateModal();
|
||||||
|
break;
|
||||||
|
case 'support':
|
||||||
|
if (window.modalManager) {
|
||||||
|
window.modalManager.toggleModal('supportModal');
|
||||||
|
renderSupporters().catch(error => {
|
||||||
|
console.error('Error loading supporters:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHamburgerThemeIcon() {
|
||||||
|
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
|
||||||
|
if (!themeItem) return;
|
||||||
|
|
||||||
|
const currentTheme = getStorageItem('theme') || 'auto';
|
||||||
|
const icon = themeItem.querySelector('i');
|
||||||
|
const text = themeItem.querySelector('span');
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
|
||||||
|
if (currentTheme === 'light') {
|
||||||
|
icon.classList.add('fa-sun');
|
||||||
|
} else if (currentTheme === 'dark') {
|
||||||
|
icon.classList.add('fa-moon');
|
||||||
|
} else {
|
||||||
|
icon.classList.add('fa-adjust');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text based on current theme
|
||||||
|
if (text) {
|
||||||
|
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
|
||||||
|
currentTheme === 'dark' ? 'header.theme.switchToLight' :
|
||||||
|
'header.theme.toggle';
|
||||||
|
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHeaderForPage() {
|
updateHeaderForPage() {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export class ModelDuplicatesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.duplicateGroups = data.duplicates || [];
|
this.duplicateGroups = data.duplicates || [];
|
||||||
|
this._pruneVerificationState();
|
||||||
|
|
||||||
// Update the badge with the current count
|
// Update the badge with the current count
|
||||||
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||||
@@ -403,6 +404,44 @@ export class ModelDuplicatesManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getGroupFilePaths(group) {
|
||||||
|
return new Set((group?.models || []).map(model => model.file_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearMismatchStateForGroup(group) {
|
||||||
|
this._getGroupFilePaths(group).forEach(filePath => {
|
||||||
|
this.mismatchedFiles.delete(filePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_pruneVerificationState() {
|
||||||
|
const visiblePaths = new Set();
|
||||||
|
const visibleHashes = new Set();
|
||||||
|
|
||||||
|
this.duplicateGroups.forEach(group => {
|
||||||
|
visibleHashes.add(group.hash);
|
||||||
|
this._getGroupFilePaths(group).forEach(filePath => visiblePaths.add(filePath));
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.from(this.mismatchedFiles.keys()).forEach(filePath => {
|
||||||
|
if (!visiblePaths.has(filePath)) {
|
||||||
|
this.mismatchedFiles.delete(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.from(this.selectedForDeletion).forEach(filePath => {
|
||||||
|
if (!visiblePaths.has(filePath)) {
|
||||||
|
this.selectedForDeletion.delete(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.from(this.verifiedGroups).forEach(hash => {
|
||||||
|
if (!visibleHashes.has(hash)) {
|
||||||
|
this.verifiedGroups.delete(hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderModelCard(model, groupHash) {
|
renderModelCard(model, groupHash) {
|
||||||
// Create basic card structure
|
// Create basic card structure
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -619,10 +658,11 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
toggleSelectAllInGroup(hash) {
|
toggleSelectAllInGroup(hash) {
|
||||||
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
|
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
|
||||||
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
|
const selectableCheckboxes = Array.from(checkboxes).filter(checkbox => !checkbox.disabled);
|
||||||
|
const allSelected = selectableCheckboxes.length > 0 && selectableCheckboxes.every(checkbox => checkbox.checked);
|
||||||
|
|
||||||
// If all are selected, deselect all; otherwise select all
|
// If all are selected, deselect all; otherwise select all
|
||||||
checkboxes.forEach(checkbox => {
|
selectableCheckboxes.forEach(checkbox => {
|
||||||
checkbox.checked = !allSelected;
|
checkbox.checked = !allSelected;
|
||||||
const filePath = checkbox.dataset.filePath;
|
const filePath = checkbox.dataset.filePath;
|
||||||
const card = checkbox.closest('.model-card');
|
const card = checkbox.closest('.model-card');
|
||||||
@@ -831,10 +871,13 @@ export class ModelDuplicatesManager {
|
|||||||
const verifiedAsDuplicates = data.verified_as_duplicates;
|
const verifiedAsDuplicates = data.verified_as_duplicates;
|
||||||
const mismatchedFiles = data.mismatched_files || [];
|
const mismatchedFiles = data.mismatched_files || [];
|
||||||
|
|
||||||
|
this._clearMismatchStateForGroup(group);
|
||||||
|
|
||||||
// Update mismatchedFiles map
|
// Update mismatchedFiles map
|
||||||
if (data.new_hash_map) {
|
if (data.new_hash_map) {
|
||||||
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
|
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
|
||||||
this.mismatchedFiles.set(path, hash);
|
this.mismatchedFiles.set(path, hash);
|
||||||
|
this.selectedForDeletion.delete(path);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,6 +886,7 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
// Re-render the duplicate groups to show verification status
|
// Re-render the duplicate groups to show verification status
|
||||||
this.renderDuplicateGroups();
|
this.renderDuplicateGroups();
|
||||||
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Show appropriate toast message
|
// Show appropriate toast message
|
||||||
if (mismatchedFiles.length > 0) {
|
if (mismatchedFiles.length > 0) {
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
||||||
<div>
|
<div>
|
||||||
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
|
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
|
||||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDownloadAllowed(version) {
|
||||||
|
if (!version.usageControl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return version.usageControl === 'Download';
|
||||||
|
}
|
||||||
|
|
||||||
function buildMetaMarkup(version, options = {}) {
|
function buildMetaMarkup(version, options = {}) {
|
||||||
const segments = [];
|
const segments = [];
|
||||||
if (version.baseModel) {
|
if (version.baseModel) {
|
||||||
@@ -230,16 +237,25 @@ function buildBadge(label, tone, options = {}) {
|
|||||||
function buildActionButton(label, variant, action, options = {}) {
|
function buildActionButton(label, variant, action, options = {}) {
|
||||||
const attributes = [
|
const attributes = [
|
||||||
`class="version-action ${variant}"`,
|
`class="version-action ${variant}"`,
|
||||||
`data-version-action="${escapeHtml(action)}"`,
|
|
||||||
];
|
];
|
||||||
if (options.title) {
|
if (action) {
|
||||||
|
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
||||||
|
}
|
||||||
|
if (!options.disabled && options.title) {
|
||||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||||
}
|
}
|
||||||
|
if (options.disabled) {
|
||||||
|
attributes.push('disabled');
|
||||||
|
}
|
||||||
if (options.extraAttributes) {
|
if (options.extraAttributes) {
|
||||||
attributes.push(options.extraAttributes);
|
attributes.push(options.extraAttributes);
|
||||||
}
|
}
|
||||||
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||||
|
if (options.disabled && options.title) {
|
||||||
|
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
|
||||||
|
}
|
||||||
|
return buttonHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||||
@@ -371,6 +387,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|||||||
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!isDownloadAllowed(version)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const versionBase = normalizeBaseModelName(version.baseModel);
|
const versionBase = normalizeBaseModelName(version.baseModel);
|
||||||
if (versionBase !== normalizedBase) {
|
if (versionBase !== normalizedBase) {
|
||||||
return false;
|
return false;
|
||||||
@@ -502,6 +521,17 @@ function renderRow(version, options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDownloadAllowed(version)) {
|
||||||
|
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
|
||||||
|
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.onSiteOnlyTooltip',
|
||||||
|
{},
|
||||||
|
'This version is only available for on-site generation on Civitai'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (version.shouldIgnore) {
|
if (version.shouldIgnore) {
|
||||||
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||||
title: translate(
|
title: translate(
|
||||||
@@ -524,25 +554,36 @@ function renderRow(version, options) {
|
|||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (!version.isInLibrary) {
|
if (!version.isInLibrary) {
|
||||||
// Download button with optional EA bolt icon
|
const canDownload = isDownloadAllowed(version);
|
||||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||||
|
let downloadTitle;
|
||||||
|
if (!canDownload) {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
||||||
|
{},
|
||||||
|
'This version is only available for on-site generation on Civitai'
|
||||||
|
);
|
||||||
|
} else if (isEarlyAccess) {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||||
|
{},
|
||||||
|
'Download this early access version from Civitai'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadTooltip',
|
||||||
|
{},
|
||||||
|
'Download this version'
|
||||||
|
);
|
||||||
|
}
|
||||||
actions.push(buildActionButton(
|
actions.push(buildActionButton(
|
||||||
downloadLabel,
|
downloadLabel,
|
||||||
'version-action-primary',
|
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||||
'download',
|
canDownload ? 'download' : '',
|
||||||
{
|
{
|
||||||
title: isEarlyAccess
|
title: downloadTitle,
|
||||||
? translate(
|
|
||||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
|
||||||
{},
|
|
||||||
'Download this early access version from Civitai'
|
|
||||||
)
|
|
||||||
: translate(
|
|
||||||
'modals.model.versions.actions.downloadTooltip',
|
|
||||||
{},
|
|
||||||
'Download this version'
|
|
||||||
),
|
|
||||||
iconMarkup: downloadIcon,
|
iconMarkup: downloadIcon,
|
||||||
|
disabled: !canDownload,
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
} else if (version.filePath) {
|
} else if (version.filePath) {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { translate } from '../../utils/i18nHelpers.js';
|
|||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { escapeAttribute, escapeHtml } from './utils.js';
|
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||||
|
|
||||||
|
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
||||||
|
const MAX_TRIGGER_WORD_GROUPS = 100;
|
||||||
|
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch trained words for a model
|
* Fetch trained words for a model
|
||||||
* @param {string} filePath - Path to the model file
|
* @param {string} filePath - Path to the model file
|
||||||
@@ -223,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
const escapedWord = escapeHtml(word);
|
const escapedWord = escapeHtml(word);
|
||||||
const escapedAttr = escapeAttribute(word);
|
const escapedAttr = escapeAttribute(word);
|
||||||
return `
|
return `
|
||||||
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
|
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
@@ -261,6 +265,8 @@ export function setupTriggerWordsEditMode() {
|
|||||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||||
if (!editBtn) return;
|
if (!editBtn) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag);
|
||||||
|
|
||||||
editBtn.addEventListener('click', async function () {
|
editBtn.addEventListener('click', async function () {
|
||||||
const triggerWordsSection = this.closest('.trigger-words');
|
const triggerWordsSection = this.closest('.trigger-words');
|
||||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||||
@@ -293,7 +299,9 @@ export function setupTriggerWordsEditMode() {
|
|||||||
|
|
||||||
// Disable click-to-copy and show delete buttons
|
// Disable click-to-copy and show delete buttons
|
||||||
triggerWordTags.forEach(tag => {
|
triggerWordTags.forEach(tag => {
|
||||||
tag.onclick = null;
|
teardownDisplayTriggerWordTag(tag);
|
||||||
|
tag.addEventListener('click', startEditTriggerWord);
|
||||||
|
tag.title = translate('modals.model.triggerWords.editWord');
|
||||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
|
||||||
@@ -337,6 +345,12 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// Focus the input
|
// Focus the input
|
||||||
addForm.querySelector('input').focus();
|
addForm.querySelector('input').focus();
|
||||||
|
|
||||||
|
const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag;
|
||||||
|
delete triggerWordsSection._pendingTriggerWordEditTag;
|
||||||
|
if (pendingEditTag && document.contains(pendingEditTag)) {
|
||||||
|
startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } });
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||||
this.title = translate('modals.model.triggerWords.edit');
|
this.title = translate('modals.model.triggerWords.edit');
|
||||||
@@ -350,6 +364,7 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// If canceling, restore original trigger words
|
// If canceling, restore original trigger words
|
||||||
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
||||||
} else {
|
} else {
|
||||||
|
commitActiveTriggerWordEdit(triggerWordsSection);
|
||||||
// If saving, reset UI state on current trigger words
|
// If saving, reset UI state on current trigger words
|
||||||
resetTriggerWordsUIState(triggerWordsSection);
|
resetTriggerWordsUIState(triggerWordsSection);
|
||||||
// Reset the skip restore flag
|
// Reset the skip restore flag
|
||||||
@@ -429,15 +444,18 @@ function deleteTriggerWord(e) {
|
|||||||
* @param {HTMLElement} section - The trigger words section
|
* @param {HTMLElement} section - The trigger words section
|
||||||
*/
|
*/
|
||||||
function resetTriggerWordsUIState(section) {
|
function resetTriggerWordsUIState(section) {
|
||||||
|
commitActiveTriggerWordEdit(section);
|
||||||
|
|
||||||
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
||||||
|
|
||||||
triggerWordTags.forEach(tag => {
|
triggerWordTags.forEach(tag => {
|
||||||
const word = tag.dataset.word;
|
|
||||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
|
||||||
// Restore click-to-copy functionality
|
// Restore click-to-copy functionality
|
||||||
tag.onclick = () => copyTriggerWord(tag.dataset.word);
|
tag.removeEventListener('click', startEditTriggerWord);
|
||||||
|
setupDisplayTriggerWordTag(tag);
|
||||||
|
tag.title = translate('modals.model.triggerWords.copyWord');
|
||||||
|
|
||||||
// Show copy icon, hide delete button
|
// Show copy icon, hide delete button
|
||||||
if (copyIcon) copyIcon.style.display = '';
|
if (copyIcon) copyIcon.style.display = '';
|
||||||
@@ -471,25 +489,236 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
|||||||
|
|
||||||
// Recreate original tags
|
// Recreate original tags
|
||||||
originalWords.forEach(word => {
|
originalWords.forEach(word => {
|
||||||
const tag = document.createElement('div');
|
tagsContainer.appendChild(createTriggerWordTag(word, false));
|
||||||
tag.className = 'trigger-word-tag';
|
|
||||||
tag.dataset.word = word;
|
|
||||||
tag.onclick = () => copyTriggerWord(tag.dataset.word);
|
|
||||||
|
|
||||||
const escapedWord = escapeHtml(word);
|
|
||||||
tag.innerHTML = `
|
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
|
||||||
<span class="trigger-word-copy">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</span>
|
|
||||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
tagsContainer.appendChild(tag);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a trigger word tag element
|
||||||
|
* @param {string} word - Trigger word
|
||||||
|
* @param {boolean} isEditMode - Whether the tag should be editable
|
||||||
|
* @returns {HTMLElement} Tag element
|
||||||
|
*/
|
||||||
|
function createTriggerWordTag(word, isEditMode = false) {
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.className = 'trigger-word-tag';
|
||||||
|
tag.dataset.word = word;
|
||||||
|
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
|
||||||
|
|
||||||
|
const escapedWord = escapeHtml(word);
|
||||||
|
tag.innerHTML = `
|
||||||
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
|
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</span>
|
||||||
|
<button class="metadata-delete-btn" style="${isEditMode ? '' : 'display:none;'}" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
tag.addEventListener('click', startEditTriggerWord);
|
||||||
|
} else {
|
||||||
|
setupDisplayTriggerWordTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up display-mode click-to-copy and double-click-to-edit behavior
|
||||||
|
* @param {HTMLElement} tag - Trigger word tag
|
||||||
|
*/
|
||||||
|
function setupDisplayTriggerWordTag(tag) {
|
||||||
|
teardownDisplayTriggerWordTag(tag);
|
||||||
|
|
||||||
|
tag.addEventListener('click', handleDisplayTriggerWordClick);
|
||||||
|
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
|
||||||
|
tag.title = translate('modals.model.triggerWords.copyWord');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove display-mode handlers and pending copy action
|
||||||
|
* @param {HTMLElement} tag - Trigger word tag
|
||||||
|
*/
|
||||||
|
function teardownDisplayTriggerWordTag(tag) {
|
||||||
|
if (tag.dataset.copyTimerId) {
|
||||||
|
clearTimeout(Number(tag.dataset.copyTimerId));
|
||||||
|
delete tag.dataset.copyTimerId;
|
||||||
|
}
|
||||||
|
tag.onclick = null;
|
||||||
|
tag.removeEventListener('click', handleDisplayTriggerWordClick);
|
||||||
|
tag.removeEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy trigger word after a short delay so dblclick can cancel it
|
||||||
|
* @param {MouseEvent} e - Click event
|
||||||
|
*/
|
||||||
|
function handleDisplayTriggerWordClick(e) {
|
||||||
|
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||||
|
|
||||||
|
const tag = this.closest('.trigger-word-tag');
|
||||||
|
if (!tag || tag.closest('.trigger-words')?.classList.contains('edit-mode')) return;
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (tag.dataset.copyTimerId) {
|
||||||
|
clearTimeout(Number(tag.dataset.copyTimerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
delete tag.dataset.copyTimerId;
|
||||||
|
copyTriggerWord(tag.dataset.word);
|
||||||
|
}, TRIGGER_WORD_CLICK_DELAY_MS);
|
||||||
|
tag.dataset.copyTimerId = String(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter edit mode and start editing the double-clicked trigger word
|
||||||
|
* @param {MouseEvent} e - Double-click event
|
||||||
|
*/
|
||||||
|
function handleDisplayTriggerWordDoubleClick(e) {
|
||||||
|
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||||
|
|
||||||
|
const tag = this.closest('.trigger-word-tag');
|
||||||
|
const section = tag?.closest('.trigger-words');
|
||||||
|
const editBtn = section?.querySelector('.edit-trigger-words-btn');
|
||||||
|
if (!tag || !section || !editBtn) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (tag.dataset.copyTimerId) {
|
||||||
|
clearTimeout(Number(tag.dataset.copyTimerId));
|
||||||
|
delete tag.dataset.copyTimerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!section.classList.contains('edit-mode')) {
|
||||||
|
section._pendingTriggerWordEditTag = tag;
|
||||||
|
editBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startEditTriggerWord.call(tag, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a trigger word against existing tags
|
||||||
|
* @param {string} word - Trigger word
|
||||||
|
* @param {HTMLElement} tagsContainer - Tags container
|
||||||
|
* @param {HTMLElement|null} currentTag - Tag being edited, if any
|
||||||
|
* @returns {boolean} Whether the word is valid
|
||||||
|
*/
|
||||||
|
function validateTriggerWord(word, tagsContainer, currentTag = null) {
|
||||||
|
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
|
||||||
|
showToast('toast.triggerWords.tooLong', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||||
|
const existingWords = Array.from(currentTags)
|
||||||
|
.filter(tag => tag !== currentTag)
|
||||||
|
.map(tag => tag.dataset.word);
|
||||||
|
|
||||||
|
if (existingWords.includes(word)) {
|
||||||
|
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start inline editing for a trigger word tag
|
||||||
|
* @param {Event} e - Click event
|
||||||
|
*/
|
||||||
|
function startEditTriggerWord(e) {
|
||||||
|
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||||
|
|
||||||
|
const tag = this.closest('.trigger-word-tag');
|
||||||
|
const section = tag?.closest('.trigger-words');
|
||||||
|
if (!tag || !section?.classList.contains('edit-mode') || tag.classList.contains('is-editing')) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
commitActiveTriggerWordEdit(section);
|
||||||
|
|
||||||
|
const content = tag.querySelector('.trigger-word-content');
|
||||||
|
const originalWord = tag.dataset.word;
|
||||||
|
const originalRect = tag.getBoundingClientRect();
|
||||||
|
if (originalRect.width > 0) {
|
||||||
|
tag.style.setProperty('--trigger-word-edit-width', `${Math.ceil(originalRect.width)}px`);
|
||||||
|
}
|
||||||
|
if (originalRect.height > 0) {
|
||||||
|
tag.style.setProperty('--trigger-word-edit-height', `${Math.ceil(originalRect.height)}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = document.createElement('textarea');
|
||||||
|
editor.className = 'trigger-word-edit-input';
|
||||||
|
editor.rows = 1;
|
||||||
|
editor.value = originalWord;
|
||||||
|
editor.setAttribute('aria-label', translate('modals.model.triggerWords.editWord'));
|
||||||
|
editor.placeholder = translate('modals.model.triggerWords.editPlaceholder');
|
||||||
|
|
||||||
|
let finished = false;
|
||||||
|
const finish = (shouldCommit) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
|
||||||
|
const nextWord = editor.value.trim().replace(/\s*\n+\s*/g, ' ');
|
||||||
|
if (shouldCommit && nextWord && nextWord !== originalWord) {
|
||||||
|
const tagsContainer = tag.closest('.trigger-words-tags');
|
||||||
|
if (tagsContainer && validateTriggerWord(nextWord, tagsContainer, tag)) {
|
||||||
|
tag.dataset.word = nextWord;
|
||||||
|
content.textContent = nextWord;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.remove();
|
||||||
|
content.style.display = '';
|
||||||
|
tag.classList.remove('is-editing');
|
||||||
|
tag.style.removeProperty('--trigger-word-edit-width');
|
||||||
|
tag.style.removeProperty('--trigger-word-edit-height');
|
||||||
|
updateTrainedWordsDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.addEventListener('click', event => event.stopPropagation());
|
||||||
|
editor.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
finish(true);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
editor.addEventListener('blur', () => finish(true));
|
||||||
|
|
||||||
|
editor.style.visibility = 'hidden';
|
||||||
|
content.after(editor);
|
||||||
|
tag.classList.add('is-editing');
|
||||||
|
content.style.display = 'none';
|
||||||
|
editor.style.visibility = '';
|
||||||
|
editor.focus();
|
||||||
|
editor.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit an active inline trigger word edit if one exists
|
||||||
|
* @param {HTMLElement} section - Trigger words section
|
||||||
|
*/
|
||||||
|
function commitActiveTriggerWordEdit(section) {
|
||||||
|
const input = section.querySelector('.trigger-word-edit-input');
|
||||||
|
if (input) {
|
||||||
|
input.dispatchEvent(new FocusEvent('blur'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new trigger word
|
* Add a new trigger word
|
||||||
* @param {string} word - Trigger word to add
|
* @param {string} word - Trigger word to add
|
||||||
@@ -522,46 +751,16 @@ function addNewTriggerWord(word) {
|
|||||||
noTriggerWordsMsg.style.display = 'none';
|
noTriggerWordsMsg.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check length
|
|
||||||
if (word.split(/\s+/).length > 100) {
|
|
||||||
showToast('toast.triggerWords.tooLong', {}, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation: Check total number
|
// Validation: Check total number
|
||||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||||
if (currentTags.length >= 100) {
|
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
|
||||||
showToast('toast.triggerWords.tooMany', {}, 'error');
|
showToast('toast.triggerWords.tooMany', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check for duplicates
|
if (!validateTriggerWord(word, tagsContainer)) return;
|
||||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
|
||||||
if (existingWords.includes(word)) {
|
|
||||||
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new tag
|
|
||||||
const newTag = document.createElement('div');
|
|
||||||
newTag.className = 'trigger-word-tag';
|
|
||||||
newTag.dataset.word = word;
|
|
||||||
|
|
||||||
const escapedWord = escapeHtml(word);
|
|
||||||
newTag.innerHTML = `
|
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
|
||||||
<span class="trigger-word-copy" style="display:none;">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</span>
|
|
||||||
<button class="metadata-delete-btn" onclick="event.stopPropagation();">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add event listener to delete button
|
|
||||||
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
|
|
||||||
deleteBtn.addEventListener('click', deleteTriggerWord);
|
|
||||||
|
|
||||||
|
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
|
||||||
tagsContainer.appendChild(newTag);
|
tagsContainer.appendChild(newTag);
|
||||||
|
|
||||||
// Update status of items in the trained words dropdown
|
// Update status of items in the trained words dropdown
|
||||||
@@ -633,6 +832,8 @@ async function saveTriggerWords() {
|
|||||||
const filePath = editBtn.dataset.filePath;
|
const filePath = editBtn.dataset.filePath;
|
||||||
const triggerWordsSection = editBtn.closest('.trigger-words');
|
const triggerWordsSection = editBtn.closest('.trigger-words');
|
||||||
|
|
||||||
|
commitActiveTriggerWordEdit(triggerWordsSection);
|
||||||
|
|
||||||
// Auto-commit any pending input to prevent data loss
|
// Auto-commit any pending input to prevent data loss
|
||||||
const input = triggerWordsSection.querySelector('.metadata-input');
|
const input = triggerWordsSection.querySelector('.metadata-input');
|
||||||
if (input && input.value.trim()) {
|
if (input && input.value.trim()) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
|||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
@@ -41,7 +41,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
@@ -53,7 +55,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.CHECKPOINT]: {
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
@@ -65,7 +69,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
addTags: false,
|
addTags: false,
|
||||||
@@ -77,7 +83,9 @@ export class BulkManager {
|
|||||||
autoOrganize: false,
|
autoOrganize: false,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: false
|
skipMetadataRefresh: false,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1090,6 +1098,60 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setBulkFavorites(value) {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = state.selectedModels.size;
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
|
||||||
|
state.loadingManager.showSimpleLoading(
|
||||||
|
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
|
||||||
|
);
|
||||||
|
let cancelled = false;
|
||||||
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
cancelled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failureCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
if (cancelled) {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isRecipesPage) {
|
||||||
|
await updateRecipeMetadata(filePath, { favorite: value });
|
||||||
|
} else {
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
await apiClient.saveModelMetadata(filePath, { favorite: value });
|
||||||
|
}
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
failureCount++;
|
||||||
|
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.loadingManager?.hide?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === totalCount) {
|
||||||
|
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
|
||||||
|
showToast(toastKey, {}, 'success');
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
|
||||||
|
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show bulk base model modal
|
* Show bulk base model modal
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { escapeHtml } from '../components/shared/utils.js';
|
import { escapeHtml } from '../components/shared/utils.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
const MAX_CONSOLE_ENTRIES = 200;
|
const MAX_CONSOLE_ENTRIES = 200;
|
||||||
|
|
||||||
@@ -258,6 +259,15 @@ export class DoctorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderInlineDetail(detail) {
|
renderInlineDetail(detail) {
|
||||||
|
if (detail.conflict_groups || detail.total_conflict_files) {
|
||||||
|
return `
|
||||||
|
<div class="doctor-inline-detail">
|
||||||
|
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
|
||||||
|
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (detail.client_version || detail.server_version) {
|
if (detail.client_version || detail.server_version) {
|
||||||
return `
|
return `
|
||||||
<div class="doctor-inline-detail">
|
<div class="doctor-inline-detail">
|
||||||
@@ -317,6 +327,9 @@ export class DoctorManager {
|
|||||||
case 'repair-cache':
|
case 'repair-cache':
|
||||||
await this.repairCache();
|
await this.repairCache();
|
||||||
break;
|
break;
|
||||||
|
case 'resolve-filename-conflicts':
|
||||||
|
await this.resolveFilenameConflicts();
|
||||||
|
break;
|
||||||
case 'reload-page':
|
case 'reload-page':
|
||||||
this.reloadUi();
|
this.reloadUi();
|
||||||
break;
|
break;
|
||||||
@@ -345,6 +358,47 @@ export class DoctorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveFilenameConflicts() {
|
||||||
|
try {
|
||||||
|
this.setLoading(true);
|
||||||
|
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const renamedCount = payload.count || 0;
|
||||||
|
showToast(
|
||||||
|
'doctor.toast.conflictsResolved',
|
||||||
|
{ count: renamedCount },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update scroller items so model cards reflect new filenames immediately
|
||||||
|
if (state.virtualScroller && payload.renamed) {
|
||||||
|
for (const renamed of payload.renamed) {
|
||||||
|
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
|
||||||
|
state.virtualScroller.updateSingleItem(renamed.old_path, {
|
||||||
|
file_name: baseName,
|
||||||
|
file_path: renamed.new_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshDiagnostics({ silent: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Doctor filename conflict resolution failed:', error);
|
||||||
|
showToast(
|
||||||
|
'doctor.toast.conflictsResolveFailed',
|
||||||
|
{ message: error.message },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportBundle() {
|
async exportBundle() {
|
||||||
try {
|
try {
|
||||||
this.setLoading(true);
|
this.setLoading(true);
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -682,7 +682,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes(true); // true to reset pagination
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -879,6 +879,12 @@ export class SettingsManager {
|
|||||||
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
|
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set show version on card
|
||||||
|
const showVersionOnCardCheckbox = document.getElementById('showVersionOnCard');
|
||||||
|
if (showVersionOnCardCheckbox) {
|
||||||
|
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
|
||||||
|
}
|
||||||
|
|
||||||
// Set model name display setting
|
// Set model name display setting
|
||||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||||
if (modelNameDisplaySelect) {
|
if (modelNameDisplaySelect) {
|
||||||
@@ -914,6 +920,23 @@ export class SettingsManager {
|
|||||||
autoDownloadExampleImagesCheckbox.checked = state.global.settings.auto_download_example_images || false;
|
autoDownloadExampleImagesCheckbox.checked = state.global.settings.auto_download_example_images || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exampleImagesOpenModeSelect = document.getElementById('exampleImagesOpenMode');
|
||||||
|
if (exampleImagesOpenModeSelect) {
|
||||||
|
exampleImagesOpenModeSelect.value = state.global.settings.example_images_open_mode || 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleImagesLocalRootInput = document.getElementById('exampleImagesLocalRoot');
|
||||||
|
if (exampleImagesLocalRootInput) {
|
||||||
|
exampleImagesLocalRootInput.value = state.global.settings.example_images_local_root || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleImagesOpenUriTemplateInput = document.getElementById('exampleImagesOpenUriTemplate');
|
||||||
|
if (exampleImagesOpenUriTemplateInput) {
|
||||||
|
exampleImagesOpenUriTemplateInput.value = state.global.settings.example_images_open_uri_template || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateExampleImagesOpenSettingsVisibility();
|
||||||
|
|
||||||
// Load download path templates
|
// Load download path templates
|
||||||
this.loadDownloadPathTemplates();
|
this.loadDownloadPathTemplates();
|
||||||
|
|
||||||
@@ -2015,6 +2038,25 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateExampleImagesOpenSettingsVisibility() {
|
||||||
|
const openMode = state.global.settings.example_images_open_mode || 'system';
|
||||||
|
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
|
||||||
|
const uriTemplateSetting = document.getElementById('exampleImagesUriTemplateSetting');
|
||||||
|
|
||||||
|
if (localRootSetting) {
|
||||||
|
localRootSetting.style.display = openMode === 'system' ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uriTemplateSetting) {
|
||||||
|
uriTemplateSetting.style.display = openMode === 'uri_template' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleExampleImagesOpenModeChange() {
|
||||||
|
await this.saveSelectSetting('exampleImagesOpenMode', 'example_images_open_mode');
|
||||||
|
this.updateExampleImagesOpenSettingsVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
async loadMetadataArchiveSettings() {
|
async loadMetadataArchiveSettings() {
|
||||||
try {
|
try {
|
||||||
// Load current settings from state
|
// Load current settings from state
|
||||||
@@ -2821,7 +2863,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes();
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
@@ -2854,6 +2896,10 @@ export class SettingsManager {
|
|||||||
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
||||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||||
|
|
||||||
|
// Apply show version on card setting
|
||||||
|
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
|
||||||
|
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
|
||||||
|
|
||||||
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
|
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
|
||||||
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
|
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
|
||||||
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
refreshVirtualScroll();
|
await refreshVirtualScroll({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
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,
|
||||||
@@ -35,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
show_folder_sidebar: true,
|
show_folder_sidebar: true,
|
||||||
model_name_display: 'model_name',
|
model_name_display: 'model_name',
|
||||||
model_card_footer_action: 'example_images',
|
model_card_footer_action: 'example_images',
|
||||||
|
show_version_on_card: true,
|
||||||
include_trigger_words: false,
|
include_trigger_words: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
|
|||||||
@@ -104,69 +104,74 @@ export class VirtualScroller {
|
|||||||
// Get display density setting
|
// Get display density setting
|
||||||
const displayDensity = state.global.settings?.display_density || 'default';
|
const displayDensity = state.global.settings?.display_density || 'default';
|
||||||
|
|
||||||
// Set exact column counts and grid widths to match CSS container widths
|
// Base gap between cards
|
||||||
let maxColumns, maxGridWidth;
|
const baseGap = 12;
|
||||||
|
this.columnGap = baseGap;
|
||||||
|
|
||||||
// Match exact column counts and CSS container width values based on density
|
// Define minimum card width based on density setting to ensure usability
|
||||||
|
// Cards smaller than this become hard to interact with and view
|
||||||
|
const minCardWidths = {
|
||||||
|
'default': 240, // Default: comfortable minimum
|
||||||
|
'medium': 200, // Medium: slightly smaller
|
||||||
|
'compact': 170 // Compact: smallest usable size
|
||||||
|
};
|
||||||
|
const minCardWidth = minCardWidths[displayDensity] || 240;
|
||||||
|
|
||||||
|
// Calculate maximum possible columns that fit in available width
|
||||||
|
// Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
|
||||||
|
const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));
|
||||||
|
|
||||||
|
// Ensure at least 1 column
|
||||||
|
const maxColumns = Math.max(1, maxPossibleColumns);
|
||||||
|
|
||||||
|
// Define preferred maximum columns based on display density and screen size
|
||||||
|
// These are upper limits to prevent too many columns on ultra-wide screens
|
||||||
|
let preferredMaxColumns;
|
||||||
if (window.innerWidth >= 3000) { // 4K
|
if (window.innerWidth >= 3000) { // 4K
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 8;
|
preferredMaxColumns = 8;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 9;
|
preferredMaxColumns = 10;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 10;
|
preferredMaxColumns = 12;
|
||||||
}
|
}
|
||||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
|
||||||
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 6;
|
preferredMaxColumns = 6;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 7;
|
preferredMaxColumns = 8;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 8;
|
preferredMaxColumns = 10;
|
||||||
}
|
}
|
||||||
maxGridWidth = 1800; // Match exact CSS container width for 2K
|
} else { // 1080p and smaller
|
||||||
} else {
|
|
||||||
// 1080p
|
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 5;
|
preferredMaxColumns = 5;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 6;
|
preferredMaxColumns = 6;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 7;
|
preferredMaxColumns = 8;
|
||||||
}
|
}
|
||||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate baseCardWidth based on desired column count and available space
|
// Use the smaller of: max columns that fit, or preferred max
|
||||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
// This ensures cards are never smaller than minCardWidth
|
||||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
|
||||||
|
|
||||||
// Use the smaller of available content width or max grid width
|
// Calculate card width to perfectly fill available space
|
||||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
// Formula: (availableWidth - totalGap) / columns
|
||||||
|
const totalGap = (this.columnsCount - 1) * this.columnGap;
|
||||||
|
this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;
|
||||||
|
|
||||||
// Set exact column count based on screen size and mode
|
// Calculate height based on aspect ratio (896/1152)
|
||||||
this.columnsCount = maxColumns;
|
|
||||||
|
|
||||||
// When available width is smaller than maxGridWidth, recalculate columns
|
|
||||||
if (availableContentWidth < maxGridWidth) {
|
|
||||||
// Calculate how many columns can fit in the available space
|
|
||||||
this.columnsCount = Math.max(1, Math.floor(
|
|
||||||
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate actual item width
|
|
||||||
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
|
||||||
|
|
||||||
// Calculate height based on aspect ratio
|
|
||||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||||
|
|
||||||
// Calculate the left offset to center the grid within the content area
|
// Edge-to-edge layout: no offset, grid fills container
|
||||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
this.leftOffset = 0;
|
||||||
|
const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;
|
||||||
|
|
||||||
// Update grid element max-width to match available width
|
// Update grid element to fill available width
|
||||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||||
|
this.gridElement.style.width = `${actualGridWidth}px`;
|
||||||
|
|
||||||
// Add or remove density classes for style adjustments
|
// Add or remove density classes for style adjustments
|
||||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||||
@@ -478,6 +483,12 @@ export class VirtualScroller {
|
|||||||
element.style.width = `${this.itemWidth}px`;
|
element.style.width = `${this.itemWidth}px`;
|
||||||
element.style.height = `${this.itemHeight}px`;
|
element.style.height = `${this.itemHeight}px`;
|
||||||
|
|
||||||
|
// Remove max-width constraint from model-card to allow dynamic sizing
|
||||||
|
const modelCard = element.querySelector('.model-card');
|
||||||
|
if (modelCard) {
|
||||||
|
modelCard.style.maxWidth = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export const BASE_MODELS = {
|
|||||||
HUNYUAN_VIDEO: "Hunyuan Video",
|
HUNYUAN_VIDEO: "Hunyuan Video",
|
||||||
// Other models
|
// Other models
|
||||||
ANIMA: "Anima",
|
ANIMA: "Anima",
|
||||||
|
ERNIE: "Ernie",
|
||||||
|
ERNIE_TURBO: "Ernie Turbo",
|
||||||
|
NUCLEUS: "Nucleus",
|
||||||
PONY_V7: "Pony V7",
|
PONY_V7: "Pony V7",
|
||||||
// Default
|
// Default
|
||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
@@ -191,6 +194,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
|||||||
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
||||||
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
||||||
[BASE_MODELS.ANIMA]: 'ANI',
|
[BASE_MODELS.ANIMA]: 'ANI',
|
||||||
|
[BASE_MODELS.ERNIE]: 'ERNI',
|
||||||
|
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
||||||
|
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||||
@@ -394,6 +400,7 @@ export const BASE_MODEL_CATEGORIES = {
|
|||||||
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
||||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
||||||
|
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
|
||||||
BASE_MODELS.UNKNOWN
|
BASE_MODELS.UNKNOWN
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,6 +64,33 @@ export function openCivitaiUrl(url) {
|
|||||||
return window.open(url, '_blank', 'noopener,noreferrer');
|
return window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyExampleImagesValue(value, successKey, fallbackKey, paramsKey = 'path') {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { [paramsKey]: value };
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
showToast(successKey, params, 'success');
|
||||||
|
return true;
|
||||||
|
} catch (clipboardErr) {
|
||||||
|
console.warn('Clipboard API not available:', clipboardErr);
|
||||||
|
showToast(fallbackKey, params, 'info');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryOpenExternalUri(uri) {
|
||||||
|
try {
|
||||||
|
const openedWindow = window.open(uri, '_blank', 'noopener,noreferrer');
|
||||||
|
return openedWindow !== null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to open external URI:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function to copy text to clipboard with fallback for older browsers
|
* Utility function to copy text to clipboard with fallback for older browsers
|
||||||
* @param {string} text - The text to copy to clipboard
|
* @param {string} text - The text to copy to clipboard
|
||||||
@@ -1088,7 +1115,31 @@ export async function openExampleImagesFolder(modelHash) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const message = translate('uiHelpers.exampleImages.openingFolder', {}, 'Opening example images folder');
|
if (result.mode === 'clipboard' && result.path) {
|
||||||
|
await copyExampleImagesValue(
|
||||||
|
result.path,
|
||||||
|
'uiHelpers.exampleImages.copiedPath',
|
||||||
|
'uiHelpers.exampleImages.clipboardFallback',
|
||||||
|
'path'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.mode === 'uri' && result.uri) {
|
||||||
|
const opened = tryOpenExternalUri(result.uri);
|
||||||
|
if (!opened) {
|
||||||
|
await copyExampleImagesValue(
|
||||||
|
result.uri,
|
||||||
|
'uiHelpers.exampleImages.copiedUri',
|
||||||
|
'uiHelpers.exampleImages.uriClipboardFallback',
|
||||||
|
'uri'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast('uiHelpers.exampleImages.opened', {}, 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
showToast('uiHelpers.exampleImages.opened', {}, 'success');
|
showToast('uiHelpers.exampleImages.opened', {}, 'success');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
5
templates/components/breadcrumb.html
Normal file
5
templates/components/breadcrumb.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||||
|
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||||
|
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
@@ -77,6 +77,9 @@
|
|||||||
<div class="context-menu-item" data-action="set-base-model">
|
<div class="context-menu-item" data-action="set-base-model">
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-favorite">
|
||||||
|
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="set-content-rating">
|
<div class="context-menu-item" data-action="set-content-rating">
|
||||||
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,11 +129,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breadcrumb Navigation -->
|
|
||||||
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
|
||||||
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
|
||||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +1,53 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<div class="header-branding">
|
<!-- Left section: Logo + Navigation -->
|
||||||
<a href="/loras" class="logo-link">
|
<div class="header-left">
|
||||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
<div class="header-branding">
|
||||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
<a href="/loras" class="logo-link">
|
||||||
</a>
|
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||||
|
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% set current_path = request.path %}
|
||||||
|
{% if current_path.startswith('/loras/recipes') %}
|
||||||
|
{% set current_page = 'recipes' %}
|
||||||
|
{% elif current_path.startswith('/checkpoints') %}
|
||||||
|
{% set current_page = 'checkpoints' %}
|
||||||
|
{% elif current_path.startswith('/embeddings') %}
|
||||||
|
{% set current_page = 'embeddings' %}
|
||||||
|
{% elif current_path.startswith('/statistics') %}
|
||||||
|
{% set current_page = 'statistics' %}
|
||||||
|
{% else %}
|
||||||
|
{% set current_page = 'loras' %}
|
||||||
|
{% endif %}
|
||||||
|
<nav class="main-nav">
|
||||||
|
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||||
|
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||||
|
id="recipesNavItem">
|
||||||
|
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||||
|
id="checkpointsNavItem">
|
||||||
|
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||||
|
id="embeddingsNavItem">
|
||||||
|
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||||
|
id="statisticsNavItem">
|
||||||
|
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{% set current_path = request.path %}
|
|
||||||
{% if current_path.startswith('/loras/recipes') %}
|
<!-- Center section: Search -->
|
||||||
{% set current_page = 'recipes' %}
|
|
||||||
{% elif current_path.startswith('/checkpoints') %}
|
|
||||||
{% set current_page = 'checkpoints' %}
|
|
||||||
{% elif current_path.startswith('/embeddings') %}
|
|
||||||
{% set current_page = 'embeddings' %}
|
|
||||||
{% elif current_path.startswith('/statistics') %}
|
|
||||||
{% set current_page = 'statistics' %}
|
|
||||||
{% else %}
|
|
||||||
{% set current_page = 'loras' %}
|
|
||||||
{% endif %}
|
|
||||||
{% set search_disabled = current_page == 'statistics' %}
|
{% set search_disabled = current_page == 'statistics' %}
|
||||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||||
current_page %}
|
current_page %}
|
||||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||||
<nav class="main-nav">
|
|
||||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
|
||||||
id="recipesNavItem">
|
|
||||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
|
||||||
id="checkpointsNavItem">
|
|
||||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
|
||||||
id="embeddingsNavItem">
|
|
||||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
|
||||||
id="statisticsNavItem">
|
|
||||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Context-aware search container -->
|
|
||||||
<div class="{{ header_search_class }}" id="headerSearch">
|
<div class="{{ header_search_class }}" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||||
@@ -62,9 +65,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<!-- Right section: Controls -->
|
||||||
<!-- Integrated corner controls -->
|
<div class="header-right">
|
||||||
<div class="header-controls">
|
<div class="header-controls" id="headerControls">
|
||||||
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
||||||
<i class="fas fa-moon dark-icon"></i>
|
<i class="fas fa-moon dark-icon"></i>
|
||||||
<i class="fas fa-sun light-icon"></i>
|
<i class="fas fa-sun light-icon"></i>
|
||||||
@@ -85,6 +88,34 @@
|
|||||||
<i class="fas fa-heart"></i>
|
<i class="fas fa-heart"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Hamburger menu button (visible on mobile) -->
|
||||||
|
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" title="{{ t('common.actions.menu') }}">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Hamburger dropdown menu -->
|
||||||
|
<div class="hamburger-dropdown" id="hamburgerDropdown">
|
||||||
|
<div class="dropdown-item theme-toggle-item" data-action="theme">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
|
<span>{{ t('header.theme.toggle') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span>{{ t('common.actions.settings') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="help">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
<span>{{ t('common.actions.help') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="notifications">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>{{ t('header.actions.notifications') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item" data-action="support">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
<span>{{ t('header.actions.support') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -138,6 +138,9 @@
|
|||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
|
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
|
||||||
|
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
|
||||||
|
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control select-control">
|
<div class="setting-control select-control">
|
||||||
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
|
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
|
||||||
@@ -551,6 +554,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="showVersionOnCard">
|
||||||
|
{{ t('settings.layoutSettings.showVersionOnCard') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showVersionOnCardHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="showVersionOnCard"
|
||||||
|
onchange="settingsManager.saveToggleSetting('showVersionOnCard', 'show_version_on_card')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -1099,6 +1120,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="exampleImagesOpenMode">
|
||||||
|
{{ t('settings.exampleImages.openMode') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.openModeHelp') }}"></i>
|
||||||
|
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Remote-Open-for-Example-Images" target="_blank" rel="noopener" title="{{ t('settings.exampleImages.openModeWikiLink') }}">
|
||||||
|
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="exampleImagesOpenMode" onchange="settingsManager.handleExampleImagesOpenModeChange()">
|
||||||
|
<option value="system">{{ t('settings.exampleImages.openModeOptions.system') }}</option>
|
||||||
|
<option value="clipboard">{{ t('settings.exampleImages.openModeOptions.clipboard') }}</option>
|
||||||
|
<option value="uri_template">{{ t('settings.exampleImages.openModeOptions.uriTemplate') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" id="exampleImagesLocalRootSetting" style="display: none;">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="exampleImagesLocalRoot">
|
||||||
|
{{ t('settings.exampleImages.localRoot') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.localRootHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control path-control">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="exampleImagesLocalRoot"
|
||||||
|
placeholder="{{ t('settings.exampleImages.localRootPlaceholder') }}"
|
||||||
|
onchange="settingsManager.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" id="exampleImagesUriTemplateSetting" style="display: none;">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="exampleImagesOpenUriTemplate">
|
||||||
|
{{ t('settings.exampleImages.uriTemplate') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.uriTemplateHelp') }} {{ t('settings.exampleImages.uriTemplatePlaceholders') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control path-control">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="exampleImagesOpenUriTemplate"
|
||||||
|
placeholder="{{ t('settings.exampleImages.uriTemplatePlaceholder') }}"
|
||||||
|
onchange="settingsManager.saveInputSetting('exampleImagesOpenUriTemplate', 'example_images_open_uri_template')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-organize -->
|
<!-- Auto-organize -->
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
|
|||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
self.upsert_calls = []
|
self.upsert_calls = []
|
||||||
self._renamed = False
|
self._renamed = False
|
||||||
|
self.active_library = "default"
|
||||||
|
|
||||||
def get_libraries(self):
|
def get_libraries(self):
|
||||||
if self._renamed:
|
if self._renamed:
|
||||||
@@ -62,6 +63,11 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
|
|||||||
def rename_library(self, old_name: str, new_name: str):
|
def rename_library(self, old_name: str, new_name: str):
|
||||||
self.rename_calls.append((old_name, new_name))
|
self.rename_calls.append((old_name, new_name))
|
||||||
self._renamed = True
|
self._renamed = True
|
||||||
|
if self.active_library == old_name:
|
||||||
|
self.active_library = new_name
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
def delete_library(self, name: str): # pragma: no cover - defensive guard
|
def delete_library(self, name: str): # pragma: no cover - defensive guard
|
||||||
self.delete_calls.append(name)
|
self.delete_calls.append(name)
|
||||||
@@ -104,6 +110,7 @@ def test_save_paths_logs_warning_when_upsert_fails(
|
|||||||
class RaisingSettingsService:
|
class RaisingSettingsService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.upsert_attempts = []
|
self.upsert_attempts = []
|
||||||
|
self.active_library = "comfyui"
|
||||||
|
|
||||||
def get_libraries(self):
|
def get_libraries(self):
|
||||||
return {
|
return {
|
||||||
@@ -116,6 +123,9 @@ def test_save_paths_logs_warning_when_upsert_fails(
|
|||||||
def rename_library(self, *_):
|
def rename_library(self, *_):
|
||||||
raise AssertionError("rename_library should not be invoked")
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
def upsert_library(self, name: str, **payload):
|
def upsert_library(self, name: str, **payload):
|
||||||
self.upsert_attempts.append((name, payload))
|
self.upsert_attempts.append((name, payload))
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
@@ -135,6 +145,8 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
|
|||||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
class FakeSettingsService:
|
class FakeSettingsService:
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
def get_libraries(self):
|
def get_libraries(self):
|
||||||
return {
|
return {
|
||||||
"comfyui": {
|
"comfyui": {
|
||||||
@@ -148,6 +160,9 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
|
|||||||
def rename_library(self, *_):
|
def rename_library(self, *_):
|
||||||
raise AssertionError("rename_library should not be invoked")
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
def upsert_library(self, name: str, **payload):
|
def upsert_library(self, name: str, **payload):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
@@ -167,6 +182,8 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
|
|||||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
class FakeSettingsService:
|
class FakeSettingsService:
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
def get_libraries(self):
|
def get_libraries(self):
|
||||||
return {
|
return {
|
||||||
"comfyui": {
|
"comfyui": {
|
||||||
@@ -180,6 +197,9 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
|
|||||||
def rename_library(self, *_):
|
def rename_library(self, *_):
|
||||||
raise AssertionError("rename_library should not be invoked")
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
def upsert_library(self, name: str, **payload):
|
def upsert_library(self, name: str, **payload):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
@@ -199,6 +219,8 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
|
|||||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
class FakeSettingsService:
|
class FakeSettingsService:
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
def get_libraries(self):
|
def get_libraries(self):
|
||||||
return {
|
return {
|
||||||
"comfyui": {
|
"comfyui": {
|
||||||
@@ -212,6 +234,9 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
|
|||||||
def rename_library(self, *_):
|
def rename_library(self, *_):
|
||||||
raise AssertionError("rename_library should not be invoked")
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
def upsert_library(self, name: str, **payload):
|
def upsert_library(self, name: str, **payload):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
@@ -258,6 +283,7 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
|||||||
self.rename_calls = []
|
self.rename_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
self.upsert_calls = []
|
self.upsert_calls = []
|
||||||
|
self.active_library = "default"
|
||||||
|
|
||||||
def get_libraries(self):
|
def get_libraries(self):
|
||||||
return self.libraries
|
return self.libraries
|
||||||
@@ -265,6 +291,8 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
|||||||
def rename_library(self, old_name: str, new_name: str):
|
def rename_library(self, old_name: str, new_name: str):
|
||||||
self.rename_calls.append((old_name, new_name))
|
self.rename_calls.append((old_name, new_name))
|
||||||
self.libraries[new_name] = self.libraries.pop(old_name)
|
self.libraries[new_name] = self.libraries.pop(old_name)
|
||||||
|
if self.active_library == old_name:
|
||||||
|
self.active_library = new_name
|
||||||
|
|
||||||
def delete_library(self, name: str):
|
def delete_library(self, name: str):
|
||||||
self.delete_calls.append(name)
|
self.delete_calls.append(name)
|
||||||
@@ -273,6 +301,11 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
|||||||
def upsert_library(self, name: str, **payload):
|
def upsert_library(self, name: str, **payload):
|
||||||
self.upsert_calls.append((name, payload))
|
self.upsert_calls.append((name, payload))
|
||||||
self.libraries[name] = {**payload}
|
self.libraries[name] = {**payload}
|
||||||
|
if payload.get("activate"):
|
||||||
|
self.active_library = name
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
fake_settings = FakeSettingsService()
|
fake_settings = FakeSettingsService()
|
||||||
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
@@ -313,6 +346,199 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
|||||||
assert payload["activate"] is True
|
assert payload["activate"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_keeps_default_roots_in_extra_paths(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
extra_lora_dir = tmp_path / "extra_loras"
|
||||||
|
extra_checkpoint_dir = tmp_path / "extra_checkpoints"
|
||||||
|
extra_embedding_dir = tmp_path / "extra_embeddings"
|
||||||
|
|
||||||
|
for directory in (extra_lora_dir, extra_checkpoint_dir, extra_embedding_dir):
|
||||||
|
directory.mkdir()
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return {
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||||
|
"extra_folder_paths": {
|
||||||
|
"loras": [str(extra_lora_dir)],
|
||||||
|
"checkpoints": [str(extra_checkpoint_dir)],
|
||||||
|
"embeddings": [str(extra_embedding_dir)],
|
||||||
|
},
|
||||||
|
"default_lora_root": str(extra_lora_dir),
|
||||||
|
"default_checkpoint_root": str(extra_checkpoint_dir),
|
||||||
|
"default_embedding_root": str(extra_embedding_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.name = name
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert fake_settings.name == "comfyui"
|
||||||
|
assert fake_settings.payload["extra_folder_paths"]["loras"] == [str(extra_lora_dir).replace("\\", "/")]
|
||||||
|
assert fake_settings.payload["extra_folder_paths"]["checkpoints"] == [
|
||||||
|
str(extra_checkpoint_dir).replace("\\", "/")
|
||||||
|
]
|
||||||
|
assert fake_settings.payload["extra_folder_paths"]["embeddings"] == [
|
||||||
|
str(extra_embedding_dir).replace("\\", "/")
|
||||||
|
]
|
||||||
|
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/")
|
||||||
|
assert fake_settings.payload["default_checkpoint_root"] == str(extra_checkpoint_dir).replace("\\", "/")
|
||||||
|
assert fake_settings.payload["default_embedding_root"] == str(extra_embedding_dir).replace("\\", "/")
|
||||||
|
assert fake_settings.payload["activate"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_keeps_default_roots_in_extra_paths_with_windows_slash_mismatch(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return {
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||||
|
"extra_folder_paths": {
|
||||||
|
"loras": ["U:\\Lora7\\Loras"],
|
||||||
|
"checkpoints": ["U:\\Lora7\\Models"],
|
||||||
|
"embeddings": [],
|
||||||
|
},
|
||||||
|
"default_lora_root": "U:/Lora7/Loras",
|
||||||
|
"default_checkpoint_root": "U:/Lora7/Models",
|
||||||
|
"default_embedding_root": folder_paths["embeddings"][0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.name = name
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert fake_settings.name == "comfyui"
|
||||||
|
assert fake_settings.payload["default_lora_root"] == "U:/Lora7/Loras"
|
||||||
|
assert fake_settings.payload["default_checkpoint_root"] == "U:/Lora7/Models"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
_setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
extra_lora_dir = tmp_path / "extra_loras"
|
||||||
|
extra_lora_dir.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config_module.folder_paths,
|
||||||
|
"get_folder_paths",
|
||||||
|
lambda kind: [] if kind == "loras" else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
active_library = "comfyui"
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return {
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": [],
|
||||||
|
"checkpoints": [],
|
||||||
|
"unet": [],
|
||||||
|
"embeddings": [],
|
||||||
|
},
|
||||||
|
"extra_folder_paths": {
|
||||||
|
"loras": [str(extra_lora_dir)],
|
||||||
|
},
|
||||||
|
"default_lora_root": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.name = name
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert fake_settings.name == "comfyui"
|
||||||
|
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_does_not_activate_comfyui_library_when_another_library_is_active(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||||
|
):
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_library = "studio"
|
||||||
|
self.upsert_calls = []
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return {
|
||||||
|
"studio": {
|
||||||
|
"folder_paths": {"loras": ["/studio/loras"]},
|
||||||
|
},
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||||
|
"default_lora_root": folder_paths["loras"][0],
|
||||||
|
"default_checkpoint_root": folder_paths["checkpoints"][0],
|
||||||
|
"default_embedding_root": folder_paths["embeddings"][0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.upsert_calls.append((name, payload))
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert len(fake_settings.upsert_calls) == 1
|
||||||
|
name, payload = fake_settings.upsert_calls[0]
|
||||||
|
assert name == "comfyui"
|
||||||
|
assert payload["activate"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
|
def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
|
||||||
"""Test that apply_library_settings correctly merges folder_paths with extra_folder_paths."""
|
"""Test that apply_library_settings correctly merges folder_paths with extra_folder_paths."""
|
||||||
loras_dir = tmp_path / "loras"
|
loras_dir = tmp_path / "loras"
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
|
|||||||
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
||||||
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
||||||
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
||||||
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
|
// 454210a4 replaced renderFunction() with widget.value setter + widget.callback()
|
||||||
|
expect(widget.callback).toHaveBeenCalledWith(widget.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
||||||
|
|||||||
232
tests/frontend/components/modelDuplicatesManager.test.js
Normal file
232
tests/frontend/components/modelDuplicatesManager.test.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const resetAndReloadMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
resetAndReload: resetAndReloadMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
|
||||||
|
const { state } = await import('../../../static/js/state/index.js');
|
||||||
|
|
||||||
|
const carPath = '/models/loras/aspark-owl.safetensors';
|
||||||
|
const copyPath = '/models/loras/aspark-owl-copy.safetensors';
|
||||||
|
const stalePath = '/models/loras/old-mismatch.safetensors';
|
||||||
|
|
||||||
|
function createModel(filePath, sha256, modelName = 'Aspark Owl - 2019') {
|
||||||
|
return {
|
||||||
|
file_path: filePath,
|
||||||
|
file_name: filePath.split('/').pop(),
|
||||||
|
model_name: modelName,
|
||||||
|
sha256,
|
||||||
|
preview_url: '',
|
||||||
|
preview_nsfw_level: 0,
|
||||||
|
modified: Date.now(),
|
||||||
|
civitai: { name: 'Version 1' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroup(hash = 'actual-hash') {
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
models: [
|
||||||
|
createModel(carPath, hash),
|
||||||
|
createModel(copyPath, hash, 'Aspark Owl - 2019 Copy'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManager() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="modelGrid"></div>
|
||||||
|
<span id="duplicatesBadge"></span>
|
||||||
|
<span id="duplicatesSelectedCount"></span>
|
||||||
|
<button class="btn-delete-selected"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({ success: true, duplicates: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new ModelDuplicatesManager({}, 'loras');
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
global.fetch.mockClear();
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
state.loadingManager = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
state.loadingManager = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ModelDuplicatesManager verification state', () => {
|
||||||
|
it('clears stale Different Hash state when a later verification confirms the group is duplicate', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
const group = createGroup();
|
||||||
|
|
||||||
|
manager.duplicateGroups = [group];
|
||||||
|
manager.mismatchedFiles.set(carPath, 'old-actual-hash');
|
||||||
|
manager.renderDuplicateGroups();
|
||||||
|
|
||||||
|
expect(document.querySelector(`[data-file-path="${carPath}"]`).classList.contains('hash-mismatch')).toBe(true);
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
verified_as_duplicates: true,
|
||||||
|
mismatched_files: [],
|
||||||
|
new_hash_map: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.handleVerifyHashes(group);
|
||||||
|
|
||||||
|
const carCard = document.querySelector(`[data-file-path="${carPath}"]`);
|
||||||
|
const carCheckbox = carCard.querySelector('.selector-checkbox');
|
||||||
|
|
||||||
|
expect(manager.mismatchedFiles.has(carPath)).toBe(false);
|
||||||
|
expect(carCard.classList.contains('hash-mismatch')).toBe(false);
|
||||||
|
expect(carCard.querySelector('.mismatch-badge')).toBeNull();
|
||||||
|
expect(carCheckbox.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps showing Different Hash for files returned as mismatched by the current verification', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
const group = createGroup('metadata-hash');
|
||||||
|
|
||||||
|
manager.duplicateGroups = [group];
|
||||||
|
manager.selectedForDeletion.add(carPath);
|
||||||
|
manager.selectedForDeletion.add(copyPath);
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
verified_as_duplicates: false,
|
||||||
|
mismatched_files: [carPath],
|
||||||
|
new_hash_map: {
|
||||||
|
[carPath]: 'actual-car-hash',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.handleVerifyHashes(group);
|
||||||
|
|
||||||
|
const carCard = document.querySelector(`[data-file-path="${carPath}"]`);
|
||||||
|
const carCheckbox = carCard.querySelector('.selector-checkbox');
|
||||||
|
|
||||||
|
expect(manager.mismatchedFiles.get(carPath)).toBe('actual-car-hash');
|
||||||
|
expect(manager.selectedForDeletion.has(carPath)).toBe(false);
|
||||||
|
expect(manager.selectedForDeletion.has(copyPath)).toBe(true);
|
||||||
|
expect(carCard.classList.contains('hash-mismatch')).toBe(true);
|
||||||
|
expect(carCard.querySelector('.mismatch-badge')?.textContent).toContain('Different Hash');
|
||||||
|
expect(carCheckbox.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes selected count and delete button when selected files become mismatched', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
const group = createGroup('metadata-hash');
|
||||||
|
|
||||||
|
manager.duplicateGroups = [group];
|
||||||
|
manager.selectedForDeletion.add(carPath);
|
||||||
|
manager.updateSelectedCount();
|
||||||
|
|
||||||
|
expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('1');
|
||||||
|
expect(document.querySelector('.btn-delete-selected').disabled).toBe(false);
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
verified_as_duplicates: false,
|
||||||
|
mismatched_files: [carPath],
|
||||||
|
new_hash_map: {
|
||||||
|
[carPath]: 'actual-car-hash',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.handleVerifyHashes(group);
|
||||||
|
|
||||||
|
expect(manager.selectedForDeletion.size).toBe(0);
|
||||||
|
expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('0');
|
||||||
|
expect(document.querySelector('.btn-delete-selected').disabled).toBe(true);
|
||||||
|
expect(document.querySelector('.btn-delete-selected').classList.contains('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves valid selected deletion candidates when verification succeeds', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
const group = createGroup();
|
||||||
|
|
||||||
|
manager.duplicateGroups = [group];
|
||||||
|
manager.selectedForDeletion.add(carPath);
|
||||||
|
manager.selectedForDeletion.add(copyPath);
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
verified_as_duplicates: true,
|
||||||
|
mismatched_files: [],
|
||||||
|
new_hash_map: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.handleVerifyHashes(group);
|
||||||
|
|
||||||
|
expect(manager.selectedForDeletion.has(carPath)).toBe(true);
|
||||||
|
expect(manager.selectedForDeletion.has(copyPath)).toBe(true);
|
||||||
|
expect(document.querySelector(`[data-file-path="${carPath}"] .selector-checkbox`).checked).toBe(true);
|
||||||
|
expect(document.querySelector(`[data-file-path="${copyPath}"] .selector-checkbox`).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes mismatch and verified state that no longer belongs to refreshed duplicate groups', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
const visibleGroup = createGroup('visible-hash');
|
||||||
|
|
||||||
|
manager.mismatchedFiles.set(stalePath, 'stale-hash');
|
||||||
|
manager.mismatchedFiles.set(carPath, 'visible-mismatch');
|
||||||
|
manager.verifiedGroups.add('stale-group-hash');
|
||||||
|
manager.verifiedGroups.add('visible-hash');
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
duplicates: [visibleGroup],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.findDuplicates();
|
||||||
|
|
||||||
|
expect(manager.mismatchedFiles.has(stalePath)).toBe(false);
|
||||||
|
expect(manager.mismatchedFiles.has(carPath)).toBe(true);
|
||||||
|
expect(manager.verifiedGroups.has('stale-group-hash')).toBe(false);
|
||||||
|
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@ describe("TriggerWords HTML Escaping", () => {
|
|||||||
expect(html).toContain('data-word="word'with'quotes"');
|
expect(html).toContain('data-word="word'with'quotes"');
|
||||||
expect(html).toContain('data-word="<tag>"');
|
expect(html).toContain('data-word="<tag>"');
|
||||||
|
|
||||||
// Check for the onclick handler
|
// Copy/edit handlers are attached by setupTriggerWordsEditMode, not inline HTML.
|
||||||
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
|
expect(html).not.toContain('onclick="copyTriggerWord');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
158
tests/frontend/components/triggerWords.inlineEdit.test.js
Normal file
158
tests/frontend/components/triggerWords.inlineEdit.test.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
TRIGGER_WORDS_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => fallback || key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: vi.fn(() => ({
|
||||||
|
saveModelMetadata: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("TriggerWords inline editing", () => {
|
||||||
|
let renderTriggerWords;
|
||||||
|
let setupTriggerWordsEditMode;
|
||||||
|
let showToast;
|
||||||
|
let copyToClipboard;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
trained_words: [],
|
||||||
|
class_tokens: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const module = await import(TRIGGER_WORDS_MODULE);
|
||||||
|
const uiHelpers = await import(UI_HELPERS_MODULE);
|
||||||
|
renderTriggerWords = module.renderTriggerWords;
|
||||||
|
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
|
||||||
|
showToast = uiHelpers.showToast;
|
||||||
|
copyToClipboard = uiHelpers.copyToClipboard;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function enterEditMode(words = ["alpha", "beta"]) {
|
||||||
|
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
|
||||||
|
setupTriggerWordsEditMode();
|
||||||
|
|
||||||
|
document.querySelector('.edit-trigger-words-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editFirstTag(nextValue, key = 'Enter') {
|
||||||
|
const firstTag = document.querySelector('.trigger-word-tag');
|
||||||
|
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
const input = firstTag.querySelector('.trigger-word-edit-input');
|
||||||
|
input.value = nextValue;
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
return firstTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("updates an existing trigger word in place", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const firstTag = editFirstTag("gamma");
|
||||||
|
|
||||||
|
expect(firstTag.dataset.word).toBe("gamma");
|
||||||
|
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("gamma");
|
||||||
|
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters edit mode and edits the double-clicked tag from display mode without copying", async () => {
|
||||||
|
document.body.innerHTML = renderTriggerWords(["alpha", "beta"], "test.safetensors");
|
||||||
|
setupTriggerWordsEditMode();
|
||||||
|
|
||||||
|
const firstTag = document.querySelector('.trigger-word-tag');
|
||||||
|
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
firstTag.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.trigger-words').classList.contains('edit-mode')).toBe(true);
|
||||||
|
expect(firstTag.querySelector('.trigger-word-edit-input')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 260));
|
||||||
|
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original word and shows a toast when editing to a duplicate", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const firstTag = editFirstTag("beta");
|
||||||
|
|
||||||
|
expect(firstTag.dataset.word).toBe("alpha");
|
||||||
|
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
|
||||||
|
expect(showToast).toHaveBeenCalledWith('toast.triggerWords.alreadyExists', {}, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the original value when Escape is pressed", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const firstTag = editFirstTag("gamma", "Escape");
|
||||||
|
|
||||||
|
expect(firstTag.dataset.word).toBe("alpha");
|
||||||
|
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the current tag dimensions while editing long trigger words", async () => {
|
||||||
|
await enterEditMode(["alpha beta gamma delta epsilon zeta eta theta"]);
|
||||||
|
|
||||||
|
const firstTag = document.querySelector('.trigger-word-tag');
|
||||||
|
vi.spyOn(firstTag, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
width: 320,
|
||||||
|
height: 44,
|
||||||
|
top: 0,
|
||||||
|
right: 320,
|
||||||
|
bottom: 44,
|
||||||
|
left: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
const editor = firstTag.querySelector('.trigger-word-edit-input');
|
||||||
|
expect(editor.tagName).toBe('TEXTAREA');
|
||||||
|
expect(firstTag.style.getPropertyValue('--trigger-word-edit-width')).toBe('320px');
|
||||||
|
expect(firstTag.style.getPropertyValue('--trigger-word-edit-height')).toBe('44px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores all original trigger words when edit mode is canceled", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
editFirstTag("gamma");
|
||||||
|
|
||||||
|
document.querySelector('.edit-trigger-words-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
const words = Array.from(document.querySelectorAll('.trigger-word-tag'))
|
||||||
|
.map(tag => tag.dataset.word);
|
||||||
|
expect(words).toEqual(["alpha", "beta"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -340,4 +340,52 @@ describe('SettingsManager library controls', () => {
|
|||||||
expect(aria2PathSetting.style.display).toBe('none');
|
expect(aria2PathSetting.style.display).toBe('none');
|
||||||
expect(saveSpy).toHaveBeenCalledWith('downloadBackend', 'download_backend');
|
expect(saveSpy).toHaveBeenCalledWith('downloadBackend', 'download_backend');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads example image remote-open settings and updates field visibility', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<select id="exampleImagesOpenMode">
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="clipboard">Clipboard</option>
|
||||||
|
<option value="uri_template">URI</option>
|
||||||
|
</select>
|
||||||
|
<div id="exampleImagesLocalRootSetting" style="display: none;"></div>
|
||||||
|
<div id="exampleImagesUriTemplateSetting" style="display: none;"></div>
|
||||||
|
<input id="exampleImagesLocalRoot" />
|
||||||
|
<input id="exampleImagesOpenUriTemplate" />
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
example_images_open_mode: 'uri_template',
|
||||||
|
example_images_local_root: '/Volumes/ComfyUI/examples',
|
||||||
|
example_images_open_uri_template: 'shortcuts://run-shortcut?text={{encoded_local_path}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.loadSettingsToUI();
|
||||||
|
|
||||||
|
expect(document.getElementById('exampleImagesOpenMode').value).toBe('uri_template');
|
||||||
|
expect(document.getElementById('exampleImagesLocalRoot').value).toBe('/Volumes/ComfyUI/examples');
|
||||||
|
expect(document.getElementById('exampleImagesOpenUriTemplate').value)
|
||||||
|
.toBe('shortcuts://run-shortcut?text={{encoded_local_path}}');
|
||||||
|
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('block');
|
||||||
|
|
||||||
|
state.global.settings.example_images_open_mode = 'clipboard';
|
||||||
|
manager.updateExampleImagesOpenSettingsVisibility();
|
||||||
|
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
|
||||||
|
|
||||||
|
state.global.settings.example_images_open_mode = 'system';
|
||||||
|
manager.updateExampleImagesOpenSettingsVisibility();
|
||||||
|
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('none');
|
||||||
|
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ describe('UI helper DOM utilities', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
delete global.fetch;
|
delete global.fetch;
|
||||||
|
delete navigator.clipboard;
|
||||||
|
delete window.open;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates toast elements and cleans them up after timeout', async () => {
|
it('creates toast elements and cleans them up after timeout', async () => {
|
||||||
@@ -230,4 +232,49 @@ describe('UI helper DOM utilities', () => {
|
|||||||
'noopener,noreferrer'
|
'noopener,noreferrer'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copies mapped local example-image paths when the backend requests clipboard mode', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
mode: 'clipboard',
|
||||||
|
path: '/Volumes/ComfyUI/examples/demo',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
navigator.clipboard = {
|
||||||
|
writeText: vi.fn().mockResolvedValue(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
|
||||||
|
|
||||||
|
const result = await openExampleImagesFolder('abc123');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/Volumes/ComfyUI/examples/demo');
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/open-example-images-folder', expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens custom URIs for example-image folders when requested by the backend', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
mode: 'uri',
|
||||||
|
uri: 'shortcuts://run-shortcut?name=OpenFinder',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.open = vi.fn(() => ({}));
|
||||||
|
|
||||||
|
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
|
||||||
|
|
||||||
|
const result = await openExampleImagesFolder('abc123');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(window.open).toHaveBeenCalledWith(
|
||||||
|
'shortcuts://run-shortcut?name=OpenFinder',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,6 +177,383 @@ def test_attention_bias_clip_text_encode_prompts_are_collected(metadata_registry
|
|||||||
assert prompt_results["negative_prompt"] == "low quality"
|
assert prompt_results["negative_prompt"] == "low quality"
|
||||||
|
|
||||||
|
|
||||||
|
def test_myoriginalwaifu_text_provider_uses_processed_prompt_outputs(
|
||||||
|
metadata_registry, monkeypatch
|
||||||
|
):
|
||||||
|
prompt_graph = {
|
||||||
|
"text_provider": {
|
||||||
|
"class_type": "TextProvider",
|
||||||
|
"inputs": {
|
||||||
|
"positive": "raw positive",
|
||||||
|
"negative": "raw negative",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"encode_pos": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": ["text_provider", 0], "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"encode_neg": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": ["text_provider", 1], "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": ["encode_pos", 0],
|
||||||
|
"negative": ["encode_neg", 0],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
pos_conditioning = object()
|
||||||
|
neg_conditioning = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-myoriginalwaifu-text")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"text_provider",
|
||||||
|
"TextProvider",
|
||||||
|
{"positive": "raw positive", "negative": "raw negative"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"text_provider",
|
||||||
|
"TextProvider",
|
||||||
|
[("processed positive", "processed negative")],
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncode", {"text": "processed positive"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncode", [(pos_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncode", {"text": "processed negative"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncode", [(neg_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"KSampler",
|
||||||
|
{
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": pos_conditioning,
|
||||||
|
"negative": neg_conditioning,
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-myoriginalwaifu-text")
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata)
|
||||||
|
|
||||||
|
assert metadata[PROMPTS]["text_provider"]["positive_text"] == "processed positive"
|
||||||
|
assert metadata[PROMPTS]["text_provider"]["negative_text"] == "processed negative"
|
||||||
|
assert params["prompt"] == "processed positive"
|
||||||
|
assert params["negative_prompt"] == "processed negative"
|
||||||
|
|
||||||
|
|
||||||
|
def test_myoriginalwaifu_clip_provider_prompts_are_collected_without_clip_text_encode(
|
||||||
|
metadata_registry, monkeypatch
|
||||||
|
):
|
||||||
|
prompt_graph = {
|
||||||
|
"clip_provider": {
|
||||||
|
"class_type": "ClipProvider",
|
||||||
|
"inputs": {
|
||||||
|
"positive": "direct positive",
|
||||||
|
"negative": "direct negative",
|
||||||
|
"clip": ["clip", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": ["clip_provider", 0],
|
||||||
|
"negative": ["clip_provider", 1],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
pos_conditioning = object()
|
||||||
|
neg_conditioning = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-myoriginalwaifu-clip")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"clip_provider",
|
||||||
|
"ClipProvider",
|
||||||
|
{"positive": "direct positive", "negative": "direct negative"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"clip_provider", "ClipProvider", [(pos_conditioning, neg_conditioning)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"KSampler",
|
||||||
|
{
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": pos_conditioning,
|
||||||
|
"negative": neg_conditioning,
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-myoriginalwaifu-clip")
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata)
|
||||||
|
|
||||||
|
assert params["prompt"] == "direct positive"
|
||||||
|
assert params["negative_prompt"] == "direct negative"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conditioning_provenance_recovers_combined_controlnet_prompts(
|
||||||
|
metadata_registry, monkeypatch
|
||||||
|
):
|
||||||
|
import types
|
||||||
|
|
||||||
|
prompt_graph = {
|
||||||
|
"encode_wd": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": "wd14 tags", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"encode_manual": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": "manual tags", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"encode_neg": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": "low quality", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"combine": {
|
||||||
|
"class_type": "ConditioningCombine",
|
||||||
|
"inputs": {
|
||||||
|
"conditioning_1": ["encode_wd", 0],
|
||||||
|
"conditioning_2": ["encode_manual", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"controlnet": {
|
||||||
|
"class_type": "ControlNetApplyAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"positive": ["combine", 0],
|
||||||
|
"negative": ["encode_neg", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": ["controlnet", 0],
|
||||||
|
"negative": ["controlnet", 1],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
wd_conditioning = object()
|
||||||
|
manual_conditioning = object()
|
||||||
|
negative_conditioning = object()
|
||||||
|
combined_conditioning = object()
|
||||||
|
controlnet_positive = object()
|
||||||
|
controlnet_negative = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-provenance")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_wd", "CLIPTextEncode", {"text": "wd14 tags"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_wd", "CLIPTextEncode", [(wd_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_manual", "CLIPTextEncode", {"text": "manual tags"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_manual", "CLIPTextEncode", [(manual_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncode", {"text": "low quality"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncode", [(negative_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"combine",
|
||||||
|
"ConditioningCombine",
|
||||||
|
{
|
||||||
|
"conditioning_1": wd_conditioning,
|
||||||
|
"conditioning_2": manual_conditioning,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"combine", "ConditioningCombine", [(combined_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"controlnet",
|
||||||
|
"ControlNetApplyAdvanced",
|
||||||
|
{
|
||||||
|
"positive": combined_conditioning,
|
||||||
|
"negative": negative_conditioning,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"controlnet",
|
||||||
|
"ControlNetApplyAdvanced",
|
||||||
|
[(controlnet_positive, controlnet_negative)],
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"KSampler",
|
||||||
|
{
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": controlnet_positive,
|
||||||
|
"negative": controlnet_negative,
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-provenance")
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata)
|
||||||
|
|
||||||
|
assert params["prompt"] == "wd14 tags, manual tags"
|
||||||
|
assert params["negative_prompt"] == "low quality"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conditioning_provenance_recovers_kj_set_get_prompts(
|
||||||
|
metadata_registry, monkeypatch
|
||||||
|
):
|
||||||
|
import types
|
||||||
|
|
||||||
|
prompt_graph = {
|
||||||
|
"encode_pos": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": "from set node", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"set_positive": {
|
||||||
|
"class_type": "SetNode",
|
||||||
|
"inputs": {"CONDITIONING": ["encode_pos", 0], "name": "positive"},
|
||||||
|
},
|
||||||
|
"get_positive": {
|
||||||
|
"class_type": "GetNode",
|
||||||
|
"inputs": {"name": "positive"},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": ["get_positive", 0],
|
||||||
|
"negative": ["encode_pos", 0],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
original_conditioning = object()
|
||||||
|
get_conditioning = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-kj-get")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncode", {"text": "from set node"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncode", [(original_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"set_positive",
|
||||||
|
"SetNode",
|
||||||
|
{"CONDITIONING": original_conditioning, "name": "positive"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"get_positive", "GetNode", {"name": "positive"}, None
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"get_positive", "GetNode", [(get_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"KSampler",
|
||||||
|
{
|
||||||
|
"seed": 123,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": get_conditioning,
|
||||||
|
"negative": original_conditioning,
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-kj-get")
|
||||||
|
params = MetadataProcessor.extract_generation_params(metadata)
|
||||||
|
|
||||||
|
assert params["prompt"] == "from set node"
|
||||||
|
assert params["negative_prompt"] == "from set node"
|
||||||
|
|
||||||
|
|
||||||
def test_sampler_custom_advanced_recovers_prompt_text_through_guidance_nodes(metadata_registry, monkeypatch):
|
def test_sampler_custom_advanced_recovers_prompt_text_through_guidance_nodes(metadata_registry, monkeypatch):
|
||||||
import types
|
import types
|
||||||
|
|
||||||
@@ -354,3 +731,33 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
|
|||||||
metadata = metadata_registry.get_metadata("prompt3")
|
metadata = metadata_registry.get_metadata("prompt3")
|
||||||
|
|
||||||
assert "lora_node" not in metadata[LORAS]
|
assert "lora_node" not in metadata[LORAS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
|
||||||
|
metadata_registry.start_collection("prompt1")
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"checkpoint_node",
|
||||||
|
"CheckpointLoaderLM",
|
||||||
|
{"ckpt_name": ["models/checkpoint.safetensors"]},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"unet_node",
|
||||||
|
"UNETLoaderLM",
|
||||||
|
{"unet_name": ["models/diffusion_model.safetensors"], "weight_dtype": ["default"]},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt1")
|
||||||
|
|
||||||
|
assert metadata[MODELS]["checkpoint_node"] == {
|
||||||
|
"name": "models/checkpoint.safetensors",
|
||||||
|
"type": "checkpoint",
|
||||||
|
"node_id": "checkpoint_node",
|
||||||
|
}
|
||||||
|
assert metadata[MODELS]["unet_node"] == {
|
||||||
|
"name": "models/diffusion_model.safetensors",
|
||||||
|
"type": "checkpoint",
|
||||||
|
"node_id": "unet_node",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import piexif
|
import piexif
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
from py.nodes.save_image import SaveImageLM
|
from py.nodes.save_image import SaveImageLM
|
||||||
|
|
||||||
|
|
||||||
@@ -151,3 +153,213 @@ def test_process_image_returns_empty_ui_images_when_save_fails(monkeypatch, tmp_
|
|||||||
|
|
||||||
assert result["result"] == (images,)
|
assert result["result"] == (images,)
|
||||||
assert result["ui"] == {"images": []}
|
assert result["ui"] == {"images": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_image_does_not_save_recipe_by_default(monkeypatch, tmp_path):
|
||||||
|
_configure_save_paths(monkeypatch, tmp_path)
|
||||||
|
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SaveImageLM,
|
||||||
|
"_save_image_as_recipe",
|
||||||
|
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
|
||||||
|
)
|
||||||
|
|
||||||
|
node = SaveImageLM()
|
||||||
|
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_image_saves_recipe_when_enabled(monkeypatch, tmp_path):
|
||||||
|
_configure_save_paths(monkeypatch, tmp_path)
|
||||||
|
metadata_dict = {"prompt": "prompt text", "seed": 123}
|
||||||
|
_configure_metadata(monkeypatch, metadata_dict)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SaveImageLM,
|
||||||
|
"_save_image_as_recipe",
|
||||||
|
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
|
||||||
|
)
|
||||||
|
|
||||||
|
node = SaveImageLM()
|
||||||
|
node.save_images(
|
||||||
|
[_make_image()],
|
||||||
|
"ComfyUI",
|
||||||
|
"png",
|
||||||
|
id="node-1",
|
||||||
|
save_as_recipe=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [(str(tmp_path / "sample_00001_.png"), metadata_dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_image_saves_recipe_for_each_successful_batch_image(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("folder_paths.get_output_directory", lambda: str(tmp_path), raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"folder_paths.get_save_image_path",
|
||||||
|
lambda *_args, **_kwargs: (str(tmp_path), "sample", 7, "", "sample"),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
metadata_dict = {"prompt": "prompt text", "seed": 123}
|
||||||
|
_configure_metadata(monkeypatch, metadata_dict)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SaveImageLM,
|
||||||
|
"_save_image_as_recipe",
|
||||||
|
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
|
||||||
|
)
|
||||||
|
|
||||||
|
node = SaveImageLM()
|
||||||
|
node.save_images(
|
||||||
|
[_make_image(), _make_image()],
|
||||||
|
"ComfyUI",
|
||||||
|
"png",
|
||||||
|
id="node-1",
|
||||||
|
save_as_recipe=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
(str(tmp_path / "sample_00007_.png"), metadata_dict),
|
||||||
|
(str(tmp_path / "sample_00008_.png"), metadata_dict),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_image_does_not_save_recipe_when_image_save_fails(monkeypatch, tmp_path):
|
||||||
|
_configure_save_paths(monkeypatch, tmp_path)
|
||||||
|
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||||
|
|
||||||
|
def _raise_save_error(*args, **kwargs):
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(Image.Image, "save", _raise_save_error)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SaveImageLM,
|
||||||
|
"_save_image_as_recipe",
|
||||||
|
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
|
||||||
|
)
|
||||||
|
|
||||||
|
node = SaveImageLM()
|
||||||
|
node.save_images(
|
||||||
|
[_make_image()],
|
||||||
|
"ComfyUI",
|
||||||
|
"png",
|
||||||
|
id="node-1",
|
||||||
|
save_as_recipe=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_image_keeps_image_result_when_recipe_save_fails(monkeypatch, tmp_path):
|
||||||
|
_configure_save_paths(monkeypatch, tmp_path)
|
||||||
|
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||||
|
|
||||||
|
def _raise_recipe_error(*args, **kwargs):
|
||||||
|
raise RuntimeError("recipe unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(SaveImageLM, "_save_image_as_recipe", _raise_recipe_error)
|
||||||
|
|
||||||
|
images = [_make_image()]
|
||||||
|
node = SaveImageLM()
|
||||||
|
|
||||||
|
result = node.process_image(images, id="node-1", save_as_recipe=True)
|
||||||
|
|
||||||
|
assert result["result"] == (images,)
|
||||||
|
assert result["ui"] == {
|
||||||
|
"images": [{"filename": "sample_00001_.png", "subfolder": "", "type": "output"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_image_as_recipe_writes_recipe_without_async_scanner_calls(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
_configure_save_paths(monkeypatch, tmp_path)
|
||||||
|
source_image = tmp_path / "source.png"
|
||||||
|
Image.new("RGB", (16, 16), color=(10, 20, 30)).save(source_image)
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
|
||||||
|
class _Cache:
|
||||||
|
def __init__(self, raw_data=None):
|
||||||
|
self.raw_data = raw_data or []
|
||||||
|
self.sorted_by_name = []
|
||||||
|
self.sorted_by_date = []
|
||||||
|
self.folders = []
|
||||||
|
self.folder_tree = {}
|
||||||
|
|
||||||
|
class _ModelScanner:
|
||||||
|
def __init__(self, raw_data):
|
||||||
|
self._cache = _Cache(raw_data)
|
||||||
|
|
||||||
|
class _PersistentCache:
|
||||||
|
def __init__(self):
|
||||||
|
self.updates = []
|
||||||
|
|
||||||
|
def update_recipe(self, recipe_data, json_path):
|
||||||
|
self.updates.append((recipe_data, json_path))
|
||||||
|
|
||||||
|
class _RecipeScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self.recipes_dir = str(recipes_dir)
|
||||||
|
self._cache = _Cache([])
|
||||||
|
self._json_path_map = {}
|
||||||
|
self._persistent_cache = _PersistentCache()
|
||||||
|
self._lora_scanner = _ModelScanner(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"file_name": "foo",
|
||||||
|
"sha256": "ABC123",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {
|
||||||
|
"id": 456,
|
||||||
|
"name": "Foo v1",
|
||||||
|
"model": {"name": "Foo"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._checkpoint_scanner = _ModelScanner([])
|
||||||
|
self.fts_updates = []
|
||||||
|
|
||||||
|
def _update_folder_metadata(self, cache):
|
||||||
|
cache.folders = [""]
|
||||||
|
cache.folder_tree = {}
|
||||||
|
|
||||||
|
def _update_fts_index_for_recipe(self, recipe_data, operation):
|
||||||
|
self.fts_updates.append((recipe_data["id"], operation))
|
||||||
|
|
||||||
|
scanner = _RecipeScanner()
|
||||||
|
monkeypatch.setitem(ServiceRegistry._services, "recipe_scanner", scanner)
|
||||||
|
|
||||||
|
node = SaveImageLM()
|
||||||
|
node._save_image_as_recipe(
|
||||||
|
str(source_image),
|
||||||
|
{
|
||||||
|
"prompt": "prompt text",
|
||||||
|
"seed": 123,
|
||||||
|
"checkpoint": "model.safetensors",
|
||||||
|
"loras": "<lora:foo:0.7>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_files = list(recipes_dir.glob("*.recipe.json"))
|
||||||
|
preview_files = list(recipes_dir.glob("*.webp"))
|
||||||
|
|
||||||
|
assert len(recipe_files) == 1
|
||||||
|
assert len(preview_files) == 1
|
||||||
|
assert len(scanner._cache.raw_data) == 1
|
||||||
|
assert len(scanner._persistent_cache.updates) == 1
|
||||||
|
|
||||||
|
recipe = json.loads(recipe_files[0].read_text(encoding="utf-8"))
|
||||||
|
assert recipe["file_path"] == os.path.normpath(str(preview_files[0]))
|
||||||
|
assert recipe["title"] == "foo-0.70"
|
||||||
|
assert recipe["base_model"] == "SDXL"
|
||||||
|
assert recipe["loras"][0]["hash"] == "abc123"
|
||||||
|
assert recipe["loras"][0]["modelVersionId"] == 456
|
||||||
|
assert recipe["gen_params"] == {"prompt": "prompt text", "seed": 123}
|
||||||
|
assert scanner._json_path_map[recipe["id"]] == os.path.normpath(str(recipe_files[0]))
|
||||||
|
assert scanner.fts_updates == [(recipe["id"], "add")]
|
||||||
|
|||||||
@@ -99,6 +99,99 @@ def test_duplicate_groups_respect_active_state():
|
|||||||
assert filtered == "A, B"
|
assert filtered == "A, B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_mode_can_exclude_individual_tags_within_active_group():
|
||||||
|
node = TriggerWordToggleLM()
|
||||||
|
trigger_data = [
|
||||||
|
{
|
||||||
|
"text": "outfit red, outfit blue, smiling",
|
||||||
|
"active": True,
|
||||||
|
"strength": None,
|
||||||
|
"highlighted": False,
|
||||||
|
"items": [
|
||||||
|
{"text": "outfit red", "active": True, "highlighted": False},
|
||||||
|
{"text": "outfit blue", "active": False, "highlighted": False},
|
||||||
|
{"text": "smiling", "active": True, "highlighted": False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
(filtered,) = node.process_trigger_words(
|
||||||
|
id="node",
|
||||||
|
group_mode=True,
|
||||||
|
default_active=True,
|
||||||
|
allow_strength_adjustment=False,
|
||||||
|
orinalMessage="outfit red, outfit blue, smiling",
|
||||||
|
toggle_trigger_words=trigger_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filtered == "outfit red, smiling"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_mode_keeps_group_strength_when_individual_tags_are_excluded():
|
||||||
|
node = TriggerWordToggleLM()
|
||||||
|
trigger_data = [
|
||||||
|
{
|
||||||
|
"text": "(outfit red, outfit blue, smiling:1.15)",
|
||||||
|
"active": True,
|
||||||
|
"strength": 1.15,
|
||||||
|
"highlighted": False,
|
||||||
|
"items": [
|
||||||
|
{"text": "outfit red", "active": True, "highlighted": False},
|
||||||
|
{"text": "outfit blue", "active": False, "highlighted": False},
|
||||||
|
{"text": "smiling", "active": True, "highlighted": False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
(filtered,) = node.process_trigger_words(
|
||||||
|
id="node",
|
||||||
|
group_mode=True,
|
||||||
|
default_active=True,
|
||||||
|
allow_strength_adjustment=True,
|
||||||
|
orinalMessage="outfit red, outfit blue, smiling",
|
||||||
|
toggle_trigger_words=trigger_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filtered == "(outfit red, smiling:1.15)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_mode_omits_group_when_all_children_are_disabled():
|
||||||
|
node = TriggerWordToggleLM()
|
||||||
|
trigger_data = [
|
||||||
|
{
|
||||||
|
"text": "A, B",
|
||||||
|
"active": True,
|
||||||
|
"strength": None,
|
||||||
|
"highlighted": False,
|
||||||
|
"items": [
|
||||||
|
{"text": "A", "active": False, "highlighted": False},
|
||||||
|
{"text": "B", "active": False, "highlighted": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "C, D",
|
||||||
|
"active": True,
|
||||||
|
"strength": None,
|
||||||
|
"highlighted": False,
|
||||||
|
"items": [
|
||||||
|
{"text": "C", "active": True, "highlighted": False},
|
||||||
|
{"text": "D", "active": True, "highlighted": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
(filtered,) = node.process_trigger_words(
|
||||||
|
id="node",
|
||||||
|
group_mode=True,
|
||||||
|
default_active=True,
|
||||||
|
allow_strength_adjustment=False,
|
||||||
|
orinalMessage="A, B,, C, D",
|
||||||
|
toggle_trigger_words=trigger_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filtered == "C, D"
|
||||||
|
|
||||||
|
|
||||||
def test_trigger_words_override_different_from_original():
|
def test_trigger_words_override_different_from_original():
|
||||||
node = TriggerWordToggleLM()
|
node = TriggerWordToggleLM()
|
||||||
trigger_data = [
|
trigger_data = [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
from py.routes.handlers.misc_handlers import (
|
from py.routes.handlers.misc_handlers import (
|
||||||
BackupHandler,
|
BackupHandler,
|
||||||
DoctorHandler,
|
DoctorHandler,
|
||||||
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
|
|||||||
|
|
||||||
|
|
||||||
class DummyDoctorScanner:
|
class DummyDoctorScanner:
|
||||||
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None):
|
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None, hash_index=None):
|
||||||
self.model_type = model_type
|
self.model_type = model_type
|
||||||
self._raw_data = list(raw_data or [])
|
self._raw_data = list(raw_data or [])
|
||||||
self._rebuild_error = rebuild_error
|
self._rebuild_error = rebuild_error
|
||||||
|
self._hash_index = hash_index
|
||||||
self._persistent_cache = SimpleNamespace(
|
self._persistent_cache = SimpleNamespace(
|
||||||
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
)
|
)
|
||||||
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
|
|||||||
raise self._rebuild_error
|
raise self._rebuild_error
|
||||||
return SimpleNamespace(raw_data=list(self._raw_data))
|
return SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
|
|
||||||
|
async def update_single_model_cache(self, original_path, new_path, metadata):
|
||||||
|
for item in self._raw_data:
|
||||||
|
if item.get("file_path") == original_path:
|
||||||
|
item["file_path"] = new_path
|
||||||
|
item["file_name"] = metadata.get("file_name", item.get("file_name", ""))
|
||||||
|
if metadata.get("preview_url"):
|
||||||
|
item["preview_url"] = metadata["preview_url"]
|
||||||
|
break
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DummyCivitaiClient:
|
class DummyCivitaiClient:
|
||||||
def __init__(self, *, success=True, result=None):
|
def __init__(self, *, success=True, result=None):
|
||||||
@@ -1582,3 +1594,107 @@ def test_wsl_to_windows_path_returns_none_on_subprocess_error(tmp_path):
|
|||||||
):
|
):
|
||||||
result = _wsl_to_windows_path("/mnt/c/test")
|
result = _wsl_to_windows_path("/mnt/c/test")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── DoctorHandler filename conflict tests ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_filename_conflicts_returns_ok_when_no_duplicates():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora", raw_data=[], hash_index=hash_index
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
assert diagnostic_map["filename_conflicts"]["status"] == "ok"
|
||||||
|
assert "No duplicate filenames" in diagnostic_map["filename_conflicts"]["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_filename_conflicts_detects_duplicates():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
hash_index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
hash_index.add_entry("def456", "/b/lora.safetensors")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora",
|
||||||
|
raw_data=[
|
||||||
|
{"file_path": "/a/lora.safetensors"},
|
||||||
|
{"file_path": "/b/lora.safetensors"},
|
||||||
|
],
|
||||||
|
hash_index=hash_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
conflict_diag = diagnostic_map["filename_conflicts"]
|
||||||
|
assert conflict_diag["status"] == "warning"
|
||||||
|
assert "1 filename(s)" in conflict_diag["summary"]
|
||||||
|
assert any("resolve-filename-conflicts" in str(a) for a in conflict_diag.get("actions", []))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_filename_conflicts_returns_renamed_list():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
hash_index.add_entry("abc123", "lora.safetensors")
|
||||||
|
hash_index.add_entry("def456", "lora.safetensors")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora",
|
||||||
|
raw_data=[],
|
||||||
|
hash_index=hash_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
# Files don't exist on disk, so nothing gets renamed
|
||||||
|
assert payload["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
|
||||||
|
class ErrorScanner:
|
||||||
|
model_type = "lora"
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
raise RuntimeError("scanner unavailable")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return ErrorScanner()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["count"] == 0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for checkpoint lazy hash calculation feature."""
|
"""Tests for checkpoint lazy hash calculation feature."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -199,6 +200,160 @@ async def test_calculate_hash_skips_if_already_completed(tmp_path: Path, monkeyp
|
|||||||
mock_calc.assert_not_called(), "Should not recalculate if already completed"
|
mock_calc.assert_not_called(), "Should not recalculate if already completed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_for_model_singleflight_same_file(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Concurrent calls for the same checkpoint should share one SHA256 task."""
|
||||||
|
checkpoints_root = tmp_path / "checkpoints"
|
||||||
|
checkpoints_root.mkdir()
|
||||||
|
|
||||||
|
checkpoint_file = checkpoints_root / "test_model.safetensors"
|
||||||
|
checkpoint_file.write_text("fake content", encoding="utf-8")
|
||||||
|
|
||||||
|
normalized_root = _normalize(checkpoints_root)
|
||||||
|
normalized_file = _normalize(checkpoint_file)
|
||||||
|
real_file = os.path.realpath(normalized_file)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_scanner.config,
|
||||||
|
"base_models_roots",
|
||||||
|
[normalized_root],
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = CheckpointScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(normalized_file)
|
||||||
|
assert metadata is not None
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_calculate_sha256(file_path: str) -> str:
|
||||||
|
calls.append(file_path)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return "a" * 64
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
|
||||||
|
):
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[scanner.calculate_hash_for_model(normalized_file) for _ in range(8)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [real_file]
|
||||||
|
assert results == ["a" * 64] * 8
|
||||||
|
assert scanner._hash_calculation_tasks == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_for_model_cleans_task_after_failure_and_retries(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""A failed in-flight task should be removed so later calls can retry."""
|
||||||
|
checkpoints_root = tmp_path / "checkpoints"
|
||||||
|
checkpoints_root.mkdir()
|
||||||
|
|
||||||
|
checkpoint_file = checkpoints_root / "retry_model.safetensors"
|
||||||
|
checkpoint_file.write_text("fake content", encoding="utf-8")
|
||||||
|
|
||||||
|
normalized_root = _normalize(checkpoints_root)
|
||||||
|
normalized_file = _normalize(checkpoint_file)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_scanner.config,
|
||||||
|
"base_models_roots",
|
||||||
|
[normalized_root],
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = CheckpointScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(normalized_file)
|
||||||
|
assert metadata is not None
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def fake_calculate_sha256(_file_path: str) -> str:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
raise RuntimeError("hash failed")
|
||||||
|
return "b" * 64
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
|
||||||
|
):
|
||||||
|
assert await scanner.calculate_hash_for_model(normalized_file) is None
|
||||||
|
assert scanner._hash_calculation_tasks == {}
|
||||||
|
|
||||||
|
hash_result = await scanner.calculate_hash_for_model(normalized_file)
|
||||||
|
|
||||||
|
assert hash_result == "b" * 64
|
||||||
|
assert attempts == 2
|
||||||
|
assert scanner._hash_calculation_tasks == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_for_model_uses_separate_tasks_for_different_files(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Different checkpoint files should not share the same in-flight task."""
|
||||||
|
checkpoints_root = tmp_path / "checkpoints"
|
||||||
|
checkpoints_root.mkdir()
|
||||||
|
|
||||||
|
checkpoint_files = [
|
||||||
|
checkpoints_root / "model_a.safetensors",
|
||||||
|
checkpoints_root / "model_b.safetensors",
|
||||||
|
]
|
||||||
|
for checkpoint_file in checkpoint_files:
|
||||||
|
checkpoint_file.write_text(
|
||||||
|
f"fake content for {checkpoint_file.name}", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_root = _normalize(checkpoints_root)
|
||||||
|
normalized_files = [
|
||||||
|
_normalize(checkpoint_file) for checkpoint_file in checkpoint_files
|
||||||
|
]
|
||||||
|
real_files = {os.path.realpath(file_path) for file_path in normalized_files}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_scanner.config,
|
||||||
|
"base_models_roots",
|
||||||
|
[normalized_root],
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = CheckpointScanner()
|
||||||
|
for normalized_file in normalized_files:
|
||||||
|
metadata = await scanner._create_default_metadata(normalized_file)
|
||||||
|
assert metadata is not None
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
hashes_by_path = {
|
||||||
|
os.path.realpath(normalized_files[0]): "c" * 64,
|
||||||
|
os.path.realpath(normalized_files[1]): "d" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_calculate_sha256(file_path: str) -> str:
|
||||||
|
calls.append(file_path)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return hashes_by_path[file_path]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
|
||||||
|
):
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
scanner.calculate_hash_for_model(file_path)
|
||||||
|
for file_path in normalized_files
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert set(calls) == real_files
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert set(results) == {"c" * 64, "d" * 64}
|
||||||
|
assert scanner._hash_calculation_tasks == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calculate_hash_for_model_bootstraps_missing_metadata(tmp_path: Path, monkeypatch):
|
async def test_calculate_hash_for_model_bootstraps_missing_metadata(tmp_path: Path, monkeypatch):
|
||||||
"""Test that calculate_hash_for_model creates pending metadata when it is missing."""
|
"""Test that calculate_hash_for_model creates pending metadata when it is missing."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from py.services import civitai_client as civitai_client_module
|
from py.services import civitai_client as civitai_client_module
|
||||||
from py.services.civitai_client import CivitaiClient
|
from py.services.civitai_client import CivitaiClient
|
||||||
|
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
|
||||||
from py.services.errors import RateLimitError, ResourceNotFoundError
|
from py.services.errors import RateLimitError, ResourceNotFoundError
|
||||||
from py.services.model_metadata_provider import ModelMetadataProviderManager
|
from py.services.model_metadata_provider import ModelMetadataProviderManager
|
||||||
|
|
||||||
@@ -115,6 +116,20 @@ async def test_get_model_by_hash_handles_not_found(monkeypatch, downloader):
|
|||||||
assert error == "Model not found"
|
assert error == "Model not found"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_model_by_hash_handles_offline_cooldown(downloader):
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
return False, OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
result, error = await client.get_model_by_hash("missing")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert error == OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
async def test_get_model_by_hash_propagates_rate_limit(monkeypatch, downloader):
|
async def test_get_model_by_hash_propagates_rate_limit(monkeypatch, downloader):
|
||||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
return False, RateLimitError("limited", retry_after=4)
|
return False, RateLimitError("limited", retry_after=4)
|
||||||
|
|||||||
125
tests/services/test_connectivity_guard.py
Normal file
125
tests/services/test_connectivity_guard.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import asyncio
|
||||||
|
import errno
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.connectivity_guard import (
|
||||||
|
OFFLINE_COOLDOWN_ERROR,
|
||||||
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
|
ConnectivityGuard,
|
||||||
|
)
|
||||||
|
from py.services.downloader import Downloader
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_connectivity_guard_singleton():
|
||||||
|
ConnectivityGuard._instance = None
|
||||||
|
yield
|
||||||
|
ConnectivityGuard._instance = None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connectivity_guard_enters_cooldown_after_threshold():
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
|
||||||
|
assert guard.online is True
|
||||||
|
assert guard.should_block_request() is False
|
||||||
|
|
||||||
|
guard.register_network_failure(OSError(errno.ENETUNREACH, "unreachable"))
|
||||||
|
guard.register_network_failure(asyncio.TimeoutError("timeout"))
|
||||||
|
|
||||||
|
assert guard.should_block_request() is False
|
||||||
|
assert guard.failure_count == 2
|
||||||
|
|
||||||
|
guard.register_network_failure(ConnectionRefusedError("refused"))
|
||||||
|
|
||||||
|
assert guard.online is False
|
||||||
|
assert guard.failure_count == 3
|
||||||
|
assert guard.should_block_request() is True
|
||||||
|
assert guard.cooldown_remaining_seconds() > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connectivity_guard_scopes_cooldown_to_destination():
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
|
||||||
|
destination_a = "civitai.com"
|
||||||
|
destination_b = "api.github.com"
|
||||||
|
|
||||||
|
guard.register_network_failure(
|
||||||
|
OSError(errno.ENETUNREACH, "unreachable"),
|
||||||
|
destination_a,
|
||||||
|
)
|
||||||
|
guard.register_network_failure(asyncio.TimeoutError("timeout"), destination_a)
|
||||||
|
guard.register_network_failure(ConnectionRefusedError("refused"), destination_a)
|
||||||
|
|
||||||
|
assert guard.should_block_request(destination_a) is True
|
||||||
|
assert guard.should_block_request(destination_b) is False
|
||||||
|
|
||||||
|
guard.register_success(destination_a)
|
||||||
|
assert guard.should_block_request(destination_a) is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connectivity_guard_recovers_after_success():
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
guard.online = False
|
||||||
|
guard.failure_count = 5
|
||||||
|
guard.cooldown_until = datetime.now() + timedelta(seconds=90)
|
||||||
|
|
||||||
|
guard.register_success()
|
||||||
|
|
||||||
|
assert guard.online is True
|
||||||
|
assert guard.failure_count == 0
|
||||||
|
assert guard.cooldown_until is None
|
||||||
|
assert guard.should_block_request() is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_downloader_short_circuits_all_request_helpers_during_cooldown():
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
destination = "example.invalid"
|
||||||
|
guard.register_network_failure(
|
||||||
|
OSError(errno.ENETUNREACH, "unreachable"),
|
||||||
|
destination,
|
||||||
|
)
|
||||||
|
guard.register_network_failure(asyncio.TimeoutError("timeout"), destination)
|
||||||
|
guard.register_network_failure(
|
||||||
|
ConnectionRefusedError("refused"),
|
||||||
|
destination,
|
||||||
|
)
|
||||||
|
|
||||||
|
downloader = Downloader()
|
||||||
|
|
||||||
|
ok, payload = await downloader.make_request("GET", f"https://{destination}")
|
||||||
|
assert ok is False
|
||||||
|
assert payload == OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
|
ok, payload, headers = await downloader.download_to_memory(f"https://{destination}")
|
||||||
|
assert ok is False
|
||||||
|
assert payload == OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
assert headers is None
|
||||||
|
|
||||||
|
ok, payload = await downloader.get_response_headers(f"https://{destination}")
|
||||||
|
assert ok is False
|
||||||
|
assert payload == OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
async def test_downloader_only_short_circuits_requests_for_same_destination():
|
||||||
|
guard = await ConnectivityGuard.get_instance()
|
||||||
|
guard.register_network_failure(
|
||||||
|
OSError(errno.ENETUNREACH, "unreachable"),
|
||||||
|
"example.invalid",
|
||||||
|
)
|
||||||
|
guard.register_network_failure(asyncio.TimeoutError("timeout"), "example.invalid")
|
||||||
|
guard.register_network_failure(
|
||||||
|
ConnectionRefusedError("refused"),
|
||||||
|
"example.invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
downloader = Downloader()
|
||||||
|
ok, payload = await downloader.make_request("GET", "https://example.invalid")
|
||||||
|
assert ok is False
|
||||||
|
assert payload == OFFLINE_COOLDOWN_ERROR
|
||||||
|
|
||||||
|
assert (
|
||||||
|
guard.should_block_request(downloader._guard_destination("https://example.com"))
|
||||||
|
is False
|
||||||
|
)
|
||||||
@@ -233,6 +233,136 @@ async def test_successful_download_uses_defaults(
|
|||||||
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
|
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_download_schedules_auto_example_images(
|
||||||
|
monkeypatch, scanners, metadata_provider, tmp_path
|
||||||
|
):
|
||||||
|
manager = DownloadManager()
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
async def fake_execute_download(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
download_urls,
|
||||||
|
save_dir,
|
||||||
|
metadata,
|
||||||
|
version_info,
|
||||||
|
relative_path,
|
||||||
|
progress_callback,
|
||||||
|
model_type,
|
||||||
|
download_id,
|
||||||
|
transfer_backend=None,
|
||||||
|
):
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
async def fake_schedule(self, *, metadata, model_type):
|
||||||
|
scheduled.append({"metadata": metadata, "model_type": model_type})
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager,
|
||||||
|
"_schedule_auto_example_images_download",
|
||||||
|
fake_schedule,
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=99,
|
||||||
|
save_dir=str(tmp_path),
|
||||||
|
use_default_paths=True,
|
||||||
|
progress_callback=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(scheduled) == 1
|
||||||
|
assert scheduled[0]["model_type"] == "lora"
|
||||||
|
assert scheduled[0]["metadata"].sha256 == "sha256"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_example_images_download_uses_settings_payload(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
manager = DownloadManager()
|
||||||
|
settings = get_settings_manager()
|
||||||
|
settings.settings["auto_download_example_images"] = True
|
||||||
|
settings.settings["example_images_path"] = str(tmp_path / "examples")
|
||||||
|
settings.settings["optimize_example_images"] = False
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class DummyExampleImagesManager:
|
||||||
|
async def start_force_download(self, payload):
|
||||||
|
calls.append(payload)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
from py.utils import example_images_download_manager
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_websocket_manager",
|
||||||
|
AsyncMock(return_value=object()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
example_images_download_manager,
|
||||||
|
"get_default_download_manager",
|
||||||
|
lambda _ws_manager: DummyExampleImagesManager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = SimpleNamespace(sha256="ABCDEF", file_path="model.safetensors")
|
||||||
|
await manager._schedule_auto_example_images_download(
|
||||||
|
metadata=metadata,
|
||||||
|
model_type="lora",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
if calls:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
{
|
||||||
|
"model_hashes": ["abcdef"],
|
||||||
|
"optimize": False,
|
||||||
|
"model_types": ["lora"],
|
||||||
|
"delay": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_example_images_download_skips_without_configuration(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
manager = DownloadManager()
|
||||||
|
settings = get_settings_manager()
|
||||||
|
settings.settings["auto_download_example_images"] = True
|
||||||
|
settings.settings["example_images_path"] = ""
|
||||||
|
|
||||||
|
get_ws_manager = AsyncMock(return_value=object())
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_websocket_manager", get_ws_manager)
|
||||||
|
|
||||||
|
await manager._schedule_auto_example_images_download(
|
||||||
|
metadata=SimpleNamespace(sha256="abcdef", file_path="model.safetensors"),
|
||||||
|
model_type="lora",
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
get_ws_manager.assert_not_called()
|
||||||
|
|
||||||
|
settings.settings["example_images_path"] = str(tmp_path / "examples")
|
||||||
|
await manager._schedule_auto_example_images_download(
|
||||||
|
metadata=SimpleNamespace(sha256="", file_path="model.safetensors"),
|
||||||
|
model_type="lora",
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
get_ws_manager.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_uses_active_mirrors(
|
async def test_download_uses_active_mirrors(
|
||||||
monkeypatch, scanners, metadata_provider, tmp_path
|
monkeypatch, scanners, metadata_provider, tmp_path
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
|
||||||
from py.services.errors import RateLimitError
|
from py.services.errors import RateLimitError
|
||||||
from py.services.metadata_sync_service import MetadataSyncService
|
from py.services.metadata_sync_service import MetadataSyncService
|
||||||
|
|
||||||
@@ -243,17 +244,32 @@ async def test_fetch_and_update_model_handles_missing_remote_metadata(tmp_path):
|
|||||||
|
|
||||||
assert not ok
|
assert not ok
|
||||||
assert "Model not found" in error
|
assert "Model not found" in error
|
||||||
assert model_data["from_civitai"] is False
|
|
||||||
assert model_data["civitai_deleted"] is True
|
|
||||||
|
|
||||||
helpers.metadata_manager.hydrate_model_data.assert_not_awaited()
|
|
||||||
assert model_data["hydrated"] is True
|
|
||||||
|
|
||||||
helpers.metadata_manager.save_metadata.assert_awaited_once()
|
@pytest.mark.asyncio
|
||||||
call_args = helpers.metadata_manager.save_metadata.await_args
|
async def test_fetch_and_update_model_returns_friendly_offline_message(tmp_path):
|
||||||
assert call_args.args[0].endswith("model.safetensors")
|
helpers = build_service()
|
||||||
assert "folder" not in call_args.args[1]
|
helpers.default_provider.get_model_by_hash.return_value = (None, OFFLINE_COOLDOWN_ERROR)
|
||||||
assert call_args.args[1]["hydrated"] is True
|
|
||||||
|
model_path = tmp_path / "model.safetensors"
|
||||||
|
model_data = {
|
||||||
|
"model_name": "Local",
|
||||||
|
"folder": "root",
|
||||||
|
"file_path": str(model_path),
|
||||||
|
}
|
||||||
|
update_cache = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
ok, error = await helpers.service.fetch_and_update_model(
|
||||||
|
sha256="abc",
|
||||||
|
file_path=str(model_path),
|
||||||
|
model_data=model_data,
|
||||||
|
update_cache_func=update_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert error is not None
|
||||||
|
assert OFFLINE_FRIENDLY_MESSAGE in error
|
||||||
|
update_cache.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
113
tests/services/test_model_hash_index.py
Normal file
113
tests/services/test_model_hash_index.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import pytest
|
||||||
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexRemoveByPath:
|
||||||
|
def test_remove_by_path_finds_hash_in_hash_to_path(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
index.remove_by_path("/models/lora.safetensors")
|
||||||
|
assert len(index) == 0
|
||||||
|
assert not index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
def test_remove_by_path_falls_back_to_duplicate_hashes(self):
|
||||||
|
"""When a path is only tracked in _duplicate_hashes, remove_by_path
|
||||||
|
should still find and remove it."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora_v1.safetensors")
|
||||||
|
index.add_entry("abc123", "/models/lora_v2.safetensors")
|
||||||
|
|
||||||
|
# lora_v1 is the primary (_hash_to_path), lora_v2 is in _duplicate_hashes
|
||||||
|
index.remove_by_path("/models/lora_v2.safetensors")
|
||||||
|
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index._hash_to_path.get("abc123") == "/models/lora_v1.safetensors"
|
||||||
|
assert "abc123" not in index._duplicate_hashes
|
||||||
|
|
||||||
|
def test_remove_by_path_cleans_up_duplicate_filenames(self):
|
||||||
|
"""After removing a path, _duplicate_filenames should be updated."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/other/mylora.safetensors")
|
||||||
|
|
||||||
|
assert "mylora" in index.get_duplicate_filenames()
|
||||||
|
assert len(index.get_duplicate_filenames()["mylora"]) == 2
|
||||||
|
|
||||||
|
index.remove_by_path("/other/mylora.safetensors")
|
||||||
|
|
||||||
|
# After removing one duplicate, only one path remains — no longer a duplicate
|
||||||
|
assert "mylora" not in index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
def test_remove_by_path_keeps_duplicate_filenames_with_three_entries(self):
|
||||||
|
"""With 3 entries for the same filename, removing one should leave 2."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/other/mylora.safetensors")
|
||||||
|
index.add_entry("ghi789", "/third/mylora.safetensors")
|
||||||
|
|
||||||
|
index.remove_by_path("/other/mylora.safetensors")
|
||||||
|
|
||||||
|
assert "mylora" in index.get_duplicate_filenames()
|
||||||
|
paths = index.get_duplicate_filenames()["mylora"]
|
||||||
|
assert len(paths) == 2
|
||||||
|
assert "/other/mylora.safetensors" not in paths
|
||||||
|
|
||||||
|
def test_remove_by_path_noop_on_unknown_path(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
# Should not raise
|
||||||
|
index.remove_by_path("/nonexistent/lora.safetensors")
|
||||||
|
assert len(index) == 1
|
||||||
|
|
||||||
|
def test_remove_by_path_handles_hash_from_duplicate_hashes_only(self):
|
||||||
|
"""Remove a path whose hash exists ONLY in _duplicate_hashes,
|
||||||
|
not in _hash_to_path (edge case from index rebuilds)."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/model.safetensors")
|
||||||
|
index.add_entry("abc123", "/b/model.safetensors")
|
||||||
|
|
||||||
|
# Manually remove the primary entry to simulate edge case
|
||||||
|
del index._hash_to_path["abc123"]
|
||||||
|
# Now the path is only referenced in _duplicate_hashes
|
||||||
|
assert "abc123" in index._duplicate_hashes
|
||||||
|
|
||||||
|
index.remove_by_path("/b/model.safetensors")
|
||||||
|
# The remaining path is promoted to _hash_to_path, duplicates cleared
|
||||||
|
assert "abc123" not in index._duplicate_hashes
|
||||||
|
assert index._hash_to_path.get("abc123") == "/a/model.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexGetDuplicateFilenames:
|
||||||
|
def test_empty_index_returns_empty_dict(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
|
|
||||||
|
def test_no_duplicates_returns_empty_dict(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
index.add_entry("def456", "/models/other.safetensors")
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
|
|
||||||
|
def test_duplicate_filenames_detected(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/b/mylora.safetensors")
|
||||||
|
dupes = index.get_duplicate_filenames()
|
||||||
|
assert "mylora" in dupes
|
||||||
|
assert len(dupes["mylora"]) == 2
|
||||||
|
|
||||||
|
def test_same_hash_same_name_not_a_filename_duplicate(self):
|
||||||
|
"""Same hash with same filename = hash duplicate, not filename conflict."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
# Same hash, same filename — this is a true duplicate (hash collision)
|
||||||
|
# but the filename index only tracks different files with same name
|
||||||
|
# Currently add_entry for same hash+path would update, not create duplicate
|
||||||
|
# This is correct behavior — filename dupes are for different files
|
||||||
|
|
||||||
|
def test_add_entry_idempotent_for_same_path_and_hash(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
@@ -624,3 +624,42 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
|
|||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
cached_paths = {item["file_path"] for item in cache.raw_data}
|
cached_paths = {item["file_path"] for item in cache.raw_data}
|
||||||
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
|
||||||
|
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
|
||||||
|
a single warning log with the conflict count and total file count."""
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
scanner = DummyScanner(root)
|
||||||
|
|
||||||
|
# Simulate duplicate filenames in the hash index
|
||||||
|
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
|
||||||
|
scanner._hash_index.add_entry("bbb222", str(root / "dir" / "model.safetensors"))
|
||||||
|
|
||||||
|
scanner._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
assert len(caplog.records) >= 1
|
||||||
|
log_msg = caplog.records[-1].message
|
||||||
|
assert "Duplicate filename conflict detected" in log_msg
|
||||||
|
assert "1 dummy filename(s)" in log_msg
|
||||||
|
assert "2 files total" in log_msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path: Path, caplog):
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
scanner = DummyScanner(root)
|
||||||
|
scanner._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
# No warning should be logged when there are no duplicates
|
||||||
|
for record in caplog.records:
|
||||||
|
assert "Duplicate filename conflict detected" not in record.message
|
||||||
|
|||||||
@@ -442,6 +442,42 @@ async def test_has_updates_bulk_returns_mapping(tmp_path):
|
|||||||
assert await service.has_update("lora", 9) is True
|
assert await service.has_update("lora", 9) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_has_updates_bulk_handles_more_than_sqlite_max_variables(tmp_path):
|
||||||
|
"""Bulk query with >999 model IDs must not raise 'too many SQL variables'."""
|
||||||
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
service = ModelUpdateService(str(db_path), ttl_seconds=3600)
|
||||||
|
|
||||||
|
model_ids = list(range(1, 1201))
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
conn.execute("INSERT INTO model_update_status (model_id, model_type) VALUES (?, ?)", (1, "lora"))
|
||||||
|
conn.execute("INSERT INTO model_update_versions (model_id, version_id, sort_index, name) VALUES (?, ?, ?, ?)", (1, 10, 0, "v1"))
|
||||||
|
|
||||||
|
mapping = await service.has_updates_bulk("lora", model_ids)
|
||||||
|
|
||||||
|
assert mapping[1] is True
|
||||||
|
assert len(mapping) == len(model_ids)
|
||||||
|
assert all(v is False for k, v in mapping.items() if k != 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_records_bulk_handles_more_than_sqlite_max_variables(tmp_path):
|
||||||
|
"""Bulk record fetch with >999 model IDs must not raise 'too many SQL variables'."""
|
||||||
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
service = ModelUpdateService(str(db_path), ttl_seconds=3600)
|
||||||
|
|
||||||
|
model_ids = list(range(1, 1201))
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
conn.execute("INSERT INTO model_update_status (model_id, model_type) VALUES (?, ?)", (1, "lora"))
|
||||||
|
conn.execute("INSERT INTO model_update_versions (model_id, version_id, sort_index, name) VALUES (?, ?, ?, ?)", (1, 10, 0, "v1"))
|
||||||
|
|
||||||
|
records = await service.get_records_bulk("lora", model_ids)
|
||||||
|
|
||||||
|
assert 1 in records
|
||||||
|
assert records[1].model_id == 1
|
||||||
|
assert len(records) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refresh_allows_duplicate_version_ids_across_models(tmp_path):
|
async def test_refresh_allows_duplicate_version_ids_across_models(tmp_path):
|
||||||
db_path = tmp_path / "updates.sqlite"
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import piexif
|
||||||
import pytest
|
import pytest
|
||||||
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
from py.services.recipes.analysis_service import RecipeAnalysisService
|
from py.services.recipes.analysis_service import RecipeAnalysisService
|
||||||
from py.services.recipes.errors import (
|
from py.services.recipes.errors import (
|
||||||
@@ -13,6 +16,7 @@ from py.services.recipes.errors import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from py.services.recipes.persistence_service import RecipePersistenceService
|
from py.services.recipes.persistence_service import RecipePersistenceService
|
||||||
|
from py.utils.exif_utils import ExifUtils
|
||||||
|
|
||||||
|
|
||||||
class DummyExifUtils:
|
class DummyExifUtils:
|
||||||
@@ -420,6 +424,56 @@ async def test_save_recipe_derives_allowed_fields_from_raw_metadata(tmp_path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_preserves_workflow_when_png_is_converted_to_webp(tmp_path):
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
png_info = PngImagePlugin.PngInfo()
|
||||||
|
png_info.add_text("parameters", "prompt text\nSteps: 20")
|
||||||
|
png_info.add_text("workflow", '{"nodes":[{"id":1}]}')
|
||||||
|
|
||||||
|
image_buffer = BytesIO()
|
||||||
|
Image.new("RGB", (96, 48), color="purple").save(
|
||||||
|
image_buffer, format="PNG", pnginfo=png_info
|
||||||
|
)
|
||||||
|
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=ExifUtils,
|
||||||
|
card_preview_width=64,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.save_recipe(
|
||||||
|
recipe_scanner=DummyScanner(tmp_path),
|
||||||
|
image_bytes=image_buffer.getvalue(),
|
||||||
|
image_base64=None,
|
||||||
|
name="Workflow Recipe",
|
||||||
|
tags=["workflow"],
|
||||||
|
metadata={"base_model": "sd", "loras": []},
|
||||||
|
extension=".png",
|
||||||
|
)
|
||||||
|
|
||||||
|
image_path = Path(result.payload["image_path"])
|
||||||
|
exif_dict = piexif.load(str(image_path))
|
||||||
|
assert (
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.ImageDescription].decode("utf-8")
|
||||||
|
== 'Workflow:{"nodes":[{"id":1}]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
user_comment = exif_dict["Exif"][piexif.ExifIFD.UserComment]
|
||||||
|
decoded_comment = user_comment[8:].decode("utf-16be")
|
||||||
|
assert "prompt text" in decoded_comment
|
||||||
|
assert "Recipe metadata:" in decoded_comment
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
|
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
@@ -491,6 +545,9 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
|||||||
async def get_local_lora(self, name): # pragma: no cover - no lookups expected
|
async def get_local_lora(self, name): # pragma: no cover - no lookups expected
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_local_checkpoint(self, name):
|
||||||
|
return None
|
||||||
|
|
||||||
async def add_recipe(self, recipe_data):
|
async def add_recipe(self, recipe_data):
|
||||||
self.added.append(recipe_data)
|
self.added.append(recipe_data)
|
||||||
|
|
||||||
@@ -518,9 +575,90 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
|||||||
|
|
||||||
assert stored["loras"] == []
|
assert stored["loras"] == []
|
||||||
assert stored["title"] == "recipe"
|
assert stored["title"] == "recipe"
|
||||||
|
assert stored["checkpoint"] == {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"name": "base-model.safetensors",
|
||||||
|
"file_name": "base-model",
|
||||||
|
"hash": "",
|
||||||
|
}
|
||||||
assert scanner.added and scanner.added[0]["loras"] == []
|
assert scanner.added and scanner.added[0]["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_from_widget_enriches_checkpoint_from_local_cache(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.added = []
|
||||||
|
self.checkpoint_queries = []
|
||||||
|
|
||||||
|
async def get_local_lora(self, name): # pragma: no cover - no loras
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_local_checkpoint(self, name):
|
||||||
|
self.checkpoint_queries.append(name)
|
||||||
|
if name != "matched-model":
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"file_name": "matched-model",
|
||||||
|
"file_path": "/models/checkpoints/folder/matched-model.safetensors",
|
||||||
|
"sha256": "ABC123",
|
||||||
|
"base_model": "Illustrious",
|
||||||
|
"civitai": {
|
||||||
|
"id": 456,
|
||||||
|
"name": "v1.0",
|
||||||
|
"baseModel": "Illustrious",
|
||||||
|
"model": {
|
||||||
|
"id": 123,
|
||||||
|
"name": "Matched Model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
self.added.append(recipe_data)
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.save_recipe_from_widget(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
metadata={
|
||||||
|
"loras": "",
|
||||||
|
"checkpoint": "folder/matched-model.safetensors",
|
||||||
|
"prompt": "a calm scene",
|
||||||
|
},
|
||||||
|
image_bytes=b"image-bytes",
|
||||||
|
)
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
|
||||||
|
assert scanner.checkpoint_queries == [
|
||||||
|
"folder/matched-model.safetensors",
|
||||||
|
"matched-model.safetensors",
|
||||||
|
"matched-model",
|
||||||
|
]
|
||||||
|
assert stored["base_model"] == "Illustrious"
|
||||||
|
assert stored["checkpoint"] == {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelId": 123,
|
||||||
|
"modelVersionId": 456,
|
||||||
|
"name": "Matched Model",
|
||||||
|
"version": "v1.0",
|
||||||
|
"hash": "abc123",
|
||||||
|
"file_name": "matched-model",
|
||||||
|
"modelName": "Matched Model",
|
||||||
|
"modelVersionName": "v1.0",
|
||||||
|
"baseModel": "Illustrious",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_move_recipe_updates_paths(tmp_path):
|
async def test_move_recipe_updates_paths(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user