Skip to content

Commit 1e7cad5

Browse files
committed
refactor: extract custom-node model adapters from inline handlers
1 parent 67a0fc2 commit 1e7cad5

23 files changed

Lines changed: 940 additions & 496 deletions

__init__.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ def setup_routes(self):
355355

356356
# Import resolver modules
357357
try:
358+
from .core.custom_nodes import (
359+
adapt_custom_node_loaded_model,
360+
should_skip_existing_custom_node_reference,
361+
)
358362
from .core.metadata_audit import audit_metadata_sizes
359363
from .core.metadata_builder import (
360364
build_missing_local_metadata,
@@ -712,22 +716,14 @@ def update_analysis_progress(payload):
712716
force_rescan=force_rescan,
713717
)
714718

715-
# Filter out LoraManager lorAs that already exist locally (exists=True)
716-
# These should not appear in missing models at all
717719
missing_models = result.get("missing_models", [])
718720
filtered_missing = []
719721
for missing in missing_models:
720-
is_lora = missing.get("is_lora_v2")
721-
exists = missing.get("exists")
722722
name = missing.get("name") or missing.get("original_path", "")
723-
self.logger.debug(
724-
f"Filtering: {name} is_lora_v2={is_lora} exists={exists}"
725-
)
726-
727-
# Skip LoraManager lorAs that already exist locally
728-
if is_lora and exists:
723+
if should_skip_existing_custom_node_reference(missing):
729724
self.logger.info(
730-
f"Filtered out LoraManager lora: {name}"
725+
"Filtered existing custom-node model "
726+
f"reference: {name}"
731727
)
732728
continue
733729
filtered_missing.append(missing)
@@ -2001,10 +1997,11 @@ def node_matches_ref(node, ref):
20011997
if ref.get("strength") is not None:
20021998
strength = ref.get("strength")
20031999

2004-
# For text-based lora loaders (LoraLoaderV2, LoraManager), get strength from ref
2005-
if ref.get("is_lora_v2"):
2006-
strength = ref.get("strength")
2007-
model_name = ref.get("name", model_name)
2000+
model_name, strength = adapt_custom_node_loaded_model(
2001+
ref,
2002+
model_name,
2003+
strength,
2004+
)
20082005

20092006
# Check if model exists locally
20102007
exists = ref.get("exists", False)
@@ -2050,7 +2047,9 @@ def node_matches_ref(node, ref):
20502047
"strength": strength,
20512048
"original_path": original_path,
20522049
"is_urn": ref.get("is_urn", False),
2053-
"is_lora_v2": ref.get("is_lora_v2", False),
2050+
"custom_node_adapter": ref.get(
2051+
"custom_node_adapter"
2052+
),
20542053
"active": ref.get("active"),
20552054
"connected": ref.get("connected", True),
20562055
"resolved_path": (

core/custom_nodes/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Backend custom-node model adapters
2+
3+
Backend integrations for non-standard workflow node formats live here.
4+
5+
Each adapter declares its node types, category hints, and fixed widget
6+
categories. Optional hooks can provide:
7+
8+
- custom workflow reference extraction;
9+
- lightweight potential-reference detection;
10+
- custom workflow path updates;
11+
- existing-reference filtering;
12+
- Loaded Models display adjustments.
13+
14+
Register new adapters in `registry.py`. Generic dictionary-backed model fields,
15+
including `nested_key`, stay in the shared analyzer and updater because they are
16+
not specific to one node package.

core/custom_nodes/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Backend custom-node model adapter registry."""
2+
3+
from .registry import (
4+
CUSTOM_NODE_MODEL_ADAPTERS,
5+
adapt_custom_node_loaded_model,
6+
analyze_custom_node_references,
7+
custom_node_has_potential_model_reference,
8+
get_custom_node_adapter_for_reference,
9+
get_custom_node_category_hints,
10+
get_custom_node_model_adapter,
11+
get_custom_node_resolution_metadata,
12+
get_custom_node_widget_categories,
13+
should_skip_existing_custom_node_reference,
14+
update_custom_node_model_path,
15+
)
16+
17+
__all__ = [
18+
"CUSTOM_NODE_MODEL_ADAPTERS",
19+
"adapt_custom_node_loaded_model",
20+
"analyze_custom_node_references",
21+
"custom_node_has_potential_model_reference",
22+
"get_custom_node_adapter_for_reference",
23+
"get_custom_node_category_hints",
24+
"get_custom_node_model_adapter",
25+
"get_custom_node_resolution_metadata",
26+
"get_custom_node_widget_categories",
27+
"should_skip_existing_custom_node_reference",
28+
"update_custom_node_model_path",
29+
]

core/custom_nodes/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Shared contract for backend custom-node model adapters."""
2+
3+
from dataclasses import dataclass, field
4+
from typing import Any, Callable, Dict, Optional, Tuple
5+
6+
7+
@dataclass(frozen=True)
8+
class CustomNodeModelAdapter:
9+
"""Describe backend behavior supplied by one custom-node integration."""
10+
11+
adapter_id: str
12+
node_types: Tuple[str, ...]
13+
category_hint: Optional[str] = None
14+
widget_categories: Dict[int, str] = field(default_factory=dict)
15+
analyze_references: Optional[Callable[..., Any]] = None
16+
has_potential_reference: Optional[Callable[..., bool]] = None
17+
update_model_path: Optional[Callable[..., Optional[bool]]] = None
18+
should_skip_existing: Optional[Callable[..., bool]] = None
19+
adapt_loaded_model: Optional[Callable[..., Any]] = None

core/custom_nodes/lora_manager.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""Backend adapter for ComfyUI-Lora-Manager loader nodes."""
2+
3+
import os
4+
from typing import Any, Dict, List, Optional
5+
6+
from ..log_system import create_module_logger
7+
from .base import CustomNodeModelAdapter
8+
9+
log = create_module_logger(__name__)
10+
11+
ADAPTER_ID = "lora-manager"
12+
NODE_TYPES = (
13+
"LoraLoaderV2",
14+
"Lora Loader (LoraManager)",
15+
"Lora Stacker (LoraManager)",
16+
)
17+
LORA_LIST_WIDGET_INDEX = 2
18+
TEXT_WIDGET_INDEX = 1
19+
20+
21+
def analyze_references(
22+
node: Dict[str, Any],
23+
available_models: Optional[List[Dict[str, Any]]] = None,
24+
*,
25+
is_active: bool,
26+
get_widget_name_hint: Any,
27+
) -> Optional[List[Dict[str, Any]]]:
28+
"""Extract LoRA references stored in Lora Manager's list widget."""
29+
widgets_values = node.get("widgets_values", [])
30+
if len(widgets_values) < 3:
31+
return None
32+
33+
from ..scanner import get_model_files
34+
35+
all_loras = (
36+
available_models if available_models is not None else get_model_files()
37+
)
38+
lora_files = [
39+
model for model in all_loras if model.get("category") == "loras"
40+
]
41+
lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
42+
for lora_file in lora_files:
43+
filename = lora_file.get("filename", "")
44+
if not filename:
45+
continue
46+
base_name = os.path.splitext(filename)[0]
47+
lora_lookup.setdefault(base_name, []).append(lora_file)
48+
49+
lora_list = widgets_values[LORA_LIST_WIDGET_INDEX]
50+
if not isinstance(lora_list, list):
51+
return []
52+
53+
node_id = node.get("id")
54+
node_type = node.get("type", "")
55+
node_title = str(node.get("title", "") or "").strip()
56+
model_refs: List[Dict[str, Any]] = []
57+
for lora_item in lora_list:
58+
if not isinstance(lora_item, dict):
59+
continue
60+
61+
name = lora_item.get("name", "")
62+
if not name:
63+
continue
64+
65+
lora_exists = False
66+
lora_full_path = None
67+
if name in lora_lookup:
68+
lora_full_path = lora_lookup[name][0].get("path")
69+
lora_exists = (
70+
os.path.exists(lora_full_path) if lora_full_path else False
71+
)
72+
else:
73+
for extension in [".safetensors", ".ckpt", ".pt", ".pth"]:
74+
test_name = name + extension
75+
if test_name not in lora_lookup:
76+
continue
77+
lora_full_path = lora_lookup[test_name][0].get("path")
78+
lora_exists = (
79+
os.path.exists(lora_full_path) if lora_full_path else False
80+
)
81+
if lora_exists:
82+
break
83+
84+
log.debug(f"Lora {name}: exists={lora_exists}, path={lora_full_path}")
85+
model_refs.append(
86+
{
87+
"node_id": node_id,
88+
"node_type": node_type,
89+
"widget_index": LORA_LIST_WIDGET_INDEX,
90+
"widget_name": get_widget_name_hint(
91+
node, LORA_LIST_WIDGET_INDEX
92+
),
93+
"original_path": name,
94+
"name": name,
95+
"strength": float(lora_item.get("strength", 1.0)),
96+
"active": lora_item.get("active", True),
97+
"node_title": node_title,
98+
"category": "loras",
99+
"category_hints": ["loras"],
100+
"folder_key_hints": ["loras"],
101+
"full_path": lora_full_path,
102+
"exists": lora_exists,
103+
"is_urn": False,
104+
"custom_node_adapter": ADAPTER_ID,
105+
"connected": is_active,
106+
}
107+
)
108+
return model_refs
109+
110+
111+
def has_potential_reference(node: Dict[str, Any]) -> bool:
112+
"""Return whether the serialized LoRA list contains a named entry."""
113+
widgets_values = node.get("widgets_values")
114+
if not isinstance(widgets_values, list) or len(widgets_values) < 3:
115+
return False
116+
lora_list = widgets_values[LORA_LIST_WIDGET_INDEX]
117+
if not isinstance(lora_list, list):
118+
return False
119+
return any(
120+
isinstance(item, dict) and str(item.get("name") or "").strip()
121+
for item in lora_list
122+
)
123+
124+
125+
def update_model_path(
126+
node: Dict[str, Any],
127+
widget_index: int,
128+
resolved_model: Optional[Dict[str, Any]],
129+
mapping: Optional[Dict[str, Any]],
130+
) -> Optional[bool]:
131+
"""Update one LoRA name in both the list and formatted text widgets."""
132+
mapping = mapping or {}
133+
adapter_id = mapping.get("custom_node_adapter")
134+
is_legacy_mapping = mapping.get("is_lora_v2") is True
135+
if adapter_id != ADAPTER_ID and not is_legacy_mapping:
136+
return None
137+
138+
original_name = (
139+
mapping.get("custom_node_original_identity")
140+
or mapping.get("original_lora_name")
141+
)
142+
if not original_name or widget_index != LORA_LIST_WIDGET_INDEX:
143+
return None
144+
145+
widgets_values = node.get("widgets_values", [])
146+
lora_list = widgets_values[LORA_LIST_WIDGET_INDEX]
147+
if not isinstance(lora_list, list):
148+
log.warning(
149+
"Lora Manager list widget is not a list: "
150+
f"{type(lora_list)}"
151+
)
152+
return False
153+
154+
new_name = None
155+
if resolved_model:
156+
new_name = resolved_model.get("filename") or resolved_model.get(
157+
"name", ""
158+
)
159+
if new_name and "." in new_name:
160+
new_name = new_name.rsplit(".", 1)[0]
161+
if not new_name:
162+
return False
163+
164+
original_stripped = str(original_name).strip()
165+
updated = False
166+
for lora_item in lora_list:
167+
if not isinstance(lora_item, dict):
168+
continue
169+
current_name = str(lora_item.get("name", "")).strip()
170+
if (
171+
current_name == original_stripped
172+
or current_name.lower() == original_stripped.lower()
173+
):
174+
lora_item["name"] = new_name
175+
updated = True
176+
break
177+
178+
if not updated:
179+
available = [
180+
item.get("name") for item in lora_list if isinstance(item, dict)
181+
]
182+
log.warning(
183+
f"Lora '{original_name}' not found in Lora Manager list. "
184+
f"Available: {available}"
185+
)
186+
return False
187+
188+
if (
189+
len(widgets_values) > TEXT_WIDGET_INDEX
190+
and isinstance(widgets_values[TEXT_WIDGET_INDEX], str)
191+
):
192+
old_text = widgets_values[TEXT_WIDGET_INDEX]
193+
new_text = old_text.replace(
194+
f"<lora:{original_name}:", f"<lora:{new_name}:"
195+
)
196+
new_text = new_text.replace(
197+
f":{original_name}:", f":{new_name}:"
198+
)
199+
widgets_values[TEXT_WIDGET_INDEX] = new_text
200+
201+
log.info(
202+
f"Updated Lora Manager model: {original_name} -> {new_name}"
203+
)
204+
return True
205+
206+
207+
def should_skip_existing(reference: Dict[str, Any]) -> bool:
208+
"""Existing list entries do not require matching or relinking."""
209+
return reference.get("exists") is True
210+
211+
212+
def adapt_loaded_model(
213+
reference: Dict[str, Any],
214+
model_name: str,
215+
strength: Any,
216+
) -> tuple[str, Any]:
217+
"""Use the list entry's display name and strength."""
218+
return (
219+
reference.get("name", model_name),
220+
reference.get("strength", strength),
221+
)
222+
223+
224+
ADAPTER = CustomNodeModelAdapter(
225+
adapter_id=ADAPTER_ID,
226+
node_types=NODE_TYPES,
227+
category_hint="loras",
228+
widget_categories={LORA_LIST_WIDGET_INDEX: "loras"},
229+
analyze_references=analyze_references,
230+
has_potential_reference=has_potential_reference,
231+
update_model_path=update_model_path,
232+
should_skip_existing=should_skip_existing,
233+
adapt_loaded_model=adapt_loaded_model,
234+
)

0 commit comments

Comments
 (0)