feat: add workflow metadata transfer preview

This commit is contained in:
Dariusz L
2026-09-13 16:10:58 +02:00
parent 18ef647c83
commit 3a106e8d67
12 changed files with 2599 additions and 4 deletions
+2
View File
@@ -144,6 +144,7 @@ def register_routes(self):
from .source_search import register_source_search_routes
from .version import register_version_routes
from .workflow_analysis import register_workflow_analysis_routes
from .workflow_transfer import register_workflow_transfer_routes
except ImportError as e:
self.logger.error(f"Model Resolver: Could not import core modules: {e}")
return False
@@ -253,6 +254,7 @@ def register_routes(self):
register_base_model_routes(routes, web, json_api_endpoint)
route_context = RouteContext.from_namespaces(globals(), locals())
register_workflow_analysis_routes(route_context)
register_workflow_transfer_routes(route_context)
register_hash_routes(route_context)
register_metadata_routes(route_context)
register_image_metadata_routes(route_context)
+16
View File
@@ -0,0 +1,16 @@
"""Workflow metadata transfer route registration."""
from ..services.workflow_transfer_service import WorkflowTransferService
from .context import RouteContext
from .helpers import register_service_route
def register_workflow_transfer_routes(context: RouteContext):
"""Register the workflow metadata transfer endpoint."""
service = WorkflowTransferService(context)
register_service_route(
context,
path="/model_resolver/transfer-models",
error_prefix="transfer-models",
operation=service.transfer_models,
)
@@ -0,0 +1,75 @@
"""HTTP service for transferring metadata models into the active workflow."""
from ..request_utils import read_optional_object_payload, validate_workflow_payload
from ..workflow.formats import detect_workflow_format, normalize_workflow_payload
from ..workflow_transfer import transfer_models_to_workflow
class WorkflowTransferService:
"""Validate transfer requests and apply them through the shared workflow updater."""
def __init__(self, context):
self.asyncio = context.require("asyncio")
self.get_workflow_model_inventory = context.require(
"get_workflow_model_inventory"
)
self.web = context.require("web")
async def transfer_models(self, request):
"""Transfer selected source models into selected target slots."""
data = await read_optional_object_payload(request)
workflow, workflow_error = validate_workflow_payload(
data.get("workflow"),
empty_is_missing=True,
)
if workflow_error:
return self.web.json_response({"error": workflow_error}, status=400)
source_models = data.get("source_models")
target_refs = data.get("target_refs")
if not isinstance(source_models, list):
return self.web.json_response(
{"error": "source_models must be an array"},
status=400,
)
if not isinstance(target_refs, list):
return self.web.json_response(
{"error": "target_refs must be an array"},
status=400,
)
if len(source_models) > 500 or len(target_refs) > 1000:
return self.web.json_response(
{"error": "The transfer selection is too large"},
status=400,
)
mode = data.get("mode", "replace")
if not isinstance(mode, str):
return self.web.json_response(
{"error": "Transfer mode must be a string"},
status=400,
)
if detect_workflow_format(workflow) == "api":
try:
workflow, _workflow_format = normalize_workflow_payload(workflow)
except ValueError as exc:
return self.web.json_response({"error": str(exc)}, status=400)
try:
inventory = await self.asyncio.to_thread(
self.get_workflow_model_inventory,
workflow,
)
result = await self.asyncio.to_thread(
transfer_models_to_workflow,
workflow,
source_models,
target_refs,
mode=mode,
inventory=inventory,
)
except (TypeError, ValueError) as exc:
return self.web.json_response({"error": str(exc)}, status=400)
return self.web.json_response(result)
+737
View File
@@ -0,0 +1,737 @@
"""Transfer model selections from one workflow metadata source to another."""
import math
import os
import re
from collections import OrderedDict
from collections.abc import Mapping, Sequence
from copy import deepcopy
from typing import Any, Dict, List, Optional
from .contracts import ModelReference, Resolution, ResolvedModel
from .custom_nodes import get_custom_node_resolution_metadata
from .path_utils import get_filename_from_path
from .workflow import dynamic_widgets
from .workflow.widgets import get_widget_name_candidates, normalize_widget_name
from .workflow_updater import update_model_path
_CATEGORY_ALIASES = {
"checkpoint": "checkpoints",
"checkpoints": "checkpoints",
"ckpt": "checkpoints",
"lora": "loras",
"loras": "loras",
"text_encoder": "text_encoders",
"text_encoders": "text_encoders",
"clip": "text_encoders",
"vae": "vae",
"controlnet": "controlnet",
"control_net": "controlnet",
"upscale": "upscale_models",
"upscaler": "upscale_models",
"upscale_model": "upscale_models",
"upscale_models": "upscale_models",
"diffusion_model": "diffusion_models",
"diffusion_models": "diffusion_models",
"unet": "diffusion_models",
"embedding": "embeddings",
"embeddings": "embeddings",
}
_LORA_MANAGER_NODE_TYPES = {
"LoraLoaderV2",
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
}
_POWER_LORA_NODE_TYPE = "Power Lora Loader (rgthree)"
_LORA_MODEL_WIDGET_TYPES = {
"LoraLoader",
"LoraLoaderModelOnly",
"LoraLoaderBypass",
"LoraLoaderBypassModelOnly",
"CreateHookLora",
"CreateHookLoraModelOnly",
}
_LORA_STRENGTH_NAMES = {
"strength",
"strength_model",
"lora_strength",
"lora_model_strength",
"model_strength",
}
def normalize_transfer_category(value: Any) -> str:
"""Normalize the category names used by workflow and metadata payloads."""
token = str(value or "").strip().lower().replace("-", "_")
if not token or token in {"unknown", "none", "null"}:
return ""
return _CATEGORY_ALIASES.get(token, token)
def _normalize_identity(value: Any) -> str:
return str(value or "").strip().replace("\\", "/").casefold()
def _coerce_strength(value: Any) -> Optional[float]:
if isinstance(value, bool) or value in (None, ""):
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _coerce_active(value: Any) -> Optional[bool]:
if isinstance(value, bool):
return value
return None
def _set_named_lora_strength(
node: Mapping[str, Any],
reference: ModelReference,
strength: float,
) -> None:
widget_index = reference.widget_index
if not isinstance(widget_index, int):
return
named_values = node.get("widgets_values_named")
if not isinstance(named_values, dict):
return
widget_name = reference.extra_value("widget_name")
if not widget_name:
keys = list(named_values)
if widget_index < 0 or widget_index >= len(keys):
return
widget_name = keys[widget_index]
named_value = named_values.get(widget_name)
if reference.extra_value("nested_key") == "lora" and isinstance(named_value, dict):
named_value["strength"] = strength
def _source_model(value: Any) -> Optional[Dict[str, Any]]:
if not isinstance(value, Mapping):
return None
path = ""
for key in ("original_path", "resolved_path", "path", "name", "filename"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate.strip():
path = candidate.strip()
break
category = normalize_transfer_category(value.get("category"))
if not path or not category:
return None
if path.casefold().startswith("urn:") or value.get("is_urn"):
return None
return {
"path": path,
"category": category,
"strength": _coerce_strength(value.get("strength")),
"active": _coerce_active(value.get("active")),
"name": str(value.get("name") or get_filename_from_path(path)).strip(),
}
def _scope_key(reference: ModelReference) -> tuple[str, bool, str]:
return (
str(reference.node_id),
reference.is_top_level is not False,
str(reference.subgraph_id or ""),
)
def _selector_matches(reference: ModelReference, selector: Mapping[str, Any]) -> bool:
if str(reference.node_id) != str(selector.get("node_id")):
return False
selector_is_top_level = selector.get("is_top_level") is not False
if (reference.is_top_level is not False) != selector_is_top_level:
return False
if not selector_is_top_level and str(reference.subgraph_id or "") != str(
selector.get("subgraph_id") or ""
):
return False
widget_index = selector.get("widget_index")
if isinstance(widget_index, bool) or not isinstance(widget_index, int):
return False
if reference.widget_index != widget_index:
return False
selector_category = normalize_transfer_category(selector.get("category"))
reference_category = normalize_transfer_category(reference.category)
if selector_category and reference_category and selector_category != reference_category:
return False
selector_identity = ""
for key in ("original_path", "name", "filename"):
value = selector.get(key)
if isinstance(value, str) and value.strip():
selector_identity = _normalize_identity(value)
break
reference_identity = _normalize_identity(reference.original_path)
return not (
selector_identity
and reference_identity
and selector_identity != reference_identity
)
def _find_workflow_node(workflow: Mapping[str, Any], reference: ModelReference) -> Optional[Dict[str, Any]]:
node_id = str(reference.node_id)
if reference.is_top_level is not False:
nodes = workflow.get("nodes", [])
if isinstance(nodes, list):
return next(
(
node
for node in nodes
if isinstance(node, dict) and str(node.get("id")) == node_id
),
None,
)
return None
definitions = workflow.get("definitions", {})
subgraphs = definitions.get("subgraphs", []) if isinstance(definitions, dict) else []
if not isinstance(subgraphs, list):
return None
for subgraph in subgraphs:
if not isinstance(subgraph, dict) or str(subgraph.get("id")) != str(
reference.subgraph_id or ""
):
continue
nodes = subgraph.get("nodes", [])
if not isinstance(nodes, list):
return None
return next(
(
node
for node in nodes
if isinstance(node, dict) and str(node.get("id")) == node_id
),
None,
)
return None
def _resolved_model(source: Mapping[str, Any]) -> ResolvedModel:
path = str(source["path"])
return ResolvedModel(
path=path if os.path.isabs(path) else "",
filename=get_filename_from_path(path),
relative_path=path,
category=str(source["category"]),
)
def _build_resolution(reference: ModelReference, source: Mapping[str, Any]) -> Resolution:
promoted_widget_name = reference.extra_value("promoted_widget_name") or reference.extra_value(
"widget_name"
)
return Resolution(
node_id=reference.node_id,
widget_index=reference.widget_index,
resolved_path=str(source["path"]),
category=normalize_transfer_category(reference.category) or source["category"],
resolved_model=_resolved_model(source),
subgraph_id=reference.subgraph_id,
is_top_level=reference.is_top_level is not False,
nested_key=reference.extra_value("nested_key"),
promoted_widget_name=promoted_widget_name,
reference=reference,
custom_node_metadata=get_custom_node_resolution_metadata(reference),
)
def _resolution_payload(resolution: Resolution) -> Dict[str, Any]:
payload = {
"node_id": resolution.node_id,
"widget_index": resolution.widget_index,
"resolved_path": resolution.resolved_path,
"category": resolution.category,
"subgraph_id": resolution.subgraph_id,
"is_top_level": resolution.is_top_level,
"nested_key": resolution.nested_key,
"promoted_widget_name": resolution.promoted_widget_name,
}
metadata = resolution.custom_node_metadata.to_dict()
if metadata:
payload["custom_node_metadata"] = metadata
return payload
def _assign_sources(
sources: Sequence[Mapping[str, Any]],
references: Sequence[ModelReference],
) -> List[tuple[ModelReference, Mapping[str, Any]]]:
if not sources or not references:
return []
if len(sources) == 1:
return [(reference, sources[0]) for reference in references]
return list(zip(references, sources, strict=False))
def _set_lora_strength(
node: Dict[str, Any],
reference: ModelReference,
strength: Optional[float],
*,
fallback_identity: Optional[str] = None,
) -> bool:
"""Copy a source LoRA strength where the target node exposes one."""
if strength is None or normalize_transfer_category(reference.category) != "loras":
return False
widgets_values = node.get("widgets_values", [])
if not isinstance(widgets_values, list):
return False
widget_index = reference.widget_index
if not isinstance(widget_index, int) or widget_index < 0 or widget_index >= len(widgets_values):
return False
value = widgets_values[widget_index]
nested_key = reference.extra_value("nested_key")
if nested_key == "lora" and isinstance(value, dict):
value["strength"] = strength
_set_named_lora_strength(node, reference, strength)
return True
adapter_id = reference.extra_value("custom_node_adapter")
if adapter_id == "lora-manager" or node.get("type") in _LORA_MANAGER_NODE_TYPES:
if len(widgets_values) <= 2 or not isinstance(widgets_values[2], list):
return False
target_identities = {
identity
for identity in (
_normalize_identity(reference.original_path),
_normalize_identity(fallback_identity),
)
if identity
}
for item in widgets_values[2]:
if not isinstance(item, dict):
continue
if _normalize_identity(item.get("name")) in target_identities:
item["strength"] = strength
if len(widgets_values) > 1 and isinstance(widgets_values[1], str):
item_name = str(item.get("name") or "").strip()
if item_name:
widgets_values[1] = re.sub(
rf"(<lora:{re.escape(item_name)}:)[^>]+>",
rf"\g<1>{strength:g}>",
widgets_values[1],
count=1,
flags=re.IGNORECASE,
)
return True
return False
widget_names_by_index: Dict[int, set[str]] = {}
for index in range(len(widgets_values)):
names = {
normalize_widget_name(name)
for name in [
*get_widget_name_candidates(node, index),
dynamic_widgets.get_dynamic_serialized_widget_name(node, index),
]
if name
}
widget_names_by_index[index] = names
target_names = widget_names_by_index.get(widget_index, set())
strength_indexes: List[int] = []
indexed_match = next(
(
match
for name in target_names
for match in [re.fullmatch(r"lora_(\d+)_name", name)]
if match
),
None,
)
if indexed_match:
number = indexed_match.group(1)
for index, names in widget_names_by_index.items():
if names.intersection(
{
f"lora_{number}_model_strength",
f"lora_{number}_strength",
}
):
strength_indexes.append(index)
if not strength_indexes:
for index, names in widget_names_by_index.items():
if names.intersection(_LORA_STRENGTH_NAMES):
strength_indexes.append(index)
if not strength_indexes and node.get("type") in _LORA_MODEL_WIDGET_TYPES:
candidate_index = widget_index + 1
if candidate_index < len(widgets_values):
strength_indexes.append(candidate_index)
for index in strength_indexes:
if index == widget_index:
continue
widgets_values[index] = strength
return True
return False
def _lora_name(source: Mapping[str, Any]) -> str:
filename = get_filename_from_path(str(source["path"]))
stem, _extension = os.path.splitext(filename)
return stem or filename
def _sync_lora_manager_named_values(node: Mapping[str, Any]) -> None:
"""Mirror Lora Manager's list and text widgets when named values exist."""
named_values = node.get("widgets_values_named")
widgets_values = node.get("widgets_values")
if not isinstance(named_values, dict) or not isinstance(widgets_values, list):
return
if (
len(widgets_values) > 1
and "text" in named_values
and isinstance(widgets_values[1], str)
):
named_values["text"] = widgets_values[1]
if (
len(widgets_values) > 2
and "loras" in named_values
and isinstance(widgets_values[2], list)
):
named_values["loras"] = deepcopy(widgets_values[2])
def _insert_named_power_lora_values(
node: Dict[str, Any],
items: Sequence[Mapping[str, Any]],
) -> None:
"""Keep Power LoRA's named widget mirror aligned with inserted slots."""
named_values = node.get("widgets_values_named")
if not isinstance(named_values, dict) or not items:
return
lora_key_indexes = [
index
for index, key in enumerate(named_values)
if re.fullmatch(r"lora_\d+", str(key))
]
if lora_key_indexes:
insert_index = lora_key_indexes[-1] + 1
numbers = []
for key in named_values:
match = re.fullmatch(r"lora_(\d+)", str(key))
if match:
numbers.append(int(match.group(1)))
next_number = max(numbers, default=0) + 1
else:
insert_index = len(named_values)
next_number = 1
entries = list(named_values.items())
additions = [
(f"lora_{next_number + offset}", deepcopy(item))
for offset, item in enumerate(items)
]
named_values.clear()
named_values.update(
entries[:insert_index] + additions + entries[insert_index:]
)
def _append_lora_manager_models(
node: Dict[str, Any],
sources: Sequence[Mapping[str, Any]],
) -> int:
widgets_values = node.get("widgets_values", [])
if not isinstance(widgets_values, list) or len(widgets_values) <= 2:
return 0
lora_list = widgets_values[2]
if not isinstance(lora_list, list):
return 0
template = next((item for item in lora_list if isinstance(item, dict)), {})
appended_names = []
for source in sources:
item = deepcopy(template)
name = _lora_name(source)
item["name"] = name
source_strength = source.get("strength")
item["strength"] = source_strength if source_strength is not None else 1.0
if "clipStrength" in item:
item["clipStrength"] = item["strength"]
source_active = source.get("active")
item["active"] = source_active if source_active is not None else True
lora_list.append(item)
appended_names.append((name, item["strength"]))
if len(widgets_values) > 1 and isinstance(widgets_values[1], str) and appended_names:
tokens = " ".join(
f"<lora:{name}:{strength:g}>"
for name, strength in appended_names
if isinstance(strength, (int, float))
)
if tokens:
widgets_values[1] = f"{widgets_values[1]} {tokens}".strip()
return len(appended_names)
def _append_power_lora_models(
node: Dict[str, Any],
sources: Sequence[Mapping[str, Any]],
) -> int:
widgets_values = node.get("widgets_values", [])
if not isinstance(widgets_values, list):
return 0
template = next(
(item for item in reversed(widgets_values) if isinstance(item, dict) and "lora" in item),
{"on": True, "lora": "", "strength": 1.0},
)
appended_items = []
for source in sources:
item = deepcopy(template)
item["lora"] = str(source["path"])
source_strength = source.get("strength")
item["strength"] = source_strength if source_strength is not None else 1.0
source_active = source.get("active")
item["on"] = source_active if source_active is not None else True
appended_items.append(item)
if not appended_items:
return 0
last_lora_index = max(
(
index
for index, value in enumerate(widgets_values)
if isinstance(value, dict) and "lora" in value
),
default=len(widgets_values) - 1,
)
insert_index = last_lora_index + 1
widgets_values[insert_index:insert_index] = appended_items
_insert_named_power_lora_values(node, appended_items)
return len(appended_items)
def transfer_models_to_workflow(
workflow: Dict[str, Any],
source_models: Sequence[Any],
target_refs: Sequence[Any],
*,
mode: str = "replace",
inventory: Any = None,
) -> Dict[str, Any]:
"""Apply selected metadata models to selected workflow model slots.
``replace`` changes only checked target slots. ``merge``/``add`` keeps the
current values and appends to the two supported multi-LoRA list formats.
"""
if not isinstance(workflow, dict):
raise ValueError("Workflow JSON must be an object")
normalized_mode = str(mode or "replace").strip().lower().replace("_", "-")
if normalized_mode in {"merge", "add", "merge-add"}:
normalized_mode = "merge"
elif normalized_mode != "replace":
raise ValueError("Transfer mode must be replace or merge")
if not isinstance(source_models, Sequence) or isinstance(source_models, (str, bytes)):
raise ValueError("source_models must be an array")
if not isinstance(target_refs, Sequence) or isinstance(target_refs, (str, bytes)):
raise ValueError("target_refs must be an array")
sources = [normalized for item in source_models if (normalized := _source_model(item))]
if not sources:
raise ValueError("No transferable metadata models were selected")
if not target_refs:
raise ValueError("No workflow model slots were selected")
if inventory is None:
from .workflow.inventory import get_workflow_model_inventory
inventory = get_workflow_model_inventory(workflow)
available_refs = [
item if isinstance(item, ModelReference) else ModelReference.from_mapping(item)
for item in (getattr(inventory, "model_refs", ()) or ())
]
selected_refs: List[ModelReference] = []
used_ref_indexes = set()
skipped: List[Dict[str, Any]] = []
for selector in target_refs:
if not isinstance(selector, Mapping):
skipped.append({"reason": "Invalid target model slot"})
continue
match_index = next(
(
index
for index, reference in enumerate(available_refs)
if index not in used_ref_indexes and _selector_matches(reference, selector)
),
None,
)
if match_index is None:
skipped.append({
"node_id": selector.get("node_id"),
"widget_index": selector.get("widget_index"),
"reason": "The selected workflow model slot is no longer available",
})
continue
used_ref_indexes.add(match_index)
selected_refs.append(available_refs[match_index])
if not selected_refs:
raise ValueError("The selected workflow model slots are no longer available")
refs_by_scope: OrderedDict[tuple[str, bool, str], List[ModelReference]] = OrderedDict()
for reference in selected_refs:
refs_by_scope.setdefault(_scope_key(reference), []).append(reference)
sources_by_category: OrderedDict[str, List[Dict[str, Any]]] = OrderedDict()
for source in sources:
sources_by_category.setdefault(source["category"], []).append(source)
# Build assignments in selection order across the complete workflow. One
# source intentionally fans out to every compatible target; multiple
# sources map one-to-one until either side is exhausted.
source_by_reference: Dict[int, Mapping[str, Any]] = {}
selected_refs_by_category: OrderedDict[str, List[ModelReference]] = OrderedDict()
for reference in selected_refs:
category = normalize_transfer_category(reference.category)
if category:
selected_refs_by_category.setdefault(category, []).append(reference)
for category, category_refs in selected_refs_by_category.items():
for reference, source in _assign_sources(
sources_by_category.get(category, []),
category_refs,
):
source_by_reference[id(reference)] = source
resolutions: List[Dict[str, Any]] = []
updates: List[Dict[str, Any]] = []
requires_full_reload = normalized_mode == "merge"
strength_changed = False
for _scope, scope_refs in refs_by_scope.items():
node = _find_workflow_node(workflow, scope_refs[0])
if not node:
skipped.append({"node_id": scope_refs[0].node_id, "reason": "Workflow node not found"})
continue
node_type = str(node.get("type") or "")
refs_by_category: OrderedDict[str, List[ModelReference]] = OrderedDict()
for reference in scope_refs:
category = normalize_transfer_category(reference.category)
if category:
refs_by_category.setdefault(category, []).append(reference)
if normalized_mode == "merge":
if node_type in _LORA_MANAGER_NODE_TYPES:
lora_sources = sources_by_category.get("loras", [])
appended = _append_lora_manager_models(node, lora_sources)
if appended:
_sync_lora_manager_named_values(node)
updates.append({
"node_id": scope_refs[0].node_id,
"node_type": node_type,
"category": "loras",
"operation": "merge",
"count": appended,
})
elif lora_sources:
skipped.append({
"node_id": scope_refs[0].node_id,
"reason": "This LoRA Manager node has no appendable LoRA list",
})
continue
if node_type == _POWER_LORA_NODE_TYPE:
lora_sources = sources_by_category.get("loras", [])
appended = _append_power_lora_models(node, lora_sources)
if appended:
updates.append({
"node_id": scope_refs[0].node_id,
"node_type": node_type,
"category": "loras",
"operation": "merge",
"count": appended,
})
elif lora_sources:
skipped.append({
"node_id": scope_refs[0].node_id,
"reason": "This Power LoRA node has no appendable LoRA slots",
})
continue
matching_source_categories = [
category for category in refs_by_category if sources_by_category.get(category)
]
if matching_source_categories:
skipped.append({
"node_id": scope_refs[0].node_id,
"node_type": node_type,
"reason": "Merge/Add is available for multi-model LoRA nodes only; the current values were kept",
})
continue
for category, references in refs_by_category.items():
if not sources_by_category.get(category):
continue
for reference in references:
source = source_by_reference.get(id(reference))
if source is None:
continue
resolution = _build_resolution(reference, source)
if not update_model_path(workflow, resolution):
skipped.append({
"node_id": reference.node_id,
"widget_index": reference.widget_index,
"reason": "The selected workflow model slot could not be updated",
})
continue
if _set_lora_strength(
node,
reference,
source.get("strength"),
fallback_identity=_lora_name(source),
):
strength_changed = True
if node_type in _LORA_MANAGER_NODE_TYPES:
_sync_lora_manager_named_values(node)
resolutions.append(_resolution_payload(resolution))
updates.append({
"node_id": reference.node_id,
"widget_index": reference.widget_index,
"node_type": reference.node_type,
"category": category,
"operation": "replace",
"source": source["path"],
})
if reference.is_top_level is False:
requires_full_reload = True
if strength_changed:
requires_full_reload = True
updated_count = sum(
int(item.get("count", 0)) if item.get("operation") == "merge" else 1
for item in updates
)
if not updated_count:
raise ValueError("No selected workflow model slot could accept the metadata model")
return {
"success": True,
"workflow": workflow,
"mode": normalized_mode,
"updated": updated_count,
"updates": updates,
"skipped": skipped,
"requires_full_reload": requires_full_reload,
"resolutions": [] if requires_full_reload else resolutions,
}
__all__ = ["normalize_transfer_category", "transfer_models_to_workflow"]
+19
View File
@@ -269,6 +269,25 @@ def update_model_path(workflow: Dict[str, Any], resolution: Resolution) -> bool:
else:
widgets_values[widget_index] = relative_path
log.debug(f"Updated node {node_id}, widget {widget_index} to: {relative_path}")
# Some ComfyUI UI workflows keep a named mirror alongside the positional
# widgets array. Keep it in sync so promoted subgraph values do not appear
# to revert on the next workflow analysis.
named_values = node.get("widgets_values_named")
if isinstance(named_values, dict):
named_widget = resolution.promoted_widget_name
if not named_widget and resolution.reference:
named_widget = resolution.reference.extra_value("widget_name")
if not named_widget:
named_keys = list(named_values)
if 0 <= widget_index < len(named_keys):
named_widget = named_keys[widget_index]
if named_widget:
named_value = named_values.get(named_widget)
if nested_key and isinstance(named_value, dict):
named_value[nested_key] = relative_path
else:
named_values[named_widget] = relative_path
return True
+5
View File
@@ -95,6 +95,7 @@ class TestRefactoringTargets(unittest.IsolatedAsyncioTestCase):
"workflow": {
("POST", "/model_resolver/analyze"),
("GET", "/model_resolver/analyze-progress/{analysis_id}"),
("POST", "/model_resolver/transfer-models"),
("POST", "/model_resolver/resolve"),
("POST", "/model_resolver/local-matches"),
("POST", "/model_resolver/local-model-hashes"),
@@ -170,6 +171,9 @@ class TestRefactoringTargets(unittest.IsolatedAsyncioTestCase):
("POST", "/model_resolver/resolve"),
("POST", "/model_resolver/local-matches"),
},
"workflow_transfer": {
("POST", "/model_resolver/transfer-models"),
},
"workflow_hashes": {
("POST", "/model_resolver/local-model-hashes"),
("GET", "/model_resolver/model-preview"),
@@ -219,6 +223,7 @@ class TestRefactoringTargets(unittest.IsolatedAsyncioTestCase):
("POST", "/model_resolver/base-models/update"),
("POST", "/model_resolver/analyze"),
("GET", "/model_resolver/analyze-progress/{analysis_id}"),
("POST", "/model_resolver/transfer-models"),
("POST", "/model_resolver/resolve"),
("POST", "/model_resolver/local-matches"),
("POST", "/model_resolver/local-model-hashes"),
+1
View File
@@ -75,6 +75,7 @@ def test_register_routes_registers_each_route_family(route_environment):
assert {
("GET", "/model_resolver/base-models"),
("POST", "/model_resolver/analyze"),
("POST", "/model_resolver/transfer-models"),
("POST", "/model_resolver/local-model-hashes"),
("POST", "/model_resolver/civitai-search"),
("POST", "/model_resolver/search"),
+460
View File
@@ -0,0 +1,460 @@
from copy import deepcopy
from types import SimpleNamespace
import pytest
from core.contracts import ModelReference
from core.workflow_transfer import (
normalize_transfer_category,
transfer_models_to_workflow,
)
def _reference(
node_id,
original_path,
*,
category,
node_type="",
widget_index=0,
is_top_level=None,
subgraph_id=None,
**extra,
):
payload = {
"node_id": node_id,
"node_type": node_type,
"widget_index": widget_index,
"original_path": original_path,
"category": category,
"exists": False,
}
if is_top_level is not None:
payload["is_top_level"] = is_top_level
if subgraph_id is not None:
payload["subgraph_id"] = subgraph_id
payload.update(extra)
return ModelReference.from_mapping(payload)
def _inventory(*references):
return SimpleNamespace(model_refs=tuple(references))
def _selector(reference):
return reference.to_dict()
def test_normalize_transfer_category_covers_workflow_aliases():
assert normalize_transfer_category("UPSCALE-MODEL") == "upscale_models"
assert normalize_transfer_category("checkpoint") == "checkpoints"
assert normalize_transfer_category("unknown") == ""
def test_replace_fans_one_source_to_selected_slots_and_syncs_named_values():
workflow = {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"widgets_values": ["old-a.safetensors"],
"widgets_values_named": {"ckpt_name": "old-a.safetensors"},
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"widgets_values": ["old-b.safetensors"],
"widgets_values_named": {"ckpt_name": "old-b.safetensors"},
},
]
}
refs = (
_reference(
1,
"old-a.safetensors",
category="checkpoints",
node_type="CheckpointLoaderSimple",
widget_name="ckpt_name",
),
_reference(
2,
"old-b.safetensors",
category="checkpoints",
node_type="CheckpointLoaderSimple",
widget_name="ckpt_name",
),
)
result = transfer_models_to_workflow(
workflow,
[{"original_path": "new.safetensors", "category": "checkpoint"}],
[_selector(ref) for ref in refs],
inventory=_inventory(*refs),
)
assert result["updated"] == 2
assert result["requires_full_reload"] is False
assert [node["widgets_values"][0] for node in workflow["nodes"]] == [
"new.safetensors",
"new.safetensors",
]
assert [
node["widgets_values_named"]["ckpt_name"] for node in workflow["nodes"]
] == ["new.safetensors", "new.safetensors"]
def test_replace_zips_multiple_sources_and_copies_lora_strength():
workflow = {
"nodes": [
{
"id": 10,
"type": "LoraLoader",
"widgets_values": ["old-a.safetensors", 1.0],
"widgets": [{"name": "lora_name"}, {"name": "strength_model"}],
},
{
"id": 11,
"type": "LoraLoader",
"widgets_values": ["old-b.safetensors", 1.0],
"widgets": [{"name": "lora_name"}, {"name": "strength_model"}],
},
]
}
refs = (
_reference(
10,
"old-a.safetensors",
category="loras",
node_type="LoraLoader",
widget_name="lora_name",
),
_reference(
11,
"old-b.safetensors",
category="loras",
node_type="LoraLoader",
widget_name="lora_name",
),
)
result = transfer_models_to_workflow(
workflow,
[
{"original_path": "new-a.safetensors", "category": "loras", "strength": 0.4},
{"original_path": "new-b.safetensors", "category": "loras", "strength": 0.8},
],
[_selector(ref) for ref in refs],
inventory=_inventory(*refs),
)
assert result["updated"] == 2
assert result["requires_full_reload"] is True
assert workflow["nodes"][0]["widgets_values"] == ["new-a.safetensors", 0.4]
assert workflow["nodes"][1]["widgets_values"] == ["new-b.safetensors", 0.8]
def test_replace_updates_inner_subgraph_node_and_requests_full_reload():
workflow = {
"nodes": [],
"definitions": {
"subgraphs": [
{
"id": "group-1",
"nodes": [
{
"id": 7,
"type": "UpscaleModelLoader",
"widgets_values": ["old-upscale.pth"],
}
],
}
]
},
}
ref = _reference(
7,
"old-upscale.pth",
category="upscale_models",
node_type="UpscaleModelLoader",
is_top_level=False,
subgraph_id="group-1",
widget_name="model_name",
)
result = transfer_models_to_workflow(
workflow,
[{"original_path": "new-upscale.pth", "category": "upscaler"}],
[_selector(ref)],
inventory=_inventory(ref),
)
assert result["updated"] == 1
assert result["requires_full_reload"] is True
assert (
workflow["definitions"]["subgraphs"][0]["nodes"][0]["widgets_values"][0]
== "new-upscale.pth"
)
def test_replace_updates_lora_manager_entry_and_strength():
workflow = {
"nodes": [
{
"id": 20,
"type": "Lora Loader (LoraManager)",
"widgets_values": [
None,
"old prompt <lora:old_style:0.7>",
[{"name": "old_style", "strength": 0.7, "active": True}],
],
"widgets_values_named": {
"text": "old prompt <lora:old_style:0.7>",
"loras": [
{"name": "old_style", "strength": 0.7, "active": True}
],
},
}
]
}
ref = _reference(
20,
"old_style",
category="loras",
node_type="Lora Loader (LoraManager)",
widget_index=2,
custom_node_adapter="lora-manager",
name="old_style",
strength=0.7,
)
result = transfer_models_to_workflow(
workflow,
[{"original_path": "new_style.safetensors", "category": "loras", "strength": 0.45}],
[_selector(ref)],
inventory=_inventory(ref),
)
entry = workflow["nodes"][0]["widgets_values"][2][0]
assert result["updated"] == 1
assert entry["name"] == "new_style"
assert entry["strength"] == 0.45
assert "<lora:old_style:" not in workflow["nodes"][0]["widgets_values"][1]
assert "<lora:new_style:0.45>" in workflow["nodes"][0]["widgets_values"][1]
assert workflow["nodes"][0]["widgets_values_named"]["loras"] == [entry]
assert workflow["nodes"][0]["widgets_values_named"]["text"] == workflow["nodes"][0]["widgets_values"][1]
def test_merge_appends_lora_manager_entry_without_removing_existing_values():
workflow = {
"nodes": [
{
"id": 21,
"type": "LoraLoaderV2",
"widgets_values": [
None,
"existing prompt",
[{"name": "existing", "strength": 1.0, "active": True}],
],
}
]
}
ref = _reference(
21,
"existing",
category="loras",
node_type="LoraLoaderV2",
widget_index=2,
custom_node_adapter="lora-manager",
)
result = transfer_models_to_workflow(
workflow,
[{"original_path": "added.safetensors", "category": "loras", "strength": 0.6}],
[_selector(ref)],
mode="merge",
inventory=_inventory(ref),
)
loras = workflow["nodes"][0]["widgets_values"][2]
assert result["updated"] == 1
assert result["requires_full_reload"] is True
assert [item["name"] for item in loras] == ["existing", "added"]
assert loras[0]["strength"] == 1.0
assert loras[1]["active"] is True
assert "<lora:added:0.6>" in workflow["nodes"][0]["widgets_values"][1]
def test_merge_appends_power_lora_slot_without_removing_existing_values():
workflow = {
"nodes": [
{
"id": 22,
"type": "Power Lora Loader (rgthree)",
"widgets_values": [
{},
{"type": "PowerLoraLoaderHeaderWidget"},
{"on": True, "lora": "existing.safetensors", "strength": 1.0},
{},
"",
],
"widgets_values_named": {
"divider": {},
"PowerLoraLoaderHeaderWidget": {
"type": "PowerLoraLoaderHeaderWidget"
},
"lora_1": {
"on": True,
"lora": "existing.safetensors",
"strength": 1.0,
},
"➕ Add Lora": "",
},
}
]
}
ref = _reference(
22,
"existing.safetensors",
category="loras",
node_type="Power Lora Loader (rgthree)",
custom_node_adapter="rgthree-power-lora-loader",
nested_key="lora",
strength=1.0,
)
result = transfer_models_to_workflow(
workflow,
[{"original_path": "added.safetensors", "category": "loras"}],
[_selector(ref)],
mode="add",
inventory=_inventory(ref),
)
values = workflow["nodes"][0]["widgets_values"]
named_values = workflow["nodes"][0]["widgets_values_named"]
assert result["updated"] == 1
assert [item["lora"] for item in values if "lora" in item] == [
"existing.safetensors",
"added.safetensors",
]
assert values[-2:] == [{}, ""]
assert named_values["lora_2"]["lora"] == "added.safetensors"
assert values[3]["strength"] == 1.0
assert values[3]["on"] is True
assert named_values["➕ Add Lora"] == ""
def test_replace_updates_power_lora_named_mirror_and_strength():
workflow = {
"nodes": [
{
"id": 25,
"type": "Power Lora Loader (rgthree)",
"widgets_values": [
{},
{"type": "PowerLoraLoaderHeaderWidget"},
{"on": True, "lora": "old.safetensors", "strength": 1.0},
{},
"",
],
"widgets_values_named": {
"divider": {},
"PowerLoraLoaderHeaderWidget": {
"type": "PowerLoraLoaderHeaderWidget"
},
"lora_1": {
"on": True,
"lora": "old.safetensors",
"strength": 1.0,
},
"➕ Add Lora": "",
},
}
]
}
ref = _reference(
25,
"old.safetensors",
category="loras",
node_type="Power Lora Loader (rgthree)",
widget_index=2,
nested_key="lora",
custom_node_adapter="rgthree-power-lora-loader",
)
result = transfer_models_to_workflow(
workflow,
[{"original_path": "new.safetensors", "category": "loras", "strength": 0.35}],
[_selector(ref)],
inventory=_inventory(ref),
)
value = workflow["nodes"][0]["widgets_values"][2]
named_value = workflow["nodes"][0]["widgets_values_named"]["lora_1"]
assert result["updated"] == 1
assert result["requires_full_reload"] is True
assert value == {"on": True, "lora": "new.safetensors", "strength": 0.35}
assert named_value == value
def test_merge_keeps_regular_single_model_nodes_unchanged():
workflow = {
"nodes": [
{
"id": 23,
"type": "CheckpointLoaderSimple",
"widgets_values": ["existing.safetensors"],
}
]
}
ref = _reference(
23,
"existing.safetensors",
category="checkpoints",
node_type="CheckpointLoaderSimple",
)
original = deepcopy(workflow)
with pytest.raises(ValueError, match="could accept"):
transfer_models_to_workflow(
workflow,
[{"original_path": "added.safetensors", "category": "checkpoints"}],
[_selector(ref)],
mode="merge",
inventory=_inventory(ref),
)
assert workflow == original
def test_stale_target_selector_is_rejected_without_mutating_workflow():
workflow = {
"nodes": [
{
"id": 24,
"type": "CheckpointLoaderSimple",
"widgets_values": ["current.safetensors"],
}
]
}
current_ref = _reference(
24,
"current.safetensors",
category="checkpoints",
node_type="CheckpointLoaderSimple",
)
stale_selector = {
**_selector(current_ref),
"original_path": "stale.safetensors",
}
original = deepcopy(workflow)
with pytest.raises(ValueError, match="no longer available"):
transfer_models_to_workflow(
workflow,
[{"original_path": "new.safetensors", "category": "checkpoints"}],
[stale_selector],
inventory=_inventory(current_ref),
)
assert workflow == original
+188
View File
@@ -0,0 +1,188 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp import web
from core.contracts import ModelReference
from core.routes.context import RouteContext
from core.routes.workflow_transfer import register_workflow_transfer_routes
class _Routes:
def __init__(self):
self.handlers = {}
def post(self, path):
def register(handler):
self.handlers[("POST", path)] = handler
return handler
return register
def _reference(
node_id=1,
original_path="old.safetensors",
*,
category="checkpoints",
node_type="CheckpointLoaderSimple",
):
return ModelReference.from_mapping(
{
"node_id": node_id,
"node_type": node_type,
"widget_index": 0,
"original_path": original_path,
"category": category,
"exists": False,
"widget_name": "ckpt_name",
}
)
def _request(payload):
return SimpleNamespace(json=AsyncMock(return_value=payload))
def _build_routes(inventory):
routes = _Routes()
get_inventory = MagicMock(return_value=inventory)
context = RouteContext(
{
"asyncio": asyncio,
"get_workflow_model_inventory": get_inventory,
"json_api_endpoint": lambda _prefix: lambda handler: handler,
"routes": routes,
"web": web,
}
)
register_workflow_transfer_routes(context)
return routes.handlers, get_inventory
@pytest.mark.asyncio
async def test_transfer_route_returns_updated_workflow():
reference = _reference()
handlers, get_inventory = _build_routes(
SimpleNamespace(model_refs=(reference,))
)
workflow = {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"widgets_values": ["old.safetensors"],
}
]
}
response = await handlers[("POST", "/model_resolver/transfer-models")](
_request(
{
"workflow": workflow,
"source_models": [
{"original_path": "new.safetensors", "category": "checkpoints"}
],
"target_refs": [reference.to_dict()],
}
)
)
body = json.loads(response.text)
assert response.status == 200
assert body["success"] is True
assert body["updated"] == 1
assert body["workflow"]["nodes"][0]["widgets_values"] == [
"new.safetensors"
]
get_inventory.assert_called_once()
@pytest.mark.asyncio
async def test_transfer_route_normalizes_api_prompt_before_scanning_and_updating():
reference = _reference(node_id="1")
handlers, get_inventory = _build_routes(
SimpleNamespace(model_refs=(reference,))
)
api_workflow = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "old.safetensors"},
}
}
response = await handlers[("POST", "/model_resolver/transfer-models")](
_request(
{
"workflow": api_workflow,
"source_models": [
{"original_path": "new.safetensors", "category": "checkpoints"}
],
"target_refs": [reference.to_dict()],
}
)
)
body = json.loads(response.text)
scanned_workflow = get_inventory.call_args.args[0]
assert response.status == 200
assert scanned_workflow["nodes"][0]["id"] == "1"
assert body["workflow"]["nodes"][0]["widgets_values"] == [
"new.safetensors"
]
@pytest.mark.asyncio
async def test_transfer_route_rejects_malformed_selection():
handlers, get_inventory = _build_routes(SimpleNamespace(model_refs=()))
response = await handlers[("POST", "/model_resolver/transfer-models")](
_request(
{
"workflow": {"nodes": []},
"source_models": {},
"target_refs": [],
}
)
)
assert response.status == 400
assert json.loads(response.text) == {"error": "source_models must be an array"}
get_inventory.assert_not_called()
@pytest.mark.asyncio
async def test_transfer_route_rejects_malformed_target_selector():
reference = _reference()
handlers, get_inventory = _build_routes(
SimpleNamespace(model_refs=(reference,))
)
response = await handlers[("POST", "/model_resolver/transfer-models")](
_request(
{
"workflow": {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"widgets_values": ["old.safetensors"],
}
]
},
"source_models": [
{"original_path": "new.safetensors", "category": "checkpoints"}
],
"target_refs": [{"node_id": 1, "widget_index": "not-an-index"}],
}
)
)
assert response.status == 400
assert json.loads(response.text) == {
"error": "The selected workflow model slots are no longer available"
}
get_inventory.assert_called_once()
+367
View File
@@ -4797,6 +4797,13 @@ button.mr-info-link:disabled {
align-items: center;
}
.mr-image-inspector-header-actions {
display: flex;
align-items: center;
gap: var(--mr-spacing-8);
flex: 0 0 auto;
}
.mr-image-inspector-tools {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -4911,6 +4918,342 @@ button.mr-info-link:disabled {
animation: mr-image-inspector-spin 700ms linear infinite;
}
.mr-image-transfer {
margin: var(--mr-spacing-12) 0 var(--mr-spacing-16);
padding: var(--mr-spacing-12);
border: 1px solid var(--mr-border);
border-radius: var(--mr-radius-xl);
background: rgba(255, 255, 255, 0.018);
}
.mr-image-transfer-header,
.mr-image-transfer-column-header,
.mr-image-transfer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mr-spacing-10);
}
.mr-image-transfer-header {
margin-bottom: var(--mr-spacing-12);
}
.mr-image-transfer-header .mr-loaded-models-title,
.mr-image-transfer-column-header h4 {
margin: 0;
}
.mr-image-transfer-column-header h4 {
color: var(--mr-text);
font-size: var(--mr-font-md);
font-weight: 700;
}
.mr-image-transfer-column-header h4 span {
color: var(--mr-text-muted);
font-weight: 500;
}
.mr-image-transfer-mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mr-spacing-8);
}
.mr-image-transfer-mode-option,
.mr-image-transfer-row,
.mr-image-transfer-node-row {
display: flex;
align-items: flex-start;
gap: var(--mr-spacing-8);
min-width: 0;
cursor: pointer;
}
.mr-image-transfer-mode-option {
padding: var(--mr-spacing-10);
border: 1px solid var(--mr-border);
border-radius: var(--mr-radius-lg);
background: rgba(255, 255, 255, 0.018);
}
.mr-image-transfer-mode-option:has(input:checked),
.mr-image-transfer-row.is-selected,
.mr-image-transfer-node-row:has(input:checked) {
border-color: rgba(114, 211, 138, 0.42);
background: rgba(114, 211, 138, 0.07);
}
.mr-image-transfer-mode-option span,
.mr-image-transfer-row-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.mr-image-transfer-mode-option strong {
color: var(--mr-text);
font-size: var(--mr-font-sm);
}
.mr-image-transfer-mode-option small,
.mr-image-transfer-row-meta,
.mr-image-transfer-selection-count {
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
.mr-image-transfer-note {
margin: var(--mr-spacing-10) 0;
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
line-height: 1.45;
}
.mr-image-transfer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mr-spacing-12);
}
.mr-image-transfer-column {
min-width: 0;
}
.mr-image-transfer-column-header {
margin-bottom: var(--mr-spacing-8);
}
.mr-image-transfer-list {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-6);
max-height: 270px;
overflow: auto;
padding: 1px;
}
.mr-image-transfer-row,
.mr-image-transfer-node-row {
padding: var(--mr-spacing-8);
border: 1px solid transparent;
border-radius: var(--mr-radius-lg);
background: rgba(0, 0, 0, 0.12);
}
.mr-image-transfer-row:hover,
.mr-image-transfer-node-row:hover {
border-color: var(--mr-border-strong, rgba(255, 255, 255, 0.24));
}
.mr-image-transfer-row input,
.mr-image-transfer-node-row input,
.mr-image-transfer-mode-option input {
flex: 0 0 auto;
margin-top: 2px;
accent-color: var(--mr-accent);
}
.mr-image-transfer-node-list {
padding: 0;
}
.mr-image-transfer-preview {
margin-top: var(--mr-spacing-12);
padding-top: var(--mr-spacing-12);
border-top: 1px solid var(--mr-border);
}
.mr-image-transfer-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mr-spacing-10);
margin-bottom: var(--mr-spacing-8);
}
.mr-image-transfer-preview-header h4 {
margin: 0;
color: var(--mr-text);
font-size: var(--mr-font-md);
font-weight: 700;
}
.mr-image-transfer-preview-header h4 span {
color: var(--mr-text-muted);
font-weight: 500;
}
.mr-image-transfer-preview-list {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-6);
max-height: 260px;
overflow: auto;
padding: 1px;
}
.mr-image-transfer-preview-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--mr-spacing-10);
min-width: 0;
padding: var(--mr-spacing-8);
border: 1px solid var(--mr-border);
border-radius: var(--mr-radius-lg);
background: rgba(0, 0, 0, 0.12);
}
.mr-image-transfer-preview-row.is-unchanged {
opacity: 0.68;
}
.mr-image-transfer-preview-side {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--mr-spacing-4);
}
.mr-image-transfer-preview-side-next {
text-align: right;
}
.mr-image-transfer-preview-label {
overflow-wrap: anywhere;
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
.mr-image-transfer-preview-value {
overflow-wrap: anywhere;
color: var(--mr-text);
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-sm);
font-weight: 650;
}
.mr-image-transfer-preview-arrow {
color: var(--mr-accent);
font-size: var(--mr-font-lg);
font-weight: 700;
}
.mr-image-transfer-preview-empty {
margin: var(--mr-spacing-8) 0;
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
.mr-image-transfer-node-chip {
min-width: min(100%, 260px);
max-width: 100%;
align-items: flex-start;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #3a3a3a;
color: var(--mr-text);
cursor: pointer;
font: inherit;
text-align: left;
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease, outline-color 120ms ease;
}
.mr-image-transfer-node-chip:hover:not(:disabled):not(.is-unavailable) {
border-color: rgba(114, 211, 138, 0.48);
background: rgba(114, 211, 138, 0.08);
}
.mr-image-transfer-node-chip:focus {
outline: none;
}
.mr-image-transfer-node-chip:focus-visible {
outline: none;
border-color: var(--mr-missing-control-focus-border, rgba(114, 211, 138, 0.30));
box-shadow: 0 0 0 2px var(--mr-missing-control-focus-ring, rgba(114, 211, 138, 0.08));
}
.mr-image-transfer-node-chip.is-selected,
.mr-image-transfer-node-chip.is-selected:hover {
color: var(--mr-accent);
border-color: rgba(114, 211, 138, 0.32);
outline: none;
background: rgba(114, 211, 138, 0.13);
box-shadow: none;
}
.mr-image-transfer-node-chip.is-unavailable,
.mr-image-transfer-node-chip:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.mr-image-transfer-node-name {
overflow-wrap: anywhere;
color: var(--mr-text);
font-size: var(--mr-font-sm);
font-weight: 650;
}
.mr-image-transfer-node-meta {
margin-top: var(--mr-spacing-4);
color: var(--mr-text-muted);
font-family: var(--mr-font-family-sans, sans-serif);
font-size: var(--mr-font-xs);
font-weight: 500;
}
.mr-image-transfer-selected-pill {
color: var(--mr-type-lora-text);
border-color: rgba(114, 211, 138, 0.28);
background: rgba(114, 211, 138, 0.1);
}
.mr-image-transfer-unavailable-pill {
color: var(--mr-text-dim);
border-color: rgba(255, 255, 255, 0.08);
}
.mr-image-transfer-row-name {
overflow-wrap: anywhere;
color: var(--mr-text);
font-size: var(--mr-font-sm);
}
.mr-image-transfer-target-group {
padding-bottom: var(--mr-spacing-6);
}
.mr-image-transfer-target-group.is-incompatible {
opacity: 0.56;
}
.mr-image-transfer-slot-list {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-4);
margin: var(--mr-spacing-4) 0 0 24px;
}
.mr-image-transfer-slot-row {
background: rgba(255, 255, 255, 0.012);
}
.mr-image-transfer-actions {
margin-top: var(--mr-spacing-12);
padding-top: var(--mr-spacing-10);
border-top: 1px solid var(--mr-border);
}
.mr-image-transfer-empty {
margin: var(--mr-spacing-8) 0;
color: var(--mr-text-muted);
font-size: var(--mr-font-xs);
}
@keyframes mr-image-inspector-spin {
to {
transform: rotate(360deg);
@@ -4921,6 +5264,30 @@ button.mr-info-link:disabled {
.mr-image-inspector-tools {
grid-template-columns: 1fr;
}
.mr-image-transfer-grid,
.mr-image-transfer-mode {
grid-template-columns: 1fr;
}
.mr-image-transfer-preview-row {
grid-template-columns: minmax(0, 1fr);
}
.mr-image-transfer-preview-arrow {
justify-self: start;
transform: rotate(90deg);
}
.mr-image-transfer-preview-side-next {
text-align: left;
}
.mr-image-transfer-header,
.mr-image-transfer-actions {
align-items: flex-start;
flex-direction: column;
}
}
.mr-info-hash-control {
@@ -926,6 +926,9 @@ export const workflowStateMethods = {
await this.loadLoadedModels(workflow, {
preserveContent: !routeChanged && reason === 'node-widget-change'
});
} else if (this.activeTab === 'metadata') {
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
await this.openMetadataTransfer?.();
}
}
};
@@ -1,6 +1,53 @@
const MAX_IMAGE_FILE_SIZE = 64 * 1024 * 1024;
const MAX_WORKFLOW_FILE_SIZE = 16 * 1024 * 1024;
const TRANSFER_CATEGORY_ALIASES = Object.freeze({
checkpoint: 'checkpoints',
checkpoints: 'checkpoints',
ckpt: 'checkpoints',
lora: 'loras',
loras: 'loras',
clip: 'text_encoders',
text_encoder: 'text_encoders',
text_encoders: 'text_encoders',
vae: 'vae',
control_net: 'controlnet',
controlnet: 'controlnet',
upscale: 'upscale_models',
upscaler: 'upscale_models',
upscale_model: 'upscale_models',
upscale_models: 'upscale_models',
unet: 'diffusion_models',
diffusion_model: 'diffusion_models',
diffusion_models: 'diffusion_models',
embedding: 'embeddings',
embeddings: 'embeddings',
});
const MERGE_LORA_NODE_TYPES = new Set([
'LoraLoaderV2',
'Lora Loader (LoraManager)',
'Lora Stacker (LoraManager)',
]);
const MERGE_POWER_LORA_NODE_TYPE = 'Power Lora Loader (rgthree)';
function normalizeTransferCategory(value) {
const token = String(value || '').trim().toLowerCase().replaceAll('-', '_');
return TRANSFER_CATEGORY_ALIASES[token] || token;
}
function getTransferNodeKey(model = {}) {
const isTopLevel = model.is_top_level !== false;
const scope = isTopLevel
? 'top'
: `sub:${String(model.subgraph_id || '')}`;
return `${scope}:${String(model.node_id ?? '')}`;
}
function normalizeTransferModelIdentity(value) {
return String(value || '').trim().replaceAll('\\', '/').toLowerCase();
}
function getImageInspectorState(dialog) {
if (!dialog.imageInspectorState) {
dialog.imageInspectorState = {
@@ -9,6 +56,7 @@ function getImageInspectorState(dialog) {
error: '',
loading: '',
requestToken: null,
transfer: null,
};
}
return dialog.imageInspectorState;
@@ -86,6 +134,7 @@ export const imageMetadataMethods = {
if (!this.contentElement) return null;
this.contentElement.style.overflowY = 'auto';
this.renderImageMetadataShell();
void this.openMetadataTransfer();
return null;
},
@@ -101,7 +150,9 @@ export const imageMetadataMethods = {
<h3 class="mr-loaded-models-title">Image Metadata</h3>
<p class="mr-loaded-models-subtitle">Paste workflow JSON or drop an image or JSON file anywhere in this panel.</p>
</div>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear">Clear</button>
<div class="mr-image-inspector-header-actions">
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear">Clear</button>
</div>
</div>
<div class="mr-image-inspector-tools">
<input type="file" accept="image/png,image/jpeg,image/webp,application/json,.json" hidden data-image-inspector-input="file">
@@ -139,16 +190,35 @@ export const imageMetadataMethods = {
let dragDepth = 0;
root.addEventListener('click', (event) => {
const nodeChip = event.target.closest?.('[data-image-transfer-node]');
if (nodeChip) {
this.toggleMetadataTransferNode(nodeChip.dataset.imageTransferNode);
return;
}
const action = event.target.closest?.('[data-image-inspector-action]')?.dataset?.imageInspectorAction;
if (!action) return;
if (action === 'choose-file') fileInput?.click();
if (action === 'clear') this.clearImageMetadata();
if (action === 'clear-transfer-targets') this.clearMetadataTransferTargets();
if (action === 'retry-transfer-targets') void this.openMetadataTransfer();
if (action === 'apply-transfer') void this.applyMetadataTransfer();
if (action === 'analyze-paste') {
const textarea = root.querySelector('.mr-image-inspector-textarea');
void this.inspectPastedWorkflow(textarea?.value || '');
}
});
root.addEventListener('change', (event) => {
const input = event.target;
if (input.matches?.('[data-image-transfer-mode]')) {
const state = getImageInspectorState(this);
if (state.transfer) state.transfer.mode = input.value === 'merge' ? 'merge' : 'replace';
this.renderImageMetadataResult();
return;
}
});
fileInput?.addEventListener('change', (event) => {
const file = event.target.files?.[0];
event.target.value = '';
@@ -265,6 +335,12 @@ export const imageMetadataMethods = {
state.requestToken = requestToken;
state.metadata = null;
state.loadedModels = null;
if (state.transfer) {
state.transfer.requestToken = null;
state.transfer.loading = false;
state.transfer.applying = false;
state.transfer.error = '';
}
state.error = '';
state.loading = 'metadata';
this.renderImageMetadataResult();
@@ -281,6 +357,7 @@ export const imageMetadataMethods = {
if (!metadata?.workflow) {
state.loading = '';
this.renderImageMetadataResult();
void this.openMetadataTransfer();
return metadata;
}
@@ -302,6 +379,7 @@ export const imageMetadataMethods = {
state.loadedModels = loadedModels;
state.loading = '';
this.renderImageMetadataResult();
void this.openMetadataTransfer();
return metadata;
} catch (error) {
if (state.requestToken !== requestToken) return null;
@@ -320,6 +398,7 @@ export const imageMetadataMethods = {
state.error = '';
state.loading = '';
this.renderImageMetadataShell();
void this.openMetadataTransfer();
},
renderImageMetadataResult() {
@@ -331,15 +410,24 @@ export const imageMetadataMethods = {
const message = state.loading === 'models'
? 'Scanning workflow models...'
: 'Reading embedded metadata...';
result.innerHTML = `<div class="mr-image-inspector-loading"><span class="mr-spinner"></span>${this.escapeHtml(message)}</div>`;
result.innerHTML = `
<div class="mr-image-inspector-loading"><span class="mr-spinner"></span>${this.escapeHtml(message)}</div>
${this.renderMetadataTransferPanel(state)}
`;
return;
}
if (state.error) {
result.innerHTML = `<p class="mr-error-text">${this.escapeHtml(state.error)}</p>`;
result.innerHTML = `
<p class="mr-error-text">${this.escapeHtml(state.error)}</p>
${this.renderMetadataTransferPanel(state)}
`;
return;
}
if (!state.metadata) {
result.innerHTML = '<p class="mr-image-inspector-empty">Drop an image or workflow JSON to inspect it.</p>';
result.innerHTML = `
<p class="mr-image-inspector-empty">Drop an image or workflow JSON to inspect it.</p>
${this.renderMetadataTransferPanel(state)}
`;
return;
}
@@ -401,6 +489,7 @@ export const imageMetadataMethods = {
</div>
<table class="mr-info-table mr-image-inspector-info-table"><tbody>${rowsHtml}${parametersHtml}</tbody></table>
</section>
${this.renderMetadataTransferPanel(state)}
<section class="mr-image-inspector-models">
<div id="${this.escapeHtml(modelContainerId)}"></div>
</section>
@@ -428,4 +517,637 @@ export const imageMetadataMethods = {
}
this.bindTooltips?.(result);
},
getMetadataTransferSourceModels(loadedModels = null) {
const models = Array.isArray(loadedModels?.loaded_models)
? loadedModels.loaded_models
: [];
return models
.map((model, sourceIndex) => ({ model, sourceIndex }))
.filter(({ model }) => (
model
&& !model.is_urn
&& String(model.original_path || '').trim()
&& normalizeTransferCategory(model.category)
));
},
getMetadataTransferTargetGroups(loadedModels = null) {
const models = Array.isArray(loadedModels?.loaded_models)
? loadedModels.loaded_models
: [];
const categoryGroups = new Map();
for (const model of models) {
const rawWidgetIndex = model?.widget_index;
const widgetIndex = Number(rawWidgetIndex);
const hasWidgetIndex = (
Number.isInteger(rawWidgetIndex)
|| (
typeof rawWidgetIndex === 'string'
&& rawWidgetIndex.trim() !== ''
&& Number.isInteger(widgetIndex)
)
);
if (!model || model.is_urn || !hasWidgetIndex || widgetIndex < 0) continue;
const category = normalizeTransferCategory(model.category);
if (!category) continue;
let categoryGroup = categoryGroups.get(category);
if (!categoryGroup) {
categoryGroup = {
category,
nodes: new Map(),
};
categoryGroups.set(category, categoryGroup);
}
const nodeKey = getTransferNodeKey(model);
let node = categoryGroup.nodes.get(nodeKey);
if (!node) {
node = {
nodeKey,
nodeId: model.node_id,
nodeType: String(model.node_type || '').trim(),
nodeTitle: String(model.node_title || '').trim(),
subgraphId: String(model.subgraph_id || ''),
subgraphName: String(model.subgraph_name || '').trim(),
isTopLevel: model.is_top_level !== false,
active: false,
refs: [],
};
categoryGroup.nodes.set(nodeKey, node);
}
node.active = node.active || (model.active !== false && model.connected !== false);
node.refs.push({
...model,
widget_index: widgetIndex,
transferSlot: node.refs.length + 1,
transferKey: `${category}:${nodeKey}:${node.refs.length}`,
});
}
return Array.from(categoryGroups.values()).map(group => ({
category: group.category,
nodes: Array.from(group.nodes.values()),
}));
},
getMetadataTransferModelValue(model = {}) {
return String(
model?.original_path
|| model?.name
|| model?.filename
|| model?.resolved_path
|| model?.path
|| ''
).trim();
},
getMetadataTransferModelLabel(model = {}, fallback = 'Unknown model') {
const value = this.getMetadataTransferModelValue(model);
const filename = this.getFilenameFromPath?.(value) || value;
const label = this.stripModelExtension?.(filename) || filename;
return String(label || fallback).trim() || fallback;
},
getMetadataTransferPreviewRows(state) {
const sourceEntries = this.getMetadataTransferSourceModels(state?.loadedModels);
const transfer = state?.transfer;
if (!transfer) return [];
const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : [];
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
: new Set();
const selectedTargets = [];
const sourcesByCategory = new Map();
for (const { model } of sourceEntries) {
const category = normalizeTransferCategory(model.category);
if (!category) continue;
if (!sourcesByCategory.has(category)) sourcesByCategory.set(category, []);
sourcesByCategory.get(category).push(model);
}
for (const group of targetGroups) {
const category = normalizeTransferCategory(group.category);
for (const node of group.nodes || []) {
if (!selectedNodeKeys.has(node.nodeKey)) continue;
for (const ref of node.refs || []) {
selectedTargets.push({ category, node, ref });
}
}
}
const makeRow = (
target,
nextModel = null,
{ operation = 'replace', currentLabelOverride = '', forceChange = false } = {},
) => {
const currentValue = this.getMetadataTransferModelValue(target.ref);
const currentLabel = currentLabelOverride
|| this.getMetadataTransferModelLabel(target.ref);
const nextValue = nextModel
? this.getMetadataTransferModelValue(nextModel)
: '';
const nextLabel = nextModel
? this.getMetadataTransferModelLabel(nextModel)
: 'Unchanged';
const currentIdentity = normalizeTransferModelIdentity(currentValue);
const nextIdentity = normalizeTransferModelIdentity(nextValue);
const unchanged = !forceChange && (
!nextModel
|| (
currentIdentity && nextIdentity
? currentIdentity === nextIdentity
: currentLabel === nextLabel
)
);
const nodeName = target.node.nodeTitle
|| target.node.nodeType
|| `Node ${target.node.nodeId}`;
return {
category: target.category,
nodeName,
nodeId: target.node.nodeId,
slot: target.ref?.transferSlot || 1,
currentLabel,
currentValue,
nextLabel: unchanged ? 'Unchanged' : nextLabel,
nextValue,
operation: unchanged ? 'unchanged' : operation,
unchanged,
};
};
if (transfer.mode === 'merge') {
const selectedNodes = new Map();
for (const target of selectedTargets) {
let entry = selectedNodes.get(target.node.nodeKey);
if (!entry) {
entry = { node: target.node, targets: [] };
selectedNodes.set(target.node.nodeKey, entry);
}
entry.targets.push(target);
}
const previewRows = [];
for (const { node, targets } of selectedNodes.values()) {
const nodeType = String(node.nodeType || '');
const canAppendLoras = (
MERGE_LORA_NODE_TYPES.has(nodeType)
|| nodeType === MERGE_POWER_LORA_NODE_TYPE
);
const loraSources = sourcesByCategory.get('loras') || [];
if (canAppendLoras && loraSources.length) {
for (const source of loraSources) {
previewRows.push(makeRow(
targets[0],
source,
{
operation: 'add',
currentLabelOverride: 'Existing LoRAs',
forceChange: true,
},
));
}
continue;
}
for (const target of targets) {
previewRows.push(makeRow(target, null, { operation: 'merge' }));
}
}
return previewRows;
}
const sourceIndexes = new Map();
return selectedTargets.map(target => {
const sources = sourcesByCategory.get(target.category) || [];
const sourceIndex = sourceIndexes.get(target.category) || 0;
sourceIndexes.set(target.category, sourceIndex + 1);
const source = sources.length === 1
? sources[0]
: sources[sourceIndex];
return makeRow(target, source || null);
});
},
renderMetadataTransferPreview(rows = []) {
const previewRows = Array.isArray(rows) ? rows : [];
const content = previewRows.length
? previewRows.map(row => {
const categoryLabel = this.getCategoryDisplayName(row.category).toUpperCase();
const slotLabel = row.slot > 1 ? ` · Slot ${row.slot}` : '';
const targetLabel = `${categoryLabel} · ${row.nodeName} · Node ${row.nodeId}${slotLabel}`;
const operationLabel = row.unchanged
? 'Unchanged'
: row.operation === 'add'
? 'Added'
: 'Replace';
const currentTitle = row.currentValue || row.currentLabel;
const nextTitle = row.nextValue || row.nextLabel;
return `
<div class="mr-image-transfer-preview-row${row.unchanged ? ' is-unchanged' : ''}">
<div class="mr-image-transfer-preview-side">
<span class="mr-image-transfer-preview-label">${this.escapeHtml(targetLabel)} · Current</span>
<strong class="mr-image-transfer-preview-value" title="${this.escapeHtml(currentTitle)}">${this.escapeHtml(row.currentLabel)}</strong>
</div>
<span class="mr-image-transfer-preview-arrow" aria-hidden="true">→</span>
<div class="mr-image-transfer-preview-side mr-image-transfer-preview-side-next">
<span class="mr-image-transfer-preview-label">${this.escapeHtml(operationLabel)}</span>
<strong class="mr-image-transfer-preview-value" title="${this.escapeHtml(nextTitle)}">${this.escapeHtml(row.nextLabel)}</strong>
</div>
</div>
`;
}).join('')
: '<p class="mr-image-transfer-preview-empty">Select a target node above to preview model changes.</p>';
return `
<section class="mr-image-transfer-preview">
<div class="mr-image-transfer-preview-header">
<h4>Transfer preview <span>${previewRows.length} ${previewRows.length === 1 ? 'slot' : 'slots'}</span></h4>
</div>
<div class="mr-image-transfer-preview-list">${content}</div>
</section>
`;
},
renderMetadataTransferPanel(state) {
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
const transfer = state?.transfer || {
loading: false,
applying: false,
error: '',
mode: 'replace',
targetGroups: [],
selectedNodeKeys: new Set(),
};
const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : [];
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
: new Set();
const sourceCategories = new Set(
sourceEntries
.map(({ model }) => normalizeTransferCategory(model.category))
);
const countText = (count, singular, plural = `${singular}s`) => (
`${count} ${count === 1 ? singular : plural}`
);
const sourceSummary = sourceEntries.length
? `${countText(sourceEntries.length, 'metadata model')} ready from the imported workflow.`
: state?.loading === 'models'
? 'Scanning the imported workflow for transferable models...'
: state?.loading
? 'Reading source workflow metadata...'
: state?.metadata?.workflow
? 'No transferable model paths were found in the imported workflow.'
: state?.metadata
? 'The imported source has no embedded ComfyUI workflow.'
: 'Import a workflow with model metadata to enable Apply transfer.';
if (transfer.loading) {
return `
<section class="mr-image-transfer mr-image-transfer-loading">
<div class="mr-image-inspector-loading">Scanning the current workflow for target nodes...</div>
<p class="mr-image-transfer-note">${this.escapeHtml(sourceSummary)}</p>
</section>
`;
}
if (transfer.error) {
return `
<section class="mr-image-transfer">
<div class="mr-image-transfer-header">
<div>
<h3 class="mr-loaded-models-title">Transfer models to current workflow</h3>
<p class="mr-error-text">${this.escapeHtml(transfer.error)}</p>
</div>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="retry-transfer-targets">Retry</button>
</div>
<p class="mr-image-transfer-note">${this.escapeHtml(sourceSummary)}</p>
</section>
`;
}
const renderNode = (node, isAvailable) => {
const selected = selectedNodeKeys.has(node.nodeKey);
const scopeLabel = node.isTopLevel
? ''
: ` · ${node.subgraphName || node.subgraphId || 'subgraph'}`;
const nodeName = node.nodeTitle || node.nodeType || `Node ${node.nodeId}`;
const nodeType = node.nodeType && node.nodeType !== nodeName
? node.nodeType
: 'Workflow node';
const unavailable = sourceEntries.length > 0 && !isAvailable;
const contextMenuAttrs = this.getContextMenuAttrs?.({
context_scope: 'loaded_model',
node_id: node.nodeId,
node_type: node.nodeType,
node_title: node.nodeTitle,
subgraph_id: node.subgraphId,
subgraph_name: node.subgraphName,
is_top_level: node.isTopLevel,
}, 'Right-click to open node options') || '';
return `
<button type="button" class="mr-model-chip mr-image-transfer-node-chip ${selected ? 'is-selected' : ''}${unavailable ? ' is-unavailable' : ''}"
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
aria-pressed="${selected ? 'true' : 'false'}"
aria-disabled="${unavailable ? 'true' : 'false'}"
${contextMenuAttrs}
${unavailable ? 'title="No matching model category in the metadata workflow"' : ''}>
<span class="mr-image-transfer-node-name">${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}</span>
<span class="mr-image-transfer-node-meta">${this.escapeHtml(nodeType)} · ${countText(node.refs.length, 'model slot')}</span>
</button>
`;
};
const renderNodeGroup = (nodes, label, groupClass, isAvailable) => {
if (!nodes.length) return '';
const chips = nodes
.map(node => renderNode(node, isAvailable))
.join('');
return `
<div class="mr-model-group mr-model-group-${groupClass}">
<div class="mr-model-group-head">
<span class="mr-model-group-label mr-model-group-label-${groupClass}"><span class="mr-model-group-dot"></span>${label} <span class="mr-model-group-count">${nodes.length}</span></span>
</div>
<div class="mr-model-chip-list${groupClass === 'inactive' ? ' mr-model-chip-list-inactive' : ''}">${chips}</div>
</div>
`;
};
const targetRows = targetGroups.length
? targetGroups.map(group => {
const nodes = Array.isArray(group.nodes) ? group.nodes : [];
const activeNodes = nodes.filter(node => node.active);
const inactiveNodes = nodes.filter(node => !node.active);
const isAvailable = sourceEntries.length === 0 || sourceCategories.has(group.category);
const isUnavailable = sourceEntries.length > 0 && !isAvailable;
const selectedCount = nodes.filter(node => selectedNodeKeys.has(node.nodeKey)).length;
return `
<div class="mr-model-section" data-image-transfer-category="${this.escapeHtml(group.category)}">
<div class="mr-model-section-header">
<div class="mr-model-section-heading">
<span class="mr-model-section-title">${this.escapeHtml(this.getCategoryDisplayName(group.category).toUpperCase())}</span>
<span class="mr-model-section-total">${countText(nodes.length, 'node')}</span>
</div>
<div class="mr-model-section-counts">
${activeNodes.length ? `<span class="mr-model-count-pill is-active">${countText(activeNodes.length, 'active')}</span>` : ''}
${inactiveNodes.length ? `<span class="mr-model-count-pill is-inactive">${countText(inactiveNodes.length, 'inactive')}</span>` : ''}
${selectedCount ? `<span class="mr-model-count-pill mr-image-transfer-selected-pill">${countText(selectedCount, 'selected')}</span>` : ''}
${isUnavailable ? '<span class="mr-model-count-pill mr-image-transfer-unavailable-pill">No source model</span>' : ''}
</div>
</div>
${renderNodeGroup(activeNodes, 'Active', 'active', isAvailable)}
${renderNodeGroup(inactiveNodes, 'Inactive', 'inactive', isAvailable)}
</div>
`;
}).join('')
: '<p class="mr-image-transfer-empty">No model nodes were found in the current workflow.</p>';
const mode = transfer.mode === 'merge' ? 'merge' : 'replace';
const allTargetNodes = targetGroups.flatMap(group => group.nodes || []);
const allTargetNodeKeys = new Set(allTargetNodes.map(node => node.nodeKey));
const selectedTargetCount = Array.from(selectedNodeKeys)
.filter(nodeKey => allTargetNodeKeys.has(nodeKey))
.length;
const applyDisabled = transfer.applying || !sourceEntries.length || !selectedTargetCount;
const selectionSourceText = sourceEntries.length
? countText(sourceEntries.length, 'metadata model')
: 'No source workflow imported';
const previewRows = this.getMetadataTransferPreviewRows(state);
return `
<section class="mr-image-transfer">
<div class="mr-image-transfer-header">
<div>
<h3 class="mr-loaded-models-title">Transfer models to current workflow <span class="mr-loaded-total">${selectedTargetCount}/${allTargetNodeKeys.size}</span></h3>
<p class="mr-loaded-models-subtitle">Select the target nodes. ${this.escapeHtml(sourceSummary)}</p>
</div>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear-transfer-targets">Clear selection</button>
</div>
<div class="mr-image-transfer-mode" role="group" aria-label="Transfer mode">
<label class="mr-image-transfer-mode-option">
<input type="radio" name="mr-image-transfer-mode" value="replace" data-image-transfer-mode ${mode === 'replace' ? 'checked' : ''}>
<span><strong>Replace</strong><small>Overwrite slots in selected nodes.</small></span>
</label>
<label class="mr-image-transfer-mode-option">
<input type="radio" name="mr-image-transfer-mode" value="merge" data-image-transfer-mode ${mode === 'merge' ? 'checked' : ''}>
<span><strong>Merge / Add</strong><small>Keep current values and append to multi-LoRA nodes.</small></span>
</label>
</div>
<p class="mr-image-transfer-note">Click a node name to select it. A green border marks nodes that will be updated. ${this.escapeHtml(sourceEntries.length ? 'Categories without a matching metadata model are shown as unavailable.' : 'Target nodes come from the current ComfyUI workflow. Import a source workflow with model metadata to enable Apply transfer.')}</p>
<div class="mr-models-list mr-models-list-pad mr-image-transfer-node-list">${targetRows}</div>
${this.renderMetadataTransferPreview(previewRows)}
<div class="mr-image-transfer-actions">
<span class="mr-image-transfer-selection-count">${this.escapeHtml(selectionSourceText)} · ${selectedTargetCount} selected node${selectedTargetCount === 1 ? '' : 's'}</span>
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="apply-transfer" ${applyDisabled ? 'disabled' : ''}>
${transfer.applying ? 'Applying...' : 'Apply transfer'}
</button>
</div>
</section>
`;
},
async openMetadataTransfer() {
const state = getImageInspectorState(this);
const imageRequestToken = state.requestToken;
const workflow = this.getCurrentWorkflow?.();
const workflowSignature = workflow
? (this.getWorkflowSignature?.(workflow) || '')
: '';
const previousTransfer = state.transfer;
const preserveSelection = Boolean(
workflowSignature
&& previousTransfer?.workflowSignature
&& previousTransfer.workflowSignature === workflowSignature
);
const token = `transfer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
state.transfer = {
open: true,
loading: Boolean(workflow),
applying: false,
error: '',
mode: previousTransfer?.mode === 'merge' ? 'merge' : 'replace',
requestToken: token,
workflowSignature,
targetGroups: [],
selectedNodeKeys: preserveSelection
? new Set(previousTransfer.selectedNodeKeys || [])
: new Set(),
};
this.renderImageMetadataResult();
if (!workflow) {
state.transfer.loading = false;
state.transfer.error = 'No current workflow is available.';
this.renderImageMetadataResult();
return null;
}
try {
const targetModels = await this.fetchJson(
'/model_resolver/loaded',
{
method: 'POST',
body: JSON.stringify({ workflow }),
},
'Scan current workflow for transfer'
);
if (
state.requestToken !== imageRequestToken
|| state.transfer?.requestToken !== token
) return null;
state.transfer.loading = false;
state.transfer.targetModels = targetModels;
state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels);
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
const sourceCategories = new Set(
sourceEntries
.map(({ model }) => normalizeTransferCategory(model.category))
);
const selectableNodeKeys = new Set(
state.transfer.targetGroups.flatMap(group => (
sourceEntries.length === 0 || sourceCategories.has(group.category)
? (group.nodes || []).map(node => node.nodeKey)
: []
))
);
state.transfer.selectedNodeKeys = new Set(
Array.from(state.transfer.selectedNodeKeys || [])
.filter(nodeKey => selectableNodeKeys.has(nodeKey))
);
this.renderImageMetadataResult();
return targetModels;
} catch (error) {
if (
state.requestToken !== imageRequestToken
|| state.transfer?.requestToken !== token
) return null;
state.transfer.loading = false;
state.transfer.error = error?.message || 'Could not scan the current workflow.';
this.renderImageMetadataResult();
return null;
}
},
clearMetadataTransferTargets() {
const state = getImageInspectorState(this);
if (!state.transfer) return;
state.transfer.selectedNodeKeys = new Set();
this.renderImageMetadataResult();
},
toggleMetadataTransferNode(nodeKey) {
const state = getImageInspectorState(this);
if (!state.transfer || !nodeKey) return;
const nodeGroups = (state.transfer.targetGroups || [])
.flatMap(group => group.nodes || [])
.filter(node => node.nodeKey === String(nodeKey));
if (!nodeGroups.length) return;
const sourceCategories = new Set(
this.getMetadataTransferSourceModels(state.loadedModels)
.map(({ model }) => normalizeTransferCategory(model.category))
);
const selected = new Set(state.transfer.selectedNodeKeys || []);
if (sourceCategories.size && !nodeGroups.some(node => {
const categoryGroup = (state.transfer.targetGroups || [])
.find(group => group.nodes.includes(node));
return categoryGroup && sourceCategories.has(categoryGroup.category);
}) && !selected.has(String(nodeKey))) return;
if (selected.has(String(nodeKey))) {
selected.delete(String(nodeKey));
} else {
selected.add(String(nodeKey));
}
state.transfer.selectedNodeKeys = selected;
this.renderImageMetadataResult();
},
async applyMetadataTransfer() {
const state = getImageInspectorState(this);
const transfer = state.transfer;
if (!transfer || transfer.loading || transfer.applying) return null;
const sourceModels = this.getMetadataTransferSourceModels(state.loadedModels)
.map(({ model }) => model);
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
: new Set();
const targetRefs = [];
const targetRefKeys = new Set();
for (const group of transfer.targetGroups || []) {
for (const node of group.nodes || []) {
if (!selectedNodeKeys.has(node.nodeKey)) continue;
for (const ref of node.refs || []) {
if (targetRefKeys.has(ref.transferKey)) continue;
targetRefKeys.add(ref.transferKey);
targetRefs.push(ref);
}
}
}
const workflow = this.getCurrentWorkflow?.();
if (!sourceModels.length) {
this.showNotification('Import a workflow with transferable model metadata first.', 'warning');
return null;
}
if (!targetRefs.length) {
this.showNotification('Select at least one target node.', 'warning');
return null;
}
if (!workflow) {
this.showNotification('No current workflow is available.', 'warning');
return null;
}
transfer.applying = true;
this.renderImageMetadataResult();
try {
const data = await this.fetchJson(
'/model_resolver/transfer-models',
{
method: 'POST',
body: JSON.stringify({
workflow,
mode: transfer.mode === 'merge' ? 'merge' : 'replace',
source_models: sourceModels,
target_refs: targetRefs,
}),
},
'Transfer metadata models'
);
if (!data?.success || !data.workflow) {
throw new Error(data?.error || 'The metadata model transfer failed.');
}
const applied = await this.updateWorkflowInComfyUI(
data.workflow,
data.requires_full_reload ? [] : (data.resolutions || [])
);
if (!applied) {
throw new Error('ComfyUI could not apply the updated workflow.');
}
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
this.activeWorkflowSignature = this.getWorkflowSignature?.(data.workflow) || null;
state.transfer = null;
this.renderImageMetadataResult();
void this.openMetadataTransfer();
const updatedCount = Number(data.updated) || 0;
const skippedCount = Array.isArray(data.skipped) ? data.skipped.length : 0;
const suffix = skippedCount
? ` ${skippedCount} selection${skippedCount === 1 ? '' : 's'} kept unchanged.`
: '';
this.showNotification(
`Transferred ${updatedCount} model${updatedCount === 1 ? '' : 's'} to the current workflow.${suffix}`,
skippedCount ? 'info' : 'success'
);
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
return data;
} catch (error) {
if (state.transfer) {
state.transfer.applying = false;
state.transfer.error = error?.message || 'The metadata model transfer failed.';
this.renderImageMetadataResult();
}
return null;
}
},
};