diff --git a/core/resolver.py b/core/resolver.py index ea056c9..26cbade 100644 --- a/core/resolver.py +++ b/core/resolver.py @@ -1253,6 +1253,7 @@ def apply_resolution( "nested_key": resolution.get( "nested_key" ), # For dict-type widget values + "promoted_widget_name": resolution.get("promoted_widget_name"), **get_custom_node_resolution_metadata(resolution), } diff --git a/core/workflow/analysis.py b/core/workflow/analysis.py index 12a01db..5668291 100644 --- a/core/workflow/analysis.py +++ b/core/workflow/analysis.py @@ -163,7 +163,7 @@ def _get_promoted_widget_context_cache_key( context_node = { **node, "widgets_values": ( - [None] * len(widgets_values) + list(widgets_values) if isinstance(widgets_values, list) else [] ), @@ -249,6 +249,7 @@ def analyze_workflow_models( promoted_widget_contexts = subgraph_utils._build_promoted_widget_contexts( workflow_json, subgraphs ) + promoted_instance_slots = set() # Analyze top-level nodes nodes = workflow_json.get("nodes", []) @@ -342,19 +343,25 @@ def analyze_workflow_models( # Mark with subgraph info if it's a subgraph node # For top-level subgraph instance nodes, subgraph_path is None # This distinguishes them from nodes within subgraph definitions + if subgraph_id: + for ref in model_refs: + context = promoted_widget_contexts.get( + "instance_widgets", {} + ).get((str(node.get("id")), ref.get("widget_index"))) + if context: + promoted_instance_slots.add( + (str(node.get("id")), ref.get("widget_index")) + ) + if not reused: + subgraph_utils._apply_instance_promoted_widget_context( + ref, context, available_models + ) if not reused: for ref in model_refs: ref["subgraph_id"] = subgraph_id ref["subgraph_name"] = subgraph_name ref["subgraph_path"] = None ref["is_top_level"] = True - if subgraph_id: - context = promoted_widget_contexts.get( - "instance_widgets", {} - ).get((str(node.get("id")), ref.get("widget_index"))) - subgraph_utils._apply_instance_promoted_widget_context( - ref, context, available_models - ) if node_cache_out is not None: node_cache_out[node_cache_key] = { "fingerprint": node_fingerprint, @@ -402,33 +409,47 @@ def analyze_workflow_models( and cached_node.get("fingerprint") == node_fingerprint ) if reused: - model_refs = cached_node.get("refs", []) + base_model_refs = cached_node.get("refs", []) reused_nodes += 1 else: - model_refs = references.get_node_model_info( + base_model_refs = references.get_node_model_info( node, available_models=available_models, ) analyzed_nodes += 1 analyzed_subgraph_nodes += 1 - for ref in model_refs: - ref["subgraph_id"] = subgraph_id - ref["subgraph_name"] = subgraph_name - ref["subgraph_path"] = [ - "definitions", - "subgraphs", - subgraph_id, - "nodes", - ] - ref["is_top_level"] = False - subgraph_utils._apply_promoted_widget_locator( - ref, - promoted_widget_contexts, - ) + model_refs = [] + for base_ref in base_model_refs: + scoped_ref = dict(base_ref) + scoped_ref["subgraph_id"] = subgraph_id + scoped_ref["subgraph_name"] = subgraph_name + scoped_ref["subgraph_path"] = [ + "definitions", + "subgraphs", + subgraph_id, + "nodes", + ] + scoped_ref["is_top_level"] = False + promoted_refs = subgraph_utils._promote_model_reference_to_instances( + scoped_ref, + promoted_widget_contexts, + available_models, + promoted_instance_slots, + ) + if promoted_refs is not None: + model_refs.extend(promoted_refs) + continue + + ref = scoped_ref + subgraph_utils._apply_promoted_widget_locator( + ref, + promoted_widget_contexts, + ) + model_refs.append(ref) if node_cache_out is not None: node_cache_out[node_cache_key] = { "fingerprint": node_fingerprint, - "refs": model_refs, + "refs": base_model_refs, } report_node_progress( node, diff --git a/core/workflow/subgraphs.py b/core/workflow/subgraphs.py index 10a9ec6..e2cabba 100644 --- a/core/workflow/subgraphs.py +++ b/core/workflow/subgraphs.py @@ -81,6 +81,44 @@ def _get_subgraph_input_link_ids(subgraph: Dict[str, Any], input_name: str) -> s return link_ids +def _get_node_input_by_name( + node: Dict[str, Any], input_name: str +) -> Optional[Dict[str, Any]]: + target_name = normalize_widget_name(input_name) + if not target_name: + return None + + for item in node.get("inputs", []): + candidates = _widget_item_name_candidates(item) + if any( + normalize_widget_name(candidate) == target_name for candidate in candidates + ): + return item + return None + + +def _get_node_widget_value(node: Dict[str, Any], widget_index: Any) -> Any: + try: + widget_index = int(widget_index) + except (TypeError, ValueError): + return None + + widgets_values = node.get("widgets_values", []) + if not isinstance(widgets_values, list) or widget_index < 0: + return None + if widget_index >= len(widgets_values): + return None + return widgets_values[widget_index] + + +def _has_promoted_value(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + return True + + def _promoted_widget_target_info( node: Dict[str, Any], widget_index: int, proxy_widget_name: str = "" ) -> Dict[str, Any]: @@ -92,6 +130,7 @@ def _promoted_widget_target_info( "widget_index": widget_index, "widget_name": widget_name, "category": references.get_effective_model_category_hint(node, widget_index), + "value": _get_node_widget_value(node, widget_index), } @@ -163,26 +202,33 @@ def _build_promoted_widget_contexts( proxy_widgets = instance_node.get("properties", {}).get("proxyWidgets", []) if not isinstance(proxy_widgets, list): - continue + proxy_widgets = [] - widgets_values = instance_node.get("widgets_values", []) - for proxy_index, proxy_entry in enumerate(proxy_widgets): - proxy_widget_name = _proxy_widget_name(proxy_entry) - if not proxy_widget_name: - continue + seen_proxy_indexes = set() + seen_proxy_names = set() - targets = _find_promoted_widget_targets( - subgraph, - _proxy_widget_node_id(proxy_entry), - proxy_widget_name, + def add_instance_context( + proxy_index: Optional[int], + proxy_widget_name: str, + targets: List[Dict[str, Any]], + ) -> None: + if proxy_index is None or not proxy_widget_name or not targets: + return + if proxy_index in seen_proxy_indexes: + return + + instance_input = _get_node_input_by_name( + instance_node, proxy_widget_name ) - if not targets: - continue - + input_connected = bool( + instance_input and instance_input.get("link") is not None + ) + instance_value = _get_node_widget_value(instance_node, proxy_index) + target_value = targets[0].get("value") promoted_value = ( - widgets_values[proxy_index] - if isinstance(widgets_values, list) and proxy_index < len(widgets_values) - else None + instance_value + if _has_promoted_value(instance_value) + else target_value ) instance_context = { **targets[0], @@ -191,10 +237,11 @@ def _build_promoted_widget_contexts( "proxy_widget_index": proxy_index, "proxy_widget_name": proxy_widget_name, "promoted_value": promoted_value, + "input_connected": input_connected, } - contexts["instance_widgets"][(str(instance_node.get("id")), proxy_index)] = ( - instance_context - ) + contexts["instance_widgets"][ + (str(instance_node.get("id")), proxy_index) + ] = instance_context locator = { "node_id": instance_node.get("id"), @@ -206,8 +253,11 @@ def _build_promoted_widget_contexts( "proxy_widget_index": proxy_index, "proxy_widget_name": proxy_widget_name, "promoted_value": promoted_value, + "input_connected": input_connected, } + seen_proxy_indexes.add(proxy_index) + seen_proxy_names.add(normalize_widget_name(proxy_widget_name)) for target in targets: exact_key = ( subgraph_id, @@ -227,6 +277,55 @@ def _build_promoted_widget_contexts( locator ) + for proxy_index, proxy_entry in enumerate(proxy_widgets): + proxy_widget_name = _proxy_widget_name(proxy_entry) + if not proxy_widget_name: + continue + + targets = _find_promoted_widget_targets( + subgraph, + _proxy_widget_node_id(proxy_entry), + proxy_widget_name, + ) + add_instance_context(proxy_index, proxy_widget_name, targets) + + # Some serialized workflows omit proxyWidgets even though a subgraph + # input is connected to a widget inside the definition. Reconstruct the + # same promotion from the subgraph input/link metadata in that case. + instance_inputs = instance_node.get("inputs", []) + if not isinstance(instance_inputs, list): + instance_inputs = [] + for subgraph_input in subgraph.get("inputs", []): + input_names = _widget_item_name_candidates(subgraph_input) + proxy_widget_name = next( + (name for name in input_names if str(name or "").strip()), + "", + ) + if not proxy_widget_name: + continue + if normalize_widget_name(proxy_widget_name) in seen_proxy_names: + continue + + instance_input_index = next( + ( + index + for index, item in enumerate(instance_inputs) + if _get_node_input_by_name( + {"inputs": [item]}, proxy_widget_name + ) + ), + None, + ) + if instance_input_index is None: + continue + proxy_index = _get_widget_index_for_input_index( + instance_node, instance_input_index + ) + targets = _find_promoted_widget_targets( + subgraph, "-1", proxy_widget_name + ) + add_instance_context(proxy_index, proxy_widget_name, targets) + return contexts @@ -277,12 +376,12 @@ def _select_promoted_locator( return locators[0] -def _apply_promoted_widget_locator( +def _get_promoted_widget_locators( ref: Dict[str, Any], contexts: Dict[str, Dict[Any, Any]] -) -> None: +) -> List[Dict[str, Any]]: subgraph_id = str(ref.get("subgraph_id") or "") if not subgraph_id: - return + return [] exact_key = (subgraph_id, str(ref.get("node_id")), ref.get("widget_index")) locators = contexts.get("inner_widgets", {}).get(exact_key, []) @@ -293,7 +392,97 @@ def _apply_promoted_widget_locator( normalize_widget_name(ref.get("widget_name")), ) locators = contexts.get("inner_widget_names", {}).get(name_key, []) + return locators + +def _promote_model_reference_to_instances( + ref: Dict[str, Any], + contexts: Dict[str, Dict[Any, Any]], + available_models: Optional[List[Dict[str, Any]]] = None, + existing_instance_slots: Optional[set] = None, +) -> Optional[List[Dict[str, Any]]]: + locators = _get_promoted_widget_locators(ref, contexts) + if not locators: + return None + + if existing_instance_slots is None: + existing_instance_slots = set() + promoted_refs = [] + for locator in locators: + instance_slot = ( + str(locator.get("node_id")), + locator.get("proxy_widget_index"), + ) + if instance_slot in existing_instance_slots: + continue + if locator.get("input_connected"): + continue + + value = locator.get("promoted_value") + nested_key = ref.get("nested_key") + if nested_key and isinstance(value, dict): + value = value.get(nested_key) + if not _has_promoted_value(value): + continue + + value_str = str(value).strip() + promoted = dict(ref) + promoted.update( + { + "node_id": locator.get("node_id"), + "node_type": locator.get("node_type", ""), + "node_title": locator.get("node_title", ""), + "widget_index": locator.get("proxy_widget_index"), + "widget_name": locator.get("proxy_widget_name", ""), + "original_path": value_str, + "subgraph_id": locator.get("subgraph_id", ""), + "subgraph_name": locator.get("subgraph_name", ""), + "subgraph_path": None, + "is_top_level": True, + "promoted_widget_name": locator.get("proxy_widget_name", ""), + "promoted_inner_node_id": ref.get("node_id"), + "promoted_inner_node_type": ref.get("node_type", ""), + "promoted_inner_node_title": ref.get("node_title", ""), + "promoted_inner_widget_index": ref.get("widget_index"), + "promoted_inner_widget_name": ref.get("widget_name", ""), + "locate_node_id": locator.get("node_id"), + "locate_node_type": locator.get("node_type", ""), + "locate_node_title": locator.get("node_title", ""), + "locate_subgraph_id": "", + "locate_subgraph_name": locator.get("subgraph_name", ""), + "locate_is_top_level": True, + "locate_via_promoted_widget": True, + "is_urn": bool(references.URN_REGEX.match(value_str)), + } + ) + + category_hints = promoted.get("category_hints") or [] + if not category_hints and promoted.get("category"): + category_hints = [promoted["category"]] + resolved = references.try_resolve_model_path( + value_str, + category_hints or None, + available_models=available_models, + ) + if resolved: + category, full_path = resolved + promoted["category"] = category + promoted["full_path"] = full_path + promoted["exists"] = os.path.exists(full_path) + else: + promoted["full_path"] = None + promoted["exists"] = False + + promoted_refs.append(promoted) + existing_instance_slots.add(instance_slot) + + return promoted_refs + + +def _apply_promoted_widget_locator( + ref: Dict[str, Any], contexts: Dict[str, Dict[Any, Any]] +) -> None: + locators = _get_promoted_widget_locators(ref, contexts) locator = _select_promoted_locator(locators, ref.get("original_path", "")) if not locator: return diff --git a/core/workflow_updater.py b/core/workflow_updater.py index 3252d90..80ee4dd 100644 --- a/core/workflow_updater.py +++ b/core/workflow_updater.py @@ -204,8 +204,23 @@ def update_model_path( return False widgets_values = node.get("widgets_values", []) + if not isinstance(widgets_values, list): + widgets_values = [] + node["widgets_values"] = widgets_values if widget_index >= len(widgets_values): + can_extend_promoted_input = bool( + is_top_level is True + and mapping + and mapping.get("promoted_widget_name") + ) + if can_extend_promoted_input: + widgets_values.extend([None] * (widget_index + 1 - len(widgets_values))) + else: + log.warning(f"Widget index {widget_index} out of range for node {node_id}") + return False + + if widget_index < 0: log.warning(f"Widget index {widget_index} out of range for node {node_id}") return False diff --git a/tests/test_workflow_components.py b/tests/test_workflow_components.py index 11837cc..c954db6 100644 --- a/tests/test_workflow_components.py +++ b/tests/test_workflow_components.py @@ -1,7 +1,8 @@ from types import SimpleNamespace from unittest.mock import patch -from core.workflow import references, subgraphs +from core.workflow import analysis, references, subgraphs +from core.workflow_updater import update_workflow_nodes def _loader_node(node_id=10, value="model.safetensors", link=None): @@ -161,6 +162,105 @@ def test_apply_promoted_locator_supports_name_fallback_and_value_selection(): assert reference["locate_via_promoted_widget"] is True +def test_subgraph_input_reference_targets_instance_for_update_and_location(): + workflow = { + "nodes": [ + { + "id": 1, + "type": "subgraph-1", + "title": "Model subgraph", + "inputs": [ + { + "name": "ckpt_name", + "type": "COMBO", + "widget": {"name": "ckpt_name"}, + "link": None, + } + ], + "outputs": [{"links": [20]}], + "properties": {"proxyWidgets": [["10", "ckpt_name"]]}, + # Some serialized subgraph instances have no values here; + # the connected inner widget still contains the old value. + "widgets_values": [], + } + ], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-1", + "name": "Model subgraph", + "inputs": [ + {"name": "ckpt_name", "type": "COMBO", "linkIds": [7]} + ], + "nodes": [ + { + "id": 10, + "type": "CheckpointLoaderSimple", + "inputs": [ + { + "name": "ckpt_name", + "type": "COMBO", + "widget": {"name": "ckpt_name"}, + "link": 7, + } + ], + "outputs": [{"type": "MODEL", "links": [20]}], + "widgets_values": ["inner-stale.safetensors"], + } + ], + } + ] + }, + } + + refs = analysis.analyze_workflow_models(workflow, available_models=[]) + + assert len(refs) == 1 + assert refs[0]["node_id"] == 1 + assert refs[0]["widget_index"] == 0 + assert refs[0]["subgraph_id"] == "subgraph-1" + assert refs[0]["is_top_level"] is True + assert refs[0]["subgraph_path"] is None + assert refs[0]["locate_node_id"] == 1 + assert refs[0]["locate_subgraph_id"] == "" + assert refs[0]["promoted_inner_node_id"] == 10 + + no_proxy_workflow = { + **workflow, + "nodes": [ + { + **workflow["nodes"][0], + "properties": {}, + } + ], + } + no_proxy_refs = analysis.analyze_workflow_models( + no_proxy_workflow, available_models=[] + ) + assert no_proxy_refs[0]["node_id"] == 1 + assert no_proxy_refs[0]["promoted_inner_node_id"] == 10 + + update_workflow_nodes( + workflow, + [ + { + "node_id": refs[0]["node_id"], + "widget_index": refs[0]["widget_index"], + "resolved_path": "replacement.safetensors", + "category": "checkpoints", + "subgraph_id": refs[0]["subgraph_id"], + "is_top_level": refs[0]["is_top_level"], + "promoted_widget_name": refs[0]["promoted_widget_name"], + } + ], + ) + + assert workflow["nodes"][0]["widgets_values"] == ["replacement.safetensors"] + assert workflow["definitions"]["subgraphs"][0]["nodes"][0]["widgets_values"] == [ + "inner-stale.safetensors" + ] + + def test_reference_matching_handles_placeholders_paths_and_scanner_records(tmp_path): model_path = tmp_path / "folder" / "model.safetensors" model_path.parent.mkdir() diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index b7f2ae8..31b7dee 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -1458,7 +1458,8 @@ export const queueMethods = { || ref.name || ref.original_path ), - nested_key: ref.nested_key + nested_key: ref.nested_key, + promoted_widget_name: ref.promoted_widget_name })); }, @@ -1493,6 +1494,10 @@ export const queueMethods = { || baseResolution.original_path ), nested_key: ref.nested_key ?? baseResolution.nested_key, + promoted_widget_name: ( + ref.promoted_widget_name + || baseResolution.promoted_widget_name + ), original_path: ref.original_path || baseResolution.original_path, node_type: ref.node_type || baseResolution.node_type, node_label: ref.subgraph_name || ref.node_type || baseResolution.node_label @@ -1957,6 +1962,7 @@ export const queueMethods = { || missing.original_path ), nested_key: missing.nested_key, + promoted_widget_name: missing.promoted_widget_name, node_refs: this.getResolutionNodeRefs(missing), node_type: missing.node_type, node_label: missing.subgraph_name || missing.node_type