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
+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=""),