Modifier now uses internal references, not separate instance of loaded graph. Deprecates the Modifier (from URL) node.

This commit is contained in:
Edward Niewold
2026-08-19 21:45:18 +02:00
parent 0de81b2ff8
commit 854ea97e02
7 changed files with 573 additions and 45 deletions
+7 -5
View File
@@ -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).
+113 -9
View File
@@ -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, {
+1 -1
View File
@@ -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"
+189
View File
@@ -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()
+75
View File
@@ -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()
+144 -29
View File
@@ -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=""),
+44 -1
View File
@@ -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: