diff --git a/README.md b/README.md index 61665b9..76cd885 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py index c9c2988..9b9dc64 100644 --- a/__init__.py +++ b/__init__.py @@ -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") diff --git a/workflow_node.py b/workflow_node.py index 4893299..dd44a94 100644 --- a/workflow_node.py +++ b/workflow_node.py @@ -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()) diff --git a/workflow_utils.py b/workflow_utils.py index d465b5b..a7803ba 100644 --- a/workflow_utils.py +++ b/workflow_utils.py @@ -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)