feat: add workflow metadata transfer preview
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user