mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-11 08:20:14 -03:00
feat(nodes): flag missing local models at queue and load time (#1057)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, List, Tuple
|
||||
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
@@ -58,7 +59,10 @@ class CheckpointLoaderLM:
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") == "checkpoint":
|
||||
file_path = item.get("file_path", "")
|
||||
if file_path:
|
||||
# Only offer models that still exist on disk so ComfyUI
|
||||
# flags missing checkpoints at queue time via
|
||||
# "value not in list" (the scanner cache can be stale).
|
||||
if file_path and os.path.exists(file_path):
|
||||
# Format using relative path with OS-native separator
|
||||
formatted_name = _format_model_name_for_comfyui(
|
||||
file_path, model_roots
|
||||
|
||||
@@ -15,6 +15,7 @@ from .utils import (
|
||||
any_type,
|
||||
apply_lora_syntax_format,
|
||||
get_loras_list,
|
||||
validate_lora_entries,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,6 +43,11 @@ class CreateHookLoraLM:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, loras=None):
|
||||
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||
return validate_lora_entries({"loras": loras}) or True
|
||||
|
||||
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||
FUNCTION = "create_hook"
|
||||
|
||||
@@ -14,6 +14,7 @@ from .utils import (
|
||||
get_loras_list,
|
||||
nunchaku_load_lora,
|
||||
parse_lora_syntax,
|
||||
validate_lora_entries,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,6 +143,11 @@ class LoraLoaderLM:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, loras=None):
|
||||
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||
return validate_lora_entries({"loras": loras}) or True
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
@@ -9,6 +9,7 @@ and tracks the last used combination for reuse.
|
||||
import logging
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import validate_lora_entries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +32,11 @@ class LoraRandomizerLM:
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, loras=None):
|
||||
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||
return validate_lora_entries({"loras": loras}) or True
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK",)
|
||||
RETURN_NAMES = ("LORA_STACK",)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
||||
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list, validate_lora_entries
|
||||
|
||||
import logging
|
||||
|
||||
@@ -22,6 +22,11 @@ class LoraStackerLM:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, loras=None):
|
||||
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||
return validate_lora_entries({"loras": loras}) or True
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||
FUNCTION = "stack_loras"
|
||||
|
||||
@@ -74,7 +74,10 @@ class UNETLoaderLM:
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") == "diffusion_model":
|
||||
file_path = item.get("file_path", "")
|
||||
if file_path:
|
||||
# Only offer models that still exist on disk so ComfyUI
|
||||
# flags missing diffusion models at queue time via
|
||||
# "value not in list" (the scanner cache can be stale).
|
||||
if file_path and os.path.exists(file_path):
|
||||
# Format using relative path with OS-native separator
|
||||
formatted_name = _format_model_name_for_comfyui(
|
||||
file_path, model_roots
|
||||
|
||||
@@ -44,6 +44,7 @@ import re
|
||||
import logging
|
||||
import copy
|
||||
import sys
|
||||
import asyncio
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -111,6 +112,157 @@ def get_loras_list(kwargs):
|
||||
return []
|
||||
|
||||
|
||||
_LORA_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".bin")
|
||||
|
||||
|
||||
def _strip_lora_extension(name: str) -> str:
|
||||
"""Strip a known LoRA model extension from a name (case-insensitive)."""
|
||||
lowered = name.lower()
|
||||
for ext in _LORA_EXTENSIONS:
|
||||
if lowered.endswith(ext):
|
||||
return name[: -len(ext)]
|
||||
return name
|
||||
|
||||
|
||||
def _find_missing_loras(names: list[str]) -> list[str]:
|
||||
"""Return the names that cannot be resolved to an existing local LoRA file.
|
||||
|
||||
Mirrors the matching semantics of ``get_lora_info_absolute``
|
||||
(py/utils/utils.py): after stripping the extension, a name matches a cached
|
||||
LoRA when it equals the cached file name or the ``folder/file`` path. As a
|
||||
fallback, a name containing a folder that only matches by basename resolves
|
||||
to the first basename match (same behavior as the runtime resolver). Raw
|
||||
absolute paths that exist on disk are always considered available.
|
||||
|
||||
The scanner cache is fetched once for all names; the cache may be stale, so
|
||||
resolved paths are additionally verified with ``os.path.isfile``.
|
||||
"""
|
||||
if not names:
|
||||
return []
|
||||
|
||||
async def _check() -> list[str]:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
# The scanner cache may not be hydrated yet (startup, library path
|
||||
# change). An empty cache is not authoritative — treat it as "cannot
|
||||
# verify" and skip validation instead of flagging every active LoRA
|
||||
# as missing.
|
||||
if getattr(scanner, "_cache", None) is None or getattr(
|
||||
scanner, "_is_initializing", False
|
||||
):
|
||||
return []
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
lookup = {}
|
||||
basename_candidates = {}
|
||||
for item in cache.raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
file_name = item.get("file_name", "")
|
||||
folder = item.get("folder", "")
|
||||
file_name_no_ext = _strip_lora_extension(file_name)
|
||||
path_name_no_ext = (
|
||||
f"{folder}/{file_name_no_ext}".replace("\\", "/")
|
||||
if folder
|
||||
else file_name_no_ext
|
||||
)
|
||||
lookup.setdefault(file_name_no_ext, file_path)
|
||||
lookup.setdefault(path_name_no_ext, file_path)
|
||||
basename_candidates.setdefault(file_name_no_ext, []).append(
|
||||
(folder, file_path)
|
||||
)
|
||||
|
||||
missing = []
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
normalized = name.replace("\\", "/")
|
||||
# Raw absolute paths (outside the library) are usable as-is.
|
||||
if os.path.isfile(normalized):
|
||||
continue
|
||||
no_ext = _strip_lora_extension(normalized)
|
||||
file_path = lookup.get(no_ext)
|
||||
if file_path is None and "/" in no_ext:
|
||||
# A name with a folder that matches only by basename resolves
|
||||
# at runtime like get_lora_info_absolute's fallback does:
|
||||
# prefer a candidate whose folder prefixes the name, else the
|
||||
# first basename match.
|
||||
folder, basename = no_ext.rsplit("/", 1)
|
||||
candidates = basename_candidates.get(basename, [])
|
||||
file_path = next(
|
||||
(
|
||||
fp
|
||||
for fld, fp in candidates
|
||||
if fld and no_ext.startswith(fld + "/")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if file_path is None and candidates:
|
||||
file_path = candidates[0][1]
|
||||
if file_path is None or not os.path.isfile(file_path):
|
||||
missing.append(name)
|
||||
return missing
|
||||
|
||||
try:
|
||||
# Check if we're already in an event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in a running loop, run the async check in a separate thread
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(_check())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
# No event loop is running, we can use asyncio.run()
|
||||
return asyncio.run(_check())
|
||||
|
||||
|
||||
def validate_lora_entries(kwargs):
|
||||
"""Validate active LoRA widget entries against the local library.
|
||||
|
||||
Used by node ``VALIDATE_INPUTS`` implementations so ComfyUI rejects the
|
||||
prompt at queue time (``custom_validation_failed``) when an active entry
|
||||
references a LoRA that is not available locally — mirroring how built-in
|
||||
loader nodes flag missing models before execution starts.
|
||||
|
||||
Returns:
|
||||
None when every active entry resolves to an existing local file,
|
||||
otherwise a descriptive error string listing the missing LoRAs.
|
||||
Verification failures (e.g. scanner not ready) are treated as valid
|
||||
so queueing is never blocked by validation machinery itself.
|
||||
"""
|
||||
# Missing/empty loras input is always valid; skip get_loras_list so it
|
||||
# does not log a warning for the None case on every queue.
|
||||
if not kwargs.get("loras"):
|
||||
return None
|
||||
loras = get_loras_list(kwargs)
|
||||
active_names = []
|
||||
for lora in loras:
|
||||
if not isinstance(lora, dict):
|
||||
continue
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
active_names.append(apply_lora_syntax_format(str(lora.get("name") or "")))
|
||||
try:
|
||||
missing = _find_missing_loras(active_names)
|
||||
except Exception:
|
||||
logger.exception("Failed to validate LoRA entries against the local library")
|
||||
return None
|
||||
if not missing:
|
||||
return None
|
||||
return "Missing LoRA(s) in local library: " + ", ".join(missing)
|
||||
|
||||
|
||||
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
|
||||
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
|
||||
import safetensors.torch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
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, validate_lora_entries
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,6 +35,11 @@ class WanVideoLoraSelectLM:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, loras=None):
|
||||
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||
return validate_lora_entries({"loras": loras}) or True
|
||||
|
||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
FUNCTION = "process_loras"
|
||||
|
||||
@@ -51,6 +51,29 @@ LICENSE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
_broadcast_models_changed_tasks: set = set()
|
||||
|
||||
|
||||
def _broadcast_models_changed() -> None:
|
||||
"""Notify connected clients that the local model library changed.
|
||||
|
||||
The ComfyUI graph page listens for this event to invalidate its cached
|
||||
model availability data (loras widget missing-model cues / error flags)
|
||||
without waiting for the cache TTL to expire.
|
||||
"""
|
||||
try:
|
||||
from ...services.websocket_manager import ws_manager
|
||||
|
||||
task = asyncio.create_task(ws_manager.broadcast({"type": "models_changed"}))
|
||||
# Keep a reference so the task is not garbage-collected mid-await.
|
||||
_broadcast_models_changed_tasks.add(task)
|
||||
task.add_done_callback(_broadcast_models_changed_tasks.discard)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).debug(
|
||||
"Failed to broadcast models_changed", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
class ModelPageView:
|
||||
"""Render the HTML view for model listings."""
|
||||
|
||||
@@ -460,6 +483,7 @@ class ModelManagementHandler:
|
||||
return web.Response(text="Model path is required", status=400)
|
||||
|
||||
result = await self._lifecycle_service.delete_model(file_path)
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
@@ -931,6 +955,8 @@ class ModelManagementHandler:
|
||||
file_path=file_path, new_file_name=new_file_name
|
||||
)
|
||||
|
||||
_broadcast_models_changed()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
**result,
|
||||
@@ -959,6 +985,7 @@ class ModelManagementHandler:
|
||||
)
|
||||
|
||||
result = await self._lifecycle_service.bulk_delete_models(file_paths)
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
@@ -1061,6 +1088,7 @@ class ModelQueryHandler:
|
||||
await self._service.scan_models(
|
||||
force_refresh=True, rebuild_cache=full_rebuild
|
||||
)
|
||||
_broadcast_models_changed()
|
||||
if self._service.scanner.is_cancelled():
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -2235,6 +2263,8 @@ class ModelMoveHandler:
|
||||
result = await self._move_service.move_model(
|
||||
file_path, target_path, use_default_paths=use_default_paths
|
||||
)
|
||||
if result.get("success"):
|
||||
_broadcast_models_changed()
|
||||
status = 200 if result.get("success") else 500
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
@@ -2254,6 +2284,8 @@ class ModelMoveHandler:
|
||||
result = await self._move_service.move_models_bulk(
|
||||
file_paths, target_path, use_default_paths=use_default_paths
|
||||
)
|
||||
if result.get("success"):
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)
|
||||
@@ -2299,6 +2331,7 @@ class ModelAutoOrganizeHandler:
|
||||
progress_callback=self._progress_callback,
|
||||
exclusion_patterns=exclusion_patterns,
|
||||
)
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result.to_dict())
|
||||
except AutoOrganizeInProgressError:
|
||||
return web.json_response(
|
||||
|
||||
Reference in New Issue
Block a user