Modifier now uses internal references, not separate instance of loaded graph. Deprecates the Modifier (from URL) node.
This commit is contained in:
@@ -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 
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user