From 5ede17309abe5d592080dc86f49933cbdc2e60d9 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Tue, 2 Dec 2025 23:51:26 -0800 Subject: [PATCH 01/52] Support Combo outputs in a more sane way --- comfy_api/latest/_io.py | 4 ---- comfy_execution/graph.py | 5 +++++ comfy_execution/validation.py | 5 +++++ comfy_extras/nodes_logic.py | 23 +++++++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 257f07c42fb7..f6a617c7df26 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -387,10 +387,6 @@ def __init__(self, id: str=None, display_name: str=None, options: list[str]=None super().__init__(id, display_name, tooltip, is_output_list) self.options = options if options is not None else [] - @property - def io_type(self): - return self.options - @comfytype(io_type="COMBO") class MultiCombo(ComfyTypeI): '''Multiselect Combo input (dropdown for selecting potentially more than one value).''' diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index 0d811e3546ef..d262201d9ac2 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -6,6 +6,7 @@ import inspect from comfy_execution.graph_utils import is_link, ExecutionBlocker from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, InputTypeOptions +from comfy_api.latest import IO # NOTE: ExecutionBlocker code got moved to graph_utils.py to prevent torch being imported too soon during unit tests ExecutionBlocker = ExecutionBlocker @@ -97,6 +98,10 @@ def get_input_info( extra_info = input_info[1] else: extra_info = {} + # if input_type is a list, it is a Combo defined in outdated format; convert it + if isinstance(input_type, list): + extra_info["options"] = input_type + input_type = IO.Combo.io_type return input_type, input_category, extra_info class TopologicalSort: diff --git a/comfy_execution/validation.py b/comfy_execution/validation.py index 24c0b4ed76cd..a5e9675da7e2 100644 --- a/comfy_execution/validation.py +++ b/comfy_execution/validation.py @@ -29,6 +29,11 @@ def validate_node_input( if received_type == IO.MatchType.io_type or input_type == IO.MatchType.io_type: return True + # This accounts for some custom nodes that output lists of options as the type; + # if we ever want to break them on purpose, this can be removed + if isinstance(received_type, list) and input_type == IO.Combo.io_type: + return True + # Not equal, and not strings if not isinstance(received_type, str) or not isinstance(input_type, str): return False diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 95a6ba788faf..8dde4eb1e228 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -141,6 +141,28 @@ def execute(cls, autogrow: _io.Autogrow.Type) -> io.NodeOutput: combined = ",".join([str(x) for x in vals]) return io.NodeOutput(combined) +class ComboOutputTestNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="ComboOptionTestNode", + display_name="ComboOptionTest", + category="logic", + inputs=[io.Combo.Input("combo", options=["option1", "option2", "option3"]), + io.Combo.Input("combo2", options=["option4", "option5", "option6"])], + outputs=[io.Combo.Output(), io.Combo.Output()], + ) + + @classmethod + def validate_inputs(cls, combo: io.Combo.Type) -> bool: + if combo not in ["option1", "option2", "option3"]: + return "Invalid combo: {}".format(combo) + return True + + @classmethod + def execute(cls, combo: io.Combo.Type, combo2: io.Combo.Type) -> io.NodeOutput: + return io.NodeOutput(combo, combo2) + class LogicExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: @@ -149,6 +171,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: # DCTestNode, # AutogrowNamesTestNode, # AutogrowPrefixTestNode, + ComboOutputTestNode, ] async def comfy_entrypoint() -> LogicExtension: From 0b5d4023f7ac849aca452fc6bb41e8b5b479dbae Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 00:01:23 -0800 Subject: [PATCH 02/52] Remove test validate_inputs function on test node --- comfy_extras/nodes_logic.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 8dde4eb1e228..ee9746526dc0 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -153,12 +153,6 @@ def define_schema(cls): outputs=[io.Combo.Output(), io.Combo.Output()], ) - @classmethod - def validate_inputs(cls, combo: io.Combo.Type) -> bool: - if combo not in ["option1", "option2", "option3"]: - return "Invalid combo: {}".format(combo) - return True - @classmethod def execute(cls, combo: io.Combo.Type, combo2: io.Combo.Type) -> io.NodeOutput: return io.NodeOutput(combo, combo2) From 009e90a2cd937cac6ed43563d2d2e7d4a8dcd639 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 00:28:50 -0800 Subject: [PATCH 03/52] Make curr_prefix be a list of strings instead of string for easier parsing as keys get added to dynamic types --- comfy_api/latest/_io.py | 36 ++++++++++++++++++++++++------------ comfy_extras/nodes_logic.py | 8 ++++---- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index f6a617c7df26..958392a12fd9 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -869,7 +869,7 @@ class DynamicInput(Input, ABC): def get_dynamic(self) -> list[Input]: return [] - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix=''): + def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): pass @@ -884,6 +884,17 @@ def __init__(self, id: str=None, display_name: str=None, tooltip: str=None, def get_dynamic(self) -> list[Output]: return [] +def handle_prefix(prefix_list: list | None, id: str | None = None) -> list[str]: + if prefix_list is None: + prefix_list = [] + if id is not None: + prefix_list.append(id) + return prefix_list + +def finalize_prefix(prefix_list: list[str], id: str | None = None) -> str: + if id is not None: + prefix_list = prefix_list + [id] + return ".".join(prefix_list) @comfytype(io_type="COMFY_AUTOGROW_V3") class Autogrow(ComfyTypeI): @@ -920,7 +931,8 @@ def as_dict(self): def validate(self): self.input.validate() - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix=''): + def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): + curr_prefix = handle_prefix(curr_prefix) real_inputs = [] for name, input in self.cached_inputs.items(): if name in live_inputs: @@ -981,8 +993,8 @@ def get_all(self) -> list[Input]: def validate(self): self.template.validate() - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix=''): - curr_prefix = f"{curr_prefix}{self.id}." + def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): + curr_prefix = handle_prefix(curr_prefix, self.id) # need to remove self from expected inputs dictionary; replaced by template inputs in frontend for inner_dict in d.values(): if self.id in inner_dict: @@ -1010,10 +1022,10 @@ def __init__(self, id: str, options: list[DynamicCombo.Option], super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) self.options = options - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix=''): + def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): # check if dynamic input's id is in live_inputs if self.id in live_inputs: - curr_prefix = f"{curr_prefix}{self.id}." + curr_prefix = handle_prefix(curr_prefix, self.id) key = live_inputs[self.id] selected_option = None for option in self.options: @@ -1063,9 +1075,9 @@ def __init__(self, slot: Input, inputs: list[Input], self.force_input = True self.slot.force_input = True - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix=''): + def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): if self.id in live_inputs: - curr_prefix = f"{curr_prefix}{self.id}." + curr_prefix = handle_prefix(curr_prefix, self.id) add_to_input_dict_v1(d, self.inputs, live_inputs, curr_prefix) add_dynamic_id_mapping(d, [self.slot] + self.inputs, curr_prefix) @@ -1087,13 +1099,13 @@ def validate(self): for input in self.inputs: input.validate() -def add_dynamic_id_mapping(d: dict[str, Any], inputs: list[Input], curr_prefix: str, self: DynamicInput=None): +def add_dynamic_id_mapping(d: dict[str, Any], inputs: list[Input], curr_prefix: list[str], self: DynamicInput=None): dynamic = d.setdefault("dynamic_paths", {}) if self is not None: - dynamic[self.id] = f"{curr_prefix}{self.id}" + dynamic[self.id] = finalize_prefix(curr_prefix, self.id) for i in inputs: if not isinstance(i, DynamicInput): - dynamic[f"{i.id}"] = f"{curr_prefix}{i.id}" + dynamic[f"{i.id}"] = finalize_prefix(curr_prefix, i.id) class V3Data(TypedDict): hidden_inputs: dict[str, Any] @@ -1380,7 +1392,7 @@ def create_input_dict_v1(inputs: list[Input], live_inputs: dict[str, Any]=None) add_to_input_dict_v1(input, inputs, live_inputs) return input -def add_to_input_dict_v1(d: dict[str, Any], inputs: list[Input], live_inputs: dict[str, Any]=None, curr_prefix=''): +def add_to_input_dict_v1(d: dict[str, Any], inputs: list[Input], live_inputs: dict[str, Any]=None, curr_prefix: list[str] | None=None): for i in inputs: if isinstance(i, DynamicInput): add_to_dict_v1(i, d) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index ee9746526dc0..2be06aa9cca1 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -161,10 +161,10 @@ class LogicExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ - # SwitchNode, - # DCTestNode, - # AutogrowNamesTestNode, - # AutogrowPrefixTestNode, + SwitchNode, + DCTestNode, + AutogrowNamesTestNode, + AutogrowPrefixTestNode, ComboOutputTestNode, ] From f8ede95149231612e6febb790e1e7f9f3cd2cce1 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 01:15:23 -0800 Subject: [PATCH 04/52] Start to account for id prefixes from frontend, need to fix bug with nested dynamics --- comfy_api/latest/_io.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 958392a12fd9..5a5addd0149f 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -888,11 +888,14 @@ def handle_prefix(prefix_list: list | None, id: str | None = None) -> list[str]: if prefix_list is None: prefix_list = [] if id is not None: - prefix_list.append(id) + prefix_list = prefix_list + [id] return prefix_list -def finalize_prefix(prefix_list: list[str], id: str | None = None) -> str: - if id is not None: +def finalize_prefix(prefix_list: list[str] | None, id: str | None = None) -> str: + assert not (prefix_list is None and id is None) + if prefix_list is None: + prefix_list = [id] + elif id is not None: prefix_list = prefix_list + [id] return ".".join(prefix_list) @@ -1102,10 +1105,10 @@ def validate(self): def add_dynamic_id_mapping(d: dict[str, Any], inputs: list[Input], curr_prefix: list[str], self: DynamicInput=None): dynamic = d.setdefault("dynamic_paths", {}) if self is not None: - dynamic[self.id] = finalize_prefix(curr_prefix, self.id) + dynamic[finalize_prefix(curr_prefix[:-1], self.id)] = finalize_prefix(curr_prefix, self.id) for i in inputs: - if not isinstance(i, DynamicInput): - dynamic[f"{i.id}"] = finalize_prefix(curr_prefix, i.id) + # if not isinstance(i, DynamicInput): + dynamic[finalize_prefix(curr_prefix, i.id)] = finalize_prefix(curr_prefix, i.id) class V3Data(TypedDict): hidden_inputs: dict[str, Any] @@ -1395,13 +1398,13 @@ def create_input_dict_v1(inputs: list[Input], live_inputs: dict[str, Any]=None) def add_to_input_dict_v1(d: dict[str, Any], inputs: list[Input], live_inputs: dict[str, Any]=None, curr_prefix: list[str] | None=None): for i in inputs: if isinstance(i, DynamicInput): - add_to_dict_v1(i, d) + add_to_dict_v1(i, d, curr_prefix=curr_prefix) if live_inputs is not None: i.expand_schema_for_dynamic(d, live_inputs, curr_prefix) else: - add_to_dict_v1(i, d) + add_to_dict_v1(i, d, curr_prefix=curr_prefix) -def add_to_dict_v1(i: Input, d: dict, dynamic_dict: dict=None): +def add_to_dict_v1(i: Input, d: dict, dynamic_dict: dict=None, curr_prefix: list[str] | None=None): key = "optional" if i.optional else "required" as_dict = i.as_dict() # for v1, we don't want to include the optional key @@ -1410,7 +1413,10 @@ def add_to_dict_v1(i: Input, d: dict, dynamic_dict: dict=None): value = (i.get_io_type(), as_dict) else: value = (i.get_io_type(), as_dict, dynamic_dict) - d.setdefault(key, {})[i.id] = value + actual_id = finalize_prefix(curr_prefix, i.id) + if actual_id == "combo.combo": + zzz = 10 + d.setdefault(key, {})[actual_id] = value def add_to_dict_v3(io: Input | Output, d: dict): d[io.id] = (io.get_io_type(), io.as_dict()) @@ -1516,6 +1522,8 @@ def FUNCTION(cls): # noqa @final @classmethod def EXECUTE_NORMALIZED(cls, *args, **kwargs) -> NodeOutput: + _args = args + _kwargs = kwargs to_return = cls.execute(*args, **kwargs) if to_return is None: to_return = NodeOutput() From c4bbb1e3205f06ed534643cab89a405d99cacd16 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 11:13:42 -0800 Subject: [PATCH 05/52] Ensure inputs/outputs/hidden are lists in schema finalize function, remove no longer needed 'is not None' checks --- comfy_api/latest/_io.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index d89339c3dd49..5422e32e0bf9 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1253,11 +1253,10 @@ def validate(self): - verify ids on inputs and outputs are unique - both internally and in relation to each other ''' nested_inputs: list[Input] = [] - if self.inputs is not None: - for input in self.inputs: - nested_inputs.extend(input.get_all()) - input_ids = [i.id for i in nested_inputs] if nested_inputs is not None else [] - output_ids = [o.id for o in self.outputs] if self.outputs is not None else [] + for input in self.inputs: + nested_inputs.extend(input.get_all()) + input_ids = [i.id for i in nested_inputs] + output_ids = [o.id for o in self.outputs] input_set = set(input_ids) output_set = set(output_ids) issues = [] @@ -1273,36 +1272,36 @@ def validate(self): if len(issues) > 0: raise ValueError("\n".join(issues)) # validate inputs and outputs - if self.inputs is not None: - for input in self.inputs: - input.validate() - if self.outputs is not None: - for output in self.outputs: - output.validate() + for input in self.inputs: + input.validate() + for output in self.outputs: + output.validate() def finalize(self): """Add hidden based on selected schema options, and give outputs without ids default ids.""" + # ensure inputs, outputs, and hidden are lists + if self.inputs is None: + self.inputs = [] + if self.outputs is None: + self.outputs = [] + if self.hidden is None: + self.hidden = [] # if is an api_node, will need key-related hidden if self.is_api_node: - if self.hidden is None: - self.hidden = [] if Hidden.auth_token_comfy_org not in self.hidden: self.hidden.append(Hidden.auth_token_comfy_org) if Hidden.api_key_comfy_org not in self.hidden: self.hidden.append(Hidden.api_key_comfy_org) # if is an output_node, will need prompt and extra_pnginfo if self.is_output_node: - if self.hidden is None: - self.hidden = [] if Hidden.prompt not in self.hidden: self.hidden.append(Hidden.prompt) if Hidden.extra_pnginfo not in self.hidden: self.hidden.append(Hidden.extra_pnginfo) # give outputs without ids default ids - if self.outputs is not None: - for i, output in enumerate(self.outputs): - if output.id is None: - output.id = f"_{i}_{output.io_type}_" + for i, output in enumerate(self.outputs): + if output.id is None: + output.id = f"_{i}_{output.io_type}_" def get_v1_info(self, cls, live_inputs: dict[str, Any]=None) -> NodeInfoV1: # NOTE: live_inputs will not be used anymore very soon and this will be done another way From 7f9bfbc916f2da6fb79176e13e86ec29f37e2b5d Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 11:32:43 -0800 Subject: [PATCH 06/52] Add raw_link and extra_dict to all relevant Inputs --- comfy_api/latest/_io.py | 44 ++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 5422e32e0bf9..c6cf72c84352 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -166,7 +166,7 @@ class Input(_IO_V3): ''' Base class for a V3 Input. ''' - def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None): + def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None): super().__init__() self.id = id self.display_name = display_name @@ -174,6 +174,7 @@ def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str self.tooltip = tooltip self.lazy = lazy self.extra_dict = extra_dict if extra_dict is not None else {} + self.rawLink = raw_link def as_dict(self): return prune_dict({ @@ -181,6 +182,7 @@ def as_dict(self): "optional": self.optional, "tooltip": self.tooltip, "lazy": self.lazy, + "rawLink": self.rawLink, }) | prune_dict(self.extra_dict) def get_io_type(self): @@ -195,8 +197,8 @@ class WidgetInput(Input): ''' def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, default: Any=None, - socketless: bool=None, widget_type: str=None, force_input: bool=None, extra_dict=None): - super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) + socketless: bool=None, widget_type: str=None, force_input: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link) self.default = default self.socketless = socketless self.widget_type = widget_type @@ -252,8 +254,8 @@ class Input(WidgetInput): '''Boolean input.''' def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, default: bool=None, label_on: str=None, label_off: str=None, - socketless: bool=None, force_input: bool=None): - super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input) + socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link) self.label_on = label_on self.label_off = label_off self.default: bool @@ -272,8 +274,8 @@ class Input(WidgetInput): '''Integer input.''' def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, default: int=None, min: int=None, max: int=None, step: int=None, control_after_generate: bool=None, - display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None): - super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input) + display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link) self.min = min self.max = max self.step = step @@ -298,8 +300,8 @@ class Input(WidgetInput): '''Float input.''' def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, default: float=None, min: float=None, max: float=None, step: float=None, round: float=None, - display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None): - super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input) + display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link) self.min = min self.max = max self.step = step @@ -324,8 +326,8 @@ class Input(WidgetInput): '''String input.''' def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, multiline=False, placeholder: str=None, default: str=None, dynamic_prompts: bool=None, - socketless: bool=None, force_input: bool=None): - super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input) + socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link) self.multiline = multiline self.placeholder = placeholder self.dynamic_prompts = dynamic_prompts @@ -358,12 +360,14 @@ def __init__( image_folder: FolderType=None, remote: RemoteOptions=None, socketless: bool=None, + extra_dict=None, + raw_link: bool=None, ): if isinstance(options, type) and issubclass(options, Enum): options = [v.value for v in options] if isinstance(default, Enum): default = default.value - super().__init__(id, display_name, optional, tooltip, lazy, default, socketless) + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, None, extra_dict, raw_link) self.multiselect = False self.options = options self.control_after_generate = control_after_generate @@ -395,8 +399,8 @@ class MultiCombo(ComfyTypeI): class Input(Combo.Input): def __init__(self, id: str, options: list[str], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, default: list[str]=None, placeholder: str=None, chip: bool=None, control_after_generate: bool=None, - socketless: bool=None): - super().__init__(id, options, display_name, optional, tooltip, lazy, default, control_after_generate, socketless=socketless) + socketless: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, options, display_name, optional, tooltip, lazy, default, control_after_generate, socketless=socketless, extra_dict=extra_dict, raw_link=raw_link) self.multiselect = True self.placeholder = placeholder self.chip = chip @@ -429,9 +433,9 @@ class Input(WidgetInput): Type = str def __init__( self, id: str, display_name: str=None, optional=False, - tooltip: str=None, lazy: bool=None, default: str=None, socketless: bool=None + tooltip: str=None, lazy: bool=None, default: str=None, socketless: bool=None, extra_dict=None, raw_link: bool=None ): - super().__init__(id, display_name, optional, tooltip, lazy, default, socketless) + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, None, extra_dict, raw_link) @comfytype(io_type="MASK") @@ -775,7 +779,7 @@ class Input(Input): ''' Input that permits more than one input type; if `id` is an instance of `ComfyType.Input`, then that input will be used to create a widget (if applicable) with overridden values. ''' - def __init__(self, id: str | Input, types: list[type[_ComfyType] | _ComfyType], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None): + def __init__(self, id: str | Input, types: list[type[_ComfyType] | _ComfyType], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None): # if id is an Input, then use that Input with overridden values self.input_override = None if isinstance(id, Input): @@ -788,7 +792,7 @@ def __init__(self, id: str | Input, types: list[type[_ComfyType] | _ComfyType], # if is a widget input, make sure widget_type is set appropriately if isinstance(self.input_override, WidgetInput): self.input_override.widget_type = self.input_override.get_io_type() - super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) + super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link) self._io_types = types @property @@ -842,8 +846,8 @@ def as_dict(self): class Input(Input): def __init__(self, id: str, template: MatchType.Template, - display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None): - super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) + display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None): + super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link) self.template = template def as_dict(self): From 154bf49204bb4811cad41cc1cd7d3581f5e0aa8d Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 14:56:31 -0800 Subject: [PATCH 07/52] Make nested DynamicCombos work properly with prefixed keys on latest frontend; breaks old Autogrow, but is pretty much ready for upcoming Autogrow keys --- comfy_api/latest/_io.py | 17 ++++++++++++----- execution.py | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index c6cf72c84352..dcfa1d89b85b 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1004,6 +1004,7 @@ def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, An curr_prefix = handle_prefix(curr_prefix, self.id) # need to remove self from expected inputs dictionary; replaced by template inputs in frontend for inner_dict in d.values(): + # TODO: once frontend is ready, replace self.id with finalize_prefix(curr_prefix, self.id) if self.id in inner_dict: del inner_dict[self.id] self.template.expand_schema_for_dynamic(d, live_inputs, curr_prefix) @@ -1031,9 +1032,10 @@ def __init__(self, id: str, options: list[DynamicCombo.Option], def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): # check if dynamic input's id is in live_inputs - if self.id in live_inputs: - curr_prefix = handle_prefix(curr_prefix, self.id) - key = live_inputs[self.id] + curr_prefix = handle_prefix(curr_prefix, self.id) + finalized_id = finalize_prefix(curr_prefix) + if finalized_id in live_inputs: + key = live_inputs[finalized_id] selected_option = None for option in self.options: if option.key == key: @@ -1111,8 +1113,13 @@ def add_dynamic_id_mapping(d: dict[str, Any], inputs: list[Input], curr_prefix: if self is not None: dynamic[finalize_prefix(curr_prefix[:-1], self.id)] = finalize_prefix(curr_prefix, self.id) for i in inputs: - # if not isinstance(i, DynamicInput): - dynamic[finalize_prefix(curr_prefix, i.id)] = finalize_prefix(curr_prefix, i.id) + if not isinstance(i, DynamicInput): + dynamic[finalize_prefix(curr_prefix, i.id)] = finalize_prefix(curr_prefix, i.id) + +def add_to_dynamic_dict(dynamic: dict[str, Any], curr_prefix: list[str], id: str, value: str): + finalize_key = finalize_prefix(curr_prefix, id) + if finalize_key not in dynamic: + dynamic[finalize_key] = value class V3Data(TypedDict): hidden_inputs: dict[str, Any] diff --git a/execution.py b/execution.py index c2186ac98147..3ca3d0daf316 100644 --- a/execution.py +++ b/execution.py @@ -756,6 +756,7 @@ async def validate_inputs(prompt_id, prompt, item, validated): validate_function_inputs = [] validate_has_kwargs = False if issubclass(obj_class, _ComfyNodeInternal): + obj_class: _io._ComfyNodeBaseInternal class_inputs, _, _ = obj_class.INPUT_TYPES(include_hidden=False, return_schema=True, live_inputs=inputs) validate_function_name = "validate_inputs" validate_function = first_real_override(obj_class, validate_function_name) From 23cd5bbe0e5b5c89885bfab49bd0a6e77523e234 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 16:04:27 -0800 Subject: [PATCH 08/52] Replace ... usage with a MISSING sentinel for clarity in nodes_logic.py --- comfy_extras/nodes_logic.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 2be06aa9cca1..3a7c5a76460f 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -3,6 +3,8 @@ from comfy_api.latest import ComfyExtension, io from comfy_api.latest import _io +# sentinel for missing inputs +MISSING = object() class SwitchNode(io.ComfyNode): @@ -25,14 +27,14 @@ def define_schema(cls): ) @classmethod - def check_lazy_status(cls, switch, on_false=..., on_true=...): - # We use ... instead of None, as None is passed for connected-but-unevaluated inputs. + def check_lazy_status(cls, switch, on_false=MISSING, on_true=MISSING): + # We use MISSING instead of None, as None is passed for connected-but-unevaluated inputs. # This trick allows us to ignore the value of the switch and still be able to run execute(). # One of the inputs may be missing, in which case we need to evaluate the other input - if on_false is ...: + if on_false is MISSING: return ["on_true"] - if on_true is ...: + if on_true is MISSING: return ["on_false"] # Normal lazy switch operation if switch and on_true is None: @@ -41,18 +43,18 @@ def check_lazy_status(cls, switch, on_false=..., on_true=...): return ["on_false"] @classmethod - def validate_inputs(cls, switch, on_false=..., on_true=...): + def validate_inputs(cls, switch, on_false=MISSING, on_true=MISSING): # This check happens before check_lazy_status(), so we can eliminate the case where # both inputs are missing. - if on_false is ... and on_true is ...: + if on_false is MISSING and on_true is MISSING: return "At least one of on_false or on_true must be connected to Switch node" return True @classmethod - def execute(cls, switch, on_true=..., on_false=...) -> io.NodeOutput: - if on_true is ...: + def execute(cls, switch, on_true=MISSING, on_false=MISSING) -> io.NodeOutput: + if on_true is MISSING: return io.NodeOutput(on_false) - if on_false is ...: + if on_false is MISSING: return io.NodeOutput(on_true) return io.NodeOutput(on_true if switch else on_false) From 3a40e08f8de36b7aa3b33d5a809aafdcfe9f3160 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 17:31:22 -0800 Subject: [PATCH 09/52] Added CustomCombo node in backend to reflect frontend node --- comfy_extras/nodes_logic.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 3a7c5a76460f..ebcb665b31e9 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -59,6 +59,27 @@ def execute(cls, switch, on_true=MISSING, on_false=MISSING) -> io.NodeOutput: return io.NodeOutput(on_true if switch else on_false) +class CustomComboNode(io.ComfyNode): + """ + Frontend node that allows user to write their own options for a combo. + This is here to make sure the node has a backend-representation to avoid some annoyances. + """ + @classmethod + def define_schema(cls): + return io.Schema( + node_id="CustomCombo", + display_name="Custom Combo", + category="util", + is_experimental=True, + inputs=[io.Combo.Input("choice", options=[])], + outputs=[io.String.Output()] + ) + + @classmethod + def execute(cls, choice: io.Combo.Type) -> io.NodeOutput: + return io.NodeOutput(choice) + + class DCTestNode(io.ComfyNode): class DCValues(TypedDict): combo: str @@ -164,6 +185,7 @@ class LogicExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ SwitchNode, + CustomComboNode, DCTestNode, AutogrowNamesTestNode, AutogrowPrefixTestNode, From 2b6106d57be952f785ef2448e01bf0659c8ec65f Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 19:00:06 -0800 Subject: [PATCH 10/52] Prepare Autogrow's expand_schema_for_dynamic to work with upcoming frontend changes --- comfy_api/latest/_io.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index dcfa1d89b85b..cb71df99a88f 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1004,9 +1004,9 @@ def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, An curr_prefix = handle_prefix(curr_prefix, self.id) # need to remove self from expected inputs dictionary; replaced by template inputs in frontend for inner_dict in d.values(): - # TODO: once frontend is ready, replace self.id with finalize_prefix(curr_prefix, self.id) - if self.id in inner_dict: - del inner_dict[self.id] + finalized_id = finalize_prefix(curr_prefix, self.id) + if finalized_id in inner_dict: + del inner_dict[finalized_id] self.template.expand_schema_for_dynamic(d, live_inputs, curr_prefix) @comfytype(io_type="COMFY_DYNAMICCOMBO_V3") From c2c4810717acbf5e4dcee2713de3f252f39b9b03 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 3 Dec 2025 23:10:06 -0800 Subject: [PATCH 11/52] Prepare for look up table for dynamic input stuff --- comfy_api/latest/_io.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index cb71df99a88f..eedcad04f851 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1121,6 +1121,27 @@ def add_to_dynamic_dict(dynamic: dict[str, Any], curr_prefix: list[str], id: str if finalize_key not in dynamic: dynamic[finalize_key] = value +DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], list[str] | None], None]] = {} +def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], list[str] | None], None]): + DYNAMIC_INPUT_LOOKUP[io_type] = func + +def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, Any], list[str] | None], None]: + return DYNAMIC_INPUT_LOOKUP[io_type] + +def setup_dynamic_input_funcs(): + # DynamicCombo.Input + def dynamic_combo_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None): + ... + register_dynamic_input_func(DynamicCombo.Input.io_type, dynamic_combo_input) + # Autogrow.Input + def autogrow_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None): + ... + register_dynamic_input_func(Autogrow.Input.io_type, autogrow_input) + # TODO: DynamicSlot.Input + +if len(DYNAMIC_INPUT_LOOKUP) == 0: + setup_dynamic_input_funcs() + class V3Data(TypedDict): hidden_inputs: dict[str, Any] dynamic_paths: dict[str, Any] From 60762e1dbed90273441b2a9c49fcc52201c50a7b Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 4 Dec 2025 11:59:07 -0800 Subject: [PATCH 12/52] More progress towards dynamic input lookup function stuff --- comfy_api/latest/_io.py | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index eedcad04f851..841ad49059f0 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1121,21 +1121,37 @@ def add_to_dynamic_dict(dynamic: dict[str, Any], curr_prefix: list[str], id: str if finalize_key not in dynamic: dynamic[finalize_key] = value -DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], list[str] | None], None]] = {} -def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], list[str] | None], None]): +DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], dict[str, Any], list[str] | None], None]] = {} +def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], dict[str, Any], list[str] | None], None]): DYNAMIC_INPUT_LOOKUP[io_type] = func -def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, Any], list[str] | None], None]: +def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, Any], dict[str, Any], list[str] | None], None]: return DYNAMIC_INPUT_LOOKUP[io_type] def setup_dynamic_input_funcs(): # DynamicCombo.Input - def dynamic_combo_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None): - ... + def dynamic_combo_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_info: dict[str, Any], curr_prefix: list[str] | None): + # id = curr_prefix[-1] + finalized_id = finalize_prefix(curr_prefix) + if finalized_id in live_inputs: + key = live_inputs[finalized_id] + selected_option = None + # get options from dict + options: list[dict[str, str | dict[str, Any]]] = curr_info["options"] + for option in options: + if option["key"] == key: + selected_option = option + break + if selected_option is not None: + parse_class_inputs(d, live_inputs, selected_option["inputs"], curr_prefix) + # TODO: add dynamic id mapping + register_dynamic_input_func(DynamicCombo.Input.io_type, dynamic_combo_input) + # Autogrow.Input def autogrow_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None): ... + register_dynamic_input_func(Autogrow.Input.io_type, autogrow_input) # TODO: DynamicSlot.Input @@ -1418,6 +1434,19 @@ def get_v3_info(self, cls) -> NodeInfoV3: ) return info +def parse_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any] | None=None, curr_prefix: list[str] | None=None) -> None: + if curr_dict is None: + curr_dict = copy.copy(d) + for input_type, inner_d in curr_dict.items(): + for id, value in inner_d.items(): + io_type = value[0] + if io_type in DYNAMIC_INPUT_LOOKUP: + handle_prefix(curr_prefix, id) + dynamic_input_func = get_dynamic_input_func(io_type) + curr_info = {} + if len(value) > 1: + curr_info = value[1] + dynamic_input_func(d, live_inputs, curr_info, curr_prefix) def create_input_dict_v1(inputs: list[Input], live_inputs: dict[str, Any]=None) -> dict: input = { @@ -1445,8 +1474,6 @@ def add_to_dict_v1(i: Input, d: dict, dynamic_dict: dict=None, curr_prefix: list else: value = (i.get_io_type(), as_dict, dynamic_dict) actual_id = finalize_prefix(curr_prefix, i.id) - if actual_id == "combo.combo": - zzz = 10 d.setdefault(key, {})[actual_id] = value def add_to_dict_v3(io: Input | Output, d: dict): From 25b47b5bd52019c8363262137367d88e2ef2a474 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 4 Dec 2025 19:36:41 -0800 Subject: [PATCH 13/52] Finished converting _expand_schema_for_dynamic to be done via lookup instead of OOP to guarantee working with process isolation, did refactoring to remove old implementation + cleaning INPUT_TYPES definition including v3 hidden definition --- comfy_api/latest/_io.py | 170 +++++++++++++++++++++++----------------- execution.py | 24 +++--- 2 files changed, 112 insertions(+), 82 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 841ad49059f0..3599a04d59ea 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -898,7 +898,7 @@ def handle_prefix(prefix_list: list | None, id: str | None = None) -> list[str]: def finalize_prefix(prefix_list: list[str] | None, id: str | None = None) -> str: assert not (prefix_list is None and id is None) if prefix_list is None: - prefix_list = [id] + return id elif id is not None: prefix_list = prefix_list + [id] return ".".join(prefix_list) @@ -1000,14 +1000,39 @@ def get_all(self) -> list[Input]: def validate(self): self.template.validate() - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): - curr_prefix = handle_prefix(curr_prefix, self.id) - # need to remove self from expected inputs dictionary; replaced by template inputs in frontend - for inner_dict in d.values(): - finalized_id = finalize_prefix(curr_prefix, self.id) - if finalized_id in inner_dict: - del inner_dict[finalized_id] - self.template.expand_schema_for_dynamic(d, live_inputs, curr_prefix) + @staticmethod + def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None): + # NOTE: purposely do not include self in out_dict; instead use only the template inputs + # need to figure out names based on template type + is_names = ("names" in value[1]["template"]) + is_prefix = ("prefix" in value[1]["template"]) + input = value[1]["template"]["input"] + if is_names: + min = value[1]["template"]["min"] + names = value[1]["template"]["names"] + max = len(names) + elif is_prefix: + prefix = value[1]["template"]["prefix"] + min = value[1]["template"]["min"] + max = value[1]["template"]["max"] + names = [f"{prefix}{i}" for i in range(max)] + # need to create a new input based on the contents of input + template_input = None + for _, dict_input in input.items(): + # for now, get just the first value from dict_input + template_input = list(dict_input.values())[0] + new_dict = {} + for i, name in enumerate(names): + expected_id = finalize_prefix(curr_prefix, name) + if expected_id in live_inputs: + # required + if i < min: + type_dict = new_dict.setdefault("required", {}) + # optional + else: + type_dict = new_dict.setdefault("optional", {}) + type_dict[name] = template_input + parse_class_inputs(out_dict, live_inputs, new_dict, curr_prefix) @comfytype(io_type="COMFY_DYNAMICCOMBO_V3") class DynamicCombo(ComfyTypeI): @@ -1030,21 +1055,6 @@ def __init__(self, id: str, options: list[DynamicCombo.Option], super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) self.options = options - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): - # check if dynamic input's id is in live_inputs - curr_prefix = handle_prefix(curr_prefix, self.id) - finalized_id = finalize_prefix(curr_prefix) - if finalized_id in live_inputs: - key = live_inputs[finalized_id] - selected_option = None - for option in self.options: - if option.key == key: - selected_option = option - break - if selected_option is not None: - add_to_input_dict_v1(d, selected_option.inputs, live_inputs, curr_prefix) - add_dynamic_id_mapping(d, selected_option.inputs, curr_prefix, self) - def get_dynamic(self) -> list[Input]: return [input for option in self.options for input in option.inputs] @@ -1062,6 +1072,24 @@ def validate(self): for input in option.inputs: input.validate() + @staticmethod + def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None): + finalized_id = finalize_prefix(curr_prefix) + if finalized_id in live_inputs: + key = live_inputs[finalized_id] + selected_option = None + # get options from dict + options: list[dict[str, str | dict[str, Any]]] = value[1]["options"] + for option in options: + if option["key"] == key: + selected_option = option + break + if selected_option is not None: + parse_class_inputs(out_dict, live_inputs, selected_option["inputs"], curr_prefix) + # add self to inputs + out_dict[input_type][finalized_id] = value + out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) + @comfytype(io_type="COMFY_DYNAMICSLOT_V3") class DynamicSlot(ComfyTypeI): Type = dict[str, Any] @@ -1108,6 +1136,16 @@ def validate(self): for input in self.inputs: input.validate() + @staticmethod + def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None): + finalized_id = finalize_prefix(curr_prefix) + if finalized_id in live_inputs: + inputs = value[1]["inputs"] + parse_class_inputs(out_dict, live_inputs, inputs, curr_prefix) + # add self to inputs + out_dict[input_type][finalized_id] = value + out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) + def add_dynamic_id_mapping(d: dict[str, Any], inputs: list[Input], curr_prefix: list[str], self: DynamicInput=None): dynamic = d.setdefault("dynamic_paths", {}) if self is not None: @@ -1121,39 +1159,20 @@ def add_to_dynamic_dict(dynamic: dict[str, Any], curr_prefix: list[str], id: str if finalize_key not in dynamic: dynamic[finalize_key] = value -DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], dict[str, Any], list[str] | None], None]] = {} -def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], dict[str, Any], list[str] | None], None]): +DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]] = {} +def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]): DYNAMIC_INPUT_LOOKUP[io_type] = func -def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, Any], dict[str, Any], list[str] | None], None]: +def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]: return DYNAMIC_INPUT_LOOKUP[io_type] def setup_dynamic_input_funcs(): # DynamicCombo.Input - def dynamic_combo_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_info: dict[str, Any], curr_prefix: list[str] | None): - # id = curr_prefix[-1] - finalized_id = finalize_prefix(curr_prefix) - if finalized_id in live_inputs: - key = live_inputs[finalized_id] - selected_option = None - # get options from dict - options: list[dict[str, str | dict[str, Any]]] = curr_info["options"] - for option in options: - if option["key"] == key: - selected_option = option - break - if selected_option is not None: - parse_class_inputs(d, live_inputs, selected_option["inputs"], curr_prefix) - # TODO: add dynamic id mapping - - register_dynamic_input_func(DynamicCombo.Input.io_type, dynamic_combo_input) - + register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic) # Autogrow.Input - def autogrow_input(d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None): - ... - - register_dynamic_input_func(Autogrow.Input.io_type, autogrow_input) - # TODO: DynamicSlot.Input + register_dynamic_input_func(Autogrow.io_type, Autogrow._expand_schema_for_dynamic) + # DynamicSlot.Input + register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic) if len(DYNAMIC_INPUT_LOOKUP) == 0: setup_dynamic_input_funcs() @@ -1434,19 +1453,39 @@ def get_v3_info(self, cls) -> NodeInfoV3: ) return info -def parse_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any] | None=None, curr_prefix: list[str] | None=None) -> None: - if curr_dict is None: - curr_dict = copy.copy(d) +def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], include_hidden=False) -> tuple[dict[str, Any], V3Data]: + out_dict = { + "required": {}, + "optional": {}, + "dynamic_paths": {}, + } + d = copy.copy(d) + # ignore hidden for parsing + hidden = d.pop("hidden", None) + parse_class_inputs(out_dict, live_inputs, d) + if hidden is not None and include_hidden: + out_dict["hidden"] = hidden + v3_data = {} + dynamic_paths = out_dict.pop("dynamic_paths", None) + if dynamic_paths is not None: + v3_data["dynamic_paths"] = dynamic_paths + return out_dict, hidden, v3_data + +def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any], curr_prefix: list[str] | None=None) -> None: for input_type, inner_d in curr_dict.items(): for id, value in inner_d.items(): io_type = value[0] if io_type in DYNAMIC_INPUT_LOOKUP: - handle_prefix(curr_prefix, id) + # dynamic inputs need to be handled with lookup functions dynamic_input_func = get_dynamic_input_func(io_type) - curr_info = {} - if len(value) > 1: - curr_info = value[1] - dynamic_input_func(d, live_inputs, curr_info, curr_prefix) + new_prefix = handle_prefix(curr_prefix, id) + dynamic_input_func(out_dict, live_inputs, value, input_type, new_prefix) + else: + # non-dynamic inputs get directly transferred + finalized_id = finalize_prefix(curr_prefix, id) + out_dict[input_type][finalized_id] = value + if curr_prefix: + out_dict["dynamic_paths"][finalized_id] = finalized_id def create_input_dict_v1(inputs: list[Input], live_inputs: dict[str, Any]=None) -> dict: input = { @@ -1743,19 +1782,10 @@ def NOT_IDEMPOTENT(cls): # noqa @final @classmethod - def INPUT_TYPES(cls, include_hidden=True, return_schema=False, live_inputs=None) -> dict[str, dict] | tuple[dict[str, dict], Schema, V3Data]: + def INPUT_TYPES(cls) -> dict[str, dict]: schema = cls.FINALIZE_SCHEMA() - info = schema.get_v1_info(cls, live_inputs) - input = info.input - if not include_hidden: - input.pop("hidden", None) - if return_schema: - v3_data: V3Data = {} - dynamic = input.pop("dynamic_paths", None) - if dynamic is not None: - v3_data["dynamic_paths"] = dynamic - return input, schema, v3_data - return input + info = schema.get_v1_info(cls) + return info.input @final @classmethod diff --git a/execution.py b/execution.py index 3ca3d0daf316..aa89e4ced679 100644 --- a/execution.py +++ b/execution.py @@ -147,13 +147,12 @@ def recursive_debug_dump(self): def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}): is_v3 = issubclass(class_def, _ComfyNodeInternal) v3_data: io.V3Data = {} + hidden_inputs_v3 = {} + valid_inputs = class_def.INPUT_TYPES() if is_v3: - valid_inputs, schema, v3_data = class_def.INPUT_TYPES(include_hidden=False, return_schema=True, live_inputs=inputs) - else: - valid_inputs = class_def.INPUT_TYPES() + valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs) input_data_all = {} missing_keys = {} - hidden_inputs_v3 = {} for x in inputs: input_data = inputs[x] _, input_category, input_info = get_input_info(class_def, x, valid_inputs) @@ -179,18 +178,18 @@ def mark_missing(): input_data_all[x] = [input_data] if is_v3: - if schema.hidden: - if io.Hidden.prompt in schema.hidden: + if hidden is not None: + if io.Hidden.prompt.name in hidden: hidden_inputs_v3[io.Hidden.prompt] = dynprompt.get_original_prompt() if dynprompt is not None else {} - if io.Hidden.dynprompt in schema.hidden: + if io.Hidden.dynprompt.name in hidden: hidden_inputs_v3[io.Hidden.dynprompt] = dynprompt - if io.Hidden.extra_pnginfo in schema.hidden: + if io.Hidden.extra_pnginfo.name in hidden: hidden_inputs_v3[io.Hidden.extra_pnginfo] = extra_data.get('extra_pnginfo', None) - if io.Hidden.unique_id in schema.hidden: + if io.Hidden.unique_id.name in hidden: hidden_inputs_v3[io.Hidden.unique_id] = unique_id - if io.Hidden.auth_token_comfy_org in schema.hidden: + if io.Hidden.auth_token_comfy_org.name in hidden: hidden_inputs_v3[io.Hidden.auth_token_comfy_org] = extra_data.get("auth_token_comfy_org", None) - if io.Hidden.api_key_comfy_org in schema.hidden: + if io.Hidden.api_key_comfy_org.name in hidden: hidden_inputs_v3[io.Hidden.api_key_comfy_org] = extra_data.get("api_key_comfy_org", None) else: if "hidden" in valid_inputs: @@ -757,7 +756,8 @@ async def validate_inputs(prompt_id, prompt, item, validated): validate_has_kwargs = False if issubclass(obj_class, _ComfyNodeInternal): obj_class: _io._ComfyNodeBaseInternal - class_inputs, _, _ = obj_class.INPUT_TYPES(include_hidden=False, return_schema=True, live_inputs=inputs) + class_inputs = obj_class.INPUT_TYPES() + class_inputs, _, _ = _io.get_finalized_class_inputs(class_inputs, inputs) validate_function_name = "validate_inputs" validate_function = first_real_override(obj_class, validate_function_name) else: From 74858bc0894639ed27101e6e94661bbcfd822daa Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 4 Dec 2025 19:53:01 -0800 Subject: [PATCH 14/52] Change order of functions --- comfy_api/latest/_io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 3599a04d59ea..1107dadb9e39 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1167,10 +1167,10 @@ def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, return DYNAMIC_INPUT_LOOKUP[io_type] def setup_dynamic_input_funcs(): - # DynamicCombo.Input - register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic) # Autogrow.Input register_dynamic_input_func(Autogrow.io_type, Autogrow._expand_schema_for_dynamic) + # DynamicCombo.Input + register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic) # DynamicSlot.Input register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic) From 5d313968f3403a811985262339e30d88bb62930f Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 4 Dec 2025 19:55:13 -0800 Subject: [PATCH 15/52] Removed some unneeded functions after dynamic refactor --- comfy_api/latest/_io.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 1107dadb9e39..db8a594b2e08 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -938,15 +938,6 @@ def as_dict(self): def validate(self): self.input.validate() - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): - curr_prefix = handle_prefix(curr_prefix) - real_inputs = [] - for name, input in self.cached_inputs.items(): - if name in live_inputs: - real_inputs.append(input) - add_to_input_dict_v1(d, real_inputs, live_inputs, curr_prefix) - add_dynamic_id_mapping(d, real_inputs, curr_prefix) - class TemplatePrefix(_AutogrowTemplate): def __init__(self, input: Input, prefix: str, min: int=1, max: int=10): super().__init__(input) @@ -1112,12 +1103,6 @@ def __init__(self, slot: Input, inputs: list[Input], self.force_input = True self.slot.force_input = True - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): - if self.id in live_inputs: - curr_prefix = handle_prefix(curr_prefix, self.id) - add_to_input_dict_v1(d, self.inputs, live_inputs, curr_prefix) - add_dynamic_id_mapping(d, [self.slot] + self.inputs, curr_prefix) - def get_dynamic(self) -> list[Input]: return [self.slot] + self.inputs @@ -1146,19 +1131,6 @@ def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, out_dict[input_type][finalized_id] = value out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) -def add_dynamic_id_mapping(d: dict[str, Any], inputs: list[Input], curr_prefix: list[str], self: DynamicInput=None): - dynamic = d.setdefault("dynamic_paths", {}) - if self is not None: - dynamic[finalize_prefix(curr_prefix[:-1], self.id)] = finalize_prefix(curr_prefix, self.id) - for i in inputs: - if not isinstance(i, DynamicInput): - dynamic[finalize_prefix(curr_prefix, i.id)] = finalize_prefix(curr_prefix, i.id) - -def add_to_dynamic_dict(dynamic: dict[str, Any], curr_prefix: list[str], id: str, value: str): - finalize_key = finalize_prefix(curr_prefix, id) - if finalize_key not in dynamic: - dynamic[finalize_key] = value - DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]] = {} def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]): DYNAMIC_INPUT_LOOKUP[io_type] = func From 0b78859e099cff0418cbbb35dfe1fb38f9fa1a66 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 5 Dec 2025 20:39:25 -0800 Subject: [PATCH 16/52] Make MatchType's output default displayname "MATCHTYPE" --- comfy_api/latest/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 9baf7e316205..ec6abd832d5e 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -858,7 +858,7 @@ def as_dict(self): }) class Output(Output): - def __init__(self, template: MatchType.Template, id: str=None, display_name: str=None, tooltip: str=None, + def __init__(self, template: MatchType.Template, id: str=None, display_name: str="MATCHTYPE", tooltip: str=None, is_output_list=False): super().__init__(id, display_name, tooltip, is_output_list) self.template = template From ad1a0f98bb3eb091b71d66735dbce910a99cc9c3 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Tue, 9 Dec 2025 19:01:07 -0800 Subject: [PATCH 17/52] Fix DynamicSlot get_all --- comfy_api/latest/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 513dbc5dbeae..12daed688692 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1109,7 +1109,7 @@ def get_dynamic(self) -> list[Input]: return [self.slot] + self.inputs def get_all(self) -> list[Input]: - return [self] + [self.slot] + self.inputs + return [self.slot] + self.inputs def as_dict(self): return super().as_dict() | prune_dict({ From a4226dbfb041f5baf2cfd89d27f718b1815f675b Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Sun, 14 Dec 2025 01:37:02 -0800 Subject: [PATCH 18/52] Removed redundant code - dynamic stuff no longer happens in OOP way --- comfy_api/latest/_io.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 927e23ab3800..7d345fd76060 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1351,10 +1351,9 @@ def finalize(self): if output.id is None: output.id = f"_{i}_{output.io_type}_" - def get_v1_info(self, cls, live_inputs: dict[str, Any]=None) -> NodeInfoV1: - # NOTE: live_inputs will not be used anymore very soon and this will be done another way + def get_v1_info(self, cls) -> NodeInfoV1: # get V1 inputs - input = create_input_dict_v1(self.inputs, live_inputs) + input = create_input_dict_v1(self.inputs) if self.hidden: for hidden in self.hidden: input.setdefault("hidden", {})[hidden.name] = (hidden.value,) @@ -1468,33 +1467,20 @@ def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], cu if curr_prefix: out_dict["dynamic_paths"][finalized_id] = finalized_id -def create_input_dict_v1(inputs: list[Input], live_inputs: dict[str, Any]=None) -> dict: +def create_input_dict_v1(inputs: list[Input]) -> dict: input = { "required": {} } - add_to_input_dict_v1(input, inputs, live_inputs) - return input - -def add_to_input_dict_v1(d: dict[str, Any], inputs: list[Input], live_inputs: dict[str, Any]=None, curr_prefix: list[str] | None=None): for i in inputs: - if isinstance(i, DynamicInput): - add_to_dict_v1(i, d, curr_prefix=curr_prefix) - if live_inputs is not None: - i.expand_schema_for_dynamic(d, live_inputs, curr_prefix) - else: - add_to_dict_v1(i, d, curr_prefix=curr_prefix) + add_to_dict_v1(i, input) + return input -def add_to_dict_v1(i: Input, d: dict, dynamic_dict: dict=None, curr_prefix: list[str] | None=None): +def add_to_dict_v1(i: Input, d: dict): key = "optional" if i.optional else "required" as_dict = i.as_dict() # for v1, we don't want to include the optional key as_dict.pop("optional", None) - if dynamic_dict is None: - value = (i.get_io_type(), as_dict) - else: - value = (i.get_io_type(), as_dict, dynamic_dict) - actual_id = finalize_prefix(curr_prefix, i.id) - d.setdefault(key, {})[actual_id] = value + d.setdefault(key, {})[i.id] = (i.get_io_type(), as_dict) def add_to_dict_v3(io: Input | Output, d: dict): d[io.id] = (io.get_io_type(), io.as_dict()) From 0d9364e942716d3450c93d46bc14f99caca8d7cf Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Sun, 14 Dec 2025 02:41:45 -0800 Subject: [PATCH 19/52] Natively support AnyType (*) without __ne__ hacks --- comfy_api/latest/_io.py | 15 ++------------- comfy_execution/validation.py | 9 +++++++++ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 7d345fd76060..80f85487994d 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -77,16 +77,6 @@ class NumberDisplay(str, Enum): slider = "slider" -class _StringIOType(str): - def __ne__(self, value: object) -> bool: - if self == "*" or value == "*": - return False - if not isinstance(value, str): - return True - a = frozenset(self.split(",")) - b = frozenset(value.split(",")) - return not (b.issubset(a) or a.issubset(b)) - class _ComfyType(ABC): Type = Any io_type: str = None @@ -126,8 +116,7 @@ def decorator(cls: T) -> T: new_cls.__module__ = cls.__module__ new_cls.__doc__ = cls.__doc__ # assign ComfyType attributes, if needed - # NOTE: use __ne__ trick for io_type (see node_typing.IO.__ne__ for details) - new_cls.io_type = _StringIOType(io_type) + new_cls.io_type = io_type if hasattr(new_cls, "Input") and new_cls.Input is not None: new_cls.Input.Parent = new_cls if hasattr(new_cls, "Output") and new_cls.Output is not None: @@ -186,7 +175,7 @@ def as_dict(self): }) | prune_dict(self.extra_dict) def get_io_type(self): - return _StringIOType(self.io_type) + return self.io_type def get_all(self) -> list[Input]: return [self] diff --git a/comfy_execution/validation.py b/comfy_execution/validation.py index a5e9675da7e2..e73624bd1ef5 100644 --- a/comfy_execution/validation.py +++ b/comfy_execution/validation.py @@ -21,9 +21,14 @@ def validate_node_input( """ # If the types are exactly the same, we can return immediately # Use pre-union behaviour: inverse of `__ne__` + # NOTE: this lets legacy '*' Any types work that override the __ne__ method of the str class. if not received_type != input_type: return True + # If one of the types is '*', we can return True immediately; this is the 'Any' type. + if received_type == IO.AnyType.io_type or input_type == IO.AnyType.io_type: + return True + # If the received type or input_type is a MatchType, we can return True immediately; # validation for this is handled by the frontend if received_type == IO.MatchType.io_type or input_type == IO.MatchType.io_type: @@ -42,6 +47,10 @@ def validate_node_input( received_types = set(t.strip() for t in received_type.split(",")) input_types = set(t.strip() for t in input_type.split(",")) + # If any of the types is '*', we can return True immediately; this is the 'Any' type. + if IO.AnyType.io_type in received_types or IO.AnyType.io_type in input_types: + return True + if strict: # In strict mode, all received types must be in the input types return received_types.issubset(input_types) From 364b9b673b4e77bd67e503f95f2f40b5e587d489 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Sun, 14 Dec 2025 03:12:48 -0800 Subject: [PATCH 20/52] Remove stray code that made it in --- comfy_api/latest/_io.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 80f85487994d..49421b3418f4 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1575,8 +1575,6 @@ def FUNCTION(cls): # noqa @final @classmethod def EXECUTE_NORMALIZED(cls, *args, **kwargs) -> NodeOutput: - _args = args - _kwargs = kwargs to_return = cls.execute(*args, **kwargs) if to_return is None: to_return = NodeOutput() From cb582ab299b2040be396f47c0713c2a68b722bc1 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Sun, 14 Dec 2025 03:20:15 -0800 Subject: [PATCH 21/52] Remove expand_schema_for_dynamic left over on DynamicInput class --- comfy_api/latest/_io.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 49421b3418f4..2e6d08ce3890 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -871,9 +871,6 @@ class DynamicInput(Input, ABC): def get_dynamic(self) -> list[Input]: return [] - def expand_schema_for_dynamic(self, d: dict[str, Any], live_inputs: dict[str, Any], curr_prefix: list[str] | None=None): - pass - class DynamicOutput(Output, ABC): ''' From 308ae94c669f64f767ed054b8529ab903d1b8f6c Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Sun, 14 Dec 2025 03:23:46 -0800 Subject: [PATCH 22/52] get_dynamic() on DynamicInput/Output was not doing anything anymore, so removed it --- comfy_api/latest/_io.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 2e6d08ce3890..fbcfb62a6886 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -868,22 +868,17 @@ class DynamicInput(Input, ABC): ''' Abstract class for dynamic input registration. ''' - def get_dynamic(self) -> list[Input]: - return [] + pass class DynamicOutput(Output, ABC): ''' Abstract class for dynamic output registration. ''' - def __init__(self, id: str=None, display_name: str=None, tooltip: str=None, - is_output_list=False): - super().__init__(id, display_name, tooltip, is_output_list) + pass - def get_dynamic(self) -> list[Output]: - return [] -def handle_prefix(prefix_list: list | None, id: str | None = None) -> list[str]: +def handle_prefix(prefix_list: list[str] | None, id: str | None = None) -> list[str]: if prefix_list is None: prefix_list = [] if id is not None: @@ -977,9 +972,6 @@ def as_dict(self): "template": self.template.as_dict(), }) - def get_dynamic(self) -> list[Input]: - return self.template.get_all() - def get_all(self) -> list[Input]: return [self] + self.template.get_all() @@ -1041,9 +1033,6 @@ def __init__(self, id: str, options: list[DynamicCombo.Option], super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) self.options = options - def get_dynamic(self) -> list[Input]: - return [input for option in self.options for input in option.inputs] - def get_all(self) -> list[Input]: return [self] + [input for option in self.options for input in option.inputs] @@ -1098,9 +1087,6 @@ def __init__(self, slot: Input, inputs: list[Input], self.force_input = True self.slot.force_input = True - def get_dynamic(self) -> list[Input]: - return [self.slot] + self.inputs - def get_all(self) -> list[Input]: return [self.slot] + self.inputs From b1b1429b4377a30da4bba35f7fb069e585b2c2ae Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Sun, 14 Dec 2025 03:37:37 -0800 Subject: [PATCH 23/52] Make validate_inputs validate combo input correctly --- execution.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/execution.py b/execution.py index aa89e4ced679..18e592af97a3 100644 --- a/execution.py +++ b/execution.py @@ -914,8 +914,11 @@ async def validate_inputs(prompt_id, prompt, item, validated): errors.append(error) continue - if isinstance(input_type, list): - combo_options = input_type + if isinstance(input_type, list) or input_type == io.Combo.io_type: + if input_type == io.Combo.io_type: + combo_options = extra_info.get("options", []) + else: + combo_options = input_type if val not in combo_options: input_config = info list_info = "" From dd7c04508309a8791ac2a084acaca76d22ebeed4 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 15 Dec 2025 19:56:46 -0800 Subject: [PATCH 24/52] Temporarily comment out conversion to 'new' (9 month old) COMBO format in get_input_info --- comfy_execution/graph.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index d262201d9ac2..632f327bf1bd 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -98,10 +98,11 @@ def get_input_info( extra_info = input_info[1] else: extra_info = {} - # if input_type is a list, it is a Combo defined in outdated format; convert it - if isinstance(input_type, list): - extra_info["options"] = input_type - input_type = IO.Combo.io_type + # if input_type is a list, it is a Combo defined in outdated format; convert it. + # NOTE: uncomment this when we are confident old format going away won't cause too much trouble. + # if isinstance(input_type, list): + # extra_info["options"] = input_type + # input_type = IO.Combo.io_type return input_type, input_category, extra_info class TopologicalSort: From ba13d10d821937be399ce727e8e51e1d88be84aa Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 15 Dec 2025 20:01:53 -0800 Subject: [PATCH 25/52] Remove refrences to resources feature scrapped from V3 --- comfy_api/latest/__init__.py | 1 - comfy_api/latest/_io.py | 3 -- comfy_api/latest/_resources.py | 72 ---------------------------------- execution.py | 2 +- 4 files changed, 1 insertion(+), 77 deletions(-) delete mode 100644 comfy_api/latest/_resources.py diff --git a/comfy_api/latest/__init__.py b/comfy_api/latest/__init__.py index fab63c7dfe3b..b0fa14ff6749 100644 --- a/comfy_api/latest/__init__.py +++ b/comfy_api/latest/__init__.py @@ -10,7 +10,6 @@ from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL from . import _io_public as io from . import _ui_public as ui -# from comfy_api.latest._resources import _RESOURCES as resources #noqa: F401 from comfy_execution.utils import get_executing_context from comfy_execution.progress import get_progress_state, PreviewImageTuple from PIL import Image diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index fbcfb62a6886..46847ee4faf5 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -26,7 +26,6 @@ from comfy_api.input import VideoInput from comfy_api.internal import (_ComfyNodeInternal, _NodeOutputInternal, classproperty, copy_class, first_real_override, is_class, prune_dict, shallow_clone_class) -from ._resources import Resources, ResourcesLocal from comfy_execution.graph_utils import ExecutionBlocker from ._util import MESH, VOXEL @@ -1487,7 +1486,6 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal): SCHEMA = None # filled in during execution - resources: Resources = None hidden: HiddenHolder = None @classmethod @@ -1534,7 +1532,6 @@ def check_lazy_status(cls, **kwargs) -> list[str]: return [name for name in kwargs if kwargs[name] is None] def __init__(self): - self.local_resources: ResourcesLocal = None self.__class__.VALIDATE_CLASS() @classmethod diff --git a/comfy_api/latest/_resources.py b/comfy_api/latest/_resources.py deleted file mode 100644 index a6bdda97204e..000000000000 --- a/comfy_api/latest/_resources.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations -import comfy.utils -import folder_paths -import logging -from abc import ABC, abstractmethod -from typing import Any -import torch - -class ResourceKey(ABC): - Type = Any - def __init__(self): - ... - -class TorchDictFolderFilename(ResourceKey): - '''Key for requesting a torch file via file_name from a folder category.''' - Type = dict[str, torch.Tensor] - def __init__(self, folder_name: str, file_name: str): - self.folder_name = folder_name - self.file_name = file_name - - def __hash__(self): - return hash((self.folder_name, self.file_name)) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, TorchDictFolderFilename): - return False - return self.folder_name == other.folder_name and self.file_name == other.file_name - - def __str__(self): - return f"{self.folder_name} -> {self.file_name}" - -class Resources(ABC): - def __init__(self): - ... - - @abstractmethod - def get(self, key: ResourceKey, default: Any=...) -> Any: - pass - -class ResourcesLocal(Resources): - def __init__(self): - super().__init__() - self.local_resources: dict[ResourceKey, Any] = {} - - def get(self, key: ResourceKey, default: Any=...) -> Any: - cached = self.local_resources.get(key, None) - if cached is not None: - logging.info(f"Using cached resource '{key}'") - return cached - logging.info(f"Loading resource '{key}'") - to_return = None - if isinstance(key, TorchDictFolderFilename): - if default is ...: - to_return = comfy.utils.load_torch_file(folder_paths.get_full_path_or_raise(key.folder_name, key.file_name), safe_load=True) - else: - full_path = folder_paths.get_full_path(key.folder_name, key.file_name) - if full_path is not None: - to_return = comfy.utils.load_torch_file(full_path, safe_load=True) - - if to_return is not None: - self.local_resources[key] = to_return - return to_return - if default is not ...: - return default - raise Exception(f"Unsupported resource key type: {type(key)}") - - -class _RESOURCES: - ResourceKey = ResourceKey - TorchDictFolderFilename = TorchDictFolderFilename - Resources = Resources - ResourcesLocal = ResourcesLocal diff --git a/execution.py b/execution.py index dedd0c9c3c45..12d3fc1ca71c 100644 --- a/execution.py +++ b/execution.py @@ -257,7 +257,7 @@ async def process_inputs(inputs, index=None, input_is_list=False): pre_execute_cb(index) # V3 if isinstance(obj, _ComfyNodeInternal) or (is_class(obj) and issubclass(obj, _ComfyNodeInternal)): - # if is just a class, then assign no resources or state, just create clone + # if is just a class, then assign no state, just create clone if is_class(obj): type_obj = obj obj.VALIDATE_CLASS() From 3b4927a1066f2a3bc851f59ce45c3a29ad08207f Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 15 Dec 2025 20:07:15 -0800 Subject: [PATCH 26/52] Expose DynamicCombo in public API --- comfy_api/latest/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 46847ee4faf5..85ab0458c4e1 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1933,7 +1933,7 @@ def as_dict(self) -> dict: "Tracks", # Dynamic Types "MatchType", - # "DynamicCombo", + "DynamicCombo", # "Autogrow", # Other classes "HiddenHolder", From c2b98d0ddf7c0cbbdaa26c00ce4b3f1827b68a19 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 15 Dec 2025 20:08:54 -0800 Subject: [PATCH 27/52] satisfy ruff after some code got commented out --- comfy_execution/graph.py | 1 - 1 file changed, 1 deletion(-) diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index 632f327bf1bd..8fc5846b7f9d 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -6,7 +6,6 @@ import inspect from comfy_execution.graph_utils import is_link, ExecutionBlocker from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, InputTypeOptions -from comfy_api.latest import IO # NOTE: ExecutionBlocker code got moved to graph_utils.py to prevent torch being imported too soon during unit tests ExecutionBlocker = ExecutionBlocker From 3c71a0dcdc65df820e592746c5e6771403ce6089 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 15 Dec 2025 20:51:16 -0800 Subject: [PATCH 28/52] Make missing input error prettier for dynamic types --- execution.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/execution.py b/execution.py index 12d3fc1ca71c..677bb6b0cdf1 100644 --- a/execution.py +++ b/execution.py @@ -760,7 +760,7 @@ async def validate_inputs(prompt_id, prompt, item, validated): if issubclass(obj_class, _ComfyNodeInternal): obj_class: _io._ComfyNodeBaseInternal class_inputs = obj_class.INPUT_TYPES() - class_inputs, _, _ = _io.get_finalized_class_inputs(class_inputs, inputs) + class_inputs, _, v3_data = _io.get_finalized_class_inputs(class_inputs, inputs) validate_function_name = "validate_inputs" validate_function = first_real_override(obj_class, validate_function_name) else: @@ -780,10 +780,11 @@ async def validate_inputs(prompt_id, prompt, item, validated): assert extra_info is not None if x not in inputs: if input_category == "required": + details = f"{x}" if not v3_data else x.split(".")[-1] error = { "type": "required_input_missing", "message": "Required input is missing", - "details": f"{x}", + "details": details, "extra_info": { "input_name": x } From 85a97cc5c8612a419d998cf0f4bd5b7c3cd58931 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 15 Dec 2025 23:20:46 -0800 Subject: [PATCH 29/52] Created a Switch2 node as a side-by-side test, will likely go with Switch2 as the initial switch node --- comfy_extras/nodes_logic.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index ebcb665b31e9..64b8ab4c13dc 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -58,6 +58,35 @@ def execute(cls, switch, on_true=MISSING, on_false=MISSING) -> io.NodeOutput: return io.NodeOutput(on_true) return io.NodeOutput(on_true if switch else on_false) +class SwitchNode2(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.MatchType.Template("switch") + return io.Schema( + node_id="ComfySwitchNode2", + display_name="Switch2", + category="logic", + inputs=[ + io.Boolean.Input("switch"), + io.MatchType.Input("on_false", template=template, lazy=True), + io.MatchType.Input("on_true", template=template, lazy=True), + ], + outputs=[ + io.MatchType.Output(template=template, display_name="output"), + ], + ) + + @classmethod + def check_lazy_status(cls, switch, on_false=None, on_true=None): + if switch and on_true is None: + return ["on_true"] + if not switch and on_false is None: + return ["on_false"] + + @classmethod + def execute(cls, switch, on_true, on_false) -> io.NodeOutput: + return io.NodeOutput(on_true if switch else on_false) + class CustomComboNode(io.ComfyNode): """ @@ -69,7 +98,7 @@ def define_schema(cls): return io.Schema( node_id="CustomCombo", display_name="Custom Combo", - category="util", + category="utils", is_experimental=True, inputs=[io.Combo.Input("choice", options=[])], outputs=[io.String.Output()] @@ -185,6 +214,7 @@ class LogicExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ SwitchNode, + SwitchNode2, CustomComboNode, DCTestNode, AutogrowNamesTestNode, From 6630cd8135657eea8014bec285fd24388b540e0b Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Tue, 16 Dec 2025 21:55:20 -0800 Subject: [PATCH 30/52] Figured out Switch situation --- comfy_api/latest/_io.py | 2 +- comfy_extras/nodes_logic.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 85ab0458c4e1..93452cf22a2a 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1838,7 +1838,7 @@ def result(self): return self.args if len(self.args) > 0 else None @classmethod - def from_dict(cls, data: dict[str, Any]) -> "NodeOutput": + def from_dict(cls, data: dict[str, Any]) -> NodeOutput: args = () ui = None expand = None diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 64b8ab4c13dc..9516393d6f5e 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -7,13 +7,13 @@ MISSING = object() -class SwitchNode(io.ComfyNode): +class SoftSwitchNode(io.ComfyNode): @classmethod def define_schema(cls): template = io.MatchType.Template("switch") return io.Schema( - node_id="ComfySwitchNode", - display_name="Switch", + node_id="ComfySoftSwitchNode", + display_name="Soft Switch", category="logic", is_experimental=True, inputs=[ @@ -58,14 +58,15 @@ def execute(cls, switch, on_true=MISSING, on_false=MISSING) -> io.NodeOutput: return io.NodeOutput(on_true) return io.NodeOutput(on_true if switch else on_false) -class SwitchNode2(io.ComfyNode): +class SwitchNode(io.ComfyNode): @classmethod def define_schema(cls): template = io.MatchType.Template("switch") return io.Schema( - node_id="ComfySwitchNode2", - display_name="Switch2", + node_id="ComfySwitchNode", + display_name="Switch", category="logic", + is_experimental=True, inputs=[ io.Boolean.Input("switch"), io.MatchType.Input("on_false", template=template, lazy=True), @@ -214,7 +215,7 @@ class LogicExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ SwitchNode, - SwitchNode2, + SoftSwitchNode, CustomComboNode, DCTestNode, AutogrowNamesTestNode, From a3d14592c97d6e05fbd3b1699abb0913929135f2 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 13:32:33 -0800 Subject: [PATCH 31/52] Pass in v3_data in IsChangedCache.get function's fingerprint_inputs, add a from_v3_data helper method to HiddenHolder --- comfy_api/latest/_io.py | 6 +++++- execution.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index f5ce3c15cea7..ccbe5bbc79ee 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1167,6 +1167,10 @@ def from_dict(cls, d: dict | None): api_key_comfy_org=d.get(Hidden.api_key_comfy_org, None), ) + @classmethod + def from_v3_data(cls, v3_data: V3Data | None) -> HiddenHolder: + return cls.from_dict(v3_data["hidden_inputs"] if v3_data else None) + class Hidden(str, Enum): ''' Enumerator for requesting hidden variables in nodes. @@ -1599,7 +1603,7 @@ def PREPARE_CLASS_CLONE(cls, v3_data: V3Data | None) -> type[ComfyNode]: c_type: type[ComfyNode] = cls if is_class(cls) else type(cls) type_clone: type[ComfyNode] = shallow_clone_class(c_type) # set hidden - type_clone.hidden = HiddenHolder.from_dict(v3_data["hidden_inputs"] if v3_data else None) + type_clone.hidden = HiddenHolder.from_v3_data(v3_data) return type_clone @final diff --git a/execution.py b/execution.py index 677bb6b0cdf1..afcc07ce7ab0 100644 --- a/execution.py +++ b/execution.py @@ -79,7 +79,7 @@ async def get(self, node_id): # Intentionally do not use cached outputs here. We only want constants in IS_CHANGED input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None) try: - is_changed = await _async_map_node_over_list(self.prompt_id, node_id, class_def, input_data_all, is_changed_name) + is_changed = await _async_map_node_over_list(self.prompt_id, node_id, class_def, input_data_all, is_changed_name, v3_data=v3_data) is_changed = await resolve_map_node_over_list_results(is_changed) node["is_changed"] = [None if isinstance(x, ExecutionBlocker) else x for x in is_changed] except Exception as e: From d764601f6ebd964655714224f5fe3a61d04fd174 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 13:37:19 -0800 Subject: [PATCH 32/52] Switch order of Switch and Soft Switch nodes in file --- comfy_extras/nodes_logic.py | 61 +++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 9516393d6f5e..d66453ae54dc 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -7,6 +7,37 @@ MISSING = object() +class SwitchNode(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.MatchType.Template("switch") + return io.Schema( + node_id="ComfySwitchNode", + display_name="Switch", + category="logic", + is_experimental=True, + inputs=[ + io.Boolean.Input("switch"), + io.MatchType.Input("on_false", template=template, lazy=True), + io.MatchType.Input("on_true", template=template, lazy=True), + ], + outputs=[ + io.MatchType.Output(template=template, display_name="output"), + ], + ) + + @classmethod + def check_lazy_status(cls, switch, on_false=None, on_true=None): + if switch and on_true is None: + return ["on_true"] + if not switch and on_false is None: + return ["on_false"] + + @classmethod + def execute(cls, switch, on_true, on_false) -> io.NodeOutput: + return io.NodeOutput(on_true if switch else on_false) + + class SoftSwitchNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -58,36 +89,6 @@ def execute(cls, switch, on_true=MISSING, on_false=MISSING) -> io.NodeOutput: return io.NodeOutput(on_true) return io.NodeOutput(on_true if switch else on_false) -class SwitchNode(io.ComfyNode): - @classmethod - def define_schema(cls): - template = io.MatchType.Template("switch") - return io.Schema( - node_id="ComfySwitchNode", - display_name="Switch", - category="logic", - is_experimental=True, - inputs=[ - io.Boolean.Input("switch"), - io.MatchType.Input("on_false", template=template, lazy=True), - io.MatchType.Input("on_true", template=template, lazy=True), - ], - outputs=[ - io.MatchType.Output(template=template, display_name="output"), - ], - ) - - @classmethod - def check_lazy_status(cls, switch, on_false=None, on_true=None): - if switch and on_true is None: - return ["on_true"] - if not switch and on_false is None: - return ["on_false"] - - @classmethod - def execute(cls, switch, on_true, on_false) -> io.NodeOutput: - return io.NodeOutput(on_true if switch else on_false) - class CustomComboNode(io.ComfyNode): """ From 2eca887466c0afa7147afcfae2d2d4281730389b Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 15:00:31 -0800 Subject: [PATCH 33/52] Temp test node for MatchType --- comfy_extras/nodes_logic.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index d66453ae54dc..4b8012e12b1f 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -1,3 +1,4 @@ +from __future__ import annotations from typing import TypedDict from typing_extensions import override from comfy_api.latest import ComfyExtension, io @@ -38,6 +39,28 @@ def execute(cls, switch, on_true, on_false) -> io.NodeOutput: return io.NodeOutput(on_true if switch else on_false) +class MatchTypeTestNode(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.MatchType.Template("switch", [io.Image, io.Mask, io.Latent]) + return io.Schema( + node_id="MatchTypeTestNode", + display_name="MatchTypeTest", + category="logic", + is_experimental=True, + inputs=[ + io.MatchType.Input("input", template=template), + ], + outputs=[ + io.MatchType.Output(template=template, display_name="output"), + ], + ) + + @classmethod + def execute(cls, input) -> io.NodeOutput: + return io.NodeOutput(input) + + class SoftSwitchNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -222,6 +245,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: AutogrowNamesTestNode, AutogrowPrefixTestNode, ComboOutputTestNode, + MatchTypeTestNode, ] async def comfy_entrypoint() -> LogicExtension: From 98cc896c3ec8a3c3d844fdaac8b2c69c64c8b57e Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 19:33:42 -0800 Subject: [PATCH 34/52] Fix missing v3_data for v1 nodes in validation --- execution.py | 1 + 1 file changed, 1 insertion(+) diff --git a/execution.py b/execution.py index afcc07ce7ab0..0f0de778c09b 100644 --- a/execution.py +++ b/execution.py @@ -755,6 +755,7 @@ async def validate_inputs(prompt_id, prompt, item, validated): errors = [] valid = True + v3_data = None validate_function_inputs = [] validate_has_kwargs = False if issubclass(obj_class, _ComfyNodeInternal): From 004b1b5b0e546292fb2db1ab11695d812e3b37a5 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 19:34:12 -0800 Subject: [PATCH 35/52] For now, remove chacking duplicate id's for dynamic types --- comfy_api/latest/_io.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index ccbe5bbc79ee..1b9be07d6f6d 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1277,7 +1277,8 @@ def validate(self): ''' nested_inputs: list[Input] = [] for input in self.inputs: - nested_inputs.extend(input.get_all()) + if not isinstance(input, DynamicInput): + nested_inputs.extend(input.get_all()) input_ids = [i.id for i in nested_inputs] output_ids = [o.id for o in self.outputs] input_set = set(input_ids) From e517a6bf4cf2f180c4eb7d40507102369284f35a Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 19:35:06 -0800 Subject: [PATCH 36/52] Add Resize Image/Mask node that thanks to MatchType+DynamicCombo is 16-nodes-in-1 --- comfy_extras/nodes_post_processing.py | 203 ++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index 34c388a5ad69..0d241e327b88 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -4,11 +4,14 @@ import torch.nn.functional as F from PIL import Image import math +from enum import Enum +from typing import TypedDict, Literal import comfy.utils import comfy.model_management import node_helpers from comfy_api.latest import ComfyExtension, io +from nodes import MAX_RESOLUTION class Blend(io.ComfyNode): @classmethod @@ -240,6 +243,205 @@ def execute(cls, image, upscale_method, megapixels) -> io.NodeOutput: s = s.movedim(1,-1) return io.NodeOutput(s) +class ResizeType(str, Enum): + SCALE_BY = "scale by multiplier" + SCALE_DIMENSIONS = "scale dimensions" + SCALE_LONGER_DIMENSION = "scale longer dimension" + SCALE_SHORTER_DIMENSION = "scale shorter dimension" + SCALE_WIDTH = "scale width" + SCALE_HEIGHT = "scale height" + SCALE_TOTAL_PIXELS = "scale total pixels" + MATCH_SIZE = "match size" + +def is_image(input: torch.Tensor) -> bool: + # images have 4 dimensions: [batch, height, width, channels] + # masks have 3 dimensions: [batch, height, width] + return len(input.shape) == 4 + +def init_image_mask_input(input: torch.Tensor, is_type_image: bool) -> torch.Tensor: + if is_type_image: + input = input.movedim(-1, 1) + else: + input = input.unsqueeze(1) + return input + +def finalize_image_mask_input(input: torch.Tensor, is_type_image: bool) -> torch.Tensor: + if is_type_image: + input = input.movedim(1, -1) + else: + input = input.squeeze(1) + return input + +def scale_by(input: torch.Tensor, multiplier: float, scale_method: str) -> torch.Tensor: + is_type_image = is_image(input) + input = init_image_mask_input(input, is_type_image) + width = round(input.shape[-1] * multiplier) + height = round(input.shape[-2] * multiplier) + + input = comfy.utils.common_upscale(input, width, height, scale_method, "disabled") + input = finalize_image_mask_input(input, is_type_image) + return input + +def scale_dimensions(input: torch.Tensor, width: int, height: int, scale_method: str, crop: str="disabled") -> torch.Tensor: + if width == 0 and height == 0: + return input + is_type_image = is_image(input) + input = init_image_mask_input(input, is_type_image) + + if width == 0: + width = max(1, round(input.shape[-1] * height / input.shape[-2])) + elif height == 0: + height = max(1, round(input.shape[-2] * width / input.shape[-1])) + + input = comfy.utils.common_upscale(input, width, height, scale_method, crop) + input = finalize_image_mask_input(input, is_type_image) + return input + +def scale_longer_dimension(input: torch.Tensor, longer_size: int, scale_method: str) -> torch.Tensor: + is_type_image = is_image(input) + input = init_image_mask_input(input, is_type_image) + width = input.shape[-1] + height = input.shape[-2] + + if height > width: + width = round((width / height) * longer_size) + height = longer_size + elif width > height: + height = round((height / width) * longer_size) + width = longer_size + else: + height = longer_size + width = longer_size + + input = comfy.utils.common_upscale(input, width, height, scale_method, "disabled") + input = finalize_image_mask_input(input, is_type_image) + return input + +def scale_shorter_dimension(input: torch.Tensor, shorter_size: int, scale_method: str) -> torch.Tensor: + is_type_image = is_image(input) + input = init_image_mask_input(input, is_type_image) + width = input.shape[-1] + height = input.shape[-2] + + if height < width: + width = round((width / height) * shorter_size) + height = shorter_size + elif width > height: + height = round((height / width) * shorter_size) + width = shorter_size + else: + height = shorter_size + width = shorter_size + + input = comfy.utils.common_upscale(input, width, height, scale_method, "disabled") + input = finalize_image_mask_input(input, is_type_image) + return input + +def scale_total_pixels(input: torch.Tensor, megapixels: float, scale_method: str) -> torch.Tensor: + is_type_image = is_image(input) + input = init_image_mask_input(input, is_type_image) + total = int(megapixels * 1024 * 1024) + + scale_by = math.sqrt(total / (input.shape[-1] * input.shape[-2])) + width = round(input.shape[-1] * scale_by) + height = round(input.shape[-2] * scale_by) + + input = comfy.utils.common_upscale(input, width, height, scale_method, "disabled") + input = finalize_image_mask_input(input, is_type_image) + return input + +def scale_match_size(input: torch.Tensor, match: torch.Tensor, scale_method: str, crop: str) -> torch.Tensor: + is_type_image = is_image(input) + input = init_image_mask_input(input, is_type_image) + match = init_image_mask_input(match, is_image(match)) + + width = match.shape[-1] + height = match.shape[-2] + input = comfy.utils.common_upscale(input, width, height, scale_method, crop) + input = finalize_image_mask_input(input, is_type_image) + return input + +class ResizeImageMaskNode(io.ComfyNode): + + scale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"] + crop_methods = ["disabled", "center"] + + class ResizeTypedDict(TypedDict): + resize_type: ResizeType + scale_method: Literal["nearest-exact", "bilinear", "area", "bicubic", "lanczos"] + crop: Literal["disabled", "center"] + multiplier: float + width: int + height: int + longer_size: int + shorter_size: int + megapixels: float + + @classmethod + def define_schema(cls): + template = io.MatchType.Template("input_type", [io.Image, io.Mask]) + crop_combo = io.Combo.Input("crop", options=cls.crop_methods) + return io.Schema( + node_id="ResizeImageMaskNode", + display_name="Resize Image/Mask", + category="transform", + inputs=[ + io.MatchType.Input("input", template=template), + io.DynamicCombo.Input("resize_type", options=[ + io.DynamicCombo.Option(ResizeType.SCALE_BY, [ + io.Float.Input("multiplier", default=1.00, min=0.01, max=8.0, step=0.01), + ]), + io.DynamicCombo.Option(ResizeType.SCALE_DIMENSIONS, [ + io.Int.Input("width", default=512, min=0, max=MAX_RESOLUTION, step=1), + io.Int.Input("height", default=512, min=0, max=MAX_RESOLUTION, step=1), + crop_combo, + ]), + io.DynamicCombo.Option(ResizeType.SCALE_LONGER_DIMENSION, [ + io.Int.Input("longer_size", default=512, min=0, max=MAX_RESOLUTION, step=1), + ]), + io.DynamicCombo.Option(ResizeType.SCALE_SHORTER_DIMENSION, [ + io.Int.Input("shorter_size", default=512, min=0, max=MAX_RESOLUTION, step=1), + ]), + io.DynamicCombo.Option(ResizeType.SCALE_WIDTH, [ + io.Int.Input("width", default=512, min=0, max=MAX_RESOLUTION, step=1), + ]), + io.DynamicCombo.Option(ResizeType.SCALE_HEIGHT, [ + io.Int.Input("height", default=512, min=0, max=MAX_RESOLUTION, step=1), + ]), + io.DynamicCombo.Option(ResizeType.SCALE_TOTAL_PIXELS, [ + io.Float.Input("megapixels", default=1.0, min=0.01, max=16.0, step=0.01), + ]), + io.DynamicCombo.Option(ResizeType.MATCH_SIZE, [ + io.MultiType.Input("match", [io.Image, io.Mask]), + crop_combo, + ]), + ]), + io.Combo.Input("scale_method", options=cls.scale_methods, default="area"), + ], + outputs=[io.MatchType.Output(template=template, display_name="resized")] + ) + + @classmethod + def execute(cls, input: io.Image.Type | io.Mask.Type, scale_method: io.Combo.Type, resize_type: ResizeTypedDict) -> io.NodeOutput: + selected_type = resize_type["resize_type"] + if selected_type == ResizeType.SCALE_BY: + return io.NodeOutput(scale_by(input, resize_type["multiplier"], scale_method)) + elif selected_type == ResizeType.SCALE_DIMENSIONS: + return io.NodeOutput(scale_dimensions(input, resize_type["width"], resize_type["height"], scale_method, resize_type["crop"])) + elif selected_type == ResizeType.SCALE_LONGER_DIMENSION: + return io.NodeOutput(scale_longer_dimension(input, resize_type["longer_size"], scale_method)) + elif selected_type == ResizeType.SCALE_SHORTER_DIMENSION: + return io.NodeOutput(scale_shorter_dimension(input, resize_type["shorter_size"], scale_method)) + elif selected_type == ResizeType.SCALE_WIDTH: + return io.NodeOutput(scale_dimensions(input, resize_type["width"], 0, scale_method)) + elif selected_type == ResizeType.SCALE_HEIGHT: + return io.NodeOutput(scale_dimensions(input, 0, resize_type["height"], scale_method)) + elif selected_type == ResizeType.SCALE_TOTAL_PIXELS: + return io.NodeOutput(scale_total_pixels(input, resize_type["megapixels"], scale_method)) + elif selected_type == ResizeType.MATCH_SIZE: + return io.NodeOutput(scale_match_size(input, resize_type["match"], scale_method, resize_type["crop"])) + raise ValueError(f"Unsupported resize type: {selected_type}") + class PostProcessingExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: @@ -249,6 +451,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: Quantize, Sharpen, ImageScaleToTotalPixels, + ResizeImageMaskNode, ] async def comfy_entrypoint() -> PostProcessingExtension: From 0958579d990b0b2cb6aa9edbbd21659dbf9e2449 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 17 Dec 2025 23:00:28 -0800 Subject: [PATCH 37/52] Made DynamicCombo references in DCTestNode use public interface --- comfy_extras/nodes_logic.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 4b8012e12b1f..a8d345c23244 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -149,14 +149,14 @@ def define_schema(cls): display_name="DCTest", category="logic", is_output_node=True, - inputs=[_io.DynamicCombo.Input("combo", options=[ - _io.DynamicCombo.Option("option1", [io.String.Input("string")]), - _io.DynamicCombo.Option("option2", [io.Int.Input("integer")]), - _io.DynamicCombo.Option("option3", [io.Image.Input("image")]), - _io.DynamicCombo.Option("option4", [ - _io.DynamicCombo.Input("subcombo", options=[ - _io.DynamicCombo.Option("opt1", [io.Float.Input("float_x"), io.Float.Input("float_y")]), - _io.DynamicCombo.Option("opt2", [io.Mask.Input("mask1", optional=True)]), + inputs=[io.DynamicCombo.Input("combo", options=[ + io.DynamicCombo.Option("option1", [io.String.Input("string")]), + io.DynamicCombo.Option("option2", [io.Int.Input("integer")]), + io.DynamicCombo.Option("option3", [io.Image.Input("image")]), + io.DynamicCombo.Option("option4", [ + io.DynamicCombo.Input("subcombo", options=[ + io.DynamicCombo.Option("opt1", [io.Float.Input("float_x"), io.Float.Input("float_y")]), + io.DynamicCombo.Option("opt2", [io.Mask.Input("mask1", optional=True)]), ]) ])] )], From e8d4074884c52ea26ec1016b9786e58438b73135 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 18 Dec 2025 00:00:57 -0800 Subject: [PATCH 38/52] Add an AnyTypeTestNode --- comfy_extras/nodes_logic.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index a8d345c23244..0e5f85e02584 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -234,6 +234,21 @@ def define_schema(cls): def execute(cls, combo: io.Combo.Type, combo2: io.Combo.Type) -> io.NodeOutput: return io.NodeOutput(combo, combo2) +class AnyTypeTestNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="AnyNodeTestNode", + display_name="AnyNodeTest", + category="logic", + inputs=[io.AnyType.Input("any")], + outputs=[io.AnyType.Output()], + ) + + @classmethod + def execute(cls, any: io.AnyType.Type) -> io.NodeOutput: + return io.NodeOutput(any) + class LogicExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: @@ -246,6 +261,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: AutogrowPrefixTestNode, ComboOutputTestNode, MatchTypeTestNode, + AnyTypeTestNode, ] async def comfy_entrypoint() -> LogicExtension: From dd4786bcf87545c34f1dab84b1902cd108530460 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 18 Dec 2025 16:20:28 -0800 Subject: [PATCH 39/52] Make lazy status for specific inputs on DynamicInputs work by having the values of the dictionary for check_lazy_status be a tuple, where the second element is the key of the input that can be returned --- comfy_api/latest/_io.py | 11 ++++++++++- execution.py | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 1b9be07d6f6d..f3c39879de33 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1131,7 +1131,11 @@ def setup_dynamic_input_funcs(): class V3Data(TypedDict): hidden_inputs: dict[str, Any] + 'Dictionary where the keys are the hidden input ids and the values are the values of the hidden inputs.' dynamic_paths: dict[str, Any] + 'Dictionary where the keys are the input ids and the values dictate how to turn the inputs into a nested dictionary.' + create_dynamic_tuple: bool + 'When True, the value of the dynamic input will be in the format (value, path_key).' class HiddenHolder: def __init__(self, unique_id: str, prompt: Any, @@ -1468,6 +1472,8 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data): values = values.copy() result = {} + create_tuple = v3_data.get("create_dynamic_tuple", False) + for key, path in paths.items(): parts = path.split(".") current = result @@ -1476,7 +1482,10 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data): is_last = (i == len(parts) - 1) if is_last: - current[p] = values.pop(key, None) + value = values.pop(key, None) + if create_tuple: + value = (value, key) + current[p] = value else: current = current.setdefault(p, {}) diff --git a/execution.py b/execution.py index 0f0de778c09b..38159b1f4141 100644 --- a/execution.py +++ b/execution.py @@ -480,7 +480,10 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed, else: lazy_status_present = getattr(obj, "check_lazy_status", None) is not None if lazy_status_present: - required_inputs = await _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, "check_lazy_status", allow_interrupt=True, v3_data=v3_data) + # for check_lazy_status, the returned data should include the original key of the input + v3_data_lazy = v3_data.copy() + v3_data_lazy["create_dynamic_tuple"] = True + required_inputs = await _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, "check_lazy_status", allow_interrupt=True, v3_data=v3_data_lazy) required_inputs = await resolve_map_node_over_list_results(required_inputs) required_inputs = set(sum([r for r in required_inputs if isinstance(r,list)], [])) required_inputs = [x for x in required_inputs if isinstance(x,str) and ( From b4bc85439863beeb2d23ec9b9dcb360d52731f50 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 18 Dec 2025 16:30:41 -0800 Subject: [PATCH 40/52] Comment out test logic nodes --- comfy_extras/nodes_logic.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 0e5f85e02584..1ca86c712342 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -254,14 +254,14 @@ class LogicExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ SwitchNode, - SoftSwitchNode, - CustomComboNode, - DCTestNode, - AutogrowNamesTestNode, - AutogrowPrefixTestNode, - ComboOutputTestNode, - MatchTypeTestNode, - AnyTypeTestNode, + # SoftSwitchNode, + # CustomComboNode, + # DCTestNode, + # AutogrowNamesTestNode, + # AutogrowPrefixTestNode, + # ComboOutputTestNode, + # MatchTypeTestNode, + # AnyTypeTestNode, ] async def comfy_entrypoint() -> LogicExtension: From 37e94ed58378c1a15128ce16e4e49a081c0b9142 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 19 Dec 2025 19:58:10 -0800 Subject: [PATCH 41/52] Make primitive float's step make more sense --- comfy_extras/nodes_primitive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_primitive.py b/comfy_extras/nodes_primitive.py index 5a1aeba80077..937321800249 100644 --- a/comfy_extras/nodes_primitive.py +++ b/comfy_extras/nodes_primitive.py @@ -66,7 +66,7 @@ def define_schema(cls): display_name="Float", category="utils/primitive", inputs=[ - io.Float.Input("value", min=-sys.maxsize, max=sys.maxsize), + io.Float.Input("value", min=-sys.maxsize, max=sys.maxsize, step=0.1), ], outputs=[io.Float.Output()], ) From 322e3d01ec64eda34a046d37fa169ca498d2eaff Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 19 Dec 2025 19:58:34 -0800 Subject: [PATCH 42/52] Add (and leave commented out) some potential logic nodes --- comfy_extras/nodes_logic.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 1ca86c712342..f354cdd2ae87 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -129,6 +129,10 @@ def define_schema(cls): outputs=[io.String.Output()] ) + @classmethod + def validate_inputs(cls, choice: io.Combo.Type) -> bool: + return True + @classmethod def execute(cls, choice: io.Combo.Type) -> io.NodeOutput: return io.NodeOutput(choice) @@ -234,6 +238,21 @@ def define_schema(cls): def execute(cls, combo: io.Combo.Type, combo2: io.Combo.Type) -> io.NodeOutput: return io.NodeOutput(combo, combo2) +class ConvertStringToComboNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="ConvertStringToComboNode", + display_name="Convert String to Combo", + category="logic", + inputs=[io.String.Input("string")], + outputs=[io.Combo.Output()], + ) + + @classmethod + def execute(cls, string: str) -> io.NodeOutput: + return io.NodeOutput(string) + class AnyTypeTestNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -249,6 +268,21 @@ def define_schema(cls): def execute(cls, any: io.AnyType.Type) -> io.NodeOutput: return io.NodeOutput(any) +class InvertBooleanNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="InvertBooleanNode", + display_name="Invert Boolean", + category="logic", + inputs=[io.Boolean.Input("boolean")], + outputs=[io.Boolean.Output()], + ) + + @classmethod + def execute(cls, boolean: bool) -> io.NodeOutput: + return io.NodeOutput(not boolean) + class LogicExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: @@ -256,10 +290,12 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: SwitchNode, # SoftSwitchNode, # CustomComboNode, + # ConvertStringToComboNode, # DCTestNode, # AutogrowNamesTestNode, # AutogrowPrefixTestNode, # ComboOutputTestNode, + # InvertBooleanNode, # MatchTypeTestNode, # AnyTypeTestNode, ] From 1929d8b032f8a156e7b5b328bc675965017fb283 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 22 Dec 2025 16:28:32 -0800 Subject: [PATCH 43/52] Change default crop option to "center" on Resize Image/Mask node --- comfy_extras/nodes_post_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index 73bc604ef786..5697a1c88e4d 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -381,7 +381,7 @@ class ResizeTypedDict(TypedDict): @classmethod def define_schema(cls): template = io.MatchType.Template("input_type", [io.Image, io.Mask]) - crop_combo = io.Combo.Input("crop", options=cls.crop_methods) + crop_combo = io.Combo.Input("crop", options=cls.crop_methods, default="center") return io.Schema( node_id="ResizeImageMaskNode", display_name="Resize Image/Mask", From b762dfadd8c379789d26666447868a9f7a020053 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Tue, 23 Dec 2025 20:05:15 -0800 Subject: [PATCH 44/52] Changed copy.copy(d) to d.copy() --- comfy_api/latest/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index f3c39879de33..1b2940badce9 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1419,7 +1419,7 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i "optional": {}, "dynamic_paths": {}, } - d = copy.copy(d) + d = d.copy() # ignore hidden for parsing hidden = d.pop("hidden", None) parse_class_inputs(out_dict, live_inputs, d) From 4b8b9833cb1782293547474e9b1dea6729b9e1fe Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 16:24:45 -0800 Subject: [PATCH 45/52] Autogrow is available in stable frontend, so exposing it in public API --- comfy_api/latest/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 1b2940badce9..f0570b1210ac 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1948,7 +1948,7 @@ def as_dict(self) -> dict: # Dynamic Types "MatchType", "DynamicCombo", - # "Autogrow", + "Autogrow", # Other classes "HiddenHolder", "Hidden", From 0d006f8e9870e57cad3766ea36a3817850904713 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 16:39:46 -0800 Subject: [PATCH 46/52] Use outputs id as display_name if no display_name present, remove v3 outputs id restriction that made them have to have unique IDs from the inputs --- comfy_api/latest/_io.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index f0570b1210ac..5157b4e1d282 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -213,8 +213,9 @@ def __init__(self, id: str=None, display_name: str=None, tooltip: str=None, self.is_output_list = is_output_list def as_dict(self): + display_name = self.display_name if self.display_name else self.id return prune_dict({ - "display_name": self.display_name, + "display_name": display_name, "tooltip": self.tooltip, "is_output_list": self.is_output_list, }) @@ -1287,16 +1288,12 @@ def validate(self): output_ids = [o.id for o in self.outputs] input_set = set(input_ids) output_set = set(output_ids) - issues = [] + issues: list[str] = [] # verify ids are unique per list if len(input_set) != len(input_ids): issues.append(f"Input ids must be unique, but {[item for item, count in Counter(input_ids).items() if count > 1]} are not.") if len(output_set) != len(output_ids): issues.append(f"Output ids must be unique, but {[item for item, count in Counter(output_ids).items() if count > 1]} are not.") - # verify ids are unique between lists - intersection = input_set & output_set - if len(intersection) > 0: - issues.append(f"Ids must be unique between inputs and outputs, but {intersection} are not.") if len(issues) > 0: raise ValueError("\n".join(issues)) # validate inputs and outputs From e4e90b610b10483d296a20f08697ce46fa035db4 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 16:46:12 -0800 Subject: [PATCH 47/52] Enable Custom Combo node as stable frontend now supports it --- comfy_extras/nodes_logic.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index f354cdd2ae87..616047b5e4b0 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -131,6 +131,9 @@ def define_schema(cls): @classmethod def validate_inputs(cls, choice: io.Combo.Type) -> bool: + # NOTE: DO NOT DO THIS unless you want to skip validation entirely on the node's inputs. + # I am doing that here because the widgets (besides the combo dropdown) on this node are fully frontend defined. + # I need to skip checking that the chosen combo option is in the options list, since those are defined by the user. return True @classmethod @@ -288,8 +291,8 @@ class LogicExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ SwitchNode, + CustomComboNode, # SoftSwitchNode, - # CustomComboNode, # ConvertStringToComboNode, # DCTestNode, # AutogrowNamesTestNode, From 9cbfb96bf70d33361869fc56e21df34becc92505 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 18:30:11 -0800 Subject: [PATCH 48/52] Make id properly act like display_name on outputs --- comfy_api/latest/_io.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 5157b4e1d282..7728df8fe080 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -208,7 +208,7 @@ class Output(_IO_V3): def __init__(self, id: str=None, display_name: str=None, tooltip: str=None, is_output_list=False): self.id = id - self.display_name = display_name + self.display_name = display_name if display_name else id self.tooltip = tooltip self.is_output_list = is_output_list @@ -854,8 +854,10 @@ def as_dict(self): }) class Output(Output): - def __init__(self, template: MatchType.Template, id: str=None, display_name: str="MATCHTYPE", tooltip: str=None, + def __init__(self, template: MatchType.Template, id: str=None, display_name: str=None, tooltip: str=None, is_output_list=False): + if not id and not display_name: + display_name = "MATCHTYPE" super().__init__(id, display_name, tooltip, is_output_list) self.template = template From 8590bcf48abfe3e628d3b2b50a8e6df86591d5a1 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 18:33:02 -0800 Subject: [PATCH 49/52] Add Batch Images/Masks/Latents node --- comfy_extras/nodes_post_processing.py | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index 5697a1c88e4d..e1b2706ff2b6 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -9,6 +9,7 @@ import comfy.utils import comfy.model_management +from comfy_extras.nodes_latent import reshape_latent_to import node_helpers from comfy_api.latest import ComfyExtension, io from nodes import MAX_RESOLUTION @@ -443,6 +444,94 @@ def execute(cls, input: io.Image.Type | io.Mask.Type, scale_method: io.Combo.Typ return io.NodeOutput(scale_match_size(input, resize_type["match"], scale_method, resize_type["crop"])) raise ValueError(f"Unsupported resize type: {selected_type}") +def batch_images(images: list[torch.Tensor]) -> torch.Tensor | None: + if len(images) == 0: + return None + # first, get the max channels count + max_channels = max(image.shape[-1] for image in images) + # then, pad all images to have the same channels count + padded_images: list[torch.Tensor] = [] + for image in images: + if image.shape[-1] < max_channels: + padded_images.append(torch.nn.functional.pad(image, (0,1), mode='constant', value=1.0)) + else: + padded_images.append(image) + # resize all images to be the same size as the first image + resized_images: list[torch.Tensor] = [] + first_image_shape = padded_images[0].shape + for image in padded_images: + if image.shape[1:] != first_image_shape[1:]: + resized_images.append(comfy.utils.common_upscale(image.movedim(-1,1), first_image_shape[2], first_image_shape[1], "bilinear", "center").movedim(1,-1)) + else: + resized_images.append(image) + # batch the images in the format [b, h, w, c] + return torch.cat(resized_images, dim=0) + +def batch_masks(masks: list[torch.Tensor]) -> torch.Tensor | None: + if len(masks) == 0: + return None + # resize all masks to be the same size as the first mask + resized_masks: list[torch.Tensor] = [] + first_mask_shape = masks[0].shape + for mask in masks: + if mask.shape[1:] != first_mask_shape[1:]: + mask = init_image_mask_input(mask, is_type_image=False) + mask = comfy.utils.common_upscale(mask, first_mask_shape[2], first_mask_shape[1], "bilinear", "center") + resized_masks.append(finalize_image_mask_input(mask, is_type_image=False)) + else: + resized_masks.append(mask) + # batch the masks in the format [b, h, w] + return torch.cat(resized_masks, dim=0) + +def batch_latents(latents: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor] | None: + if len(latents) == 0: + return None + samples_out = latents[0].copy() + samples_out["batch_index"] = [] + first_samples = latents[0]["samples"] + tensors: list[torch.Tensor] = [] + for latent in latents: + # first, deal with latent tensors + tensors.append(reshape_latent_to(first_samples.shape, latent["samples"], repeat_batch=False)) + # next, deal with batch_index + samples_out["batch_index"].extend(latent.get("batch_index", [x for x in range(0, latent["samples"].shape[0])])) + samples_out["samples"] = torch.cat(tensors, dim=0) + return samples_out + +class BatchImagesMasksLatentsNode(io.ComfyNode): + @classmethod + def define_schema(cls): + matchtype_template = io.MatchType.Template("input", allowed_types=[io.Image, io.Mask, io.Latent]) + autogrow_template = io.Autogrow.TemplatePrefix( + io.MatchType.Input("input", matchtype_template), + prefix="input", min=1, max=50) + return io.Schema( + node_id="BatchImagesMasksLatentsNode", + display_name="Batch Images/Masks/Latents", + category="util", + inputs=[ + io.Autogrow.Input("inputs", template=autogrow_template) + ], + outputs=[ + io.MatchType.Output(id=None, template=matchtype_template) + ] + ) + + @classmethod + def execute(cls, inputs: io.Autogrow.Type) -> io.NodeOutput: + batched = None + values = list(inputs.values()) + # latents + if isinstance(values[0], dict): + batched = batch_latents(values) + # images + elif is_image(values[0]): + batched = batch_images(values) + # masks + else: + batched = batch_masks(values) + return io.NodeOutput(batched) + class PostProcessingExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: @@ -453,6 +542,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: Sharpen, ImageScaleToTotalPixels, ResizeImageMaskNode, + BatchImagesMasksLatentsNode, ] async def comfy_entrypoint() -> PostProcessingExtension: From bec7cfd3d93172fa464bf41ff9fc8baedfaec0b4 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 18:36:25 -0800 Subject: [PATCH 50/52] Comment out Batch Images/Masks/Latents node for now, as Autogrow has a bug with MatchType where top connection is disconnected upon refresh --- comfy_extras/nodes_post_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index e1b2706ff2b6..c6b612af5914 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -542,7 +542,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: Sharpen, ImageScaleToTotalPixels, ResizeImageMaskNode, - BatchImagesMasksLatentsNode, + # BatchImagesMasksLatentsNode, ] async def comfy_entrypoint() -> PostProcessingExtension: From 2fdd268a670f3aa6ef5ef6843dff26cc339fc913 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 26 Dec 2025 19:49:39 -0800 Subject: [PATCH 51/52] Removed code for a couple test nodes in nodes_logic.py --- comfy_extras/nodes_logic.py | 39 ------------------------------------- 1 file changed, 39 deletions(-) diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index 616047b5e4b0..eb888316acdc 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -39,28 +39,6 @@ def execute(cls, switch, on_true, on_false) -> io.NodeOutput: return io.NodeOutput(on_true if switch else on_false) -class MatchTypeTestNode(io.ComfyNode): - @classmethod - def define_schema(cls): - template = io.MatchType.Template("switch", [io.Image, io.Mask, io.Latent]) - return io.Schema( - node_id="MatchTypeTestNode", - display_name="MatchTypeTest", - category="logic", - is_experimental=True, - inputs=[ - io.MatchType.Input("input", template=template), - ], - outputs=[ - io.MatchType.Output(template=template, display_name="output"), - ], - ) - - @classmethod - def execute(cls, input) -> io.NodeOutput: - return io.NodeOutput(input) - - class SoftSwitchNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -256,21 +234,6 @@ def define_schema(cls): def execute(cls, string: str) -> io.NodeOutput: return io.NodeOutput(string) -class AnyTypeTestNode(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="AnyNodeTestNode", - display_name="AnyNodeTest", - category="logic", - inputs=[io.AnyType.Input("any")], - outputs=[io.AnyType.Output()], - ) - - @classmethod - def execute(cls, any: io.AnyType.Type) -> io.NodeOutput: - return io.NodeOutput(any) - class InvertBooleanNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -299,8 +262,6 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: # AutogrowPrefixTestNode, # ComboOutputTestNode, # InvertBooleanNode, - # MatchTypeTestNode, - # AnyTypeTestNode, ] async def comfy_entrypoint() -> LogicExtension: From d564ef683216b1d8f15002ef2ae16ce564e5972b Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 29 Dec 2025 18:12:22 -0800 Subject: [PATCH 52/52] Add Batch Images, Batch Masks, and Batch Latents nodes with Autogrow, deprecate old Batch Images + LatentBatch nodes --- comfy_extras/nodes_latent.py | 1 + comfy_extras/nodes_post_processing.py | 63 +++++++++++++++++++++++++++ nodes.py | 1 + 3 files changed, 65 insertions(+) diff --git a/comfy_extras/nodes_latent.py b/comfy_extras/nodes_latent.py index 2815c5ffc258..9ba1c4ba8a66 100644 --- a/comfy_extras/nodes_latent.py +++ b/comfy_extras/nodes_latent.py @@ -255,6 +255,7 @@ def define_schema(cls): return io.Schema( node_id="LatentBatch", category="latent/batch", + is_deprecated=True, inputs=[ io.Latent.Input("samples1"), io.Latent.Input("samples2"), diff --git a/comfy_extras/nodes_post_processing.py b/comfy_extras/nodes_post_processing.py index c6b612af5914..01afa13a18ee 100644 --- a/comfy_extras/nodes_post_processing.py +++ b/comfy_extras/nodes_post_processing.py @@ -498,6 +498,66 @@ def batch_latents(latents: list[dict[str, torch.Tensor]]) -> dict[str, torch.Ten samples_out["samples"] = torch.cat(tensors, dim=0) return samples_out +class BatchImagesNode(io.ComfyNode): + @classmethod + def define_schema(cls): + autogrow_template = io.Autogrow.TemplatePrefix(io.Image.Input("image"), prefix="image", min=2, max=50) + return io.Schema( + node_id="BatchImagesNode", + display_name="Batch Images", + category="image", + inputs=[ + io.Autogrow.Input("images", template=autogrow_template) + ], + outputs=[ + io.Image.Output() + ] + ) + + @classmethod + def execute(cls, images: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(batch_images(list(images.values()))) + +class BatchMasksNode(io.ComfyNode): + @classmethod + def define_schema(cls): + autogrow_template = io.Autogrow.TemplatePrefix(io.Mask.Input("mask"), prefix="mask", min=2, max=50) + return io.Schema( + node_id="BatchMasksNode", + display_name="Batch Masks", + category="mask", + inputs=[ + io.Autogrow.Input("masks", template=autogrow_template) + ], + outputs=[ + io.Mask.Output() + ] + ) + + @classmethod + def execute(cls, masks: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(batch_masks(list(masks.values()))) + +class BatchLatentsNode(io.ComfyNode): + @classmethod + def define_schema(cls): + autogrow_template = io.Autogrow.TemplatePrefix(io.Latent.Input("latent"), prefix="latent", min=2, max=50) + return io.Schema( + node_id="BatchLatentsNode", + display_name="Batch Latents", + category="latent", + inputs=[ + io.Autogrow.Input("latents", template=autogrow_template) + ], + outputs=[ + io.Latent.Output() + ] + ) + + @classmethod + def execute(cls, latents: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(batch_latents(list(latents.values()))) + class BatchImagesMasksLatentsNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -542,6 +602,9 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: Sharpen, ImageScaleToTotalPixels, ResizeImageMaskNode, + BatchImagesNode, + BatchMasksNode, + BatchLatentsNode, # BatchImagesMasksLatentsNode, ] diff --git a/nodes.py b/nodes.py index 7d83ecb21db8..d9e4ebd9179b 100644 --- a/nodes.py +++ b/nodes.py @@ -1863,6 +1863,7 @@ def INPUT_TYPES(s): FUNCTION = "batch" CATEGORY = "image" + DEPRECATED = True def batch(self, image1, image2): if image1.shape[-1] != image2.shape[-1]: