Support Smart Model Loader Nova [SML2] nodes (#6)

Add custom node adapter for ComfyUI_SmartModelLoader_Nova suite:
- Support 'Smart Model Loader [SML2]', 'CLIP Loader [SML2]', and 'LoRA Stack [SML2]'
- Parse and serialize canonical JSON configuration widget
- Extract diffusion models, VAE, CLIP text encoders, and LoRAs with strengths
- Enable model resolution by hash using per-resource SHA256 stored in the node
- Update model paths and SHA256 checksums in canonical JSON format during relinking
- Add unit and end-to-end integration tests
This commit is contained in:
Dariusz L
2026-10-25 18:55:24 +01:00
parent 3af7036551
commit 55197621eb
4 changed files with 723 additions and 14 deletions
+4
View File
@@ -12,10 +12,14 @@ from .lora_manager import ADAPTER as LORA_MANAGER_ADAPTER
from .rgthree_power_lora_loader import (
ADAPTER as RGTHREE_POWER_LORA_LOADER_ADAPTER,
)
from .smart_model_loader_nova import (
ADAPTER as SMART_MODEL_LOADER_NOVA_ADAPTER,
)
CUSTOM_NODE_MODEL_ADAPTERS = (
LORA_MANAGER_ADAPTER,
RGTHREE_POWER_LORA_LOADER_ADAPTER,
SMART_MODEL_LOADER_NOVA_ADAPTER,
)
_ADAPTERS_BY_ID = {
adapter.adapter_id: adapter for adapter in CUSTOM_NODE_MODEL_ADAPTERS
@@ -0,0 +1,307 @@
"""Backend adapter for ComfyUI Smart Model Loader Nova ([SML2]) loader nodes.
These nodes keep every model choice inside one JSON "configuration" widget
(a canonical, key-sorted JSON string), for example::
{"model": {"kind": "unet", "resource": {"name": "...", "sha256": "..."}},
"clip": {"source": "external", "encoders": [{"id": "...", "resource": {...}}]},
"vae": {"source": "external", "resource": {...}},
"loras": {"entries": [{"id": "...", "resource": {...}, "strength": 1}]}, ...}
The CLIP Loader and LoRA Stack variants use the same resource objects under
``clip.encoders`` and ``entries``. Each resource becomes one reference on
widget 0; updates rewrite the matching resource name and keep the string
canonical so the node's own editor reads it back unchanged.
"""
from __future__ import annotations
import json
import os
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
from ..contracts import CustomNodeMetadata, ModelReference, ResolvedModel
from ..log_system import create_module_logger
from ..type_utils import normalize_sha256
from .base import CustomNodeModelAdapter
log = create_module_logger(__name__)
ADAPTER_ID = "smart-model-loader-nova"
NODE_TYPES = (
"Smart Model Loader [SML2]",
"CLIP Loader [SML2]",
"LoRA Stack [SML2]",
)
CONFIG_WIDGET_INDEX = 0
# model.kind -> Smart Model Loader folder role (core/model_loader/configuration.py MODES)
MODEL_KIND_ROLES = {
"checkpoint": "checkpoints",
"unet": "diffusion_models",
"gguf": "diffusion_models_gguf",
"nunchaku-flux": "diffusion_models",
"nunchaku-qwen": "diffusion_models",
"nunchaku-zimage": "diffusion_models",
}
# role -> (resolver category, ComfyUI folder keys searched for the file)
ROLE_FOLDERS = {
"checkpoints": ("checkpoints", ("checkpoints",)),
"diffusion_models": ("diffusion_models", ("diffusion_models", "unet")),
"diffusion_models_gguf": (
"diffusion_models",
("diffusion_models_gguf", "unet_gguf", "diffusion_models", "unet"),
),
"text_encoders": ("text_encoders", ("text_encoders", "clip")),
"vae": ("vae", ("vae",)),
"loras": ("loras", ("loras",)),
}
CATEGORY_ALIASES = {
"unet": "diffusion_models",
"clip": "text_encoders",
"diffusion_models_gguf": "diffusion_models",
"unet_gguf": "diffusion_models",
}
def _load_configuration(value: Any) -> Tuple[Optional[Dict[str, Any]], bool]:
"""Return (configuration, stored_as_string)."""
if isinstance(value, dict):
return value, False
if isinstance(value, str) and value.strip().startswith("{"):
try:
parsed = json.loads(value)
except ValueError:
return None, True
return (parsed if isinstance(parsed, dict) else None), True
return None, False
def _dump_configuration(config: Dict[str, Any]) -> str:
# Same canonical form as the node editor (sorted keys, no whitespace).
return json.dumps(config, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _resource_name(resource: Any) -> str:
if not isinstance(resource, dict):
return ""
name = str(resource.get("name") or "").strip()
return "" if name == "None" else name
def _iter_resources(config: Dict[str, Any]) -> Iterator[Tuple[Dict[str, Any], str, Dict[str, Any]]]:
"""Yield (resource, role, entry) for every model file the configuration names."""
model = config.get("model")
if isinstance(model, dict) and isinstance(model.get("resource"), dict):
role = MODEL_KIND_ROLES.get(str(model.get("kind") or ""), "diffusion_models")
yield model["resource"], role, model
for key in ("vae", "audioVae"):
section = config.get(key)
if (
isinstance(section, dict)
and section.get("source") == "external"
and isinstance(section.get("resource"), dict)
):
yield section["resource"], "vae", section
clip = config.get("clip")
if isinstance(clip, dict) and clip.get("source") != "baked":
if isinstance(clip.get("resource"), dict):
yield clip["resource"], "text_encoders", clip
for entry in clip.get("encoders") or []:
if isinstance(entry, dict) and isinstance(entry.get("resource"), dict):
yield entry["resource"], "text_encoders", entry
loras = config.get("loras")
lora_entries = (
loras.get("entries")
if isinstance(loras, dict)
else (loras if isinstance(loras, list) else config.get("entries"))
)
for entry in lora_entries or []:
if isinstance(entry, dict) and isinstance(entry.get("resource"), dict):
yield entry["resource"], "loras", entry
def _find_full_path(
role: str,
name: str,
available_models: Optional[List[ResolvedModel]] = None,
) -> Optional[str]:
try:
import folder_paths
except ImportError:
folder_paths = None
if folder_paths is not None:
for folder in ROLE_FOLDERS.get(role, ("", ()))[1]:
try:
full_path = folder_paths.get_full_path(folder, name)
except Exception:
full_path = None
if full_path and os.path.exists(full_path):
return full_path
if available_models:
category = ROLE_FOLDERS.get(role, ("diffusion_models", ()))[0]
norm_name = name.replace("\\", "/").lower()
for model in available_models:
if model.category == category:
model_rel = (model.relative_path or "").replace("\\", "/").lower()
model_fn = (model.filename or "").replace("\\", "/").lower()
if norm_name in (model_rel, model_fn):
return model.path
return None
def analyze_references(
node: Dict[str, Any],
available_models: Optional[List[ResolvedModel]] = None,
*,
is_active: bool,
get_widget_name_hint: Callable[[Dict[str, Any], int], str],
) -> Optional[List[ModelReference]]:
"""Extract every model file named inside the configuration widget."""
widgets_values = node.get("widgets_values")
if not isinstance(widgets_values, list) or not widgets_values:
return None
config, _ = _load_configuration(widgets_values[CONFIG_WIDGET_INDEX])
if config is None:
return None
node_title = str(node.get("title", "") or "").strip()
references: List[ModelReference] = []
for resource, role, entry in _iter_resources(config):
name = _resource_name(resource)
if not name:
continue
category, folder_keys = ROLE_FOLDERS.get(role, ("diffusion_models", ()))
full_path = _find_full_path(role, name, available_models=available_models)
reference = {
"node_id": node.get("id"),
"node_type": node.get("type", ""),
"widget_index": CONFIG_WIDGET_INDEX,
"widget_name": get_widget_name_hint(node, CONFIG_WIDGET_INDEX),
"original_path": name,
"name": name,
"node_title": node_title,
"category": category,
"category_hints": [category],
"folder_key_hints": list(folder_keys),
"full_path": full_path,
"exists": bool(full_path),
"is_urn": False,
"custom_node_adapter": ADAPTER_ID,
"custom_node_original_identity": name,
"sml2_role": role,
"connected": bool(is_active),
}
sha256 = normalize_sha256(resource.get("sha256"))
if sha256:
# Lets the resolver find the same file by hash under any name or folder.
reference["workflow_sha256"] = sha256
reference["hash_lookup_source"] = "sml2_configuration"
if role == "loras":
try:
reference["strength"] = float(entry.get("strength", 1.0))
except (TypeError, ValueError):
reference["strength"] = 1.0
reference["active"] = entry.get("enabled", entry.get("active", True)) is not False
references.append(ModelReference.from_mapping(reference))
return references
def has_potential_reference(node: Dict[str, Any]) -> bool:
widgets_values = node.get("widgets_values")
if not isinstance(widgets_values, list) or not widgets_values:
return False
config, _ = _load_configuration(widgets_values[CONFIG_WIDGET_INDEX])
if config is None:
return False
return any(_resource_name(resource) for resource, _, _ in _iter_resources(config))
def _category_matches(role: str, resolved_category: str) -> bool:
if not resolved_category:
return True
wanted = CATEGORY_ALIASES.get(resolved_category, resolved_category)
return ROLE_FOLDERS.get(role, ("",))[0] == wanted
def update_model_path(
node: Dict[str, Any],
widget_index: int,
resolved_model: Optional[ResolvedModel],
metadata: CustomNodeMetadata,
) -> Optional[bool]:
"""Point the matching resource at the resolved file, keeping the JSON canonical."""
if metadata.adapter_id != ADAPTER_ID or widget_index != CONFIG_WIDGET_INDEX:
return None
original_name = str(metadata.original_identity or "").strip()
if not original_name or resolved_model is None:
return False
new_name = resolved_model.relative_path or resolved_model.filename
if not new_name:
return False
widgets_values = node.get("widgets_values")
if not isinstance(widgets_values, list) or not widgets_values:
return False
config, stored_as_string = _load_configuration(widgets_values[CONFIG_WIDGET_INDEX])
if config is None:
log.warning("Smart Model Loader Nova configuration could not be parsed")
return False
resolved_sha = normalize_sha256((resolved_model.extra or {}).get("sha256"))
updated = 0
for resource, role, _ in _iter_resources(config):
current = _resource_name(resource)
if current.replace("\\", "/").lower() != original_name.replace("\\", "/").lower():
continue
if not _category_matches(role, resolved_model.category):
continue
resource["name"] = new_name
if resolved_sha:
resource["sha256"] = resolved_sha
else:
# A different file may have been chosen; a stale hash would fail integrity checks.
resource.pop("sha256", None)
updated += 1
if not updated:
log.warning(f"'{original_name}' was not found in the Smart Model Loader Nova configuration")
return False
widgets_values[CONFIG_WIDGET_INDEX] = (
_dump_configuration(config) if stored_as_string else config
)
log.info(f"Updated Smart Model Loader Nova model: {original_name} -> {new_name}")
return True
def should_skip_existing(reference: ModelReference) -> bool:
return reference.exists is True
def adapt_loaded_model(
reference: ModelReference,
model_name: str,
strength: Any,
) -> Tuple[str, Any]:
return (
reference.extra_value("name", model_name),
reference.extra_value("strength", strength),
)
ADAPTER = CustomNodeModelAdapter(
adapter_id=ADAPTER_ID,
node_types=NODE_TYPES,
analyze_references=analyze_references,
has_potential_reference=has_potential_reference,
update_model_path=update_model_path,
should_skip_existing=should_skip_existing,
adapt_loaded_model=adapt_loaded_model,
)
+21 -14
View File
@@ -405,20 +405,27 @@ def extract_workflow_hash_metadata(workflow_json: Dict[str, Any]) -> Dict[str, D
def get_workflow_hash_info_for_ref(
workflow_hashes: Dict[str, Dict[str, Any]], model_ref: ModelReference
) -> Optional[Dict[str, Any]]:
if not workflow_hashes:
return None
original_path = model_ref.original_path
# Node/widget positions are reused after edits, and multi-model widgets
# share one position. Only model names/paths can identify a saved hash.
candidates = [
original_path,
get_filename_from_path(original_path),
model_ref.extra_value("filename", "") or "",
model_ref.extra_value("name", "") or "",
]
for candidate in candidates:
if candidate and candidate in workflow_hashes:
return workflow_hashes[candidate]
if workflow_hashes:
original_path = model_ref.original_path
# Node/widget positions are reused after edits, and multi-model widgets
# share one position. Only model names/paths can identify a saved hash.
candidates = [
original_path,
get_filename_from_path(original_path),
model_ref.extra_value("filename", "") or "",
model_ref.extra_value("name", "") or "",
]
for candidate in candidates:
if candidate and candidate in workflow_hashes:
return workflow_hashes[candidate]
# Custom-node adapters may carry the hash stored inside the node itself.
node_sha256 = normalize_sha256(model_ref.extra_value("workflow_sha256"))
if node_sha256:
return {
"sha256": node_sha256,
"source": model_ref.extra_value("hash_lookup_source") or "custom_node",
}
return None
+391
View File
@@ -0,0 +1,391 @@
"""Tests for Smart Model Loader Nova [SML2] custom node adapter."""
import json
import unittest
from core.contracts import (
CustomNodeMetadata,
ModelReference,
Resolution,
ResolvedModel,
)
from core.custom_nodes.registry import (
get_custom_node_model_adapter,
get_custom_node_resolution_metadata,
)
from core.custom_nodes.smart_model_loader_nova import (
ADAPTER_ID,
CONFIG_WIDGET_INDEX,
_dump_configuration,
_load_configuration,
analyze_references,
has_potential_reference,
update_model_path,
)
from core.resolver import get_workflow_hash_info_for_ref
from core.workflow.analysis import analyze_workflow_models
from core.workflow_updater import update_model_path as update_workflow_model_path
class SmartModelLoaderNovaTests(unittest.TestCase):
def test_adapter_registration(self):
for node_type in (
"Smart Model Loader [SML2]",
"CLIP Loader [SML2]",
"LoRA Stack [SML2]",
):
with self.subTest(node_type=node_type):
adapter = get_custom_node_model_adapter(node_type)
self.assertIsNotNone(adapter)
self.assertEqual(ADAPTER_ID, adapter.adapter_id)
def test_load_and_dump_configuration(self):
raw_dict = {"model": {"kind": "unet"}, "version": 1}
config, stored_as_string = _load_configuration(raw_dict)
self.assertFalse(stored_as_string)
self.assertEqual(raw_dict, config)
raw_json = '{"version":1,"model":{"kind":"unet"}}'
config, stored_as_string = _load_configuration(raw_json)
self.assertTrue(stored_as_string)
self.assertEqual("unet", config["model"]["kind"])
dumped = _dump_configuration(config)
self.assertEqual('{"model":{"kind":"unet"},"version":1}', dumped)
def test_analyze_references_extracts_all_resource_types(self):
config = {
"model": {
"kind": "unet",
"resource": {
"name": "Qwen/diffusion_model.safetensors",
"sha256": "a" * 64,
},
},
"clip": {
"source": "external",
"encoders": [
{
"id": "clip-1",
"resource": {
"name": "text_encoders/qwen_clip.safetensors",
"sha256": "b" * 64,
},
}
],
},
"vae": {
"source": "external",
"resource": {
"name": "vae/qwen_vae.safetensors",
},
},
"loras": {
"entries": [
{
"id": "lora-1",
"resource": {
"name": "loras/detail_slider.safetensors",
},
"strength": 1.5,
"enabled": True,
}
]
},
"version": 1,
}
node = {
"id": 10,
"type": "Smart Model Loader [SML2]",
"title": "Smart Loader",
"widgets_values": [_dump_configuration(config)],
}
available = [
ResolvedModel(
path="/models/vae/qwen_vae.safetensors",
filename="qwen_vae.safetensors",
category="vae",
relative_path="vae/qwen_vae.safetensors",
)
]
refs = analyze_references(
node,
available_models=available,
is_active=True,
get_widget_name_hint=lambda n, idx: "configuration",
)
self.assertIsNotNone(refs)
self.assertEqual(4, len(refs))
by_cat = {ref.category: ref for ref in refs}
# 1. Diffusion model
unet_ref = by_cat["diffusion_models"]
self.assertEqual("Qwen/diffusion_model.safetensors", unet_ref.original_path)
self.assertEqual("a" * 64, unet_ref.extra_value("workflow_sha256"))
self.assertEqual("sml2_configuration", unet_ref.extra_value("hash_lookup_source"))
self.assertFalse(unet_ref.exists)
# 2. Text encoder (clip)
clip_ref = by_cat["text_encoders"]
self.assertEqual("text_encoders/qwen_clip.safetensors", clip_ref.original_path)
self.assertEqual("b" * 64, clip_ref.extra_value("workflow_sha256"))
# 3. VAE (matched from available_models)
vae_ref = by_cat["vae"]
self.assertEqual("vae/qwen_vae.safetensors", vae_ref.original_path)
self.assertTrue(vae_ref.exists)
self.assertEqual("/models/vae/qwen_vae.safetensors", vae_ref.extra_value("full_path"))
# 4. LoRA
lora_ref = by_cat["loras"]
self.assertEqual("loras/detail_slider.safetensors", lora_ref.original_path)
self.assertEqual(1.5, lora_ref.extra_value("strength"))
self.assertTrue(lora_ref.extra_value("active"))
def test_analyze_references_skips_baked_and_none(self):
config = {
"model": {
"kind": "unet",
"resource": {"name": "None"},
},
"clip": {
"source": "baked",
"encoders": [{"resource": {"name": "ignored.safetensors"}}],
},
"vae": {
"source": "baked",
"resource": {"name": "ignored_vae.safetensors"},
},
}
node = {
"id": 11,
"type": "Smart Model Loader [SML2]",
"widgets_values": [_dump_configuration(config)],
}
refs = analyze_references(
node,
is_active=True,
get_widget_name_hint=lambda n, idx: "configuration",
)
self.assertEqual([], refs)
def test_has_potential_reference(self):
valid_node = {
"widgets_values": [
json.dumps({"model": {"resource": {"name": "model.safetensors"}}})
]
}
empty_node = {
"widgets_values": [
json.dumps({"model": {"resource": {"name": "None"}}})
]
}
invalid_node = {"widgets_values": ["not-json"]}
self.assertTrue(has_potential_reference(valid_node))
self.assertFalse(has_potential_reference(empty_node))
self.assertFalse(has_potential_reference(invalid_node))
def test_update_model_path_rewrites_resource_and_hash(self):
config = {
"model": {
"kind": "unet",
"resource": {
"name": "AuthorFolder/qwen_unet.safetensors",
"sha256": "1" * 64,
},
},
"version": 1,
}
node = {
"id": 12,
"type": "Smart Model Loader [SML2]",
"widgets_values": [_dump_configuration(config)],
}
resolved = ResolvedModel(
path="C:/models/diffusion_models/Local/qwen_unet.safetensors",
filename="qwen_unet.safetensors",
category="diffusion_models",
relative_path="Local/qwen_unet.safetensors",
extra={"sha256": "2" * 64},
)
metadata = CustomNodeMetadata(
adapter_id=ADAPTER_ID,
original_identity="AuthorFolder/qwen_unet.safetensors",
)
success = update_model_path(node, CONFIG_WIDGET_INDEX, resolved, metadata)
self.assertTrue(success)
updated_config, is_str = _load_configuration(node["widgets_values"][0])
self.assertTrue(is_str)
resource = updated_config["model"]["resource"]
self.assertEqual("Local/qwen_unet.safetensors", resource["name"])
self.assertEqual("2" * 64, resource["sha256"])
def test_update_model_path_removes_stale_hash_when_resolved_lacks_hash(self):
config = {
"model": {
"kind": "unet",
"resource": {
"name": "AuthorFolder/qwen_unet.safetensors",
"sha256": "1" * 64,
},
},
}
node = {
"id": 13,
"type": "Smart Model Loader [SML2]",
"widgets_values": [_dump_configuration(config)],
}
resolved = ResolvedModel(
path="C:/models/diffusion_models/other.safetensors",
filename="other.safetensors",
category="diffusion_models",
relative_path="other.safetensors",
)
metadata = CustomNodeMetadata(
adapter_id=ADAPTER_ID,
original_identity="AuthorFolder/qwen_unet.safetensors",
)
success = update_model_path(node, CONFIG_WIDGET_INDEX, resolved, metadata)
self.assertTrue(success)
updated_config, _ = _load_configuration(node["widgets_values"][0])
resource = updated_config["model"]["resource"]
self.assertEqual("other.safetensors", resource["name"])
self.assertNotIn("sha256", resource)
def test_get_workflow_hash_info_for_ref_custom_node_fallback(self):
ref = ModelReference.from_mapping(
{
"node_id": 14,
"original_path": "model.safetensors",
"name": "model.safetensors",
"workflow_sha256": "c" * 64,
"hash_lookup_source": "sml2_configuration",
}
)
# 1. With empty workflow_hashes: falls back to node's own hash
hash_info = get_workflow_hash_info_for_ref({}, ref)
self.assertIsNotNone(hash_info)
self.assertEqual("c" * 64, hash_info["sha256"])
self.assertEqual("sml2_configuration", hash_info["source"])
# 2. When workflow_hashes has a match, workflow_hashes takes priority
workflow_hashes = {
"model.safetensors": {"sha256": "d" * 64, "source": "workflow_metadata"}
}
priority_info = get_workflow_hash_info_for_ref(workflow_hashes, ref)
self.assertEqual("d" * 64, priority_info["sha256"])
self.assertEqual("workflow_metadata", priority_info["source"])
def test_workflow_analyzer_and_updater_end_to_end(self):
"""Verify the full pipeline: analyze_workflow_models -> resolution metadata -> update_model_path."""
config = {
"model": {
"kind": "unet",
"resource": {
"name": "Qwen Image/qwen_image_2.1_int8_convrot.safetensors",
"sha256": "cb74" * 16,
},
},
"clip": {
"source": "external",
"type": "qwen_image",
"encoders": [
{
"id": "clip-1",
"resource": {
"name": "qwen3-vl-8b-heretic-1.3.0_int8_convrot.safetensors",
"sha256": "443f" * 16,
},
}
],
},
"vae": {
"source": "external",
"resource": {
"name": "qwen_image_2.1_vae_bf16.safetensors",
},
},
"loras": {
"entries": [
{
"id": "lora-1",
"resource": {
"name": "qwen/Pornmaster_QI2.1_Breasts_Slider_V1.safetensors",
},
"strength": 5.0,
}
]
},
"version": 1,
}
workflow = {
"nodes": [
{
"id": 42,
"type": "Smart Model Loader [SML2]",
"widgets_values": [_dump_configuration(config)],
}
]
}
# 1. Workflow analyzer extracts references via custom node adapter
refs = analyze_workflow_models(workflow, available_models=[])
self.assertEqual(4, len(refs))
by_cat = {ref.category: ref for ref in refs}
unet_ref = by_cat["diffusion_models"]
self.assertEqual(
"Qwen Image/qwen_image_2.1_int8_convrot.safetensors",
unet_ref.original_path,
)
self.assertEqual(ADAPTER_ID, unet_ref.extra_value("custom_node_adapter"))
self.assertEqual("cb74" * 16, unet_ref.extra_value("workflow_sha256"))
# 2. Verify resolution metadata extraction for workflow updater
metadata = get_custom_node_resolution_metadata(unet_ref)
self.assertEqual(ADAPTER_ID, metadata.adapter_id)
self.assertEqual(
"Qwen Image/qwen_image_2.1_int8_convrot.safetensors",
metadata.original_identity,
)
# 3. Test end-to-end update through core.workflow_updater.update_model_path
resolution = Resolution(
node_id=42,
widget_index=CONFIG_WIDGET_INDEX,
resolved_path="diffusion_models/qwen_fixed.safetensors",
category="diffusion_models",
resolved_model=ResolvedModel(
path="/models/diffusion_models/qwen_fixed.safetensors",
filename="qwen_fixed.safetensors",
category="diffusion_models",
relative_path="qwen_fixed.safetensors",
extra={"sha256": "1111" * 16},
),
custom_node_metadata=metadata,
)
updated = update_workflow_model_path(workflow, resolution)
self.assertTrue(updated)
updated_config, _ = _load_configuration(
workflow["nodes"][0]["widgets_values"][0]
)
res = updated_config["model"]["resource"]
self.assertEqual("qwen_fixed.safetensors", res["name"])
self.assertEqual("1111" * 16, res["sha256"])
if __name__ == "__main__":
unittest.main()