diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py index 39a01e1..bada957 100644 --- a/core/workflow_analyzer.py +++ b/core/workflow_analyzer.py @@ -309,6 +309,105 @@ def normalize_widget_name(value: Any) -> str: return re.sub(r"[_\s-]+", "_", str(value or "").strip().lower()).strip("_") +def _unique_strings(values: List[Any]) -> List[str]: + seen = set() + unique = [] + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + unique.append(text) + return unique + + +def _widget_item_name_candidates(item: Any) -> List[str]: + if not isinstance(item, dict): + return [] + + candidates = [] + for key in ("name", "label", "localized_name"): + if item.get(key): + candidates.append(item.get(key)) + + widget = item.get("widget") + if isinstance(widget, dict): + for key in ("name", "label"): + if widget.get(key): + candidates.append(widget.get(key)) + elif widget: + candidates.append(widget) + + return _unique_strings(candidates) + + +def _has_widget_input(item: Any) -> bool: + return isinstance(item, dict) and item.get("widget") is not None + + +def _get_widget_inputs(node: Dict[str, Any]) -> List[Dict[str, Any]]: + return [ + item + for item in node.get("inputs", []) + if _has_widget_input(item) + ] + + +def _get_proxy_widget_entry(node: Dict[str, Any], widget_index: int) -> Any: + properties = node.get("properties", {}) + proxy_widgets = properties.get("proxyWidgets", []) + if isinstance(proxy_widgets, list) and widget_index < len(proxy_widgets): + return proxy_widgets[widget_index] + return None + + +def _proxy_widget_name(proxy_entry: Any) -> str: + if isinstance(proxy_entry, (list, tuple)) and len(proxy_entry) >= 2: + return str(proxy_entry[1] or "").strip() + if isinstance(proxy_entry, dict): + for key in ("name", "widget_name", "widgetName", "targetWidgetName", "inputName"): + if proxy_entry.get(key): + return str(proxy_entry.get(key)).strip() + return "" + + +def _proxy_widget_node_id(proxy_entry: Any) -> str: + if isinstance(proxy_entry, (list, tuple)) and proxy_entry: + return str(proxy_entry[0] or "").strip() + if isinstance(proxy_entry, dict): + for key in ("node_id", "nodeId", "target_node_id", "targetNodeId"): + if proxy_entry.get(key) is not None: + return str(proxy_entry.get(key)).strip() + return "" + + +def get_widget_name_candidates(node: Dict[str, Any], widget_index: int) -> List[str]: + candidates = [] + + proxy_name = _proxy_widget_name(_get_proxy_widget_entry(node, widget_index)) + if proxy_name: + candidates.append(proxy_name) + + widgets = node.get("widgets", []) + if isinstance(widgets, list) and widget_index < len(widgets): + candidates.extend(_widget_item_name_candidates(widgets[widget_index])) + + widget_inputs = _get_widget_inputs(node) + if widget_index < len(widget_inputs): + candidates.extend(_widget_item_name_candidates(widget_inputs[widget_index])) + + inputs = node.get("inputs", []) + if isinstance(inputs, list) and widget_index < len(inputs): + candidates.extend(_widget_item_name_candidates(inputs[widget_index])) + + return _unique_strings(candidates) + + +def get_widget_name_hint(node: Dict[str, Any], widget_index: int) -> str: + candidates = get_widget_name_candidates(node, widget_index) + return candidates[0] if candidates else "" + + MODEL_WIDGET_PLACEHOLDERS = { "", "none", @@ -339,26 +438,7 @@ def get_widget_category_hint(node: Dict[str, Any], widget_index: int) -> Optiona include input/widget metadata. Use it when present; otherwise callers fall back to node-type hints and folder_paths resolution. """ - candidates = [] - - for key in ("widgets", "inputs"): - items = node.get(key) - if isinstance(items, list) and widget_index < len(items): - item = items[widget_index] - if isinstance(item, dict): - for name in ("name", "label"): - if item.get(name): - candidates.append(str(item.get(name, "")).strip()) - - widget = item.get("widget") - if isinstance(widget, dict): - widget_name = widget.get("name") - if widget_name: - candidates.append(str(widget_name).strip()) - elif widget: - candidates.append(str(widget).strip()) - - for candidate in candidates: + for candidate in get_widget_name_candidates(node, widget_index): category = MODEL_WIDGET_NAME_TO_CATEGORY.get(normalize_widget_name(candidate)) if category: return category @@ -390,6 +470,32 @@ def get_node_output_category_hint(node: Dict[str, Any]) -> Optional[str]: return None +def get_model_widget_category_hint( + node: Dict[str, Any], widget_index: int +) -> Optional[str]: + node_type = node.get("type", "") + indexed_category_hint = get_node_model_widget_category_hint(node_type, widget_index) + widget_category_hint = get_widget_category_hint(node, widget_index) + output_category_hint = get_node_output_category_hint(node) + widgets_values = node.get("widgets_values", []) + has_single_widget_value = len(widgets_values) == 1 + output_widget_category_hint = ( + output_category_hint + if (indexed_category_hint or widget_category_hint or has_single_widget_value) + else None + ) + + return indexed_category_hint or output_widget_category_hint or widget_category_hint + + +def get_effective_model_category_hint( + node: Dict[str, Any], widget_index: int +) -> Optional[str]: + return get_model_widget_category_hint( + node, widget_index + ) or NODE_TYPE_TO_CATEGORY_HINTS.get(node.get("type", "")) + + def is_placeholder_model_value(value: Any) -> bool: if not isinstance(value, str): return False @@ -714,6 +820,7 @@ def get_node_model_info( "node_id": node_id, "node_type": node_type, "widget_index": 2, # Index in lora list + "widget_name": get_widget_name_hint(node, 2), "original_path": name, "name": name, "strength": float(strength), @@ -729,24 +836,11 @@ def get_node_model_info( ) return model_refs - # Get category hints for this node type - category_hint = NODE_TYPE_TO_CATEGORY_HINTS.get(node_type) - output_category_hint = get_node_output_category_hint(node) - has_single_widget_value = len(widgets_values) == 1 - # For each widget value, check if it looks like a model file or URN for idx, value in enumerate(widgets_values): - widget_category_hint = get_widget_category_hint(node, idx) - indexed_category_hint = get_node_model_widget_category_hint(node_type, idx) - output_widget_category_hint = ( - output_category_hint - if (indexed_category_hint or widget_category_hint or has_single_widget_value) - else None - ) - model_widget_category_hint = ( - indexed_category_hint or output_widget_category_hint or widget_category_hint - ) - effective_category_hint = model_widget_category_hint or category_hint + widget_name = get_widget_name_hint(node, idx) + model_widget_category_hint = get_model_widget_category_hint(node, idx) + effective_category_hint = get_effective_model_category_hint(node, idx) categories_to_try_for_widget = ( [effective_category_hint] if effective_category_hint else None ) @@ -790,6 +884,7 @@ def get_node_model_info( "node_id": node_id, "node_type": node_type, "widget_index": idx, + "widget_name": widget_name, "original_path": value_str, "node_title": node_title, "category": category, @@ -830,6 +925,7 @@ def get_node_model_info( "node_id": node_id, "node_type": node_type, "widget_index": idx, + "widget_name": widget_name, "original_path": value_str, "urn": { "full": value_str, @@ -869,6 +965,7 @@ def get_node_model_info( "node_id": node_id, "node_type": node_type, "widget_index": idx, + "widget_name": widget_name, "original_path": value_str, "node_title": node_title, "category": category, @@ -882,6 +979,279 @@ def get_node_model_info( return model_refs +def _get_node_by_id(nodes: List[Dict[str, Any]], node_id: Any) -> Optional[Dict[str, Any]]: + node_id_text = str(node_id) + for node in nodes: + if str(node.get("id")) == node_id_text: + return node + return None + + +def _get_widget_index_for_input_index( + node: Dict[str, Any], input_index: int +) -> Optional[int]: + widget_index = -1 + for idx, item in enumerate(node.get("inputs", [])): + if _has_widget_input(item): + widget_index += 1 + if idx == input_index: + return widget_index if _has_widget_input(item) else None + return None + + +def _get_widget_index_by_name( + node: Dict[str, Any], widget_name: str +) -> Optional[int]: + target_name = normalize_widget_name(widget_name) + if not target_name: + return None + + widgets_values = node.get("widgets_values", []) + for widget_index in range(len(widgets_values)): + candidates = get_widget_name_candidates(node, widget_index) + if any(normalize_widget_name(candidate) == target_name for candidate in candidates): + return widget_index + + for input_index, item in enumerate(node.get("inputs", [])): + if not _has_widget_input(item): + continue + candidates = _widget_item_name_candidates(item) + if any(normalize_widget_name(candidate) == target_name for candidate in candidates): + return _get_widget_index_for_input_index(node, input_index) + + return None + + +def _get_subgraph_input_link_ids(subgraph: Dict[str, Any], input_name: str) -> set: + target_name = normalize_widget_name(input_name) + if not target_name: + return set() + + link_ids = set() + for subgraph_input in subgraph.get("inputs", []): + candidates = _widget_item_name_candidates(subgraph_input) + if not any(normalize_widget_name(candidate) == target_name for candidate in candidates): + continue + for link_id in subgraph_input.get("linkIds", []) or []: + link_ids.add(str(link_id)) + return link_ids + + +def _promoted_widget_target_info( + node: Dict[str, Any], widget_index: int, proxy_widget_name: str = "" +) -> Dict[str, Any]: + widget_name = get_widget_name_hint(node, widget_index) or proxy_widget_name + return { + "node_id": node.get("id"), + "node_type": node.get("type", ""), + "node_title": str(node.get("title", "") or "").strip(), + "widget_index": widget_index, + "widget_name": widget_name, + "category": get_effective_model_category_hint(node, widget_index), + } + + +def _find_promoted_widget_targets( + subgraph: Dict[str, Any], proxy_node_id: str, proxy_widget_name: str +) -> List[Dict[str, Any]]: + nodes = subgraph.get("nodes", []) + targets: List[Dict[str, Any]] = [] + seen = set() + + def add_target(node: Optional[Dict[str, Any]], widget_index: Optional[int]): + if node is None or widget_index is None: + return + key = (str(node.get("id")), int(widget_index)) + if key in seen: + return + seen.add(key) + targets.append(_promoted_widget_target_info(node, widget_index, proxy_widget_name)) + + if proxy_node_id and proxy_node_id != "-1": + target_node = _get_node_by_id(nodes, proxy_node_id) + add_target(target_node, _get_widget_index_by_name(target_node or {}, proxy_widget_name)) + return targets + + link_ids = _get_subgraph_input_link_ids(subgraph, proxy_widget_name) + if link_ids: + for node in nodes: + for input_index, item in enumerate(node.get("inputs", [])): + if not _has_widget_input(item): + continue + if str(item.get("link")) not in link_ids: + continue + add_target(node, _get_widget_index_for_input_index(node, input_index)) + + if targets: + return targets + + for node in nodes: + add_target(node, _get_widget_index_by_name(node, proxy_widget_name)) + + return targets + + +def _build_promoted_widget_contexts( + workflow_json: Dict[str, Any], subgraphs: List[Dict[str, Any]] +) -> Dict[str, Dict[Any, Any]]: + subgraphs_by_id = {str(sg.get("id")): sg for sg in subgraphs if sg.get("id")} + subgraph_names = { + str(sg.get("id")): sg.get("name", sg.get("id")) + for sg in subgraphs + if sg.get("id") + } + contexts = { + "instance_widgets": {}, + "inner_widgets": {}, + "inner_widget_names": {}, + } + + for instance_node in workflow_json.get("nodes", []): + subgraph_id = str(instance_node.get("type", "")) + subgraph = subgraphs_by_id.get(subgraph_id) + if not subgraph: + continue + + proxy_widgets = instance_node.get("properties", {}).get("proxyWidgets", []) + if not isinstance(proxy_widgets, list): + continue + + 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 + + targets = _find_promoted_widget_targets( + subgraph, + _proxy_widget_node_id(proxy_entry), + proxy_widget_name, + ) + if not targets: + continue + + promoted_value = ( + widgets_values[proxy_index] + if isinstance(widgets_values, list) and proxy_index < len(widgets_values) + else None + ) + instance_context = { + **targets[0], + "subgraph_id": subgraph_id, + "subgraph_name": subgraph_names.get(subgraph_id, subgraph_id), + "proxy_widget_index": proxy_index, + "proxy_widget_name": proxy_widget_name, + "promoted_value": promoted_value, + } + contexts["instance_widgets"][(str(instance_node.get("id")), proxy_index)] = ( + instance_context + ) + + locator = { + "node_id": instance_node.get("id"), + "node_type": instance_node.get("type", ""), + "node_title": str(instance_node.get("title", "") or "").strip(), + "subgraph_id": subgraph_id, + "subgraph_name": subgraph_names.get(subgraph_id, subgraph_id), + "is_top_level": True, + "proxy_widget_index": proxy_index, + "proxy_widget_name": proxy_widget_name, + "promoted_value": promoted_value, + } + + for target in targets: + exact_key = ( + subgraph_id, + str(target.get("node_id")), + target.get("widget_index"), + ) + contexts["inner_widgets"].setdefault(exact_key, []).append(locator) + + widget_name = normalize_widget_name(target.get("widget_name")) + if widget_name: + name_key = (subgraph_id, str(target.get("node_id")), widget_name) + contexts["inner_widget_names"].setdefault(name_key, []).append(locator) + + return contexts + + +def _apply_instance_promoted_widget_context( + ref: Dict[str, Any], + context: Optional[Dict[str, Any]], + available_models: Optional[List[Dict[str, Any]]], +) -> None: + if not context: + return + + ref["promoted_widget_name"] = context.get("proxy_widget_name", "") + ref["promoted_inner_node_id"] = context.get("node_id") + ref["promoted_inner_node_type"] = context.get("node_type", "") + ref["promoted_inner_node_title"] = context.get("node_title", "") + ref["promoted_inner_widget_index"] = context.get("widget_index") + ref["promoted_inner_widget_name"] = context.get("widget_name", "") + + category = context.get("category") + original_path = ref.get("original_path", "") + if not category or not original_path: + return + + resolved = try_resolve_model_path( + original_path, + [category], + available_models=available_models, + ) + ref["category"] = category + if resolved: + _, full_path = resolved + ref["full_path"] = full_path + ref["exists"] = os.path.exists(full_path) + else: + ref["full_path"] = None + ref["exists"] = False + + +def _select_promoted_locator( + locators: List[Dict[str, Any]], original_path: str +) -> Optional[Dict[str, Any]]: + if not locators: + return None + + for locator in locators: + if locator.get("promoted_value") == original_path: + return locator + return locators[0] + + +def _apply_promoted_widget_locator( + ref: Dict[str, Any], contexts: Dict[str, Dict[Any, Any]] +) -> None: + subgraph_id = str(ref.get("subgraph_id") or "") + if not subgraph_id: + return + + exact_key = (subgraph_id, str(ref.get("node_id")), ref.get("widget_index")) + locators = contexts.get("inner_widgets", {}).get(exact_key, []) + if not locators and ref.get("widget_name"): + name_key = ( + subgraph_id, + str(ref.get("node_id")), + normalize_widget_name(ref.get("widget_name")), + ) + locators = contexts.get("inner_widget_names", {}).get(name_key, []) + + locator = _select_promoted_locator(locators, ref.get("original_path", "")) + if not locator: + return + + ref["locate_node_id"] = locator.get("node_id") + ref["locate_node_type"] = locator.get("node_type", "") + ref["locate_node_title"] = locator.get("node_title", "") + ref["locate_subgraph_id"] = "" + ref["locate_subgraph_name"] = locator.get("subgraph_name", "") + ref["locate_is_top_level"] = True + ref["locate_via_promoted_widget"] = True + + def analyze_workflow_models( workflow_json: Dict[str, Any], available_models: Optional[List[Dict[str, Any]]] = None, @@ -902,6 +1272,9 @@ def analyze_workflow_models( definitions = workflow_json.get("definitions", {}) subgraphs = definitions.get("subgraphs", []) subgraph_lookup = {sg.get("id"): sg.get("name", sg.get("id")) for sg in subgraphs} + promoted_widget_contexts = _build_promoted_widget_contexts( + workflow_json, subgraphs + ) # Analyze top-level nodes nodes = workflow_json.get("nodes", []) @@ -925,6 +1298,13 @@ def analyze_workflow_models( ref["subgraph_name"] = subgraph_name ref["subgraph_path"] = None # Top-level, not in definitions.subgraphs ref["is_top_level"] = True # Flag to indicate this is a top-level node + if subgraph_id: + context = promoted_widget_contexts.get( + "instance_widgets", {} + ).get((str(node.get("id")), ref.get("widget_index"))) + _apply_instance_promoted_widget_context( + ref, context, available_models + ) all_model_refs.extend(model_refs) except Exception as e: log_warn(f"Error analyzing node {node.get('id', 'unknown')}: {e}") @@ -957,6 +1337,7 @@ def analyze_workflow_models( "nodes", ] ref["is_top_level"] = False # This is inside a subgraph definition + _apply_promoted_widget_locator(ref, promoted_widget_contexts) all_model_refs.extend(model_refs) except Exception as e: log_warn( diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 68d1bf4..966bec6 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -238,30 +238,44 @@ export const lifecycleGraphMethods = { /** * Locate and focus a node in the ComfyUI canvas */ - locateNodeInGraph(nodeId) { + locateNodeInGraph(nodeId, options = {}) { try { if (!app?.graph) { this.showNotification('Cannot locate node - graph not available', 'error'); return; } - // Find the node in the graph - const node = app.graph.getNodeById(nodeId); - if (!node) { - this.showNotification(`Node #${nodeId} not found in graph`, 'error'); + const locateOptions = typeof options === 'string' + ? { subgraphId: options } + : (options || {}); + const target = this.findLocateTarget(nodeId, locateOptions); + if (!target?.node) { + const subgraphText = locateOptions.subgraphId ? ` in subgraph ${locateOptions.subgraphId}` : ''; + this.showNotification(`Node #${nodeId}${subgraphText} not found in graph`, 'error'); return; } + this.activateGraphForLocate(target); + + const node = target.node; + const graph = target.graph || node.graph || app.canvas?.graph || app.graph; let locatedWithAnimation = false; // Focus on the node in the canvas with animated panning when possible if (app.canvas?.ds && app.canvas?.canvas) { - locatedWithAnimation = this.animateCanvasToNode(node); + locatedWithAnimation = this.animateCanvasToNode(node, graph); } - if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) { + let canvasNode = null; + if (!locatedWithAnimation && graph?._nodes && typeof graph._nodes.get === 'function') { + const numericNodeId = Number(nodeId); + canvasNode = graph._nodes.get(nodeId) + || graph._nodes.get(String(nodeId)) + || (!Number.isNaN(numericNodeId) ? graph._nodes.get(numericNodeId) : null); + } + + if (!locatedWithAnimation && canvasNode) { // Alternative method for older versions - const canvasNode = app.graph._nodes.get(nodeId); if (canvasNode && canvasNode.setSelected && canvasNode.graph) { canvasNode.setSelected(true); // Scroll to node @@ -277,23 +291,251 @@ export const lifecycleGraphMethods = { // Also try to flash/select the node // Deselect all nodes first - if (app.graph && app.graph.nodes) { - app.graph.nodes.forEach(n => { + if (graph?.nodes) { + graph.nodes.forEach(n => { if (n.selected) n.selected = false; }); } // Select and highlight our node node.selected = true; + graph?.setDirtyCanvas?.(true, true); + app.canvas?.setDirty?.(true, true); - this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info'); + const graphLabel = target.subgraphId || graph?.id || ''; + const scopeText = graphLabel && graph !== app.graph ? ` in subgraph ${target.subgraphName || graphLabel}` : ''; + this.showNotification(`Focused on Node #${nodeId} (${node.type})${scopeText}`, 'info'); } catch (e) { console.error('Model Resolver: Error locating node:', e); this.showNotification('Error locating node: ' + e.message, 'error'); } }, - animateCanvasToNode(node) { + findLocateTarget(nodeId, options = {}) { + const rootGraph = app?.graph; + if (!rootGraph) return null; + + const isTopLevel = options.isTopLevel !== false; + const subgraphId = String(options.subgraphId || '').trim(); + + if (isTopLevel) { + const rootNode = this.getGraphNodeById(rootGraph, nodeId); + if (rootNode) { + return { node: rootNode, graph: rootGraph, path: [], subgraphId: '', subgraphName: '' }; + } + } + + if (subgraphId) { + const graphMatch = this.findSubgraphById(rootGraph, subgraphId); + const graphNode = graphMatch?.graph ? this.getGraphNodeById(graphMatch.graph, nodeId) : null; + if (graphNode) { + return { + node: graphNode, + graph: graphMatch.graph, + path: graphMatch.path, + subgraphId, + subgraphName: graphMatch.name || '' + }; + } + } + + return this.findNodeInGraphTree(rootGraph, nodeId, { + preferSubgraphId: subgraphId, + includeRoot: true + }); + }, + + getGraphNodeById(graph, nodeId) { + if (!graph) return null; + const idText = String(nodeId); + const numericId = Number(nodeId); + + if (typeof graph.getNodeById === 'function') { + const direct = graph.getNodeById(nodeId); + if (direct) return direct; + if (!Number.isNaN(numericId)) { + const numeric = graph.getNodeById(numericId); + if (numeric) return numeric; + } + } + + if (graph._nodes && typeof graph._nodes.get === 'function') { + const direct = graph._nodes.get(nodeId) || graph._nodes.get(idText); + if (direct) return direct; + if (!Number.isNaN(numericId)) { + const numeric = graph._nodes.get(numericId); + if (numeric) return numeric; + } + } + + return (graph.nodes || []).find(node => String(node?.id) === idText) || null; + }, + + getGraphId(graph) { + return graph?.id || graph?._id || graph?.config?.id || ''; + }, + + getSubgraphDisplayName(graph, ownerNode = null) { + return graph?.name || graph?.title || ownerNode?.title || ownerNode?.type || this.getGraphId(graph); + }, + + findSubgraphById(rootGraph, subgraphId) { + const targetId = String(subgraphId || '').trim(); + if (!rootGraph || !targetId) return null; + + const visited = new Set(); + const walk = (graph, path = []) => { + if (!graph || visited.has(graph)) return null; + visited.add(graph); + + if (String(this.getGraphId(graph)) === targetId) { + const lastStep = path[path.length - 1] || null; + return { + graph, + path, + name: this.getSubgraphDisplayName(graph, lastStep?.node) + }; + } + + for (const node of graph.nodes || []) { + if (!node?.subgraph) continue; + const result = walk(node.subgraph, [ + ...path, + { parentGraph: graph, node, graph: node.subgraph } + ]); + if (result) return result; + } + return null; + }; + + return walk(rootGraph, []); + }, + + findNodeInGraphTree(rootGraph, nodeId, options = {}) { + if (!rootGraph) return null; + const preferSubgraphId = String(options.preferSubgraphId || '').trim(); + const visited = new Set(); + let fallback = null; + + const walk = (graph, path = []) => { + if (!graph || visited.has(graph)) return null; + visited.add(graph); + + const graphId = String(this.getGraphId(graph) || ''); + const node = options.includeRoot !== false || path.length + ? this.getGraphNodeById(graph, nodeId) + : null; + if (node) { + const target = { + node, + graph, + path, + subgraphId: graphId, + subgraphName: this.getSubgraphDisplayName(graph, path[path.length - 1]?.node) + }; + if (!preferSubgraphId || graphId === preferSubgraphId) return target; + fallback = fallback || target; + } + + for (const childNode of graph.nodes || []) { + if (!childNode?.subgraph) continue; + const result = walk(childNode.subgraph, [ + ...path, + { parentGraph: graph, node: childNode, graph: childNode.subgraph } + ]); + if (result) return result; + } + + return null; + }; + + return walk(rootGraph, []) || fallback; + }, + + activateGraphForLocate(target) { + const canvas = app?.canvas; + if (!canvas || !target?.graph) return false; + + const path = Array.isArray(target.path) ? target.path : []; + for (const step of path) { + if (step.parentGraph && canvas.graph !== step.parentGraph) { + this.setActiveCanvasGraph(step.parentGraph); + } + + if (step.node?.subgraph && canvas.graph !== step.node.subgraph) { + const opened = this.openSubgraphNodeForLocate(step.node); + if (!opened || canvas.graph !== step.node.subgraph) { + this.setActiveCanvasGraph(step.node.subgraph); + } + } + } + + if (canvas.graph !== target.graph) { + this.setActiveCanvasGraph(target.graph); + } + + return canvas.graph === target.graph; + }, + + openSubgraphNodeForLocate(node) { + const canvas = app?.canvas; + if (!canvas || !node?.subgraph) return false; + + const attempts = [ + [canvas, 'openSubgraph', [node]], + [canvas, 'openSubgraph', [node.subgraph, node]], + [canvas, 'enterSubgraph', [node]], + [canvas, 'openSubgraphNode', [node]], + [canvas, 'showSubgraph', [node]], + [node, 'openSubgraph', []], + [node, 'enterSubgraph', []], + [node, 'showSubgraph', []], + [node, 'onOpenSubgraph', [canvas]] + ]; + + for (const [target, method, args] of attempts) { + if (typeof target?.[method] !== 'function') continue; + try { + target[method](...args); + if (canvas.graph === node.subgraph) return true; + } catch (error) { + console.debug?.(`Model Resolver: ${method} failed while opening subgraph`, error); + } + } + + return false; + }, + + setActiveCanvasGraph(graph) { + const canvas = app?.canvas; + if (!canvas || !graph) return false; + + const methods = ['setGraph', 'switchToGraph', 'changeGraph', 'showGraph']; + for (const method of methods) { + if (typeof canvas[method] !== 'function') continue; + try { + canvas[method](graph); + break; + } catch (error) { + console.debug?.(`Model Resolver: canvas.${method} failed while switching graph`, error); + } + } + + if (canvas.graph !== graph) { + try { + canvas.graph = graph; + } catch (error) { + console.debug?.('Model Resolver: assigning canvas.graph failed', error); + } + } + + graph.setDirtyCanvas?.(true, true); + canvas.setDirty?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + return canvas.graph === graph; + }, + + animateCanvasToNode(node, graph = null) { const canvas = app?.canvas; const ds = canvas?.ds; const htmlCanvas = canvas?.canvas; @@ -350,6 +592,7 @@ export const lifecycleGraphMethods = { ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased); ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased); canvas.setDirty?.(true, true); + graph?.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); if (progress < 1) { diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 67a13fd..586a0f0 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -7,19 +7,38 @@ export const missingBrowserMethods = { return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model'; }, + getMissingLocateTarget(missing = {}) { + const hasLocateNode = missing.locate_node_id !== undefined + && missing.locate_node_id !== null + && missing.locate_node_id !== ''; + const nodeId = hasLocateNode ? missing.locate_node_id : missing.node_id; + return { + nodeId: nodeId ?? '', + nodeType: hasLocateNode ? (missing.locate_node_type || missing.node_type || '') : (missing.node_type || ''), + nodeTitle: hasLocateNode ? (missing.locate_node_title || '') : (missing.node_title || ''), + subgraphId: hasLocateNode ? (missing.locate_subgraph_id || '') : (missing.subgraph_id || ''), + subgraphName: hasLocateNode ? (missing.locate_subgraph_name || missing.subgraph_name || '') : (missing.subgraph_name || ''), + isTopLevel: hasLocateNode ? missing.locate_is_top_level !== false : missing.is_top_level !== false + }; + }, + getMissingNodeDisplay(missing = {}) { - const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + const locateTarget = this.getMissingLocateTarget(missing); + const isSubgraphNode = locateTarget.nodeType && locateTarget.nodeType.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); let nodeLabel; - if (missing.subgraph_name) { - nodeLabel = missing.subgraph_name; + if (locateTarget.subgraphName) { + nodeLabel = locateTarget.subgraphName; } else if (isSubgraphNode) { nodeLabel = 'Subgraph'; } else { - nodeLabel = missing.node_type || 'Node'; + nodeLabel = locateTarget.nodeType || 'Node'; } - const nodeId = missing.node_id ?? ''; - const customNodeTitle = String(missing.node_title || '').trim(); + const nodeId = locateTarget.nodeId ?? ''; + const promotedDetail = missing.locate_via_promoted_widget + ? (missing.node_title || missing.node_type || '') + : (missing.promoted_inner_node_title || missing.promoted_inner_node_type || ''); + const customNodeTitle = String(promotedDetail || locateTarget.nodeTitle || missing.node_title || '').trim(); const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel; const text = hasCustomNodeTitle ? `${nodeLabel} #${nodeId} ยท ${customNodeTitle}` @@ -28,7 +47,13 @@ export const missingBrowserMethods = { return { label: nodeLabel, text, - canLocate: missing.is_top_level !== false && nodeId !== '' + canLocate: nodeId !== '', + locateTarget, + locateTooltip: missing.locate_via_promoted_widget + ? 'Center this subgraph node in the ComfyUI graph.' + : locateTarget.isTopLevel === false + ? 'Open this subgraph and center the node in the ComfyUI graph.' + : 'Center this node in the ComfyUI graph.' }; }, @@ -262,9 +287,10 @@ export const missingBrowserMethods = { const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel); const nodeDisplay = this.getMissingNodeDisplay(missing); - const nodeId = missing.node_id ?? ''; + const locateTarget = nodeDisplay.locateTarget || this.getMissingLocateTarget(missing); + const nodeId = locateTarget.nodeId ?? ''; const rowNodeHtml = nodeDisplay.canLocate - ? `` + ? `` : `${this.escapeHtml(nodeDisplay.text)}`; html += ` @@ -385,7 +411,10 @@ export const missingBrowserMethods = { event.stopPropagation(); const rawNodeId = button.dataset.nodeId; const numericNodeId = Number(rawNodeId); - this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId); + this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, { + subgraphId: button.dataset.subgraphId || '', + isTopLevel: button.dataset.isTopLevel !== 'false' + }); }); }); @@ -555,9 +584,13 @@ export const missingBrowserMethods = { const locateId = `locate-${missing.node_id}-${missing.widget_index}`; const locateBtn = container.querySelector(`#${locateId}`); - if (locateBtn && missing.is_top_level !== false) { + const locateTarget = this.getMissingLocateTarget(missing); + if (locateBtn && locateTarget.nodeId !== undefined && locateTarget.nodeId !== null && locateTarget.nodeId !== '') { locateBtn.addEventListener('click', () => { - this.locateNodeInGraph(missing.node_id); + this.locateNodeInGraph(locateTarget.nodeId, { + subgraphId: locateTarget.subgraphId || '', + isTopLevel: locateTarget.isTopLevel + }); }); } @@ -854,15 +887,15 @@ export const missingBrowserMethods = { } html += ``; const locateId = `locate-${missing.node_id}-${missing.widget_index}`; - const nodeChipClasses = missing.is_top_level !== false ? 'mr-node-chip is-locatable' : 'mr-node-chip'; - const nodeChipTitle = missing.is_top_level !== false ? 'Center this node in the ComfyUI graph.' : ''; + const nodeChipClasses = nodeDisplay.canLocate ? 'mr-node-chip is-locatable' : 'mr-node-chip'; + const nodeChipTitle = nodeDisplay.canLocate ? nodeDisplay.locateTooltip : ''; html += `