* feat(lora): cache LoraManager's layout behind file signatures (#362, phase 1) A new py/lora_layout_cache.py keeps one (install, layout) entry and reuses it while the configured path, the plugin settings file's and the settings file's signatures (st_mtime_ns, st_size), the install and every media root's real path are unchanged and the entry is younger than 30 s. A load whose watched file changed within 3 s (git's racy rule), could not be checked, or whose settings were unreadable serves its request only. clear() bumps a generation so a load that started earlier stores nothing. Hits take no lock. Not wired yet; phase 2 puts it behind the image hot paths. * fix(lora): treat an offline Windows share as unvalidated and make cache tests portable (#362) Review findings on phase 1: Windows raises FileNotFoundError with winerror 53/67 for an offline share, which is a temporary failure, not a missing file. Test times are whole milliseconds (NTFS keeps 100 ns steps) and a file system that rounds them skips; cleanup no longer ignores errors; tests added for ValueError, NotADirectoryError, a failed reload, resolve failures and the watched-file set. * perf(lora): serve the image hot paths from the layout cache (#362, phase 2) _lora_image_dirs takes (install, layout) from lora_utils.cached_lora_manager_layout instead of resolving the install and reading LoraManager's settings files on every Library, inspect, reveal and serve request. serve_image builds the allow-list in the executor instead of on the event loop. A finished import (System job or legacy scan, both through replace_lora_imports) clears the cache, so files linked under a folder LoraManager just created are served at once. The integration-off check and the configured-path rule still run before the cache. * fix(lora): fingerprint every scanned sidecar, kept or skipped (#362, phase 3) Discovery keeps every metadata file it saw (scanned_files, resolved and sorted; scanned is now its length). The fingerprint hashes each one's path, size, modification time and whether it was kept, so a skipped sidecar added, removed or edited between the review and Start refuses Start with plan-changed, as the review's counts promised. * fix(lora): warn about refused default LoRA folders and log what failed (#362, phase 4) ComfyUI's default LoRA folders already pass the folder guard; a refused one that exists is now listed in the layout's refused_defaults, and the layout cache warns once per newly refused folder. The cache writes one INFO line when a stored layout differs from the last one logged. BAD_SIDECAR_ROOT and the module docstring name a folder that contains the home folder. A relative preview_url writes a DEBUG line before the suffix search; a non-OSError failure while loading trigger words logs its traceback. Review follow-ups harden two serve tests. * fix(lora): skip a sidecar that is not UTF-8 instead of failing the load (#362) Review follow-up on phase 4: read_lora_metadata caught JSONDecodeError but not UnicodeDecodeError, so one non-UTF-8 sidecar failed the whole trigger-word load (now logged with a traceback every retry) and the import review. It is skipped like any unreadable sidecar. The relative preview_url DEBUG line uses lazy formatting. * docs(testing): the LoRA linked-only gate, layout cache and fingerprint scope (#362, phase 5) TESTING.md's LoRA reference import contract now states what #358 and #362 shipped: the linked-only gate and _media_roots, linked_only media, the cover link order, the guard on ComfyUI's default LoRA folders and refused_defaults, the folder-unreadable code, the trigger cache's catch and 60 s retry, the fingerprint over every scanned sidecar, and the layout cache's key, signatures, racy window, maximum age, root re-check and clears. The targeted command includes tests.test_lora_layout_cache and tests.test_api_images. * fix(lora): a cached layout needs the same home folder; give the rebuild poll its time (#362) Final review follow-ups: the folder guard compares against the home folder, so a cache hit now also needs Path.home() unchanged. TESTING.md says the configured-path rule runs on the cache's result and that a non-UTF-8 sidecar is skipped. CI: 'Rebuild all is explicit...' (webkit) polled the job for the default 5 s while it regenerated 24 previews; the failure screenshot shows 12 of 24 done at the deadline on the busy runner (0.17 s locally). The poll now allows 30 s for that real work.
654 lines
23 KiB
Python
654 lines
23 KiB
Python
"""Where LoraManager keeps its files (#355).
|
|
|
|
LoraManager (``custom_nodes/comfyui-lora-manager``) stores one
|
|
``<name>.metadata.json`` sidecar per model, a preview image or video beside
|
|
it, and each model's downloaded CivitAI example images. Where it stores them
|
|
is a user setting, and its settings file itself can live in three places.
|
|
This module repeats LoraManager's own rules, reading its files only (never
|
|
importing it), so the LoRA import, the trigger-word cache and the image
|
|
server share one definition:
|
|
|
|
- ``load_layout`` finds the settings file (environment folder, portable
|
|
plugin file, or the user config folder, in LoraManager's order), keeps only
|
|
the seven keys the import needs, and resolves the LoRA roots, the sidecar
|
|
root (centralized storage), the example-images root and the active
|
|
LoraManager library. Every folder from the settings, and each of
|
|
ComfyUI's default LoRA folders, passes the folder guard: it must exist and
|
|
must not be a filesystem root, the home folder or a folder that contains
|
|
it. Default folders the guard refuses are listed in ``refused_defaults``.
|
|
- ``discover_sidecars`` finds every sidecar whose model file exists inside a
|
|
LoRA root: beside the models in ``alongside`` mode, under the sidecar root
|
|
(matched to its model by ``file_path``) in ``centralized`` mode.
|
|
- ``local_media`` lists a LoRA's preview and example images that the image
|
|
server may serve.
|
|
|
|
Standard library plus ``platformdirs`` only (LoraManager depends on it), so
|
|
it is testable outside ComfyUI.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
from typing import (
|
|
AbstractSet,
|
|
Callable,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
Mapping,
|
|
Optional,
|
|
Sequence,
|
|
Tuple,
|
|
)
|
|
|
|
# py/utils/settings_paths.py: SETTINGS_DIR_ENV, _LM_PORTABLE_ENV, APP_NAME.
|
|
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
|
|
PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
|
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
|
# py/utils/constants.py PREVIEW_EXTENSIONS, in LoraManager's order.
|
|
PREVIEW_EXTENSIONS = (
|
|
".webp",
|
|
".preview.webp",
|
|
".preview.png",
|
|
".preview.jpeg",
|
|
".preview.jpg",
|
|
".preview.mp4",
|
|
".png",
|
|
".jpeg",
|
|
".jpg",
|
|
".mp4",
|
|
".gif",
|
|
".webm",
|
|
".avif",
|
|
".jxl",
|
|
)
|
|
# LoraManager's LoRA scanner accepts .safetensors only (py/services/lora_scanner.py).
|
|
MODEL_SUFFIX = ".safetensors"
|
|
# py/utils/sidecar_paths.py METADATA_SUFFIX.
|
|
METADATA_SUFFIX = ".metadata.json"
|
|
MAX_SETTINGS_BYTES = 5 * 1024 * 1024
|
|
# The only settings the import reads; every other key (the file can hold API
|
|
# keys) is dropped right after parsing.
|
|
SETTINGS_KEYS = frozenset(
|
|
{
|
|
"sidecar_storage_mode",
|
|
"sidecar_storage_path",
|
|
"example_images_path",
|
|
"folder_paths",
|
|
"extra_folder_paths",
|
|
"libraries",
|
|
"active_library",
|
|
}
|
|
)
|
|
# py/utils/sidecar_paths.py STORAGE_MODE_ALONGSIDE / STORAGE_MODE_CENTRALIZED.
|
|
MODE_ALONGSIDE = "alongside"
|
|
MODE_CENTRALIZED = "centralized"
|
|
# utils/video_metadata.py VIDEO_EXTENSIONS (not imported: that package needs
|
|
# the node's relative imports).
|
|
VIDEO_SUFFIXES = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".wmv"})
|
|
|
|
SOURCE_ENVIRONMENT = "environment"
|
|
SOURCE_PORTABLE = "portable"
|
|
SOURCE_USER_CONFIG = "user config"
|
|
|
|
NO_INSTALL = "no LoraManager install found"
|
|
UNREADABLE = "the settings file could not be read"
|
|
NOT_JSON = "the settings file is not valid JSON"
|
|
NOT_OBJECT = "the settings file is not a JSON object"
|
|
TOO_LARGE = "the settings file is larger than 5 MB"
|
|
NO_PLATFORMDIRS = "platformdirs is not available"
|
|
NO_SIDECAR_ROOT = "the sidecar folder does not exist"
|
|
BAD_SIDECAR_ROOT = (
|
|
"the sidecar folder is a filesystem root, the home folder or a folder "
|
|
"that contains it"
|
|
)
|
|
|
|
logger = logging.getLogger("prompt_manager.lora_manager_layout")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoraManagerLayout:
|
|
"""What LoraManager's settings say about where its files are."""
|
|
|
|
settings_path: Optional[Path]
|
|
source: Optional[str]
|
|
error: Optional[str]
|
|
mode: str
|
|
lora_roots: Tuple[str, ...]
|
|
sidecar_root: Optional[str]
|
|
example_images_root: Optional[str]
|
|
library: str
|
|
media_roots: Tuple[str, ...]
|
|
# ComfyUI's default LoRA folders that exist but that the folder guard
|
|
# refused (canonical): the layout cache logs them (#362).
|
|
refused_defaults: Tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Sidecar:
|
|
"""One LoRA's metadata file and the model file it describes."""
|
|
|
|
metadata_path: Path
|
|
model_path: Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Discovery:
|
|
"""The sidecars kept, every metadata file seen, and those unread."""
|
|
|
|
layout: LoraManagerLayout
|
|
sidecars: List[Sidecar]
|
|
# Every ``*.metadata.json`` seen, kept or skipped: resolved, once each,
|
|
# sorted by ``str(path)``. The import's fingerprint covers all of them.
|
|
scanned_files: Tuple[Path, ...]
|
|
unreadable: List[str]
|
|
|
|
@property
|
|
def scanned(self) -> int:
|
|
"""How many metadata files were seen."""
|
|
return len(self.scanned_files)
|
|
|
|
|
|
class _SettingsError(Exception):
|
|
"""The settings file cannot be used, for the reason it carries."""
|
|
|
|
|
|
# ── paths ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _canonical(path: str, pathmod=os.path) -> str:
|
|
# py/config.py _canonical_path (not imported: py.config imports ComfyUI's
|
|
# ``server`` at import time).
|
|
return pathmod.normcase(pathmod.realpath(path))
|
|
|
|
|
|
def _is_filesystem_root(path: str, pathmod=os.path) -> bool:
|
|
# py/config.py _is_filesystem_root: '/', 'C:\\' and UNC share roots.
|
|
return pathmod.dirname(path) == path
|
|
|
|
|
|
def _guard_folder(
|
|
path: str,
|
|
home: str,
|
|
*,
|
|
pathmod=os.path,
|
|
isdir: Optional[Callable[[str], bool]] = None,
|
|
) -> Optional[str]:
|
|
"""The canonical form of ``path`` when it may be read and served, else None.
|
|
|
|
Kept only when it is a folder, not a filesystem root, and neither the
|
|
home folder nor one of its ancestors (``/home``, ``C:\\Users``: a
|
|
security review ruled that a folder holding the home folder must never
|
|
be read or served).
|
|
"""
|
|
isdir = isdir or os.path.isdir
|
|
try:
|
|
canonical = _canonical(path, pathmod)
|
|
if not isdir(canonical) or _is_filesystem_root(canonical, pathmod):
|
|
return None
|
|
if _inside(home, (canonical,), pathmod):
|
|
return None
|
|
except (OSError, ValueError):
|
|
return None
|
|
return canonical
|
|
|
|
|
|
def _inside(path, roots: Sequence[str], pathmod=os.path) -> bool:
|
|
"""True when ``path`` (canonical) is one of ``roots`` or below one."""
|
|
try:
|
|
candidate = _canonical(str(path), pathmod)
|
|
except (OSError, ValueError):
|
|
return False
|
|
for root in roots:
|
|
prefix = root if root.endswith(pathmod.sep) else root + pathmod.sep
|
|
if candidate == root or candidate.startswith(prefix):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _absolute(value) -> Optional[str]:
|
|
"""A settings path, stripped, ``~``-expanded and absolute; None when unset."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
return os.path.abspath(os.path.expanduser(value.strip()))
|
|
|
|
|
|
# ── the settings file ───────────────────────────────────────────────
|
|
|
|
|
|
def _platform_user_config_dir() -> str:
|
|
from platformdirs import user_config_dir
|
|
|
|
return user_config_dir(APP_NAME, appauthor=False)
|
|
|
|
|
|
def _read_bytes(path: Path) -> bytes:
|
|
with open(path, "rb") as handle:
|
|
return handle.read(MAX_SETTINGS_BYTES + 1)
|
|
|
|
|
|
def _read_settings(path: Path) -> Optional[Dict]:
|
|
"""The parsed object in ``path``; None when it does not exist."""
|
|
try:
|
|
raw = _read_bytes(path)
|
|
except FileNotFoundError:
|
|
return None
|
|
except OSError:
|
|
raise _SettingsError(UNREADABLE) from None
|
|
if len(raw) > MAX_SETTINGS_BYTES:
|
|
raise _SettingsError(TOO_LARGE)
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, ValueError):
|
|
raise _SettingsError(NOT_JSON) from None
|
|
if not isinstance(data, dict):
|
|
raise _SettingsError(NOT_OBJECT)
|
|
return data
|
|
|
|
|
|
def _settings_subset(data: Mapping) -> Dict:
|
|
"""Only the keys the import reads."""
|
|
return {key: data[key] for key in SETTINGS_KEYS if key in data}
|
|
|
|
|
|
def _portable_flag(environ: Mapping[str, str], plugin_settings: Path) -> bool:
|
|
"""LoraManager's portable mode (settings_paths._should_use_portable_settings)."""
|
|
forced = environ.get(PORTABLE_ENV)
|
|
if forced == "1":
|
|
return True
|
|
if forced == "0":
|
|
return False
|
|
try:
|
|
data = _read_settings(plugin_settings)
|
|
except _SettingsError:
|
|
return False
|
|
return bool(data) and data.get("use_portable_settings") is True
|
|
|
|
|
|
def _settings_location(
|
|
lora_manager_path: str,
|
|
environ: Mapping[str, str],
|
|
user_config_dir: Callable[[], str],
|
|
) -> Tuple[Path, str]:
|
|
"""``(settings file, source)`` in LoraManager's lookup order."""
|
|
override = environ.get(SETTINGS_DIR_ENV)
|
|
if override:
|
|
folder = os.path.abspath(os.path.expanduser(override))
|
|
return Path(folder) / "settings.json", SOURCE_ENVIRONMENT
|
|
plugin_settings = Path(lora_manager_path) / "settings.json"
|
|
if _portable_flag(environ, plugin_settings):
|
|
return plugin_settings, SOURCE_PORTABLE
|
|
try:
|
|
folder = user_config_dir()
|
|
except ImportError:
|
|
raise _SettingsError(NO_PLATFORMDIRS) from None
|
|
return Path(folder) / "settings.json", SOURCE_USER_CONFIG
|
|
|
|
|
|
# ── libraries and folders ───────────────────────────────────────────
|
|
|
|
|
|
def _sanitize_library_name(name: str) -> str:
|
|
# py/utils/example_images_paths.py sanitize_library_name.
|
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "")
|
|
return safe or "default"
|
|
|
|
|
|
def _active_library(settings: Mapping) -> Tuple[str, Optional[Dict], int]:
|
|
"""``(name, library dict or None, number of libraries)``."""
|
|
libraries = settings.get("libraries")
|
|
if not isinstance(libraries, dict) or not libraries:
|
|
return "default", None, 0
|
|
active = settings.get("active_library")
|
|
if not isinstance(active, str) or active not in libraries:
|
|
active = next(iter(libraries))
|
|
library = libraries[active]
|
|
return str(active), library if isinstance(library, dict) else None, len(libraries)
|
|
|
|
|
|
def _lora_list(settings: Mapping, library: Optional[Dict], key: str) -> List[str]:
|
|
"""The ``loras`` folders of ``key`` (the library's, else the top level)."""
|
|
source = library if library is not None and key in library else settings
|
|
section = source.get(key)
|
|
if not isinstance(section, dict):
|
|
return []
|
|
value = section.get("loras")
|
|
if isinstance(value, str):
|
|
value = [value]
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [entry for entry in value if isinstance(entry, str) and entry.strip()]
|
|
|
|
|
|
def _guarded(paths, home: str) -> Tuple[str, ...]:
|
|
kept = []
|
|
for path in paths:
|
|
absolute = _absolute(path)
|
|
guarded = _guard_folder(absolute, home) if absolute else None
|
|
if guarded and guarded not in kept:
|
|
kept.append(guarded)
|
|
return tuple(kept)
|
|
|
|
|
|
def _refused(paths, home: str) -> Tuple[str, ...]:
|
|
"""The folders in ``paths`` that exist but that the guard refuses."""
|
|
refused = []
|
|
for path in paths:
|
|
absolute = _absolute(path)
|
|
if not absolute or not os.path.isdir(absolute):
|
|
continue
|
|
if _guard_folder(absolute, home) is None:
|
|
try:
|
|
canonical = _canonical(absolute)
|
|
except (OSError, ValueError):
|
|
continue
|
|
if canonical not in refused:
|
|
refused.append(canonical)
|
|
return tuple(refused)
|
|
|
|
|
|
def _example_images_root(
|
|
settings: Mapping, library: str, libraries: int, home: str
|
|
) -> Optional[str]:
|
|
"""py/utils/example_images_paths.py get_library_root, when the folder exists."""
|
|
configured = _absolute(settings.get("example_images_path"))
|
|
root = _guard_folder(configured, home) if configured else None
|
|
if root is None or libraries <= 1:
|
|
return root
|
|
# Guarded (canonical) like its root: a library name keeps its case, and
|
|
# the media check compares normcased paths.
|
|
return _guard_folder(os.path.join(root, _sanitize_library_name(library)), home)
|
|
|
|
|
|
def _media_roots(*groups) -> Tuple[str, ...]:
|
|
roots = []
|
|
for group in groups:
|
|
for root in group:
|
|
if root and root not in roots:
|
|
roots.append(root)
|
|
return tuple(roots)
|
|
|
|
|
|
def load_layout(
|
|
lora_manager_path: Optional[str],
|
|
*,
|
|
default_roots: Callable[[], List[str]],
|
|
environ: Mapping[str, str] = os.environ,
|
|
user_config_dir: Callable[[], str] = _platform_user_config_dir,
|
|
) -> LoraManagerLayout:
|
|
"""Read LoraManager's settings the way LoraManager does (read only).
|
|
|
|
``lora_manager_path`` is the resolved install (``None`` when none was
|
|
found). An unusable layout carries ``error``, alongside mode and the
|
|
default LoRA roots, so serving and trigger words keep working.
|
|
"""
|
|
home = str(Path.home())
|
|
default_list = list(default_roots())
|
|
defaults = _guarded(default_list, home)
|
|
refused_defaults = _refused(default_list, home)
|
|
fallback = LoraManagerLayout(
|
|
settings_path=None,
|
|
source=None,
|
|
error=NO_INSTALL,
|
|
mode=MODE_ALONGSIDE,
|
|
lora_roots=defaults,
|
|
sidecar_root=None,
|
|
example_images_root=None,
|
|
library="default",
|
|
media_roots=defaults,
|
|
refused_defaults=refused_defaults,
|
|
)
|
|
if not lora_manager_path:
|
|
return fallback
|
|
try:
|
|
settings_path, source = _settings_location(
|
|
lora_manager_path, environ, user_config_dir
|
|
)
|
|
except _SettingsError as error:
|
|
return replace(fallback, error=str(error))
|
|
fallback = replace(fallback, settings_path=settings_path, source=source)
|
|
try:
|
|
raw = _read_settings(settings_path)
|
|
except _SettingsError as error:
|
|
return replace(fallback, error=str(error))
|
|
settings = _settings_subset(raw or {})
|
|
return _layout_from_settings(
|
|
settings, settings_path, source, defaults, home, refused_defaults
|
|
)
|
|
|
|
|
|
def _layout_from_settings(
|
|
settings, settings_path, source, defaults, home, refused_defaults=()
|
|
):
|
|
library, entry, libraries = _active_library(settings)
|
|
lora_roots = _media_roots(
|
|
defaults,
|
|
_guarded(_lora_list(settings, entry, "folder_paths"), home),
|
|
_guarded(_lora_list(settings, entry, "extra_folder_paths"), home),
|
|
)
|
|
mode = settings.get("sidecar_storage_mode")
|
|
mode = MODE_CENTRALIZED if mode == MODE_CENTRALIZED else MODE_ALONGSIDE
|
|
example_root = _example_images_root(settings, library, libraries, home)
|
|
layout = LoraManagerLayout(
|
|
settings_path=settings_path,
|
|
source=source,
|
|
error=None,
|
|
mode=mode,
|
|
lora_roots=lora_roots,
|
|
sidecar_root=None,
|
|
example_images_root=example_root,
|
|
library=library,
|
|
media_roots=_media_roots(lora_roots, [example_root]),
|
|
refused_defaults=refused_defaults,
|
|
)
|
|
if mode != MODE_CENTRALIZED:
|
|
return layout
|
|
configured = _absolute(settings.get("sidecar_storage_path"))
|
|
configured = configured or os.path.join(str(settings_path.parent), "sidecars")
|
|
sidecar_root = _guard_folder(configured, home)
|
|
if sidecar_root is None:
|
|
reason = NO_SIDECAR_ROOT if not os.path.isdir(configured) else BAD_SIDECAR_ROOT
|
|
return replace(
|
|
layout,
|
|
error=reason,
|
|
lora_roots=defaults,
|
|
example_images_root=None,
|
|
media_roots=defaults,
|
|
)
|
|
return replace(
|
|
layout,
|
|
sidecar_root=sidecar_root,
|
|
media_roots=_media_roots(lora_roots, [sidecar_root], [example_root]),
|
|
)
|
|
|
|
|
|
# ── sidecars ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _metadata_files(folder: str) -> Iterator[Path]:
|
|
"""Every ``*.metadata.json`` under ``folder``; an unlistable folder raises.
|
|
|
|
Symlinked folders are followed, each real folder once (no loops).
|
|
"""
|
|
|
|
def fail(error):
|
|
raise error
|
|
|
|
visited = set()
|
|
for current, dirs, files in os.walk(folder, onerror=fail, followlinks=True):
|
|
real = _canonical(current)
|
|
if real in visited:
|
|
dirs[:] = []
|
|
continue
|
|
visited.add(real)
|
|
dirs.sort()
|
|
for name in sorted(files):
|
|
if name.endswith(METADATA_SUFFIX):
|
|
yield Path(current) / name
|
|
|
|
|
|
def _read_sidecar(path: Path) -> Optional[Dict]:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except (OSError, ValueError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _model_for(path: Path, layout: LoraManagerLayout, unreadable: List[str]):
|
|
"""The model file a sidecar describes, when it exists inside a LoRA root."""
|
|
if layout.mode == MODE_ALONGSIDE:
|
|
base = path.name[: -len(METADATA_SUFFIX)]
|
|
model = path.parent / f"{base}{MODEL_SUFFIX}"
|
|
return model if model.is_file() else None
|
|
metadata = _read_sidecar(path)
|
|
if metadata is None:
|
|
unreadable.append(path.name)
|
|
return None
|
|
file_path = metadata.get("file_path")
|
|
if not isinstance(file_path, str) or not file_path.strip():
|
|
return None
|
|
model = Path(file_path)
|
|
if not model.is_file() or not _inside(model, layout.lora_roots):
|
|
return None
|
|
return model
|
|
|
|
|
|
def discover_sidecars(layout: LoraManagerLayout) -> Discovery:
|
|
"""Every sidecar whose model exists inside a LoRA root, sorted, once each.
|
|
|
|
Raises ``OSError`` when a folder cannot be listed: a discovery that
|
|
cannot see every folder must not replace the collection with part of it.
|
|
"""
|
|
if layout.error is not None:
|
|
return Discovery(layout, [], (), [])
|
|
if layout.mode == MODE_CENTRALIZED:
|
|
folders = [layout.sidecar_root]
|
|
else:
|
|
folders = list(layout.lora_roots)
|
|
seen = set()
|
|
scanned: List[Path] = []
|
|
unreadable: List[str] = []
|
|
sidecars = []
|
|
for folder in folders:
|
|
for path in _metadata_files(folder):
|
|
resolved = Path(os.path.realpath(path))
|
|
key = os.path.normcase(str(resolved))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
scanned.append(resolved)
|
|
model = _model_for(resolved, layout, unreadable)
|
|
if model is not None:
|
|
sidecars.append(Sidecar(resolved, Path(os.path.realpath(model))))
|
|
sidecars.sort(key=lambda sidecar: str(sidecar.metadata_path))
|
|
scanned.sort(key=str)
|
|
return Discovery(layout, sidecars, tuple(scanned), unreadable)
|
|
|
|
|
|
# ── images ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _natural_key(name: str):
|
|
parts = re.split(r"(\d+)", name)
|
|
return [int(part) if part.isdigit() else part.lower() for part in parts], name
|
|
|
|
|
|
def _example_files(folder: str) -> List[str]:
|
|
try:
|
|
with os.scandir(folder) as entries:
|
|
names = [entry.name for entry in entries if entry.is_file()]
|
|
except OSError:
|
|
return []
|
|
return [os.path.join(folder, name) for name in sorted(names, key=_natural_key)]
|
|
|
|
|
|
def _preview_candidates(sidecar: Sidecar, metadata: Dict) -> Iterator[str]:
|
|
"""The paths a preview is looked for at, in LoraManager's order."""
|
|
url = metadata.get("preview_url")
|
|
if isinstance(url, str) and url.strip():
|
|
if os.path.isabs(url.strip()):
|
|
yield url.strip()
|
|
else:
|
|
# LoraManager writes absolute paths; it would resolve a relative
|
|
# one against its working directory, not the sidecar folder.
|
|
logger.debug(
|
|
"Relative preview_url skipped in %s", sidecar.metadata_path.name
|
|
)
|
|
base = sidecar.metadata_path.name[: -len(METADATA_SUFFIX)]
|
|
for suffix in PREVIEW_EXTENSIONS:
|
|
yield str(sidecar.metadata_path.parent / f"{base}{suffix}")
|
|
|
|
|
|
def local_media(
|
|
sidecar: Sidecar,
|
|
metadata: Dict,
|
|
layout: LoraManagerLayout,
|
|
media_extensions: AbstractSet[str],
|
|
) -> List[str]:
|
|
"""The LoRA's preview, then its example images: absolute, resolved paths.
|
|
|
|
A file counts only when its extension is in ``media_extensions`` (what the
|
|
image server serves) and it lies inside ``layout.media_roots``.
|
|
"""
|
|
|
|
def usable(path: str) -> bool:
|
|
return (
|
|
os.path.splitext(path)[1].lower() in media_extensions
|
|
and os.path.isfile(path)
|
|
and _inside(path, layout.media_roots)
|
|
)
|
|
|
|
found: List[str] = []
|
|
for path in _preview_candidates(sidecar, metadata):
|
|
if usable(path):
|
|
found.append(path)
|
|
break
|
|
sha256 = metadata.get("sha256")
|
|
if layout.example_images_root and isinstance(sha256, str) and sha256.strip():
|
|
folder = os.path.join(layout.example_images_root, sha256.strip().lower())
|
|
found.extend(path for path in _example_files(folder) if usable(path))
|
|
media: List[str] = []
|
|
for path in found:
|
|
resolved = os.path.realpath(path)
|
|
if resolved not in media:
|
|
media.append(resolved)
|
|
return _cover_first(media)
|
|
|
|
|
|
def _cover_first(media: List[str]) -> List[str]:
|
|
"""A still image in front of a leading video (no thumbnails for these yet)."""
|
|
if not media or os.path.splitext(media[0])[1].lower() not in VIDEO_SUFFIXES:
|
|
return media
|
|
for index, path in enumerate(media):
|
|
if os.path.splitext(path)[1].lower() not in VIDEO_SUFFIXES:
|
|
return [path] + media[:index] + media[index + 1 :]
|
|
return media
|
|
|
|
|
|
# ── the review ──────────────────────────────────────────────────────
|
|
|
|
|
|
def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
|
|
"""What the review shows about the layout (no settings values)."""
|
|
settings_file = None
|
|
if layout.settings_path is not None:
|
|
settings_file = str(layout.settings_path)
|
|
try:
|
|
relative = Path(settings_file).relative_to(home)
|
|
settings_file = os.path.join("~", str(relative))
|
|
except ValueError:
|
|
pass
|
|
return {
|
|
"source": layout.source,
|
|
"settings_file": settings_file,
|
|
"error": layout.error,
|
|
"mode": layout.mode,
|
|
"library": layout.library,
|
|
"example_images": layout.example_images_root is not None,
|
|
}
|