On this page
- When Fixed-Arity Inputs Stop Working
- What You Need Before Reading This
- An Airport Conveyor Belt for Parameter Definitions
- Chunk 1: Defining the Custom Type and Base Class Contract
- Chunk 2: The Accumulation Method
- Chunk 3: A Concrete Subclass Is Almost Trivially Simple
- Chunk 4: The Cartesian Combination Engine
- Chunk 5: Linear Zip With Cycling
- What Breaks the Chain and How the Design Survives
When Fixed-Arity Inputs Stop Working
ComfyUI’s type system is what makes the canvas work — you can only wire a LATENT output to a LATENT input. But that same type system creates a problem when you need to aggregate data across a variable number of nodes.
Imagine you want to let users chain five parameter nodes together for an A/B test. You could give the WorkflowIterator five explicit inputs: param_1, param_2, up to param_5. But that forces a maximum. What if someone needs eight parameters? What if they only need two? A hardcoded arity is either too rigid or too wasteful.
The naive alternatives — a list widget, a JSON blob — push complexity onto the user. There’s a better way.
This tutorial covers two interrelated modules: nodes/parameter_input.py and core/combination_engine.py. Together they implement a linked-list accumulator pattern using ComfyUI’s custom type system. Each parameter node consumes the stack from its predecessor and emits a larger stack to its successor. The WorkflowIterator at the end receives the fully accumulated stack and hands it to the combination engine, which turns it into a list of parameter combinations to execute.
What You Need Before Reading This
Prerequisites:
- Python
@classmethodand class inheritance - How ComfyUI’s
INPUT_TYPES,RETURN_TYPES, andFUNCTIONwork at a basic level - What a Python
listofdicts looks like - Basic familiarity with
itertools.product(I’ll explain it, but prior exposure helps)
Environment:
Python >= 3.9
ComfyUI (any recent version)
itertools # Standard library — no install needed
pyproject.toml declares:
name = "comfyui-workflow-iterator"
version = "1.0.0"
dependencies = ["Pillow>=9.0.0"]
The combination engine (combination_engine.py) has zero dependencies beyond itertools. You can run and test it with a plain Python interpreter without ComfyUI running.
An Airport Conveyor Belt for Parameter Definitions
Each parameter node is a check-in desk. It places its bag (parameter definition) on the conveyor belt, which already carries all the bags from previous desks. The belt passes the growing collection forward. The final collection point (WorkflowIterator) receives all the bags at once. No desk knows about any other desk — it just adds its own bag and passes the belt forward.
flowchart LR
A["WIParameterInt\n(steps: 10-30,5)"] -->|"WI_PARAM_STACK\n[{steps def}]"| B["WIParameterFloat\n(cfg: 5-9, 0.5)"]
B -->|"WI_PARAM_STACK\n[{steps}, {cfg}]"| C["WIParameterCombo\n(sampler: euler,dpm)"]
C -->|"WI_PARAM_STACK\n[{steps},{cfg},{sampler}]"| D["WorkflowIterator"]
D --> E{Mode?}
E -->|"matrix"| F["combination_engine.cartesian()\n→ 5×9×2 = 90 combos"]
E -->|"linear"| G["combination_engine.linear_zip()\n→ max(5,9,2) = 9 combos"]
F --> H["Queue 90 prompts"]
G --> I["Queue 9 prompts"]
The custom type WI_PARAM_STACK is the belt. In Python it’s just a list of dicts. ComfyUI treats it as an opaque typed connection — you can only wire a WI_PARAM_STACK output to a WI_PARAM_STACK input, which is exactly what we want.
Chunk 1: Defining the Custom Type and Base Class Contract
Before any node can be written, you need to define the shared contract. The base class WIParameterBase does this.
CATEGORY = "Workflow Iterator/Parameters"
class WIParameterBase:
RETURN_TYPES = ("WI_PARAM_STACK",)
RETURN_NAMES = ("param_stack",)
CATEGORY = CATEGORY
FUNCTION = "define_parameter"
PARAM_TYPE = "STRING" # Override in subclasses
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
IS_CHANGED is a ComfyUI optimization hook. By default, if a node’s inputs haven’t changed since the last run, ComfyUI skips re-executing it and uses the cached output. For deterministic parameters, that’s fine. But for random:5 seeds, you need fresh values every run. Returning time.time() guarantees the value is always “changed,” so the node always re-executes.
Chunk 2: The Accumulation Method
This is the core of the pattern. define_parameter receives whatever stack existed before it and returns a larger one:
def define_parameter(
self,
parameter_name,
values,
target_node_title,
target_widget_name,
enabled,
param_stack_in=None,
unique_id=None,
):
# Shallow copy to avoid mutating the upstream node's cached output
stack = list(param_stack_in) if param_stack_in else []
if enabled:
stack.append(
{
"name": parameter_name,
"type": self.PARAM_TYPE,
"values_raw": values,
"target_node_title": target_node_title,
"target_widget_name": target_widget_name,
"node_id": unique_id,
}
)
return (stack,)
The enabled flag means a disabled node is invisible to the stack — it passes the incoming stack through unchanged. This is the transparent proxy sub-pattern: the node participates in the chain but contributes nothing. Users can disable parameter nodes without breaking the chain.
The list(param_stack_in) shallow copy is not optional. Without it, every node in the chain would mutate the same list object. If ComfyUI caches and reuses an upstream node’s output (which it does for unchanged nodes), one execution would corrupt another’s stack. Always copy mutable input data before appending.
Chunk 3: A Concrete Subclass Is Almost Trivially Simple
Because the base class handles all the logic, subclasses only need to declare their type and their input schema:
class WIParameterInt(WIParameterBase):
PARAM_TYPE = "INT"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parameter_name": ("STRING", {"default": "steps"}),
"values": ("STRING", {"default": "10-30, 5",
"multiline": True}),
"target_node_title": ("STRING", {"default": "KSampler"}),
"target_widget_name":("STRING", {"default": "steps"}),
"enabled": ("BOOLEAN", {"default": True}),
},
"optional": {
"param_stack_in": ("WI_PARAM_STACK",),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
The five type subclasses (WIParameterInt, WIParameterFloat, WIParameterString, WIParameterCombo, WIParameterSeed) differ only in their PARAM_TYPE attribute and their INPUT_TYPES defaults. All execution logic lives in the base class — that’s the Open/Closed principle: open for extension, closed for modification.
Chunk 4: The Cartesian Combination Engine
When the stack reaches WorkflowIterator, it goes to the combination engine. The cartesian function computes the mathematical Cartesian product of all parameter value lists:
import itertools
def cartesian(parsed_params: list[dict]) -> list[list[tuple]]:
active_params = [p for p in parsed_params if p.get("parsed_values")]
if not active_params:
return []
value_lists = [p["parsed_values"] for p in active_params]
names = [p["name"] for p in active_params]
target_infos = [
{
"target_node_title": p["target_node_title"],
"target_widget_name": p["target_widget_name"],
}
for p in active_params
]
combinations = []
for combo_values in itertools.product(*value_lists):
combination = [
(names[i], combo_values[i], target_infos[i])
for i in range(len(active_params))
]
combinations.append(combination)
return combinations
itertools.product is lazy in CPython — it generates one tuple at a time. The repo materializes it into a list immediately so it can be counted and indexed. For 100,000 combinations, this is still only ~10 MB. The real cost is GPU time.
Chunk 5: Linear Zip With Cycling
The Cartesian product grows exponentially. Three parameters with 10 values each = 1,000 renders. Five parameters with 10 values = 100,000 renders. Linear zip pairs them index-by-index instead:
def linear_zip(parsed_params: list[dict]) -> list[list[tuple]]:
active_params = [p for p in parsed_params if p.get("parsed_values")]
if not active_params:
return []
max_len = max(len(p["parsed_values"]) for p in active_params)
combinations = []
for i in range(max_len):
combination = []
for j, param in enumerate(active_params):
values = param["parsed_values"]
# Modulo cycling: short lists wrap around
value = values[i % len(values)]
combination.append((
param["name"],
value,
{
"target_node_title": param["target_node_title"],
"target_widget_name": param["target_widget_name"],
}
))
combinations.append(combination)
return combinations
If steps=[10,20,30] and cfg=[5,7], linear zip produces (10,5), (20,7), (30,5). The cfg list cycles back to 5 for the third iteration. Total renders: 3 instead of 6.
The choice between modes is a product decision. Cartesian is for exhaustive grid searches where you want every combination. Linear is for curated comparisons where you’ve already decided which values go together.
What Breaks the Chain and How the Design Survives
ComfyUI’s graph validation prevents true cycles. But _trace_param_chain in iteration_state.py uses a visited: set as a safety net — it will never revisit a node it’s already processed, breaking any accidental cycle.
If a user connects a WI_PARAM_STACK output to a LATENT input, ComfyUI rejects the connection at the canvas level before any Python runs. The custom type string acts as a compile-time guard.
If every parameter node is disabled, the stack is []. The combination engine returns [] immediately. IterationStateManager detects this and logs “no enabled parameters — running workflow once.” The workflow executes exactly once, as if the iterator nodes weren’t there. No crash, no error — graceful degradation.