* fix(quality): preserve cursor iteration and option validation Keep prefetched rows on ManagedCursor so partial loops and explicit fetches share one position, and stop existing iterators after close or retirement. Preserve chunked reads and native SQLite result lifetime on reexecution and errors. Catch OptionError before ValueError so legacy auto-tag requests receive the actual validation error. Remove the overwritten ViewerJS title option. Regression tests failed before the fixes for lost rows, reads after close/retirement, and misleading option errors. Focused Python checks pass; cursor checks also pass on Python 3.13 and 3.14. Refs #321. * fix(quality): make the test doubles legal raises and hashes The fake queue no longer raises an attribute that starts as None. The oversized kind records hashes instead of raising from __hash__, which also catches a hash that production code swallows. Hostile.__bool__ raises the TypeError a truth test may raise. * fix(quality): drop the extra saveCoverage argument * refactor(quality): remove assignments that are overwritten before use * refactor(quality): remove the remaining overwritten test assignments * refactor(quality): drop imports repeated inside functions * refactor(quality): give duplicate identity keys one shape * refactor(quality): name the unused job event loop variable * refactor(quality): release model gates in finally, not except BaseException * refactor(quality): end the job runners with an explicit return * refactor(quality): use the precise unittest asserts * refactor(quality): import unittest.mock one way in the tests * fix(quality): say why each empty except is safe, log the broad ones * refactor(quality): stop binding values the tests never read * refactor(quality): remove the unused preview aliases and tag page query * refactor(quality): remove re-exports nothing imports * refactor(quality): parenthesize deliberate string joins in lists * refactor(quality): remove unused JavaScript variables and imports * refactor(quality): use the precise assert in the model status test
655 lines
23 KiB
Python
655 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:
|
|
# outside the home folder: show the full path
|
|
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,
|
|
}
|