diff --git a/core/routes/registry.py b/core/routes/registry.py index 65cb450..c484232 100644 --- a/core/routes/registry.py +++ b/core/routes/registry.py @@ -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) diff --git a/core/routes/workflow_transfer.py b/core/routes/workflow_transfer.py new file mode 100644 index 0000000..36ad652 --- /dev/null +++ b/core/routes/workflow_transfer.py @@ -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, + ) diff --git a/core/services/workflow_transfer_service.py b/core/services/workflow_transfer_service.py new file mode 100644 index 0000000..d696e38 --- /dev/null +++ b/core/services/workflow_transfer_service.py @@ -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) diff --git a/core/workflow_transfer.py b/core/workflow_transfer.py new file mode 100644 index 0000000..497c620 --- /dev/null +++ b/core/workflow_transfer.py @@ -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"(]+>", + 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"" + 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"] diff --git a/core/workflow_updater.py b/core/workflow_updater.py index f01c738..f04d20e 100644 --- a/core/workflow_updater.py +++ b/core/workflow_updater.py @@ -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 diff --git a/tests/test_refactoring_targets.py b/tests/test_refactoring_targets.py index 5ffd273..c836855 100644 --- a/tests/test_refactoring_targets.py +++ b/tests/test_refactoring_targets.py @@ -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"), diff --git a/tests/test_route_registry.py b/tests/test_route_registry.py index 8084224..5267309 100644 --- a/tests/test_route_registry.py +++ b/tests/test_route_registry.py @@ -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"), diff --git a/tests/test_workflow_transfer.py b/tests/test_workflow_transfer.py new file mode 100644 index 0000000..e04775c --- /dev/null +++ b/tests/test_workflow_transfer.py @@ -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 ", + [{"name": "old_style", "strength": 0.7, "active": True}], + ], + "widgets_values_named": { + "text": "old prompt ", + "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 "" 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 "" 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 diff --git a/tests/test_workflow_transfer_routes.py b/tests/test_workflow_transfer_routes.py new file mode 100644 index 0000000..d225324 --- /dev/null +++ b/tests/test_workflow_transfer_routes.py @@ -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() diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 8d15e82..278219a 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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 { diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index b1ba0aa..3c8fecf 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -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?.(); } } }; diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index b221619..8a2dce8 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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 = {

Image Metadata

Paste workflow JSON or drop an image or JSON file anywhere in this panel.

- +
+ +
@@ -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 = `
${this.escapeHtml(message)}
`; + result.innerHTML = ` +
${this.escapeHtml(message)}
+ ${this.renderMetadataTransferPanel(state)} + `; return; } if (state.error) { - result.innerHTML = `

${this.escapeHtml(state.error)}

`; + result.innerHTML = ` +

${this.escapeHtml(state.error)}

+ ${this.renderMetadataTransferPanel(state)} + `; return; } if (!state.metadata) { - result.innerHTML = '

Drop an image or workflow JSON to inspect it.

'; + result.innerHTML = ` +

Drop an image or workflow JSON to inspect it.

+ ${this.renderMetadataTransferPanel(state)} + `; return; } @@ -401,6 +489,7 @@ export const imageMetadataMethods = {
${rowsHtml}${parametersHtml}
+ ${this.renderMetadataTransferPanel(state)}
@@ -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 ` +
+
+ ${this.escapeHtml(targetLabel)} · Current + ${this.escapeHtml(row.currentLabel)} +
+ +
+ ${this.escapeHtml(operationLabel)} + ${this.escapeHtml(row.nextLabel)} +
+
+ `; + }).join('') + : '

Select a target node above to preview model changes.

'; + return ` +
+
+

Transfer preview ${previewRows.length} ${previewRows.length === 1 ? 'slot' : 'slots'}

+
+
${content}
+
+ `; + }, + + 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 ` +
+
Scanning the current workflow for target nodes...
+

${this.escapeHtml(sourceSummary)}

+
+ `; + } + if (transfer.error) { + return ` +
+
+
+

Transfer models to current workflow

+

${this.escapeHtml(transfer.error)}

+
+ +
+

${this.escapeHtml(sourceSummary)}

+
+ `; + } + + 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 ` + + `; + }; + const renderNodeGroup = (nodes, label, groupClass, isAvailable) => { + if (!nodes.length) return ''; + const chips = nodes + .map(node => renderNode(node, isAvailable)) + .join(''); + return ` +
+
+ ${label} ${nodes.length} +
+
${chips}
+
+ `; + }; + + 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 ` +
+
+
+ ${this.escapeHtml(this.getCategoryDisplayName(group.category).toUpperCase())} + ${countText(nodes.length, 'node')} +
+
+ ${activeNodes.length ? `${countText(activeNodes.length, 'active')}` : ''} + ${inactiveNodes.length ? `${countText(inactiveNodes.length, 'inactive')}` : ''} + ${selectedCount ? `${countText(selectedCount, 'selected')}` : ''} + ${isUnavailable ? 'No source model' : ''} +
+
+ ${renderNodeGroup(activeNodes, 'Active', 'active', isAvailable)} + ${renderNodeGroup(inactiveNodes, 'Inactive', 'inactive', isAvailable)} +
+ `; + }).join('') + : '

No model nodes were found in the current workflow.

'; + + 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 ` +
+
+
+

Transfer models to current workflow ${selectedTargetCount}/${allTargetNodeKeys.size}

+

Select the target nodes. ${this.escapeHtml(sourceSummary)}

+
+ +
+
+ + +
+

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.')}

+
${targetRows}
+ ${this.renderMetadataTransferPreview(previewRows)} +
+ ${this.escapeHtml(selectionSourceText)} · ${selectedTargetCount} selected node${selectedTargetCount === 1 ? '' : 's'} + +
+
+ `; + }, + + 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; + } + }, };