Author SHA1 Message Date
Alistair Buxton bf6d101a15 WIP rewrite branch for new ComfyUI features. 2026-05-16 23:47:16 +01:00
11 changed files with 557 additions and 259 deletions
+1
View File
@@ -1 +1,2 @@
__pycache__
.idea
+12 -2
View File
@@ -1,7 +1,17 @@
from .registry import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, set_pack_options
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
from .registry import set_pack_options, get_nodes
set_pack_options('jobiter', 'Job Iterator')
from . import sequence, job, debug
from . import nodes
class JobIterator(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return get_nodes()
async def comfy_entrypoint() -> JobIterator: # ComfyUI calls this to load your extension and its nodes.
return JobIterator()
+56 -24
View File
@@ -3,12 +3,31 @@ import os
import textwrap
import code
from .registry import register_node
from .types import Any, Variadic
from . import register_node
@register_node
class Stringify:
"""Convert any input to str/repr."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"x": ("*", ),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("str", "repr")
FUNCTION = "go"
CATEGORY = "ali1234/debug"
def go(self, x):
return (str(x), repr(x))
class RestoreStdStreams(object):
# ComfyUI-Manager patches sys.stdout and sys.stderr
# ComfyUI-Manager patches sys.stdout and sys.stder
# which breaks GNU Readline support and makes the
# REPL annoying to use. This context manager temporarily
# puts back the originals.
@@ -39,26 +58,39 @@ class Quitter:
print(MESSAGE)
@register_node(category='debug', output=True, display_name='Interact')
def Interact(inputs: Variadic(Any)) -> ():
@register_node
class Interact:
"""Opens an interactive REPL whenever the node is evaluated."""
if sys.__stdout__.isatty():
with RestoreStdStreams():
code.interact(
banner=textwrap.dedent(f"""
Interactive debugging started.
inputs = {inputs!r}
{MESSAGE}
"""),
exitmsg="Resuming workflow...",
local={
'inputs': inputs,
'quit': Quitter(),
'exit': Quitter(),
}
)
else:
# Don't block the server if there is no tty.
print("Skipping interactive prompt because there is no tty.")
return ()
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": {x: "*" for x in ('a', 'b', 'c', 'd')},
}
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "interact"
CATEGORY = "ali1234/debug"
OUTPUT_NODE = True
def interact(self, **kwargs):
if sys.__stdout__.isatty():
with RestoreStdStreams():
code.interact(
banner=textwrap.dedent(f"""
Interactive debugging started.
Try `print(a)`.
{MESSAGE}
"""),
exitmsg="Resuming workflow...",
local={
**kwargs,
'quit': Quitter(),
'exit': Quitter()
}
)
else:
# Don't block the server if there is no tty.
print("Skipping interactive prompt because there is no tty.")
return ()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
from . import sequence, job, loop
+10 -13
View File
@@ -1,7 +1,8 @@
import math
from .registry import register_node
from .types import *
from ..registry import register_node
from ..types import *
class NamedSeq:
def nth(self, n):
@@ -10,6 +11,7 @@ class NamedSeq:
def __getitem__(self, n):
return dict(self.nth(n))
class SingleSeq(NamedSeq):
def __init__(self, name, seq):
self._name = name
@@ -57,14 +59,15 @@ class ZipSeq(CombinedSeq):
def MakeJob(sequence: Sequence(), name: String() = "") -> (Job(), ):
return SingleSeq(name, sequence),
combine_modes = {
'zip': ZipSeq,
'product': ProductSeq,
}
@register_node(display_name="Combine Jobs")
def CombineJobs(a: Job(), b: Job(), method: Combo(choices=combine_modes) = 'product') -> (Job(), ):
return method(a, b),
def CombineJobs(jobs: Autogrow(input=Job(), prefix='job', min=1), method: Combo(choices=combine_modes) = 'product') -> (Job(), ):
return method(*jobs),
@register_node(display_name="Enumerate Job")
@@ -72,12 +75,6 @@ def EnumerateJob(job: Job(), name: String() = "") -> (Job(), ):
return ZipSeq(SingleSeq(name, range(len(job))), job),
@register_node(display_name="Get Job Step")
def GetJobStep(job: Job(), n: Int()=0) -> (JobStep(), ):
return (job[n], )
@register_node(display_name="Get Step Attribute")
def GetStepAttribute(step: JobStep(), name: String() = "") -> (Any, ):
return (step[name], )
@register_node(display_name="Get Value From Mapping")
def GetValueFromMapping(mapping: Mapping(), name: String() = "") -> (Any(), ):
return mapping[name],
+216
View File
@@ -0,0 +1,216 @@
"""
Generic sequence looping via ComfyUI graph expansion (expand), supporting:
- multiple independent loops in one workflow
- nested loops
- accumulation driven ONLY by LoopEnd.value (always collects; if value unconnected -> None)
- strict "loop region" definition by forward reachability from the LoopStart
- validation: no edges may "escape" the loop body to outside nodes except from
the LoopEnd node itself (enables safe nesting and composition)
UX / wiring:
- Users wire the upstream sequence ONLY into LoopStart.
- Between LoopStart and LoopEnd there are only TWO wires:
1) flow (raw_link pairing, used for region discovery / nesting)
2) data (loop-carried state dict: index, accum, sequence)
Important implementation detail:
- The expanded clone must NOT rely on preserving external links as raw tuples.
Therefore LoopStart stores the upstream sequence in `data["seq"]`, and LoopEnd
reads it from there. This guarantees the recursive LoopEnd sees the real
sequence via an internal connection (Start.data -> End.data) inside the loop.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple, TypedDict
from comfy_api.latest import io
from comfy_execution.graph_utils import GraphBuilder, is_link
from ..types import * # SequenceType, FlowType, etc.
from ..registry import register_node_class, make_unique_name, make_category
def _build_children_adjacency(prompt: Dict[str, Any]) -> Dict[str, List[str]]:
"""Build parent->children adjacency from prompt's input links."""
children: Dict[str, List[str]] = {}
for child_id, node in prompt.items():
for _, v in node.get("inputs", {}).items():
if is_link(v):
parent_id = str(v[0])
children.setdefault(parent_id, []).append(str(child_id))
return children
def _reachable_region(children: Dict[str, List[str]], start_id: str, end_id: str) -> Set[str]:
"""Forward reachability from start_id, stopping recursion at end_id."""
inside: Set[str] = set()
stack: List[str] = [str(start_id)]
while stack:
nid = stack.pop()
if nid in inside:
continue
inside.add(nid)
if nid == str(end_id):
continue
for c in children.get(nid, []):
stack.append(c)
return inside
def _find_escape_edges(prompt: Dict[str, Any], inside: Set[str], end_id: str) -> List[Tuple[str, str, str]]:
"""
Illegal edges are any inside->outside link, excluding those originating from end_id.
Each entry: (parent_id, child_id, child_input_name)
"""
escapes: List[Tuple[str, str, str]] = []
end_id = str(end_id)
for child_id, node in prompt.items():
child_id = str(child_id)
for input_name, v in node.get("inputs", {}).items():
if not is_link(v):
continue
parent_id = str(v[0])
if parent_id in inside and child_id not in inside and parent_id != end_id:
escapes.append((parent_id, child_id, str(input_name)))
return escapes
def _format_escape_error(escapes: List[Tuple[str, str, str]], start_id: str, end_id: str) -> str:
lines = [
"Loop body has illegal connections escaping to nodes outside the loop region.",
f"LoopStart id: {start_id}",
f"LoopEnd id: {end_id}",
"",
"Illegal edges (producer -> consumer[input]):",
]
for parent_id, child_id, input_name in escapes:
lines.append(f" - {parent_id} -> {child_id}[{input_name}]")
lines += [
"",
"Fix: route values out of the loop via the LoopEnd node's outputs, or move the consumer node inside the loop.",
"Note: outside->inside edges are allowed (loop-invariant dependencies).",
]
return "\n".join(lines)
@register_node_class
class LoopStart(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id=make_unique_name(cls.__name__),
display_name="Loop Start",
category=make_category("Loop"),
inputs=[
io.MultiType.Input("sequence", types=[SequenceType, JobType]),
SequenceType.Input("accum", optional=True),
io.Int.Input("first_index", default=0),
],
outputs=[
FlowType.Output("flow"),
SequenceType.Output("accum"),
io.Int.Output("index"),
io.AnyType.Output("item"),
],
hidden=[io.Hidden.unique_id],
)
@classmethod
def execute(cls, sequence, accum=None, first_index=0):
if accum is None:
accum = []
flow = {'sequence': sequence, 'index': first_index, 'accum': accum, 'start_id': cls.hidden.unique_id}
return io.NodeOutput(flow, accum, first_index, sequence[first_index])
@register_node_class
class LoopEnd(io.ComfyNode):
DISPLAY_NAME = "Loop End"
CATEGORY = None
@classmethod
def define_schema(cls):
return io.Schema(
node_id=make_unique_name(cls.__name__),
display_name=cls.DISPLAY_NAME,
category=make_category("Loop"),
enable_expand=True,
inputs=[
FlowType.Input("flow"),
io.AnyType.Input("result", optional=True),
io.Combo.Input('mode', options=("append", "replace"), default="append")
],
outputs=[
SequenceType.Output("result"),
],
hidden=[io.Hidden.dynprompt, io.Hidden.unique_id],
)
@classmethod
def execute(cls, flow, mode, result=None):
flow = dict(flow)
if mode == "append":
flow['accum'] = flow['accum'] + [result]
elif mode == "replace":
flow['accum'] = result
flow['index'] = flow['index'] + 1
# Terminate: stop expanding.
if flow['index'] >= len(flow['sequence']):
return io.NodeOutput(flow['accum'])
# Pairing: find connected start node id via flow
start_id = flow['start_id']
end_id = flow.setdefault('end_id', cls.hidden.unique_id)
# Region discovery/validation on the ORIGINAL prompt graph
prompt = flow.setdefault('prompt', cls.hidden.dynprompt.get_original_prompt())
children = _build_children_adjacency(prompt)
inside = _reachable_region(children, start_id=start_id, end_id=end_id)
if end_id not in inside:
raise RuntimeError(
"LoopEnd is not reachable from LoopStart. "
"Ensure your loop body connects from LoopStart outputs to LoopEnd inputs."
)
escapes = _find_escape_edges(prompt, inside=inside, end_id=end_id)
if escapes:
raise ValueError(_format_escape_error(escapes, start_id=start_id, end_id=end_id))
graph = GraphBuilder()
for node_id in inside:
original_node = cls.hidden.dynprompt.get_node(node_id)
class_type = original_node["class_type"]
n = graph.node(class_type, node_id)
n.set_override_display_id(node_id)
# Wire inputs (internal links rewired to clones; external links/values kept as-is)
for node_id in inside:
original_node = cls.hidden.dynprompt.get_node(node_id)
node = graph.lookup_node(node_id)
for k, v in original_node.get("inputs", {}).items():
if node_id != start_id:
if is_link(v) and v[0] in inside:
node.set_input(k, graph.lookup_node(v[0]).out(v[1]))
else:
node.set_input(k, v)
new_start = graph.lookup_node(start_id)
new_start.set_input("sequence", flow['sequence'])
new_start.set_input("first_index", flow['index'])
new_start.set_input("accum", flow['accum'])
new_end = graph.lookup_node(end_id)
new_end.set_input("flow", flow)
return {
"result": (new_end.out(0), ), # results, data
"expand": graph.finalize(),
}
+89
View File
@@ -0,0 +1,89 @@
import ast
import itertools
import torch
from ..registry import register_node
from ..types import *
# Sequence creation.
@register_node(category="Sequence")
def Range(start: Int() = 0, stop: Int() = 10, step: Int() = 1) -> (Sequence(), ):
return range(start, stop, step),
@register_node(category="Sequence")
def Literal(literal: String(multiline = True) = "") -> (Any(), ):
return ast.literal_eval(literal),
@register_node(category="Sequence")
def Combinatorics(sequence: Sequence(), min: Int() = 3, max: Int() = 4, replacement: Bool() = False, permutations: Bool() = False) -> (Sequence(), ):
"""Sequence combinatorics."""
comb = itertools.combinations_with_replacement if replacement else itertools.combinations
combs = itertools.chain(*(comb(sequence, n) for n in range(min, max+1)))
if permutations:
combs = itertools.chain(*(itertools.permutations(x) for x in combs))
return list(combs),
reorder_modes = {'reverse': reversed, 'sort': sorted}
@register_node(category="Sequence")
def Reorder(sequence: Sequence(), mode: Combo(choices=reorder_modes) = 'reverse') -> (Sequence(), ):
return list(mode(sequence)),
format_modes = {
'mapping': lambda x, y: x.format(**y),
'iterable': lambda x, y: x.format(*y),
'single': lambda x, y: x.format(y)
}
@register_node(category="Text")
def Format(vars: Any(), string: String(multiline=True) = "", mode: Combo(choices=format_modes) = 'mapping') -> (String(), ):
"""Performs string replacement using the standard Python format() method."""
return mode(string, vars),
@register_node(category="Text")
def Join(sequence: Sequence(), sep: String() = ", ") -> (String(), ):
"""Joins a sequence of strings into a single string."""
return sep.join(str(x) for x in sequence),
@register_node(display_name="Join Tensor Sequence", category="Utils")
def JoinTensorSequence(sequence: Sequence(), dim: Int(min=0, max=10) = 0) -> (Any(), ):
return torch.cat(sequence, dim=dim),
@register_node(display_name="Split Image Batch", category="Utils")
def SplitImageBatch(images: Image(), threshold: Float(min=0.001, max=1, step=0.001, round=0.001) = 0.01) -> (Sequence(), ):
c = torch.argwhere(images.diff(dim=0).pow(2).mean(dim=(1, 2, 3)) > threshold).flatten() + 1
print(c)
last = 0
output = []
for frame in c:
print(frame)
output.append(images[last:frame])
last = frame
output.append(images[last:])
return output,
@register_node(display_name="Mapped Join", category="Text")
def MappedJoin(sequence: Sequence(), sep: String() = ", ") -> (Sequence(), ):
"""Joins a sequence of iterables of strings into a sequence of single strings."""
t = (sep.join(str(x) for x in s) for s in sequence)
return list(t),
@register_node(display_name="Make Sequence")
def MakeSequence(items: AutogrowMatch(prefix='item', min=1)) -> (Sequence(), ):
"""Turns any inputs into a sequence."""
return items,
@register_node(display_name="Get Item From Sequence", category="Sequence")
def GetFromSequence(sequence: MultiType(types=(Sequence(), Job())), index: Int() = 0) -> (Any(), ):
"""Gets the nth item from a sequence. Also works on jobs."""
return sequence[index],
+56 -103
View File
@@ -1,22 +1,13 @@
import inspect
from functools import wraps
from .types import ComfyWidgetType, Combo, Variadic
from comfy_api.latest import io
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
from .types import Autogrow, AutogrowMatch, Combo
PACK_BASE_CATEGORY = None
PACK_UID = None
class NodeTemplate:
_INPUT_TYPES = None
FUNCTION = "exec"
@classmethod
def INPUT_TYPES(cls):
return cls._INPUT_TYPES
PACK_NODES = []
def set_pack_options(uid: str, category: str = None):
@@ -25,103 +16,65 @@ def set_pack_options(uid: str, category: str = None):
PACK_UID = uid
def make_category(category):
cat_list = []
if PACK_BASE_CATEGORY is not None:
cat_list.append(PACK_BASE_CATEGORY)
if category is not None:
cat_list.append(category)
return '/'.join(cat_list)
def make_unique_name(name, version=0):
return f'{PACK_UID}_{version}_{name}'
def get_nodes():
return {k: v.exec.__doc__ for k, v in NODE_CLASS_MAPPINGS.items()}
return PACK_NODES
def register_node_class(cls):
PACK_NODES.append(cls)
return cls
def register_node(category=None, version=0, display_name=None, output=False):
def decorator(f):
node_attrs = {}
node_attrs['OUTPUT_NODE'] = output
node_attrs['_INPUT_TYPES'] = {'required': {}, 'optional': {}}
sig = inspect.signature(f)
node_attrs['RETURN_TYPES'] = tuple(x.type if isinstance(x, ComfyWidgetType) else x for x in sig.return_annotation)
for k, v in sig.parameters.items():
t = v.annotation
opts = {}
req = 'required'
if isinstance(t, Variadic):
# Variadic parameter: emit numbered optional input sockets.
for i in range(t.max):
node_attrs['_INPUT_TYPES']['optional'][f'{k}_{i}'] = (t.type, t.opts())
continue
if isinstance(t, ComfyWidgetType):
opts = t.opts()
t = t.type
if v.default is inspect._empty:
opts['forceInput'] = True
else:
opts['default'] = v.default
else:
if v.default is inspect._empty:
req = 'optional'
else:
opts['default'] = v.default
node_attrs['_INPUT_TYPES'][req][k] = (t, opts)
cat_list = []
if PACK_BASE_CATEGORY is not None:
cat_list.append(PACK_BASE_CATEGORY)
if category is not None:
cat_list.append(category)
if cat_list:
node_attrs['CATEGORY'] = '/'.join(cat_list)
else:
print(f"WARNING: No category specified for {f.__name__} and no base category. It won't be shown in menus.")
@wraps(f)
def exec(**kwargs):
call_kwargs = {}
for param_name, param in sig.parameters.items():
if isinstance(param.annotation, Variadic):
# Collect all {param_name}_N kwargs, sorted by index.
prefix = param_name + '_'
numbered = {}
for k in kwargs:
if k.startswith(prefix) and k[len(prefix):].isdigit():
numbered[int(k[len(prefix):])] = kwargs[k]
values = [numbered[i] for i in sorted(numbered)]
if isinstance(param.annotation.inner, ComfyWidgetType):
values = [param.annotation.inner[v] for v in values]
call_kwargs[param_name] = values
else:
if param_name in kwargs:
v = kwargs[param_name]
if isinstance(param.annotation, ComfyWidgetType):
# Look up Combo value from mapping
v = param.annotation[v]
call_kwargs[param_name] = v
return f(**call_kwargs)
node_attrs['exec'] = staticmethod(exec)
if PACK_UID is None:
raise Exception("PACK_UID is not set. Call set_pack_options in __init__.py to set it.")
unique_name = f'{PACK_UID}_{version}_{f.__name__}'
node_class = type(unique_name, (NodeTemplate,), node_attrs)
NODE_CLASS_MAPPINGS[unique_name] = node_class
if display_name is not None:
NODE_DISPLAY_NAME_MAPPINGS[unique_name] = display_name
else:
NODE_DISPLAY_NAME_MAPPINGS[unique_name] = f.__name__
return f
unique_name = make_unique_name(f.__name__, version)
sig = inspect.signature(f)
node_attrs = {}
final_category = make_category(category)
if not final_category:
print(f"WARNING: No category specified for {f.__name__} and no base category. It won't be shown in menus.")
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id=unique_name,
display_name=display_name or f.__name__,
category=final_category,
inputs=list(v.annotation.Input(k, v.default) for k, v in sig.parameters.items()),
outputs=list(v.Output() for v in sig.return_annotation),
)
node_attrs['define_schema'] = classmethod(define_schema)
def execute(cls, **kwargs) -> io.NodeOutput:
for k, v in sig.parameters.items():
if isinstance(v.annotation, Combo):
kwargs[k] = v.annotation[kwargs[k]]
elif isinstance(v.annotation, Autogrow) or isinstance(v.annotation, AutogrowMatch):
kwargs[k] = list(kwargs[k].values())
return io.NodeOutput(*f(**kwargs))
node_attrs['execute'] = classmethod(execute)
node_class = type(unique_name, (io.ComfyNode,), node_attrs)
PACK_NODES.append(node_class)
return node_class
return decorator
def scrape_module(m, sig_len):
for name in dir(m):
v = getattr(m, name)
if callable(v):
try:
sig = inspect.signature(v)
if len(sig.parameters) == sig_len:
yield v
except ValueError:
pass
-60
View File
@@ -1,60 +0,0 @@
import ast
import itertools
from .registry import register_node
from .types import *
# Sequence creation.
@register_node()
def Range(start: Int() = 0, stop: Int() = 10, step: Int() = 1) -> (Sequence(), ):
return (range(start, stop, step), )
@register_node()
def Literal(literal: String(multiline = True) = "") -> (Any, ):
return (ast.literal_eval(literal), )
# Processing of existing sequences.
@register_node()
def Combinatorics(sequence: Sequence(), min: Int() = 3, max: Int() = 4, replacement: Bool() = False, permutations: Bool() = False) -> (Sequence(), ):
"""Sequence combinatorics."""
comb = itertools.combinations_with_replacement if replacement else itertools.combinations
combs = itertools.chain(*(comb(sequence, n) for n in range(min, max+1)))
if permutations:
combs = itertools.chain(*(itertools.permutations(x) for x in combs))
return (list(combs), )
format_modes = {
'mapping': lambda x, y: x.format(**y),
'iterable': lambda x, y: x.format(*y),
'single': lambda x, y: x.format(y)
}
@register_node()
def Format(vars: Any, string: String(multiline=True) = "", mode: Combo(choices=format_modes) = 'mapping') -> (String(), ):
"""Performs string replacement using the standard Python format() method."""
return (mode(string, vars), )
@register_node()
def Join(sequence: Sequence(), sep: String() = ", ") -> (String(), ):
"""Joins an interable of strings into a single string."""
return (sep.join(str(x) for x in sequence), )
@register_node(display_name="Mapped Join")
def MappedJoin(sequence: Sequence(), sep: String() = ", ") -> (Sequence(), ):
"""Joins a sequence of iterables of strings into a sequence of single strings."""
t = (sep.join(str(x) for x in s) for s in sequence)
return (list(t), )
@register_node(display_name="Make Sequence")
def MakeSequence(inputs: Variadic(Any)) -> (Sequence(), ):
"""Collects variadic inputs into a single Sequence."""
return (inputs, )
+116 -57
View File
@@ -1,112 +1,171 @@
import typing
import typing, inspect
from pydantic import BaseModel
from comfy_api.latest import io
class ComfyWidgetType(BaseModel):
"""Base type for ComfyUI types that have options controlling how they are displayed."""
class ComfyType(BaseModel):
TYPE: typing.ClassVar
display_name: str = None
optional: bool = None
def opts(self):
return self.model_dump(mode='python', exclude_none=True)
@property
def type(self):
return self.TYPE
def Input(self, name, default=inspect._empty):
if default is inspect._empty:
return self.TYPE.Input(id=name, **self.opts())
else:
return self.TYPE.Input(id=name, default=default, **self.opts())
def __getitem__(self, item):
return item
def Output(self):
return self.TYPE.Output()
class ComfyWidgetType(ComfyType):
"""Base type for ComfyUI types that have options controlling how they are displayed."""
socketless: bool = None
force_input: bool = None
class Int(ComfyWidgetType):
TYPE = 'INT'
TYPE = io.Int
min: int = None
max: int = None
step: int = None
display: typing.Literal["number", "slider"] = None
display_mode: io.NumberDisplay = None
class Float(ComfyWidgetType):
TYPE = 'FLOAT'
TYPE = io.Float
min: float = None
max: float = None
step: float = None
display: typing.Literal["number", "slider"] = None
round: float = None
display_mode: io.NumberDisplay = None
class String(ComfyWidgetType):
TYPE = 'STRING'
multiline: bool = None
TYPE = io.String
multiline: bool = False
class Bool(ComfyWidgetType):
TYPE = 'BOOLEAN'
TYPE = io.Boolean
label_on: str = None
label_off: str = None
class Color(ComfyWidgetType):
"""Widget only available if you have MTB node pack"""
TYPE = 'COLOR'
class Image(ComfyWidgetType):
TYPE = io.Image
class Video(ComfyWidgetType):
TYPE = io.Video
class Combo(ComfyWidgetType):
TYPE = 'COMBO'
TYPE = io.Combo
choices: typing.Mapping[str, typing.Any]
def opts(self):
return self.model_dump(mode='python', exclude_none=True, exclude={'choices'})
return {
'options': list(self.choices.keys()),
**self.model_dump(mode='python', exclude_none=True, exclude={'choices'})
}
@property
def type(self):
return list(self.choices.keys())
def Input(self, name, default=inspect._empty):
if default is inspect._empty:
return io.MultiType.Input(id=self.TYPE.Input(id=name, **self.opts()), types=(self.TYPE, io.String))
else:
return io.MultiType.Input(id=self.TYPE.Input(id=name, default=default, **self.opts()), types=(self.TYPE, io.String))
def Output(self):
raise NotImplementedError
def __getitem__(self, item):
return self.choices[item]
# custom ones
class Sequence(ComfyWidgetType):
TYPE = 'SEQUENCE'
class Job(ComfyWidgetType):
TYPE = 'JOB'
class JobStep(ComfyWidgetType):
TYPE = 'JOBSTEP'
# Workaround ComfyUI #257
Any = type('AnyType', (str, ), {'__ne__': lambda self, value: False})("*")
class Any(ComfyType):
TYPE = io.AnyType
class Variadic:
"""Annotation wrapper for variadic (multi-socket) inputs in the node decorator.
SequenceType = io.Custom('SEQUENCE')
MappingType = io.Custom('MAPPING')
JobType = io.Custom('JOB')
FlowType = io.Custom('JOB_FLOW')
Use as a parameter annotation to declare that a node accepts up to *max*
numbered optional input sockets all sharing the same type::
@register_node()
def MyNode(items: Variadic(Int())) -> (Sequence(),):
return (list(items),)
class Sequence(ComfyType):
TYPE = SequenceType
The decorated function receives *items* as a plain Python list containing
the values of whichever numbered sockets are connected.
"""
def __init__(self, inner, max=8):
self.inner = inner
self.max = max
class Mapping(ComfyType):
TYPE = MappingType
@property
def type(self):
if isinstance(self.inner, ComfyWidgetType):
return self.inner.type
return self.inner
class Job(ComfyType):
TYPE = JobType
class Autogrow(ComfyType):
TYPE = io.Autogrow
input: ComfyType = Any()
prefix: str = None
min: int = None
max: int = None
def opts(self):
if isinstance(self.inner, ComfyWidgetType):
return self.inner.opts()
return {}
return self.model_dump(mode='python', exclude_none=True, exclude={'input', 'prefix', 'min', 'max', 'match'})
def autoopts(self):
return self.model_dump(mode='python', exclude_none=True, include={'prefix', 'min', 'max'})
def Input(self, name, default):
template = io.Autogrow.TemplatePrefix(self.input.Input(self.prefix), **self.autoopts())
return self.TYPE.Input(name, template=template, **self.opts())
def Output(self):
raise NotImplementedError
__all__ = ['Int', 'Float', 'String', 'Bool', 'Color', 'Combo', 'Any', 'Sequence', 'Job', 'JobStep', 'Variadic']
class AutogrowMatch(ComfyType):
TYPE = io.Autogrow
prefix: str = None
types: tuple = Any()
min: int = None
max: int = None
def opts(self):
return self.model_dump(mode='python', exclude_none=True, exclude={'prefix', 'min', 'max', 'types'})
def autoopts(self):
return self.model_dump(mode='python', exclude_none=True, include={'prefix', 'min', 'max'})
def Input(self, name, default):
match_template = template_matchtype = io.MatchType.Template("type")
template = io.Autogrow.TemplatePrefix(io.MatchType.Input(self.prefix, template=match_template), **self.autoopts())
return self.TYPE.Input(name, template=template, **self.opts())
def Output(self):
raise NotImplementedError
class List(ComfyType):
type: ComfyType = None
def Output(self):
return self.type.TYPE.Output(is_output_list=True)
class MultiType(ComfyType):
TYPE = io.MultiType
types: tuple = None
def opts(self):
return self.model_dump(mode='python', exclude_none=True, exclude={'types'})
def Input(self, name, default):
return self.TYPE.Input(name, types=tuple(t.TYPE for t in self.types), **self.opts())