From fedfd2a99878877d05e09296885986a879e2b2b1 Mon Sep 17 00:00:00 2001 From: t22m003 Date: Sat, 6 Dec 2025 22:46:32 +0900 Subject: [PATCH] Add Block Loop (Recursive) nodes --- README.md | 11 + __init__.py | 5 + examples/loop_generation_workflow.json | 713 +++++++++++++++++++++++++ recursive_loop.py | 114 ++++ 4 files changed, 843 insertions(+) create mode 100644 examples/loop_generation_workflow.json create mode 100644 recursive_loop.py diff --git a/README.md b/README.md index 8b55f8e..e064564 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,17 @@ This allows you to repeat execution steps by outputting a list of integers, whic - **For Loop (Count)**: Simple loop that runs `N` times. outputs `0` to `N-1`. - **For Loop (Range)**: Loop based on python `range(start, stop, step)`. +### Block Looping (Recursive) + +- **Block Loop Start**: Marks the beginning of a loop. + - `first_value`: Value for the first iteration. + - `total_loops`: How many times to loop. +- **Block Loop End**: Marks the end of a loop. + - `flow_control`: Connect the last node of your loop block here. + - `next_value`: The value to pass to the next iteration (connects to `recycle_value` of Start internally). + - `loop_id`: Connect to the `loop_id` output of `Block Loop Start`. + - Note: Connect `total_loops` and `current_loop` from `Block Loop Start` to `Block Loop End`. + ## Installation Navigate to your `ComfyUI/custom_nodes/` directory and clone this repository: diff --git a/__init__.py b/__init__.py index 1e0ea57..d80e195 100644 --- a/__init__.py +++ b/__init__.py @@ -1,12 +1,17 @@ from .loop_node import SimpleForLoop, SimpleForLoopRange +from .recursive_loop import BlockLoopStart, BlockLoopEnd NODE_CLASS_MAPPINGS = { "SimpleForLoop": SimpleForLoop, "SimpleForLoopRange": SimpleForLoopRange, + "BlockLoopStart": BlockLoopStart, + "BlockLoopEnd": BlockLoopEnd, } NODE_DISPLAY_NAME_MAPPINGS = { "SimpleForLoop": "For Loop (Count)", "SimpleForLoopRange": "For Loop (Range)", + "BlockLoopStart": "Block Loop Start", + "BlockLoopEnd": "Block Loop End", } diff --git a/examples/loop_generation_workflow.json b/examples/loop_generation_workflow.json new file mode 100644 index 0000000..7e9e9fb --- /dev/null +++ b/examples/loop_generation_workflow.json @@ -0,0 +1,713 @@ +{ + "id": "bcd98e17-ef91-4fef-88e3-4b80d71c77c8", + "revision": 0, + "last_node_id": 11, + "last_link_id": 15, + "nodes": [ + { + "id": 9, + "type": "SaveImage", + "pos": [ + 1451, + 189 + ], + "size": [ + 252.63381958007812, + 58 + ], + "flags": { + "collapsed": true + }, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "\u753b\u50cf", + "name": "images", + "type": "IMAGE", + "link": 9 + }, + { + "localized_name": "\u30d5\u30a1\u30a4\u30eb\u540d_\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68" + }, + "widgets_values": [ + "ComfyUI" + ] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [ + 1209, + 188 + ], + "size": [ + 210, + 46 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "\u30b5\u30f3\u30d7\u30eb", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "\u753b\u50cf", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 9, + 10 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + -120.81473541259766, + 358.7232666015625 + ], + "size": [ + 315, + 98 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "ckpt\u540d", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "\u30e2\u30c7\u30eb", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 11 + ] + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [ + 13 + ] + }, + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [ + 8 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "animagine-xl-4.0.safetensors" + ] + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + 473, + 609 + ], + "size": [ + 315, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "\u5e45", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "\u9ad8\u3055", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "\u30d0\u30c3\u30c1\u30b5\u30a4\u30ba", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "\u6f5c\u5728", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 2 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 896, + 1152, + 4 + ] + }, + { + "id": 3, + "type": "KSampler", + "pos": [ + 863, + 186 + ], + "size": [ + 315, + 262 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "\u30e2\u30c7\u30eb", + "name": "model", + "type": "MODEL", + "link": 12 + }, + { + "localized_name": "\u30dd\u30b8\u30c6\u30a3\u30d6", + "name": "positive", + "type": "CONDITIONING", + "link": 4 + }, + { + "localized_name": "\u30cd\u30ac\u30c6\u30a3\u30d6", + "name": "negative", + "type": "CONDITIONING", + "link": 6 + }, + { + "localized_name": "\u6f5c\u5728\u753b\u50cf", + "name": "latent_image", + "type": "LATENT", + "link": 2 + }, + { + "localized_name": "\u30b7\u30fc\u30c9", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 16 + }, + { + "localized_name": "\u30b9\u30c6\u30c3\u30d7", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "\u30b5\u30f3\u30d7\u30e9\u30fc\u540d", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "\u30b9\u30b1\u30b8\u30e5\u30fc\u30e9", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "\u30ce\u30a4\u30ba\u9664\u53bb", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "\u6f5c\u5728", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 7 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 835478302784344, + "randomize", + 28, + 15, + "euler", + "normal", + 1 + ] + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 413, + 389 + ], + "size": [ + 425.27801513671875, + 180.6060791015625 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "\u30af\u30ea\u30c3\u30d7", + "name": "clip", + "type": "CLIP", + "link": 15 + }, + { + "localized_name": "\u30c6\u30ad\u30b9\u30c8", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "\u6761\u4ef6\u4ed8\u3051", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 6 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "low quality, bad anatomy, wrong proportions, blurry, missing fingers, extra fingers, extra limbs, deformed hands, mutated hands, cropped body, missing shoes, barefoot, distorted perspective, floating body parts, poor lighting, messy background, oversaturated, underexposed, cartoonish, animal ears, wolf ears, cat ears, fox ears, anthropomorphic traits, feminine traits, cleavage, large chest, golden color, golden hair, golden accessories, golden elements\n" + ] + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 415, + 186 + ], + "size": [ + 422.84503173828125, + 164.31304931640625 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "\u30af\u30ea\u30c3\u30d7", + "name": "clip", + "type": "CLIP", + "link": 14 + }, + { + "localized_name": "\u30c6\u30ad\u30b9\u30c8", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "\u6761\u4ef6\u4ed8\u3051", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 4 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "1 man, black wolf hair with red tips, red eyes, scowling with a scornful expression, slim build, seated on an ornate throne, elbow resting on the armrest, one leg crossed over the other, both shoes clearly visible and worn, black dress shoes, viewed from a slightly elevated frontal angle (moderately looking down at him), natural perspective, cinematic lighting from a large glass window behind, night view outside, perfect anatomy, realistic hand structure, detailed clothing folds, elegant but intimidating aura, masterpiece, high detail, mystyle1004, absurdres, anime style" + ] + }, + { + "id": 10, + "type": "PreviewImage", + "pos": [ + 878.84619140625, + 499.4828369226669 + ], + "size": [ + 569.770751953125, + 532.2428588867188 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "\u753b\u50cf", + "name": "images", + "type": "IMAGE", + "link": 10 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 11, + "type": "LoraLoader", + "pos": [ + -9.72401907897563, + 549.675537109375 + ], + "size": [ + 432.13029959655375, + 126 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "\u30e2\u30c7\u30eb", + "name": "model", + "type": "MODEL", + "link": 11 + }, + { + "localized_name": "\u30af\u30ea\u30c3\u30d7", + "name": "clip", + "type": "CLIP", + "link": 13 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "localized_name": "\u30e2\u30c7\u30eb\u306e\u5f37\u5ea6", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + }, + { + "localized_name": "\u30af\u30ea\u30c3\u30d7\u306e\u5f37\u5ea6", + "name": "strength_clip", + "type": "FLOAT", + "widget": { + "name": "strength_clip" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "\u30e2\u30c7\u30eb", + "name": "MODEL", + "type": "MODEL", + "links": [ + 12 + ] + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 14, + 15 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "LoraLoader" + }, + "widgets_values": [ + "ComfyUI_trained_lora_00003_.safetensors", + 1, + 1 + ] + }, + { + "id": 12, + "type": "SimpleForLoop", + "pos": [ + 400, + 100 + ], + "size": { + "0": 210, + "1": 58 + }, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "index", + "type": "INT", + "links": [ + 16 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "SimpleForLoop" + }, + "widgets_values": [ + 4 + ] + } + ], + "links": [ + [ + 2, + 5, + 0, + 3, + 3, + "LATENT" + ], + [ + 4, + 6, + 0, + 3, + 1, + "CONDITIONING" + ], + [ + 6, + 7, + 0, + 3, + 2, + "CONDITIONING" + ], + [ + 7, + 3, + 0, + 8, + 0, + "LATENT" + ], + [ + 8, + 4, + 2, + 8, + 1, + "VAE" + ], + [ + 9, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 10, + 8, + 0, + 10, + 0, + "IMAGE" + ], + [ + 11, + 4, + 0, + 11, + 0, + "MODEL" + ], + [ + 12, + 11, + 0, + 3, + 0, + "MODEL" + ], + [ + 13, + 4, + 1, + 11, + 1, + "CLIP" + ], + [ + 14, + 11, + 1, + 6, + 0, + "CLIP" + ], + [ + 15, + 11, + 1, + 7, + 0, + "CLIP" + ], + [ + 16, + 12, + 0, + 3, + 4, + "INT" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.5982842210331787, + "offset": [ + 805.7871857209614, + -76.79577922697746 + ] + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/recursive_loop.py b/recursive_loop.py new file mode 100644 index 0000000..bba2ae2 --- /dev/null +++ b/recursive_loop.py @@ -0,0 +1,114 @@ + +import json +import uuid +from server import PromptServer +from aiohttp import web + +class BlockLoopStart: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "first_value": ("INT", {"default": 1}), + "total_loops": ("INT", {"default": 3, "min": 1, "max": 100}), + }, + "optional": { + "current_loop": ("INT", {"default": 0, "hidden": True}), # Injectable + "recycle_value": ("INT", {"default": 0, "hidden": True}), # Injectable from previous loop + }, + "hidden": { + "unique_id": "UNIQUE_ID", + } + } + + RETURN_TYPES = ("INT", "INT", "INT", "STRING") + RETURN_NAMES = ("current_value", "index", "total", "loop_id") + FUNCTION = "execute" + CATEGORY = "Loop" + + def execute(self, first_value, total_loops, current_loop=0, recycle_value=0, unique_id=None): + # On first run (current_loop=0), use first_value. + # On subsequent runs, use recycle_value. + if current_loop == 0: + val = first_value + else: + val = recycle_value + + return (val, current_loop, total_loops, unique_id) + + +class BlockLoopEnd: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "flow_control": ("*",), # Anything to enforce order + "next_value": ("INT",), # Value to pass to next iteration + "total_loops": ("INT", {"forceInput": True}), + "current_loop": ("INT", {"forceInput": True}), + "loop_id": ("STRING", {"forceInput": True}), # Link to Start Node + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO", + } + } + + RETURN_TYPES = ("*",) + RETURN_NAMES = ("flow_output",) + FUNCTION = "execute" + CATEGORY = "Loop" + OUTPUT_NODE = True + + def execute(self, flow_control, next_value, total_loops, current_loop, loop_id, prompt=None, extra_pnginfo=None): + if current_loop + 1 < total_loops: + # Prepare next iteration + new_loop_index = current_loop + 1 + print(f"Looping: {current_loop} -> {new_loop_index} / {total_loops}") + + # We need to find the specific BlockLoopStart node in the prompt and update its inputs. + # loop_id is the UNIQUE_ID of the BlockLoopStart node. + + new_prompt = prompt.copy() + target_node = new_prompt.get(str(loop_id)) + + if target_node: + # Update inputs for the next run + target_node['inputs']['current_loop'] = new_loop_index + target_node['inputs']['recycle_value'] = next_value + + # Re-queue + new_prompt_id = str(uuid.uuid4()) + + async def queue_new_prompt(): + import execution + server = PromptServer.instance + + # Validate + valid = await execution.validate_prompt(new_prompt_id, new_prompt) + + if valid[0]: + outputs_to_execute = valid[2] + # We need to get the next execution number + server.number += 1 + number = server.number + + # Extra data + new_extra_data = extra_pnginfo.copy() if extra_pnginfo else {} + + # Put in queue + server.prompt_queue.put((number, new_prompt_id, new_prompt, new_extra_data, outputs_to_execute, {})) + print(f"Loop queued: {new_prompt_id}") + else: + print(f"Loop validation failed: {valid[1]}") + + # Schedule the task + PromptServer.instance.loop.create_task(queue_new_prompt()) + + return (flow_control,)