Subworkflow node migrated from V1 to V3
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
|
||||
## Known Issues
|
||||
|
||||
- [ ] When `Subworkflow Output` is placed between two nodes, the output node fails during execution. Use a preview image node as a reproduction example.
|
||||
- [x] When `Subworkflow Output` is placed between two nodes, the output node fails during execution. Use a preview image node as a reproduction example.
|
||||
- [ ] The `Subworkflow` node progress can exceed 100%, sometimes reaching about 200%. Check with the upscale workflow example.
|
||||
- [ ] `Subworkflow Input` transparency for nodes is not always correct, for example when a concatenate string node is used.
|
||||
- [x] `Subworkflow Input` transparency for nodes is not always correct, for example when a concatenate string node is used.
|
||||
- [x] Inner workflow nodes with a randomize-after-processing feature execute, but their updated values were not preserved because the inner workflow state was reloaded from disk.
|
||||
- [ ] Nodes are currently a mix of v1 and v3. This works, but it would be better to migrate all nodes to v3 if the `Subworkflow` node can be moved safely.
|
||||
- [x] Nodes were a mix of v1 and v3; `Subworkflow`, `Subworkflow Input`, and `Subworkflow Output` are now all V3 nodes.
|
||||
|
||||
Checked items are fixed. Unchecked items are known issues or follow-up work.
|
||||
|
||||
+1
-6
@@ -18,18 +18,13 @@ log.info("ComfyUI-Subworkflow: all imports OK")
|
||||
class SubworkflowExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
nodes = [SubworkflowInput, SubworkflowOutput]
|
||||
nodes = [Subworkflow, SubworkflowInput, SubworkflowOutput]
|
||||
log.info("ComfyUI-Subworkflow: registering V3 nodes: %s",
|
||||
[n.__name__ for n in nodes])
|
||||
return nodes
|
||||
|
||||
@override
|
||||
async def on_load(self):
|
||||
import nodes as _comfy_nodes
|
||||
_comfy_nodes.NODE_CLASS_MAPPINGS["SWF_Subworkflow"] = Subworkflow
|
||||
_comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS["SWF_Subworkflow"] = "Subworkflow"
|
||||
log.info("ComfyUI-Subworkflow: registered V1 node SWF_Subworkflow")
|
||||
|
||||
server_routes.setup_routes()
|
||||
log.info("ComfyUI-Subworkflow: server routes registered")
|
||||
|
||||
|
||||
+42
-33
@@ -4,30 +4,20 @@ Subworkflow Input / Subworkflow Output nodes, and executes it as a subgraph expa
|
||||
"""
|
||||
import logging
|
||||
|
||||
from comfy_api.latest import io
|
||||
|
||||
from .workflow_utils import (
|
||||
list_workflow_files,
|
||||
load_workflow,
|
||||
get_workflow_io,
|
||||
build_expansion,
|
||||
apply_control_after_generate,
|
||||
MAX_SLOTS,
|
||||
PLACEHOLDER,
|
||||
)
|
||||
|
||||
log = logging.getLogger("ComfyUI-Subworkflow")
|
||||
|
||||
# A string subclass whose __ne__ always returns False so that ComfyUI's
|
||||
# type-matching considers it equal to any other type string.
|
||||
class _AnyType(str):
|
||||
def __eq__(self, _):
|
||||
return True
|
||||
def __ne__(self, _):
|
||||
return False
|
||||
|
||||
_ANY = _AnyType("*")
|
||||
|
||||
|
||||
class Subworkflow:
|
||||
class Subworkflow(io.ComfyNode):
|
||||
"""
|
||||
Selects a saved API-format workflow, exposes its Subworkflow Input nodes as
|
||||
inputs and its Subworkflow Output nodes as outputs, then executes the inner
|
||||
@@ -35,31 +25,49 @@ class Subworkflow:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
workflows = list_workflow_files()
|
||||
return {
|
||||
"required": {
|
||||
"workflow": (workflows, {}),
|
||||
"reload_each_execution": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "label_on": "reload", "label_off": "keep loaded"},
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SWF_Subworkflow",
|
||||
display_name="Subworkflow",
|
||||
category="subworkflow",
|
||||
description=(
|
||||
"Executes a selected workflow as an expandable subworkflow, "
|
||||
"using Subworkflow Input and Subworkflow Output boundary nodes."
|
||||
),
|
||||
inputs=[
|
||||
io.Combo.Input("workflow", options=list_workflow_files()),
|
||||
io.Boolean.Input(
|
||||
"reload_each_execution",
|
||||
default=True,
|
||||
label_on="reload",
|
||||
label_off="keep loaded",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Fixed upper bound; the JS extension hides the unused tail slots.
|
||||
RETURN_TYPES = (_ANY,) * MAX_SLOTS
|
||||
RETURN_NAMES = tuple(f"out_{i}" for i in range(MAX_SLOTS))
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "subworkflow"
|
||||
],
|
||||
outputs=[
|
||||
io.AnyType.Output(id=f"out_{i}", display_name=f"out_{i}")
|
||||
for i in range(MAX_SLOTS)
|
||||
],
|
||||
enable_expand=True,
|
||||
accept_all_inputs=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, input_types=None, **kwargs):
|
||||
def validate_inputs(cls, **kwargs):
|
||||
# Accept any incoming types for dynamic swf_in_* inputs.
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, workflow: str, reload_each_execution=True, **kwargs):
|
||||
def check_lazy_status(cls, **kwargs):
|
||||
missing_inputs = [
|
||||
name for name, value in kwargs.items()
|
||||
if name.startswith("swf_in_") and value is None
|
||||
]
|
||||
if missing_inputs:
|
||||
log.info("Subworkflow: waiting for dynamic input(s) %s", missing_inputs)
|
||||
return missing_inputs
|
||||
|
||||
@classmethod
|
||||
def fingerprint_inputs(cls, workflow: str, reload_each_execution=True, **kwargs):
|
||||
# The inner workflow can change between runs by file reload or by cached
|
||||
# control-after-generate mutations, even when outer inputs are unchanged.
|
||||
return float("NaN")
|
||||
@@ -81,9 +89,10 @@ class Subworkflow:
|
||||
if not workflow or workflow.startswith("["):
|
||||
raise ValueError("No workflow selected. Choose a workflow file from the dropdown.")
|
||||
log.info(
|
||||
"Subworkflow: executing inner workflow %r (reload_each_execution=%s)",
|
||||
"Subworkflow: executing inner workflow %r (reload_each_execution=%s, dynamic_inputs=%s)",
|
||||
workflow,
|
||||
reload_each_execution,
|
||||
sorted(k for k in kwargs if k.startswith("swf_in_")),
|
||||
)
|
||||
try:
|
||||
data = cls._get_workflow_data(workflow, reload_each_execution)
|
||||
@@ -97,4 +106,4 @@ class Subworkflow:
|
||||
while len(output_refs) < MAX_SLOTS:
|
||||
output_refs.append(None)
|
||||
|
||||
return {"result": tuple(output_refs), "expand": graph.finalize()}
|
||||
return io.NodeOutput(*output_refs, expand=graph.finalize())
|
||||
|
||||
@@ -468,8 +468,23 @@ def _build_expansion_ui(data: dict, outer_inputs: dict):
|
||||
fi_node_ids = {inp["node_id"] for inp in inputs_info}
|
||||
fo_node_ids = {out["node_id"] for out in outputs_info}
|
||||
func_node_ids = fi_node_ids | fo_node_ids
|
||||
fo_src = {out["node_id"]: dst_to_src.get(out["node_id"]) for out in outputs_info}
|
||||
|
||||
fi_value = {inp["node_id"]: outer_inputs.get(f"swf_in_{i}") for i, inp in enumerate(inputs_info)}
|
||||
missing = [
|
||||
f"swf_in_{i}:{inp['slot_name']}({inp['node_id']})"
|
||||
for i, inp in enumerate(inputs_info)
|
||||
if outer_inputs.get(f"swf_in_{i}") is None
|
||||
]
|
||||
if missing:
|
||||
log.warning("Subworkflow: missing UI inner input value(s): %s", missing)
|
||||
log.info(
|
||||
"Subworkflow: building UI expansion with %d input(s), %d output(s), %d inner node(s), outer_input_keys=%s",
|
||||
len(inputs_info),
|
||||
len(outputs_info),
|
||||
len(nodes_list),
|
||||
sorted(k for k in outer_inputs if k.startswith("swf_in_")),
|
||||
)
|
||||
|
||||
graph = GraphBuilder()
|
||||
node_refs: dict = {}
|
||||
@@ -478,6 +493,9 @@ def _build_expansion_ui(data: dict, outer_inputs: dict):
|
||||
def resolve_link(src_node_id: str, src_slot: int):
|
||||
if src_node_id in fi_value:
|
||||
return fi_value[src_node_id]
|
||||
if src_node_id in fo_src:
|
||||
src = fo_src[src_node_id]
|
||||
return resolve_link(src[0], src[1]) if src else None
|
||||
if src_node_id in subgraph_outputs:
|
||||
refs = subgraph_outputs[src_node_id]
|
||||
return refs[src_slot] if src_slot < len(refs) else None
|
||||
@@ -571,7 +589,25 @@ def _build_expansion_api(data: dict, outer_inputs: dict):
|
||||
inputs_info, outputs_info = _get_workflow_io_api(data)
|
||||
|
||||
fi_value = {inp["node_id"]: outer_inputs.get(f"swf_in_{i}") for i, inp in enumerate(inputs_info)}
|
||||
missing = [
|
||||
f"swf_in_{i}:{inp['slot_name']}({inp['node_id']})"
|
||||
for i, inp in enumerate(inputs_info)
|
||||
if outer_inputs.get(f"swf_in_{i}") is None
|
||||
]
|
||||
if missing:
|
||||
log.warning("Subworkflow: missing API inner input value(s): %s", missing)
|
||||
log.info(
|
||||
"Subworkflow: building API expansion with %d input(s), %d output(s), %d inner node(s), outer_input_keys=%s",
|
||||
len(inputs_info),
|
||||
len(outputs_info),
|
||||
len([nid for nid in data if not str(nid).startswith("_")]),
|
||||
sorted(k for k in outer_inputs if k.startswith("swf_in_")),
|
||||
)
|
||||
func_node_ids = {inp["node_id"] for inp in inputs_info} | {out["node_id"] for out in outputs_info}
|
||||
fo_src = {
|
||||
out["node_id"]: data[out["node_id"]].get("inputs", {}).get("value")
|
||||
for out in outputs_info
|
||||
}
|
||||
|
||||
graph = GraphBuilder()
|
||||
node_refs: dict = {}
|
||||
@@ -581,6 +617,9 @@ def _build_expansion_api(data: dict, outer_inputs: dict):
|
||||
src_slot = int(link_val[1])
|
||||
if src_id in fi_value:
|
||||
return fi_value[src_id]
|
||||
if src_id in fo_src:
|
||||
src = fo_src[src_id]
|
||||
return resolve_link(src) if isinstance(src, list) and len(src) == 2 else src
|
||||
ref = node_refs.get(src_id)
|
||||
if ref is None:
|
||||
log.warning("SWF api: node %s slot %d unresolvable", src_id, src_slot)
|
||||
|
||||
Reference in New Issue
Block a user