From 854ea97e02538d0d5c7a5b1da30361fb1bbcf5e2 Mon Sep 17 00:00:00 2001 From: Edward Niewold Date: Wed, 19 Aug 2026 21:45:18 +0200 Subject: [PATCH] Modifier now uses internal references, not separate instance of loaded graph. Deprecates the Modifier (from URL) node. --- README.md | 12 ++- js/subworkflow.js | 122 ++++++++++++++++++++-- pyproject.toml | 2 +- tests/test_workflow_node.py | 189 +++++++++++++++++++++++++++++++++++ tests/test_workflow_utils.py | 75 ++++++++++++++ workflow_node.py | 173 ++++++++++++++++++++++++++------ workflow_utils.py | 45 ++++++++- 7 files changed, 573 insertions(+), 45 deletions(-) create mode 100644 tests/test_workflow_node.py create mode 100644 tests/test_workflow_utils.py diff --git a/README.md b/README.md index 6e702ef..db952ac 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,10 @@ Used inside the inner workflow and accessed from outer workflow by the `Subworkf ## Node: Subworkflow Modifier Source -The `Subworkflow Modifier Source` node is used in combination with the `Subworkflow Modifier` node. -It allows for accessing the modified value from the `Subworkflow Modifier` node within the inner workflow. -This is a separate node to avoid circular reference problems that would arise if the modified value was passed directly from the `Subworkflow Modifier` node back into the inner workflow. +The `Subworkflow Modifier Source` node is used in combination with the `Subworkflow Modifier` node. +Use the native dropdown at the top of the node to select the loaded `Subworkflow` or `Subworkflow (from URL)` whose modifier values should be exposed. The selector is a widget only and has no input socket. The source shares that target node's parsed workflow data and execution settings instead of loading a second independent copy. +This remains a separate outer node to avoid the graph cycle that would arise if the modified value were passed directly from the target `Subworkflow` output back into its input. +The older `Subworkflow Modifier Source (from URL)` node remains available only for compatibility with saved workflows; new workflows should select a URL-backed target from the regular modifier source. See Features section below for an example of how to use these nodes together. ---------------- @@ -113,8 +114,8 @@ Highlights of the current features of this extension. See the example workflows ## Modifier Nodes ![New](https://img.shields.io/badge/NEW-green) -If you want to expose an output from the inner workflow, modify it (for example, add loras to a model), and then pass it back into the inner workflow, you can use the `Subworkflow Modifier` node in combination with `Subworkflow Modifier Source` nodes. -The modifier node takes an input value, passes it through to its output, and also makes it available as a source for modifier sources. The modifier sources can be used in the inner workflow to access the modified value without creating circular reference problems. +If you want to expose an output from the inner workflow, modify it (for example, add loras to a model), and then pass it back into the inner workflow, you can use the `Subworkflow Modifier` node in combination with `Subworkflow Modifier Source` nodes. +The modifier node takes an input value, passes it through to its output, and also makes it available as a source for modifier sources. On the outer modifier source, select the corresponding loaded `Subworkflow` node. The reference does not add a graph connection, so the source can expose the pre-modifier value without creating a dependency cycle. ![Example workflow using Modifier Nodes](./assets/readme_modifier.png) @@ -182,6 +183,7 @@ ComfyUI workflow JSON appears in a few different shapes depending on how it was ### Version History +- v1.3.0 - `Subworkflow Modifier Source` now selects a loaded file- or URL-backed `Subworkflow` through a native dropdown, shares its parsed workflow data per execution, and prunes unrelated sampler/output branches to prevent duplicate renders. The URL-specific source remains as deprecated compatibility support. - v1.2.3 - (re)loading an inner workflow with links updates no longer disconnects all input links; they are now preserved when possible. - v1.2.2 - Fixed a bug where inner node validation exception were raised due to removal of initial static output slots. - v1.2.1 - Removed the maximum number of inputs and outputs on the `Subworkflow` node (was previously set to 8). diff --git a/js/subworkflow.js b/js/subworkflow.js index 8238002..9faa15c 100644 --- a/js/subworkflow.js +++ b/js/subworkflow.js @@ -1,9 +1,9 @@ /** * Frontend extension for the Subworkflow nodes. * - * When the source widget changes, this extension queries the backend for the - * inner workflow's Subworkflow Input / Subworkflow Output info and dynamically - * updates the node's input and output slots. + * When a workflow source changes, this extension queries the backend for its + * boundary information and dynamically updates the node's slots. Modifier Source + * nodes mirror that information from a selected Subworkflow node on the canvas. * * Input slots are named swf_in_0, swf_in_1, ... (matching the Python backend). * Output slots are named out_0, out_1, ... (matching RETURN_NAMES). @@ -38,12 +38,11 @@ const NODE_CONFIGS = { }, SWF_SubworkflowModifierSource: { kind: "modifier_source", - widgetName: "workflow", - refreshWidgetNames: ["workflow"], - describe: "workflow", - infoPath(value) { - return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`; - }, + widgetName: "target_subworkflow", + refreshWidgetNames: ["target_subworkflow"], + describe: "Subworkflow node", + staticWidgetCount: 1, + targetNodeReference: true, }, SWF_SubworkflowModifierSourceFromURL: { kind: "modifier_source", @@ -69,6 +68,70 @@ const debugWarn = (...args) => { if (DEBUG) console.warn(LOG, ...args); }; + +const TARGET_SEPARATOR = " | "; +const TARGET_NODE_TYPES = new Set(["SWF_Subworkflow", "SWF_SubworkflowFromURL"]); + +function targetIdFromValue(value) { + return String(value || "").split(TARGET_SEPARATOR, 1)[0].trim(); +} + +function targetNodes() { + return (app.graph?._nodes || []).filter(node => TARGET_NODE_TYPES.has(node?.comfyClass)); +} + +function targetSourceValue(node) { + const config = NODE_CONFIGS[node?.comfyClass]; + return node?.widgets?.find(widget => widget.name === config?.widgetName)?.value || ""; +} + +function targetReferenceLabel(node) { + const title = node?.title || node?.type || node?.comfyClass || "Subworkflow"; + const source = targetSourceValue(node); + return [String(node?.id ?? ""), title, source].filter(Boolean).join(TARGET_SEPARATOR); +} + +function findTargetNode(value) { + const targetId = targetIdFromValue(value); + return targetNodes().find(node => String(node.id) === targetId) || null; +} + +function normalizeTargetReference(value) { + const directTarget = findTargetNode(value); + if (directTarget) return targetReferenceLabel(directTarget); + + // Migrate legacy Modifier Source nodes that stored a file name directly. + const legacyValue = String(value || ""); + if (!legacyValue || legacyValue.includes(TARGET_SEPARATOR)) return legacyValue; + const matches = targetNodes().filter(node => String(targetSourceValue(node)) === legacyValue); + return matches.length === 1 ? targetReferenceLabel(matches[0]) : legacyValue; +} + +function configureTargetSelector(node) { + const widget = node?.widgets?.find(item => item.name === "target_subworkflow"); + if (!widget) return null; + + + widget.label = "Subworkflow node"; + widget.options = { + ...(widget.options || {}), + values: () => ["", ...targetNodes().map(targetReferenceLabel)], + }; + widget.value = normalizeTargetReference(widget.value); + return widget; +} + +function refreshReferencedModifierSources(targetNode, info) { + if (!targetNode || !info) return; + for (const sourceNode of (app.graph?._nodes || [])) { + if (sourceNode?.comfyClass !== "SWF_SubworkflowModifierSource") continue; + const widget = configureTargetSelector(sourceNode); + if (!widget || targetIdFromValue(widget.value) !== String(targetNode.id)) continue; + widget.value = targetReferenceLabel(targetNode); + applyWorkflowInfo(sourceNode, info, NODE_CONFIGS.SWF_SubworkflowModifierSource); + } +} + debugLog("extension module loaded", { configuredNodeTypes: Object.keys(NODE_CONFIGS), }); @@ -173,6 +236,39 @@ async function fetchWorkflowInfo(config, value, node) { clearWorkflowError(); return null; } + if (config.targetNodeReference) { + const widget = configureTargetSelector(node); + const target = findTargetNode(widget?.value || value); + if (!target) { + showWorkflowError( + config, + value, + "The selected Subworkflow node is missing. Select a loaded Subworkflow node from the dropdown.", + ); + return null; + } + + const targetConfig = NODE_CONFIGS[target.comfyClass]; + const sourceValue = targetSourceValue(target); + if (!targetConfig || !sourceValue) { + showWorkflowError( + config, + value, + "The selected Subworkflow node has no workflow source configured.", + ); + return null; + } + + const info = target.__swfWorkflowInfo + || await fetchWorkflowInfo(targetConfig, sourceValue, target); + if (info) { + clearWorkflowError(); + target.__swfWorkflowInfo = info; + if (widget) widget.value = targetReferenceLabel(target); + } + return info; + } + const path = config.infoPath(value, node); debugLog(`fetchWorkflowInfo: fetching info for ${config.describe}`, { value, @@ -483,6 +579,9 @@ function applyWorkflowInfo(node, info, config) { } setNodeSizeAtLeast(node, node.computeSize()); app.graph.setDirtyCanvas(true, true); + if (TARGET_NODE_TYPES.has(node?.comfyClass)) { + refreshReferencedModifierSources(node, info); + } } function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize, config, pendingOverrideValues) { @@ -565,6 +664,9 @@ function applyWorkflowInfoOnLoad(node, info, savedInputCount, savedSize, config, setNodeSizeAtLeast(node, node.computeSize()); } app.graph.setDirtyCanvas(true, true); + if (TARGET_NODE_TYPES.has(node?.comfyClass)) { + refreshReferencedModifierSources(node, info); + } } app.registerExtension({ @@ -644,6 +746,7 @@ app.registerExtension({ } if (origOnConfigure) origOnConfigure.call(this, serializedNode); + if (config.targetNodeReference) configureTargetSelector(this); debugLog("onConfigure: after original handler", { nodeType: nodeData.name, nodeId: this.id, @@ -714,6 +817,7 @@ app.registerExtension({ const origOnAdded = nodeType.prototype.onAdded; nodeType.prototype.onAdded = function () { if (origOnAdded) origOnAdded.call(this); + if (config.targetNodeReference) configureTargetSelector(this); const widget = this.widgets?.find(w => w.name === config.widgetName); const val = widget?.value; debugLog(`onAdded: node placed on canvas, ${config.widgetName} =`, val, { diff --git a/pyproject.toml b/pyproject.toml index a080eae..aa55d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "comfyui-subworkflow" -version = "1.2.3" +version = "1.3.0" description = "Use ComfyUI workflows as reusable subworkflows via Subworkflow Input, Subworkflow Output, and Subworkflow nodes" license = { file = "LICENSE" } requires-python = ">=3.10" diff --git a/tests/test_workflow_node.py b/tests/test_workflow_node.py new file mode 100644 index 0000000..d9a62e3 --- /dev/null +++ b/tests/test_workflow_node.py @@ -0,0 +1,189 @@ +import importlib.util +import logging +import pathlib +import sys +import types +import unittest +from types import SimpleNamespace + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _load_workflow_node_module(): + package_name = "swf_isolated_test" + package = types.ModuleType(package_name) + package.__path__ = [str(REPO_ROOT)] + sys.modules[package_name] = package + + latest = types.ModuleType("comfy_api.latest") + latest.io = SimpleNamespace( + ComfyNode=object, + NodeOutput=lambda *args, **kwargs: (args, kwargs), + Hidden=SimpleNamespace(unique_id="UNIQUE_ID", dynprompt="DYNPROMPT"), + ) + comfy_api = types.ModuleType("comfy_api") + comfy_api.latest = latest + sys.modules["comfy_api"] = comfy_api + sys.modules["comfy_api.latest"] = latest + + debug_utils = types.ModuleType(f"{package_name}.debug_utils") + debug_utils.configure_logger = lambda name: logging.getLogger(name) + sys.modules[debug_utils.__name__] = debug_utils + + workflow_utils = types.ModuleType(f"{package_name}.workflow_utils") + for name in ( + "list_workflow_files", + "load_workflow_file", + "load_workflow_url", + "build_expansion", + "build_modifier_source_expansion", + "apply_control_after_generate", + "validate_workflow_nodes_installed", + "get_workflow_interface", + ): + setattr(workflow_utils, name, lambda *args, **kwargs: None) + sys.modules[workflow_utils.__name__] = workflow_utils + + module_name = f"{package_name}.workflow_node" + spec = importlib.util.spec_from_file_location( + module_name, + REPO_ROOT / "workflow_node.py", + ) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +workflow_node = _load_workflow_node_module() + + +class FakePrompt: + def __init__(self, nodes=None): + self.nodes = nodes or {} + + def has_node(self, node_id): + return node_id in self.nodes + + def get_node(self, node_id): + return self.nodes[node_id] + + +class ModifierSourceTargetTests(unittest.TestCase): + def test_resolves_file_and_url_subworkflow_targets(self): + prompt = FakePrompt({ + "12": { + "class_type": "SWF_Subworkflow", + "inputs": { + "workflow": "inner.json", + "reload_each_execution": False, + }, + }, + "13": { + "class_type": "SWF_SubworkflowFromURL", + "inputs": { + "url": "https://example.test/workflow.json", + "verify_ssl": False, + "reload_each_execution": True, + }, + }, + }) + workflow_node.SubworkflowModifierSource.hidden = SimpleNamespace( + dynprompt=prompt + ) + + file_target = workflow_node.SubworkflowModifierSource._resolve_target( + "12 | Local | inner.json" + ) + url_target = workflow_node.SubworkflowModifierSource._resolve_target( + "13 | Remote | https://example.test/workflow.json" + ) + + self.assertIs(file_target[1], workflow_node.Subworkflow) + self.assertEqual(file_target[2], {"workflow": "inner.json"}) + self.assertIs(file_target[3], False) + self.assertIs(url_target[1], workflow_node.SubworkflowFromURL) + self.assertEqual(url_target[2]["verify_ssl"], False) + self.assertIs(url_target[3], True) + + def test_rejects_non_subworkflow_target(self): + prompt = FakePrompt({ + "20": {"class_type": "KSampler", "inputs": {}}, + }) + workflow_node.SubworkflowModifierSource.hidden = SimpleNamespace( + dynprompt=prompt + ) + with self.assertRaisesRegex(ValueError, "not a Subworkflow"): + workflow_node.SubworkflowModifierSource._resolve_target("20 | Sampler") + + +class WorkflowDataCacheTests(unittest.TestCase): + def setUp(self): + workflow_node.BaseSubworkflow._loaded_workflows.clear() + workflow_node.BaseSubworkflow._loaded_workflow_instances.clear() + workflow_node.BaseSubworkflow._prompt_workflows.clear() + self.loads = [] + self.original_loader = workflow_node.Subworkflow.__dict__["_load_source"] + loads = self.loads + workflow_node.Subworkflow._load_source = classmethod( + lambda cls, workflow, **kwargs: ( + loads.append(workflow) or {"load": len(loads)} + ) + ) + + def tearDown(self): + workflow_node.Subworkflow._load_source = self.original_loader + + def test_reload_loads_once_per_prompt(self): + prompt = FakePrompt() + first = workflow_node.Subworkflow._get_workflow_data( + True, + instance_id="12", + dynprompt=prompt, + workflow="inner.json", + ) + second = workflow_node.Subworkflow._get_workflow_data( + True, + instance_id="12", + dynprompt=prompt, + workflow="inner.json", + ) + self.assertIs(first, second) + self.assertEqual(len(self.loads), 1) + + workflow_node.Subworkflow._get_workflow_data( + True, + instance_id="12", + dynprompt=FakePrompt(), + workflow="inner.json", + ) + self.assertEqual(len(self.loads), 2) + + def test_keep_loaded_is_isolated_by_target_node(self): + kept = workflow_node.Subworkflow._get_workflow_data( + False, + instance_id="12", + dynprompt=FakePrompt(), + workflow="inner.json", + ) + kept_again = workflow_node.Subworkflow._get_workflow_data( + False, + instance_id="12", + dynprompt=FakePrompt(), + workflow="inner.json", + ) + self.assertIs(kept, kept_again) + self.assertEqual(len(self.loads), 1) + + workflow_node.Subworkflow._get_workflow_data( + False, + instance_id="99", + dynprompt=FakePrompt(), + workflow="inner.json", + ) + self.assertEqual(len(self.loads), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_utils.py b/tests/test_workflow_utils.py new file mode 100644 index 0000000..9a0ce06 --- /dev/null +++ b/tests/test_workflow_utils.py @@ -0,0 +1,75 @@ +import importlib.util +import logging +import pathlib +import sys +import types +import unittest + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _load_workflow_utils_module(): + package_name = "swf_utils_isolated_test" + package = types.ModuleType(package_name) + package.__path__ = [str(REPO_ROOT)] + sys.modules[package_name] = package + + graph_utils = types.ModuleType("comfy_execution.graph_utils") + graph_utils.GraphBuilder = object + comfy_execution = types.ModuleType("comfy_execution") + comfy_execution.graph_utils = graph_utils + sys.modules["comfy_execution"] = comfy_execution + sys.modules["comfy_execution.graph_utils"] = graph_utils + + debug_utils = types.ModuleType(f"{package_name}.debug_utils") + debug_utils.configure_logger = lambda name: logging.getLogger(name) + sys.modules[debug_utils.__name__] = debug_utils + + module_name = f"{package_name}.workflow_utils" + spec = importlib.util.spec_from_file_location( + module_name, + REPO_ROOT / "workflow_utils.py", + ) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +workflow_utils = _load_workflow_utils_module() + + +class FakeNode: + def __init__(self, **inputs): + self.inputs = inputs + + +class FakeGraph: + def __init__(self, nodes): + self.nodes = nodes + + +class ModifierSourcePruningTests(unittest.TestCase): + def test_keeps_only_selected_output_ancestors(self): + graph = FakeGraph({ + "source": FakeNode(value=1), + "modifier_value": FakeNode(value=["source", 0]), + "sampler": FakeNode(model=["modifier_value", 0]), + "save": FakeNode(images=["sampler", 0]), + "selected": FakeNode(nested={"value": ["modifier_value", 0]}), + }) + + workflow_utils._prune_graph_to_output_refs( + graph, + [["selected", 0]], + ) + + self.assertEqual( + set(graph.nodes), + {"source", "modifier_value", "selected"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflow_node.py b/workflow_node.py index 492f8bc..28b6073 100644 --- a/workflow_node.py +++ b/workflow_node.py @@ -3,6 +3,7 @@ Subworkflow: loads an inner workflow (UI or API format), infers its I/O from Subworkflow Input / Subworkflow Output nodes, and executes it as a subgraph expansion. """ import logging +import weakref from comfy_api.latest import io from .debug_utils import configure_logger @@ -93,6 +94,8 @@ class BaseSubworkflow(io.ComfyNode): return float("NaN") _loaded_workflows: dict[str, dict] = {} + _loaded_workflow_instances: dict[str, tuple[str, dict]] = {} + _prompt_workflows = weakref.WeakKeyDictionary() @classmethod def _source_label(cls, **kwargs) -> str: @@ -107,16 +110,72 @@ class BaseSubworkflow(io.ComfyNode): raise NotImplementedError @classmethod - def _get_workflow_data(cls, reload_each_execution: bool, **kwargs) -> dict: + def _get_workflow_data( + cls, + reload_each_execution: bool, + *, + instance_id: str | None = None, + dynprompt=None, + **kwargs, + ) -> dict: cache_key = cls._source_cache_key(**kwargs) label = cls._source_label(**kwargs) - if reload_each_execution or cache_key not in cls._loaded_workflows: + + prompt_cache = None + prompt_cache_key = None + if dynprompt is not None and instance_id is not None: + try: + prompt_cache = cls._prompt_workflows.setdefault(dynprompt, {}) + prompt_cache_key = (str(instance_id), cache_key) + if prompt_cache_key in prompt_cache: + log.debug( + "[Subworkflow] reusing prompt-scoped inner workflow %r for node %s", + label, + instance_id, + ) + return prompt_cache[prompt_cache_key] + except TypeError: + # Some test and mock prompt objects may not support weak references. + prompt_cache = None + prompt_cache_key = None + + instance_key = str(instance_id) if instance_id is not None else None + instance_cached = ( + cls._loaded_workflow_instances.get(instance_key) + if instance_key is not None + else None + ) + instance_cache_hit = ( + instance_cached is not None and instance_cached[0] == cache_key + ) + + if reload_each_execution or ( + instance_key is not None and not instance_cache_hit + ) or ( + instance_key is None and cache_key not in cls._loaded_workflows + ): reason = "reload_each_execution enabled" if reload_each_execution else "cache miss" - log.info("[Subworkflow] loading inner workflow %r (%s)", label, reason) - cls._loaded_workflows[cache_key] = cls._load_source(**kwargs) + log.info( + "[Subworkflow] loading inner workflow %r for node %s (%s)", + label, + instance_id or "shared source", + reason, + ) + data = cls._load_source(**kwargs) + if instance_key is not None: + if not reload_each_execution: + cls._loaded_workflow_instances[instance_key] = (cache_key, data) + else: + cls._loaded_workflows[cache_key] = data + elif instance_key is not None: + data = instance_cached[1] else: log.debug("[Subworkflow] reusing cached inner workflow %r", label) - return cls._loaded_workflows[cache_key] + data = cls._loaded_workflows[cache_key] + + if prompt_cache is not None and prompt_cache_key is not None: + prompt_cache[prompt_cache_key] = data + return data @classmethod def _execute_source(cls, reload_each_execution=True, **kwargs): @@ -127,7 +186,12 @@ class BaseSubworkflow(io.ComfyNode): reload_each_execution, sorted(k for k in kwargs if k.startswith("swf_in_")), ) - data = cls._get_workflow_data(reload_each_execution, **kwargs) + data = cls._get_workflow_data( + reload_each_execution, + instance_id=cls.hidden.unique_id, + dynprompt=cls.hidden.dynprompt, + **kwargs, + ) validate_workflow_nodes_installed(data) effective_inputs = _apply_primitive_overrides(data, kwargs) @@ -176,6 +240,7 @@ class Subworkflow(BaseSubworkflow): io.Combo.Input("workflow", options=list_workflow_files()), _reload_input(), ], + hidden=[io.Hidden.unique_id, io.Hidden.dynprompt], outputs=_subworkflow_outputs(), enable_expand=True, accept_all_inputs=True, @@ -236,6 +301,7 @@ class SubworkflowFromURL(BaseSubworkflow): ), _reload_input(), ], + hidden=[io.Hidden.unique_id, io.Hidden.dynprompt], outputs=_subworkflow_outputs(), enable_expand=True, accept_all_inputs=True, @@ -300,42 +366,90 @@ class SubworkflowModifierSource(BaseSubworkflow): display_name="Subworkflow Modifier Source", category="Subworkflow", description=( - "Exposes all modifier source slots from a selected inner workflow. " - "Use this together with the main Subworkflow node to break outer " - "modifier dependency loops." + "Exposes modifier source slots from another Subworkflow node in " + "this workflow. Use this together with that Subworkflow node to " + "break outer modifier dependency loops." ), inputs=[ - io.Combo.Input("workflow", options=list_workflow_files()), + io.Combo.Input( + "target_subworkflow", + display_name="Subworkflow node", + options=[""], + ), ], + hidden=[io.Hidden.unique_id, io.Hidden.dynprompt], outputs=_subworkflow_outputs(), enable_expand=True, accept_all_inputs=True, ) @classmethod - def _source_label(cls, workflow: str, **kwargs) -> str: - return workflow + def _target_node_id(cls, target_subworkflow: str) -> str: + return str(target_subworkflow or "").split(" | ", 1)[0].strip() @classmethod - def _source_cache_key(cls, workflow: str, **kwargs) -> str: - return f"file:{workflow}" + def _resolve_target(cls, target_subworkflow: str): + target_id = cls._target_node_id(target_subworkflow) + if not target_id: + raise ValueError("No Subworkflow node selected.") + + dynprompt = cls.hidden.dynprompt + if dynprompt is None or not dynprompt.has_node(target_id): + raise ValueError( + f"Selected Subworkflow node {target_id!r} is not present in the queued workflow." + ) + + target = dynprompt.get_node(target_id) + inputs = target.get("inputs") or {} + class_type = target.get("class_type") + if class_type == "SWF_Subworkflow": + workflow = inputs.get("workflow") + if not isinstance(workflow, str) or not workflow: + raise ValueError( + f"Selected Subworkflow node {target_id!r} has no workflow selected." + ) + source_class = Subworkflow + source_kwargs = {"workflow": workflow} + elif class_type == "SWF_SubworkflowFromURL": + url = inputs.get("url") + verify_ssl = inputs.get("verify_ssl", True) + if not isinstance(url, str) or not url: + raise ValueError( + f"Selected Subworkflow node {target_id!r} has no workflow URL." + ) + if not isinstance(verify_ssl, bool): + raise ValueError( + f"Selected Subworkflow node {target_id!r} has an invalid verify-SSL setting." + ) + source_class = SubworkflowFromURL + source_kwargs = {"url": url, "verify_ssl": verify_ssl} + else: + raise ValueError( + f"Selected node {target_id!r} is not a Subworkflow node." + ) + + reload_each_execution = inputs.get("reload_each_execution", True) + if not isinstance(reload_each_execution, bool): + raise ValueError( + f"Selected Subworkflow node {target_id!r} has an invalid at-execution setting." + ) + return target_id, source_class, source_kwargs, reload_each_execution @classmethod - def _load_source(cls, workflow: str, **kwargs) -> dict: - if not workflow: - raise ValueError("No workflow selected. Choose a workflow file from the dropdown.") - try: - return load_workflow_file(workflow) - except FileNotFoundError: - raise ValueError(f"Workflow file not found: {workflow!r}") from None - - @classmethod - def execute(cls, workflow: str, **kwargs): - reload_each_execution = True - data = cls._get_workflow_data(reload_each_execution, workflow=workflow, **kwargs) + def execute(cls, target_subworkflow: str, **kwargs): + target_id, source_class, source_kwargs, reload_each_execution = ( + cls._resolve_target(target_subworkflow) + ) + data = source_class._get_workflow_data( + reload_each_execution, + instance_id=target_id, + dynprompt=cls.hidden.dynprompt, + **source_kwargs, + ) validate_workflow_nodes_installed(data) output_refs, graph = build_modifier_source_expansion(data, kwargs) - return cls._finalize_execution(output_refs, graph, data, reload_each_execution) + # The selected Subworkflow owns control-after-generate mutations. + return io.NodeOutput(*output_refs, expand=graph.finalize()) class SubworkflowModifierSourceFromURL(BaseSubworkflow): @@ -345,9 +459,10 @@ class SubworkflowModifierSourceFromURL(BaseSubworkflow): node_id="SWF_SubworkflowModifierSourceFromURL", display_name="Subworkflow Modifier Source (from URL)", category="Subworkflow", + is_deprecated=True, description=( - "Exposes all modifier source slots from a workflow loaded from an " - "HTTP(S) URL." + "Deprecated compatibility node. Use Subworkflow Modifier Source " + "and select a Subworkflow (from URL) node instead." ), inputs=[ io.String.Input("url", multiline=False, default=""), diff --git a/workflow_utils.py b/workflow_utils.py index ddccdc6..4964e8b 100644 --- a/workflow_utils.py +++ b/workflow_utils.py @@ -946,7 +946,7 @@ def build_modifier_source_expansion(data: dict, outer_inputs: dict): item for item in info["raw_outputs"] if item["node_id"] in {modifier["output_node_id"] for modifier in info["modifiers"]} ] - return build_expansion( + output_refs, graph = build_expansion( data, outer_inputs, runtime_inputs_info=[], @@ -954,6 +954,49 @@ def build_modifier_source_expansion(data: dict, outer_inputs: dict): boundary_inputs_info=info["raw_inputs"], boundary_outputs_info=info["raw_outputs"], ) + _prune_graph_to_output_refs(graph, output_refs) + return output_refs, graph + + +def _iter_graph_link_node_ids(value): + """Yield internal graph node IDs referenced by a possibly nested input value.""" + if _is_graph_link(value): + yield value[0] + return + if isinstance(value, dict): + values = value.values() + elif isinstance(value, (list, tuple)): + values = value + else: + return + for item in values: + yield from _iter_graph_link_node_ids(item) + + +def _prune_graph_to_output_refs(graph: GraphBuilder, output_refs: list): + """Keep only nodes required to produce the selected expansion outputs.""" + required: set[str] = set() + pending = list(_iter_graph_link_node_ids(output_refs)) + + while pending: + node_id = pending.pop() + if node_id in required: + continue + node = graph.nodes.get(node_id) + if node is None: + continue + required.add(node_id) + for value in node.inputs.values(): + pending.extend(_iter_graph_link_node_ids(value)) + + removed = set(graph.nodes) - required + for node_id in removed: + del graph.nodes[node_id] + if removed: + log.debug( + "[Subworkflow] pruned %d unrelated node(s) from modifier source expansion", + len(removed), + ) def _is_graph_link(value) -> bool: