Add loop accumulator node

This commit is contained in:
2026-06-24 22:53:50 +02:00
parent 63aad302f4
commit 04c4c0d37c
2 changed files with 253 additions and 0 deletions
+17
View File
@@ -15,6 +15,7 @@ The node is registered as:
- `prompt_builder / SxCP For Loop Start` - `prompt_builder / SxCP For Loop Start`
- `prompt_builder / SxCP For Loop End` - `prompt_builder / SxCP For Loop End`
- `prompt_builder / SxCP Loop Append` - `prompt_builder / SxCP Loop Append`
- `prompt_builder / SxCP Accumulator`
- `prompt_builder / SxCP Category Preset` - `prompt_builder / SxCP Category Preset`
- `prompt_builder / SxCP Cast Control` - `prompt_builder / SxCP Cast Control`
- `prompt_builder / SxCP Generation Profile` - `prompt_builder / SxCP Generation Profile`
@@ -118,6 +119,22 @@ want to resume a loop without changing index-derived seeds or row numbers.
you want to update each iteration. They are separate from the collector and grow you want to update each iteration. They are separate from the collector and grow
dynamically in the UI as you connect them. dynamically in the UI as you connect them.
`SxCP Accumulator` stores outputs across executions under a `store_key` or the
node id. Put it after an image-producing step inside or after a loop, connect the
generated `image`, and connect `For Loop Start.index` to `entry_id` when you want
reruns to replace the same row instead of appending duplicates. Its outputs are:
- `collection`: all stored values, or images when no explicit `value` is wired.
- `image_batch`: all stored images as one ComfyUI image batch when they share
the same height and width. Set `image_batch_mode=resize_to_first` if you want
mixed sizes resized into one batch.
- `image_list`: a ComfyUI list output containing each stored image separately.
- `image_batch_1..4`: same-size grouped batches for mixed-format runs, so a
square group and a portrait group can be saved or processed separately.
ComfyUI image batches require matching dimensions. For mixed image formats, use
`image_list` or the grouped `image_batch_1..4` outputs instead of `image_batch`.
## Character Profiles ## Character Profiles
`SxCP Woman Slot` and `SxCP Man Slot` are the scalable per-participant control `SxCP Woman Slot` and `SxCP Man Slot` are the scalable per-participant control
+236
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import random
from typing import Any from typing import Any
try: try:
@@ -21,6 +22,11 @@ except Exception:
MAX_LOOP_VALUES = 20 MAX_LOOP_VALUES = 20
MAX_CARRY_VALUES = MAX_LOOP_VALUES - 2 MAX_CARRY_VALUES = MAX_LOOP_VALUES - 2
COLLECTION_MODES = ["auto_batch", "list", "image_batch", "latent_batch", "string_lines"] COLLECTION_MODES = ["auto_batch", "list", "image_batch", "latent_batch", "string_lines"]
ACCUMULATOR_ACTIONS = ["replace_by_entry_id", "append", "clear_then_append", "clear", "read"]
ACCUMULATOR_IMAGE_BATCH_MODES = ["same_size_only", "resize_to_first"]
ACCUMULATOR_IMAGE_GROUPS = 4
_ACCUMULATOR_STORES: dict[str, list[dict[str, Any]]] = {}
class AnyType(str): class AnyType(str):
@@ -63,6 +69,91 @@ def _latent_cat(first: Any, second: Any) -> Any | None:
return merged return merged
def _torch_cat_many(values: list[Any]) -> Any | None:
if not values:
return None
result = values[0]
for value in values[1:]:
result = _torch_cat(result, value)
if result is None:
return None
return result
def _is_image_tensor(value: Any) -> bool:
try:
import torch
except Exception:
return False
return torch.is_tensor(value) and len(value.shape) == 4
def _image_shape(value: Any) -> tuple[int, ...] | None:
if not _is_image_tensor(value):
return None
return tuple(int(part) for part in value.shape[1:])
def _split_image_value(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, (list, tuple)):
images: list[Any] = []
for item in value:
images.extend(_split_image_value(item))
return images
if not _is_image_tensor(value):
return []
if int(value.shape[0]) <= 1:
return [value]
return [value[index : index + 1] for index in range(int(value.shape[0]))]
def _resize_image_to_shape(image: Any, shape: tuple[int, ...]) -> Any | None:
if not _is_image_tensor(image):
return None
try:
import comfy.utils
except Exception:
return None
height, width = int(shape[0]), int(shape[1])
return comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "lanczos", "center").movedim(1, -1)
def _image_batch_from_images(images: list[Any], mode: str = "same_size_only") -> Any | None:
if not images:
return None
mode = mode if mode in ACCUMULATOR_IMAGE_BATCH_MODES else "same_size_only"
first_shape = _image_shape(images[0])
if first_shape is None:
return None
normalized = []
for image in images:
if _image_shape(image) != first_shape:
if mode != "resize_to_first":
return None
image = _resize_image_to_shape(image, first_shape)
if image is None:
return None
normalized.append(image)
return _torch_cat_many(normalized)
def _group_image_batches(images: list[Any]) -> list[Any]:
grouped: dict[tuple[int, ...], list[Any]] = {}
order: list[tuple[int, ...]] = []
for image in images:
shape = _image_shape(image)
if shape is None:
continue
if shape not in grouped:
grouped[shape] = []
order.append(shape)
grouped[shape].append(image)
batches = [_torch_cat_many(grouped[shape]) for shape in order]
return [batch for batch in batches if batch is not None]
def _as_list(collection: Any) -> list[Any]: def _as_list(collection: Any) -> list[Any]:
if collection is None: if collection is None:
return [] return []
@@ -321,6 +412,149 @@ class SxCPLoopAppend:
return (append_collected_value(collection, value, mode=mode, skip_none=skip_none),) return (append_collected_value(collection, value, mode=mode, skip_none=skip_none),)
class SxCPAccumulator:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"store_key": ("STRING", {"default": "", "multiline": False}),
"action": (ACCUMULATOR_ACTIONS, {"default": "replace_by_entry_id"}),
"max_items": ("INT", {"default": 32, "min": 1, "max": 10000, "step": 1}),
"image_batch_mode": (ACCUMULATOR_IMAGE_BATCH_MODES, {"default": "same_size_only"}),
"skip_empty": ("BOOLEAN", {"default": True}),
},
"optional": {
"image": ("IMAGE",),
"value": (ANY_TYPE,),
"entry_id": (ANY_TYPE,),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = tuple([ANY_TYPE, "IMAGE", "IMAGE"] + ["IMAGE"] * ACCUMULATOR_IMAGE_GROUPS + ["INT", "STRING"])
RETURN_NAMES = tuple(
["collection", "image_batch", "image_list"]
+ [f"image_batch_{index}" for index in range(1, ACCUMULATOR_IMAGE_GROUPS + 1)]
+ ["count", "status"]
)
OUTPUT_IS_LIST = tuple([False, False, True] + [False] * ACCUMULATOR_IMAGE_GROUPS + [False, False])
FUNCTION = "accumulate"
CATEGORY = "prompt_builder/loop"
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return random.random()
def _store_key(self, store_key: str, unique_id: Any) -> str:
key = str(store_key or "").strip()
return key or f"node:{unique_id}"
def _entry_id(self, entry_id: Any, image_index: int, image_count: int) -> str:
if entry_id is None:
return ""
text = str(entry_id).strip()
if not text:
return ""
if image_count <= 1:
return text
return f"{text}:{image_index + 1}"
def _value_for_image(self, value: Any, image_index: int, image_count: int) -> Any:
if image_count <= 1:
return value
if isinstance(value, (list, tuple)) and len(value) == image_count:
return value[image_index]
return value
def _entry_records(self, image: Any, value: Any, entry_id: Any, skip_empty: bool) -> list[dict[str, Any]]:
images = _split_image_value(image)
if not images:
if value is None and skip_empty:
return []
return [{"id": self._entry_id(entry_id, 0, 1), "image": None, "value": value}]
image_count = len(images)
return [
{
"id": self._entry_id(entry_id, index, image_count),
"image": image_item,
"value": self._value_for_image(value, index, image_count),
}
for index, image_item in enumerate(images)
]
def _append_or_replace(self, store: list[dict[str, Any]], entries: list[dict[str, Any]], action: str) -> None:
replace = action == "replace_by_entry_id"
for entry in entries:
entry_id = entry.get("id") or ""
if replace and entry_id:
for index, existing in enumerate(store):
if existing.get("id") == entry_id:
store[index] = entry
break
else:
store.append(entry)
else:
store.append(entry)
def _collection(self, store: list[dict[str, Any]]) -> list[Any]:
collection = []
for entry in store:
value = entry.get("value")
collection.append(value if value is not None else entry.get("image"))
return collection
def _status(self, key: str, store: list[dict[str, Any]], image_batch: Any, image_batches: list[Any]) -> str:
images = [entry.get("image") for entry in store if entry.get("image") is not None]
shapes = []
for image in images:
shape = _image_shape(image)
if shape is not None and shape not in shapes:
shapes.append(shape)
shape_text = ", ".join(f"{shape[1]}x{shape[0]}" for shape in shapes) or "no images"
batch_state = "all images batched" if image_batch is not None else "mixed sizes or no image batch"
return (
f"key={key}; entries={len(store)}; image_entries={len(images)}; "
f"formats={shape_text}; grouped_batches={len(image_batches)}; {batch_state}"
)
def accumulate(
self,
store_key,
action,
max_items,
image_batch_mode,
skip_empty,
image=None,
value=None,
entry_id=None,
unique_id=None,
):
key = self._store_key(store_key, unique_id)
action = action if action in ACCUMULATOR_ACTIONS else "replace_by_entry_id"
store = _ACCUMULATOR_STORES.setdefault(key, [])
if action in ("clear", "clear_then_append"):
store.clear()
if action not in ("clear", "read"):
entries = self._entry_records(image, value, entry_id, bool(skip_empty))
self._append_or_replace(store, entries, action)
max_items = max(1, int(max_items))
if len(store) > max_items:
del store[: len(store) - max_items]
images = [entry["image"] for entry in store if entry.get("image") is not None]
image_batch = _image_batch_from_images(images, image_batch_mode)
image_batches = _group_image_batches(images)
grouped_outputs = image_batches[:ACCUMULATOR_IMAGE_GROUPS]
grouped_outputs += [None] * (ACCUMULATOR_IMAGE_GROUPS - len(grouped_outputs))
status = self._status(key, store, image_batch, image_batches)
return tuple([self._collection(store), image_batch, images] + grouped_outputs + [len(store), status])
class SxCPForLoopEnd: class SxCPForLoopEnd:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -444,6 +678,7 @@ LOOP_NODE_CLASS_MAPPINGS = {
"SxCPForLoopStart": SxCPForLoopStart, "SxCPForLoopStart": SxCPForLoopStart,
"SxCPForLoopEnd": SxCPForLoopEnd, "SxCPForLoopEnd": SxCPForLoopEnd,
"SxCPLoopAppend": SxCPLoopAppend, "SxCPLoopAppend": SxCPLoopAppend,
"SxCPAccumulator": SxCPAccumulator,
"SxCPLoopIntAdd": SxCPLoopIntAdd, "SxCPLoopIntAdd": SxCPLoopIntAdd,
"SxCPLoopLessThan": SxCPLoopLessThan, "SxCPLoopLessThan": SxCPLoopLessThan,
"SxCPLoopLessThanOrEqual": SxCPLoopLessThanOrEqual, "SxCPLoopLessThanOrEqual": SxCPLoopLessThanOrEqual,
@@ -455,6 +690,7 @@ LOOP_NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPForLoopStart": "SxCP For Loop Start", "SxCPForLoopStart": "SxCP For Loop Start",
"SxCPForLoopEnd": "SxCP For Loop End", "SxCPForLoopEnd": "SxCP For Loop End",
"SxCPLoopAppend": "SxCP Loop Append", "SxCPLoopAppend": "SxCP Loop Append",
"SxCPAccumulator": "SxCP Accumulator",
"SxCPLoopIntAdd": "SxCP Loop Int Add", "SxCPLoopIntAdd": "SxCP Loop Int Add",
"SxCPLoopLessThan": "SxCP Loop Less Than", "SxCPLoopLessThan": "SxCP Loop Less Than",
"SxCPLoopLessThanOrEqual": "SxCP Loop Less Than Or Equal", "SxCPLoopLessThanOrEqual": "SxCP Loop Less Than Or Equal",