diff --git a/Creative Commons Attribution.txt b/Creative Commons Attribution.txt
new file mode 100644
index 0000000..26990fa
--- /dev/null
+++ b/Creative Commons Attribution.txt
@@ -0,0 +1,5 @@
+ Creative Commons (Attribution 3.0 Unported)
+ https://creativecommons.org/licenses/by/3.0/deed.en
+
+Icons in 'png' folder are based on icon set from PAOMedia :
+https://www.iconfinder.com/paomedia/icon-sets
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b669f36
--- /dev/null
+++ b/README.md
@@ -0,0 +1,129 @@
+Custom ComfyUI nodes for interactive image manipulation and flexible file looping. Provides four specialized nodes for cropping, pasting, looping, and saving various file types with preview capabilities.
+
+ComfyUI-Loop is essentially a pair of nodes designed to create a simple file loop within your workflows. The operating principle is straightforward: the Save Any node saves an image by overwriting the file specified in the 'path' field, making it automatically available for the next iteration. This functionality is primarily intended for inpainting workflows.
+
+# 0.2 version - last changes 10/23/2025 :
+Better integration with last ComfyUI version. Better code structure.
+- Now there's only four nodes for two main usages, looping files and visual cutting-pasting:
+ LoopAny -> SaveAny
+ loop any file type : image (png), mask (png), latent (image/audio/whatever), audio (flac), string (or int/float) saved as text file.
+
+ ImageCrop -> ImagePaste
+ ImageCrop now works with last Comfy frontend, lastly tested with ComfyUI v0.3.64, ComfyUI_Frontend v1.27.10
+
+TL;DR : Revisited code from A to Z. Crop your images and masks, loop your files, the fun way.
+
+## ♾️ Image Crop (`ImageCropLoop`)
+**Functionality:** Interactive image cropping with live preview. Supports some keyboard controls (PageUp/Down to resize).
+
+**Inputs:**
+| Parameter | Default | Range | Description |
+|-----------|---------|-------|-------------|
+| `image` | - | Source image to crop |
+| `x` | 0-32768 | X-origin of crop rectangle |
+| `y` | 0-32768 | Y-origin of crop rectangle |
+| `size` | 256-2048 | Crop size (increments of 8) |
+| `color` | ["black","grey","red","green","blue"] | unselected preview color |
+| `show_mask` | boolean | show binary mask preview |
+| `mask` | Optional mask |
+
+**Outputs:**
+- `source`: Original image
+- `cut`: Cropped image
+- `size`: Final crop size used
+- `x`: Final X-position used
+- `y`: Final Y-position used
+- `cut_mask`: Cropped mask
+
+---
+
+## ♾️ Paste Image (`ImagePasteLoop`)
+**Functionality:** Pastes cropped images onto source images with optional masking/blending.
+
+**Inputs:**
+| Parameter | Description |
+|-----------|-------------|
+| `source` | Base image to paste onto |
+| `cut` | Image segment to paste |
+| `x` | X-position for pasting |
+| `y` | Y-position for pasting |
+| `cut_mask` | Optional mask for blending |
+
+**Outputs:**
+- `image`: final image
+
+---
+
+## ♾️ Loop Any (`LoopAny`)
+**Functionality:** Loops various file types (images, masks, latents, audio, text) from ComfyUI's output directory or one of its subfolders
+
+**Inputs:**
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `input` | ANY | - | Input data to process/loop |
+| `loop_file` | BOOL | False | Enable file looping mode |
+| `filename` | STRING | "loop_file" | Base filename (no extension) |
+| `subfolder` | STRING | "" | Output subdirectory |
+| `loop_mask` | BOOL | False | Enable mask looping ( load mask from loop image alpha channel instead of mask input|
+| `mask` | MASK | - | Optional input mask |
+
+**Outputs:**
+- `output`: Processed output data
+- `path`: Full file path
+- `width`: Output width (images)
+- `height`: Output height (images)
+- `mask`: optional mask to send to Save Any node, for saving as alpha channel
+
+---
+
+## ♾️ Save Any (`SaveAny`)
+**Functionality:** Saves various data types to output directory with optional versioned backups and optional preview.
+
+**Inputs:**
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `input` | ANY | - | Data to save |
+| `path` | STRING | "/path/to/file.ext" | Full output path |
+| `save_steps` | BOOL | False | Save timestamped copies |
+| `mask` | MASK | - | Optional mask (for images) |
+
+**SAaing Output Formats:**
+- Images: `.png`
+- Masks: `.png`
+- Latents: `.latent`
+- Audio: `.flac`
+- Text: `.txt`
+---
+
+**Install**
+No additional dependencies are required. Search for 'Loop' in the ComfyUI Custom Nodes Manager or copy the ComfyUI-Loop folder into the Custom Nodes directory — and you're ready to go!
+
+**Usage**
+Have a look at the example workflow (json or .png), for up to date informations on usage and a working example.
+- in Load Image (LOOP) node browse for an image from **output folder** (or feed the field with its name e.g. 'image.png'). **Always** work on a copy of your source file (if you don't want it to be overwritten).
+- Connect the path output from Load Image (LOOP) to the image_path input of Save Image (LOOP).
+- The mask input in Save Image (LOOP) is optional.
+- Enable save steps if you want to keep a copy of the file at each iteration.
+
+**Limitations**
+- No support for lists or batch inputs.
+- Not as user friendly as I want it to be out of the box (some small lacks in code to fix later.)
+
+**Future plans**
+- adding some sort of mask edit bridge in the Loop crop node
+- more file format outputs
+- better code
+
+I plan to revisit the code later but it works well for basic use (don't judge a fish by its ability to climb a tree, I do my best :D).
+If you encounter issues or have suggestions, ask on the repo ! :)
+
+If you enjoy this project and want to boost its development, just leave a star : your support makes a difference! ♥️
+
+MIT License. version 0.2
+https://github.com/Hullabalo/ComfyUI-Loop/
+Thanks to rgthree, chrisgoringe, pythongosssss and many, many many others for their contributions, how-to's, code snippets etc.
+
+## Icon Attribution
+Icons from `/icons` directory Icons are based on icon set from PAOMedia :
+https://www.iconfinder.com/paomedia/icon-sets
+and licensed under [Creative Commons Attribution 3.0 Unported] (https://creativecommons.org/licenses/by/3.0/deed.en)
\ No newline at end of file
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..f48d697
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,9 @@
+from .nodes import *
+from .utils.loop_path_utils import LoopPathUtils
+
+# copy preview icons
+LoopPathUtils.copy_tree("custom_nodes/ComfyUI-CustomPreviewOnNode/icons", "temp")
+
+WEB_DIRECTORY = "js"
+
+__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
diff --git a/example_workflow/loop_workflow_example.json b/example_workflow/loop_workflow_example.json
new file mode 100644
index 0000000..c8320df
--- /dev/null
+++ b/example_workflow/loop_workflow_example.json
@@ -0,0 +1,941 @@
+{
+ "id": "805208d9-8eb0-4efa-bc14-ecef54c3def8",
+ "revision": 0,
+ "last_node_id": 22,
+ "last_link_id": 40,
+ "nodes": [
+ {
+ "id": 13,
+ "type": "ImagePasteLoop",
+ "pos": [
+ 1110,
+ 440
+ ],
+ "size": [
+ 140,
+ 106
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "IMAGE",
+ "link": 36
+ },
+ {
+ "name": "cut",
+ "type": "IMAGE",
+ "link": 12
+ },
+ {
+ "name": "x",
+ "type": "INT",
+ "link": 38
+ },
+ {
+ "name": "y",
+ "type": "INT",
+ "link": 39
+ },
+ {
+ "name": "cut_mask",
+ "shape": 7,
+ "type": "MASK",
+ "link": 40
+ }
+ ],
+ "outputs": [
+ {
+ "name": "image",
+ "type": "IMAGE",
+ "links": [
+ 25
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "ImagePasteLoop"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 20,
+ "type": "SaveAny",
+ "pos": [
+ 1380,
+ 630
+ ],
+ "size": [
+ 210,
+ 150
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "color_on": "#64B5F6",
+ "label": "IMAGE",
+ "name": "input",
+ "type": "*",
+ "link": 25
+ },
+ {
+ "name": "mask",
+ "shape": 7,
+ "type": "MASK",
+ "link": null
+ },
+ {
+ "color_on": "",
+ "label": "STRING",
+ "name": "path",
+ "type": "STRING",
+ "widget": {
+ "name": "path"
+ },
+ "link": 26
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "Node name for S&R": "SaveAny"
+ },
+ "widgets_values": [
+ "/path/to/file.ext",
+ false,
+ true,
+ true
+ ]
+ },
+ {
+ "id": 12,
+ "type": "LoadImage",
+ "pos": [
+ -300,
+ 440
+ ],
+ "size": [
+ 274.14666748046875,
+ 314.0000305175781
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 23
+ ]
+ },
+ {
+ "name": "MASK",
+ "type": "MASK",
+ "links": [
+ 35
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "LoadImage"
+ },
+ "widgets_values": [
+ "clipspace/clipspace-painted-masked-3120558.png [input]",
+ "image"
+ ]
+ },
+ {
+ "id": 22,
+ "type": "ImageCropLoop",
+ "pos": [
+ 340,
+ 120
+ ],
+ "size": [
+ 300,
+ 542
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "image",
+ "type": "IMAGE",
+ "link": 34
+ },
+ {
+ "name": "mask",
+ "shape": 7,
+ "type": "MASK",
+ "link": 35
+ }
+ ],
+ "outputs": [
+ {
+ "name": "source",
+ "type": "IMAGE",
+ "links": [
+ 36
+ ]
+ },
+ {
+ "name": "cut",
+ "type": "IMAGE",
+ "links": [
+ 37
+ ]
+ },
+ {
+ "name": "size",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "x",
+ "type": "INT",
+ "links": [
+ 38
+ ]
+ },
+ {
+ "name": "y",
+ "type": "INT",
+ "links": [
+ 39
+ ]
+ },
+ {
+ "name": "cut_mask",
+ "type": "MASK",
+ "links": [
+ 40
+ ]
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "ImageCropLoop"
+ },
+ "widgets_values": [
+ 508,
+ 128,
+ 512,
+ "black",
+ true,
+ ""
+ ]
+ },
+ {
+ "id": 10,
+ "type": "52369464-f43d-41a1-9659-660d64c4d279",
+ "pos": [
+ 850,
+ 160
+ ],
+ "size": [
+ 210,
+ 96
+ ],
+ "flags": {
+ "collapsed": false
+ },
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "pixels",
+ "type": "IMAGE",
+ "link": 37
+ }
+ ],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 12
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64"
+ },
+ "widgets_values": [
+ "scary alien face, black and white picture"
+ ],
+ "color": "#232",
+ "bgcolor": "#353"
+ },
+ {
+ "id": 19,
+ "type": "LoopAny",
+ "pos": [
+ 30,
+ 650
+ ],
+ "size": [
+ 210,
+ 227
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "color_on": "#64B5F6",
+ "label": "IMAGE",
+ "name": "input",
+ "type": "*",
+ "link": 23
+ },
+ {
+ "name": "mask",
+ "shape": 7,
+ "type": "MASK",
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "IMAGE",
+ "name": "output",
+ "type": "IMAGE",
+ "links": [
+ 34
+ ]
+ },
+ {
+ "name": "path",
+ "type": "STRING",
+ "links": [
+ 26
+ ]
+ },
+ {
+ "name": "width",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "height",
+ "type": "INT",
+ "links": null
+ },
+ {
+ "name": "mask",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "LoopAny"
+ },
+ "widgets_values": [
+ false,
+ "loop_workflow_example",
+ "",
+ false
+ ]
+ }
+ ],
+ "links": [
+ [
+ 12,
+ 10,
+ 0,
+ 13,
+ 1,
+ "IMAGE"
+ ],
+ [
+ 23,
+ 12,
+ 0,
+ 19,
+ 0,
+ "*"
+ ],
+ [
+ 25,
+ 13,
+ 0,
+ 20,
+ 0,
+ "*"
+ ],
+ [
+ 26,
+ 19,
+ 1,
+ 20,
+ 2,
+ "STRING"
+ ],
+ [
+ 34,
+ 19,
+ 0,
+ 22,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 35,
+ 12,
+ 1,
+ 22,
+ 1,
+ "MASK"
+ ],
+ [
+ 36,
+ 22,
+ 0,
+ 13,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 37,
+ 22,
+ 1,
+ 10,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 38,
+ 22,
+ 3,
+ 13,
+ 2,
+ "INT"
+ ],
+ [
+ 39,
+ 22,
+ 4,
+ 13,
+ 3,
+ "INT"
+ ],
+ [
+ 40,
+ 22,
+ 5,
+ 13,
+ 4,
+ "MASK"
+ ]
+ ],
+ "groups": [],
+ "definitions": {
+ "subgraphs": [
+ {
+ "id": "52369464-f43d-41a1-9659-660d64c4d279",
+ "version": 1,
+ "state": {
+ "lastGroupId": 0,
+ "lastNodeId": 10,
+ "lastLinkId": 14,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "SDXL Subgraph",
+ "inputNode": {
+ "id": -10,
+ "bounding": [
+ 30,
+ 406,
+ 120,
+ 80
+ ]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [
+ 1479,
+ 406,
+ 120,
+ 60
+ ]
+ },
+ "inputs": [
+ {
+ "id": "73b9bdfb-64de-4e14-bacf-e751b0ab7512",
+ "name": "pixels",
+ "type": "IMAGE",
+ "linkIds": [
+ 12
+ ],
+ "pos": {
+ "0": 130,
+ "1": 426
+ }
+ },
+ {
+ "id": "556f5d4b-ebcc-43e4-8dc2-dfefcb01e2c1",
+ "name": "text",
+ "type": "STRING",
+ "linkIds": [
+ 14
+ ],
+ "pos": {
+ "0": 130,
+ "1": 446
+ }
+ }
+ ],
+ "outputs": [
+ {
+ "id": "085a3f2b-9189-41c2-b75f-4455fc4052c5",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "linkIds": [
+ 13
+ ],
+ "pos": {
+ "0": 1499,
+ "1": 426
+ }
+ }
+ ],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 7,
+ "type": "CLIPTextEncode",
+ "pos": [
+ 620,
+ 390
+ ],
+ "size": [
+ 425.27801513671875,
+ 180.6060791015625
+ ],
+ "flags": {
+ "collapsed": true
+ },
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "clip",
+ "name": "clip",
+ "type": "CLIP",
+ "link": 5
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "CONDITIONING",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "slot_index": 0,
+ "links": [
+ 6
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "CLIPTextEncode"
+ },
+ "widgets_values": [
+ "text, watermark"
+ ]
+ },
+ {
+ "id": 10,
+ "type": "VAEEncode",
+ "pos": [
+ 510,
+ 510
+ ],
+ "size": [
+ 140,
+ 46
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "pixels",
+ "name": "pixels",
+ "type": "IMAGE",
+ "link": 12
+ },
+ {
+ "localized_name": "vae",
+ "name": "vae",
+ "type": "VAE",
+ "link": 11
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "LATENT",
+ "name": "LATENT",
+ "type": "LATENT",
+ "links": [
+ 10
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "VAEEncode"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 4,
+ "type": "CheckpointLoaderSimple",
+ "pos": [
+ 210,
+ 320
+ ],
+ "size": [
+ 315,
+ 98
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "localized_name": "MODEL",
+ "name": "MODEL",
+ "type": "MODEL",
+ "slot_index": 0,
+ "links": [
+ 1
+ ]
+ },
+ {
+ "localized_name": "CLIP",
+ "name": "CLIP",
+ "type": "CLIP",
+ "slot_index": 1,
+ "links": [
+ 3,
+ 5
+ ]
+ },
+ {
+ "localized_name": "VAE",
+ "name": "VAE",
+ "type": "VAE",
+ "slot_index": 2,
+ "links": [
+ 8,
+ 11
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "CheckpointLoaderSimple"
+ },
+ "widgets_values": [
+ "SDXL/juggernautXL_ragnarokBy.safetensors"
+ ]
+ },
+ {
+ "id": 8,
+ "type": "VAEDecode",
+ "pos": [
+ 1209,
+ 188
+ ],
+ "size": [
+ 210,
+ 46
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "samples",
+ "name": "samples",
+ "type": "LATENT",
+ "link": 7
+ },
+ {
+ "localized_name": "vae",
+ "name": "vae",
+ "type": "VAE",
+ "link": 8
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "IMAGE",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "slot_index": 0,
+ "links": [
+ 13
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "VAEDecode"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 6,
+ "type": "CLIPTextEncode",
+ "pos": [
+ 570,
+ 260
+ ],
+ "size": [
+ 250,
+ 88
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "clip",
+ "name": "clip",
+ "type": "CLIP",
+ "link": 3
+ },
+ {
+ "localized_name": "text",
+ "name": "text",
+ "type": "STRING",
+ "widget": {
+ "name": "text"
+ },
+ "link": 14
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "CONDITIONING",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "slot_index": 0,
+ "links": [
+ 4
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "CLIPTextEncode"
+ },
+ "widgets_values": [
+ "scary alien face, black and white picture"
+ ]
+ },
+ {
+ "id": 3,
+ "type": "KSampler",
+ "pos": [
+ 863,
+ 186
+ ],
+ "size": [
+ 315,
+ 571
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "localized_name": "model",
+ "name": "model",
+ "type": "MODEL",
+ "link": 1
+ },
+ {
+ "localized_name": "positive",
+ "name": "positive",
+ "type": "CONDITIONING",
+ "link": 4
+ },
+ {
+ "localized_name": "negative",
+ "name": "negative",
+ "type": "CONDITIONING",
+ "link": 6
+ },
+ {
+ "localized_name": "latent_image",
+ "name": "latent_image",
+ "type": "LATENT",
+ "link": 10
+ }
+ ],
+ "outputs": [
+ {
+ "localized_name": "LATENT",
+ "name": "LATENT",
+ "type": "LATENT",
+ "slot_index": 0,
+ "links": [
+ 7
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.3.64",
+ "Node name for S&R": "KSampler"
+ },
+ "widgets_values": [
+ 106349272945971,
+ "fixed",
+ 20,
+ 8,
+ "euler",
+ "beta",
+ 0.65
+ ]
+ }
+ ],
+ "groups": [],
+ "links": [
+ {
+ "id": 1,
+ "origin_id": 4,
+ "origin_slot": 0,
+ "target_id": 3,
+ "target_slot": 0,
+ "type": "MODEL"
+ },
+ {
+ "id": 4,
+ "origin_id": 6,
+ "origin_slot": 0,
+ "target_id": 3,
+ "target_slot": 1,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 6,
+ "origin_id": 7,
+ "origin_slot": 0,
+ "target_id": 3,
+ "target_slot": 2,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 7,
+ "origin_id": 3,
+ "origin_slot": 0,
+ "target_id": 8,
+ "target_slot": 0,
+ "type": "LATENT"
+ },
+ {
+ "id": 8,
+ "origin_id": 4,
+ "origin_slot": 2,
+ "target_id": 8,
+ "target_slot": 1,
+ "type": "VAE"
+ },
+ {
+ "id": 5,
+ "origin_id": 4,
+ "origin_slot": 1,
+ "target_id": 7,
+ "target_slot": 0,
+ "type": "CLIP"
+ },
+ {
+ "id": 3,
+ "origin_id": 4,
+ "origin_slot": 1,
+ "target_id": 6,
+ "target_slot": 0,
+ "type": "CLIP"
+ },
+ {
+ "id": 10,
+ "origin_id": 10,
+ "origin_slot": 0,
+ "target_id": 3,
+ "target_slot": 3,
+ "type": "LATENT"
+ },
+ {
+ "id": 11,
+ "origin_id": 4,
+ "origin_slot": 2,
+ "target_id": 10,
+ "target_slot": 1,
+ "type": "VAE"
+ },
+ {
+ "id": 12,
+ "origin_id": -10,
+ "origin_slot": 0,
+ "target_id": 10,
+ "target_slot": 0,
+ "type": "IMAGE"
+ },
+ {
+ "id": 13,
+ "origin_id": 8,
+ "origin_slot": 0,
+ "target_id": -20,
+ "target_slot": 0,
+ "type": "IMAGE"
+ },
+ {
+ "id": 14,
+ "origin_id": -10,
+ "origin_slot": 1,
+ "target_id": 6,
+ "target_slot": 1,
+ "type": "STRING"
+ }
+ ],
+ "extra": {}
+ }
+ ]
+ },
+ "config": {},
+ "extra": {
+ "ds": {
+ "scale": 0.8390545288824012,
+ "offset": [
+ 823.231843997919,
+ -18.4205076907106
+ ]
+ },
+ "frontendVersion": "1.27.10",
+ "VHS_latentpreview": true,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true
+ },
+ "version": 0.4
+}
\ No newline at end of file
diff --git a/example_workflow/loop_workflow_example.png b/example_workflow/loop_workflow_example.png
new file mode 100644
index 0000000..9e94b2f
Binary files /dev/null and b/example_workflow/loop_workflow_example.png differ
diff --git a/icons/audio.svg b/icons/audio.svg
new file mode 100644
index 0000000..b1814e3
--- /dev/null
+++ b/icons/audio.svg
@@ -0,0 +1,41 @@
+
+
diff --git a/icons/error.svg b/icons/error.svg
new file mode 100644
index 0000000..9c9e9b3
--- /dev/null
+++ b/icons/error.svg
@@ -0,0 +1,53 @@
+
+
diff --git a/icons/latent.svg b/icons/latent.svg
new file mode 100644
index 0000000..c070ab0
--- /dev/null
+++ b/icons/latent.svg
@@ -0,0 +1,43 @@
+
+
diff --git a/icons/text.svg b/icons/text.svg
new file mode 100644
index 0000000..0865503
--- /dev/null
+++ b/icons/text.svg
@@ -0,0 +1,51 @@
+
+
diff --git a/js/loop_image-crop.js b/js/loop_image-crop.js
new file mode 100644
index 0000000..7d5b9e5
--- /dev/null
+++ b/js/loop_image-crop.js
@@ -0,0 +1,574 @@
+/* A simple image loop for your workflow. MIT License. version 0.2
+https://github.com/Hullabalo/ComfyUI-Loop/
+Thanks to rgthree, chrisgoringe, pythongosssss and many, many many others for their contributions, how-to's, code snippets etc.
+ */
+
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+
+// Constants
+const CONSTANTS = {
+ NODE_NAME: "ImageCropLoop",
+ EXTENSION_NAME: "Comfy.ImagePreviewNode",
+ EVENT_NAME: "crop-bridge-proxy",
+ API_ENDPOINT: "/api/bridge/response",
+ WIDGET_NAMES: ["x", "y", "size", "color"],
+ MIN_NODE_SIZE: 300,
+ PREVIEW_HEIGHT: 300,
+ MARGIN: 10,
+ SIZE_INCREMENT: 8,
+ DEFAULT_VALUES: {
+ minSize: 256,
+ maxSize: 2048
+ }
+};
+
+// Utility functions
+const Utils = {
+ clamp: (val, min, max) => Math.min(Math.max(val, min), max),
+
+ getImageBounds(img) {
+ if (!img?.complete || img?.naturalWidth <= 0) return null;
+ return { w: img.naturalWidth, h: img.naturalHeight };
+ },
+
+ calculateImageDimensions(img, availableWidth, availableHeight) {
+ const imgW = img.naturalWidth;
+ const imgH = img.naturalHeight;
+ const scale = Math.min(availableWidth / imgW, availableHeight / imgH, 1);
+ const scaledW = imgW * scale;
+ const scaledH = imgH * scale;
+
+ return { imgW, imgH, scale, scaledW, scaledH };
+ },
+
+ calculateMarkerBounds(widgetRefs, displayScale, scaledW, scaledH, previewScale) {
+ const x = widgetRefs.x?.value ?? 0;
+ const y = widgetRefs.y?.value ?? 0;
+ const size = widgetRefs.size?.value ?? CONSTANTS.DEFAULT_VALUES.minSize;
+
+ const markerSize = Math.min(size * displayScale, scaledW, scaledH);
+ const markerX = Utils.clamp(x * displayScale, 0, scaledW - markerSize);
+ const markerY = Utils.clamp(y * displayScale, 0, scaledH - markerSize);
+
+ return { markerX, markerY, markerSize };
+ },
+
+ constructImageURL(filename) {
+ return api.apiURL(
+ `/view?filename=${encodeURIComponent(filename)}&type=temp&subfolder=`
+ );
+ },
+
+ colorNameToRgba(colorName, alpha) {
+ const colors = {
+ red: "255, 0, 0",
+ green: "0, 255, 0",
+ blue: "0, 0, 255",
+ black: "0, 0, 0",
+ grey: "125, 125, 125"
+ };
+ const rgb = colors[colorName] ?? colors.red;
+ return `rgba(${rgb}, ${alpha})`;
+ },
+
+ hexToRgba(hex, alpha) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+};
+
+// Event handler for crop bridge proxy
+api.addEventListener(CONSTANTS.EVENT_NAME, (event) => {
+ console.log('Image Crop - Received event:', event.detail);
+
+ const { detail: data } = event;
+ const nodeId = parseInt(data.id);
+ const targetNode = app.graph.getNodeById(nodeId);
+
+ targetNode?.updatePreview?.({
+ filename: data.name,
+ maskFilename: data.mask,
+ scale: data.scale,
+ original_width: data.original_width,
+ original_height: data.original_height
+ });
+ // // Send a response to backend (commented out for now)
+ // const reply = {
+ // id: data.id,
+ // modified_params: {
+ // x: targetNode?.widgetRefs?.x?.value ?? 128,
+ // y: targetNode?.widgetRefs?.y?.value ?? 64,
+ // size: targetNode?.widgetRefs?.size?.value ?? 512
+ // }
+ // };
+
+ // try {
+ // const response = await api.fetchApi(CONSTANTS.API_ENDPOINT, {
+ // method: "POST",
+ // headers: { "Content-Type": "application/json" },
+ // body: JSON.stringify(reply)
+ // });
+
+ // if (!response.ok) {
+ // console.error("Failed to send response to backend:", response.status);
+ // }
+ // } catch (error) {
+ // console.error("Error sending response to backend:", error);
+ // }
+});
+
+// Preview widget factory
+const createPreviewWidget = (node) => ({
+ name: "preview",
+ type: "image_preview",
+ value: "",
+ height: CONSTANTS.PREVIEW_HEIGHT,
+ serializeValue: () => undefined,
+
+ draw(ctx, node, widgetWidth, y) {
+ const img = this.previewImage;
+ const availableWidth = widgetWidth - CONSTANTS.MARGIN * 2;
+ const availableHeight = node.size[1] - y - CONSTANTS.MARGIN * 2;
+
+ if (!img?.complete || img?.naturalWidth <= 0) {
+ drawPlaceholder(ctx, widgetWidth, y);
+ return;
+ }
+
+ drawImageWithMarker(ctx, node, img, availableWidth, availableHeight, y);
+ }
+});
+
+const drawPlaceholder = (ctx, widgetWidth, y) => {
+ ctx.fillStyle = "#666";
+ ctx.font = "14px Arial";
+ ctx.textAlign = "center";
+ ctx.fillText("Waiting for image...", widgetWidth / 2, y + 40);
+};
+
+const drawImageWithMarker = (ctx, node, img, availableWidth, availableHeight, y) => {
+ const dims = Utils.calculateImageDimensions(img, availableWidth, availableHeight);
+ const imgX = CONSTANTS.MARGIN + (availableWidth - dims.scaledW) / 2;
+ const imgY = y + CONSTANTS.MARGIN + (availableHeight - dims.scaledH) / 2;
+ const colorValue = node.widgetRefs.color?.value ?? "black";
+ const overlayColor = Utils.colorNameToRgba(colorValue, 0.5);
+
+ const previewScale = node.previewData?.scale ?? 1.0;
+ const displayScale = previewScale * dims.scale;
+
+ node.imageArea = {
+ x: imgX,
+ y: imgY,
+ width: dims.scaledW,
+ height: dims.scaledH,
+ scale: dims.scale,
+ displayScale: displayScale,
+ previewScale: previewScale
+ };
+
+ // Draw main image
+ ctx.drawImage(img, imgX, imgY, dims.scaledW, dims.scaledH);
+
+ // Draw mask overlay if available and show_mask is true
+ const showMask = node.widgetRefs.show_mask?.value ?? false;
+ if (showMask && node.previewWidget.previewMask) {
+ drawMaskOverlay(ctx, node.previewWidget.previewMask, imgX, imgY, dims.scaledW, dims.scaledH);
+ }
+
+ // Draw crop rectangle marker
+ const marker = Utils.calculateMarkerBounds(
+ node.widgetRefs,
+ displayScale,
+ dims.scaledW,
+ dims.scaledH,
+ dims.scale
+ );
+
+ const markerAbsX = imgX + marker.markerX;
+ const markerAbsY = imgY + marker.markerY;
+
+ ctx.save();
+
+ ctx.beginPath();
+ ctx.rect(imgX, imgY, dims.scaledW, dims.scaledH);
+ ctx.rect(markerAbsX + marker.markerSize, markerAbsY, -marker.markerSize, marker.markerSize);
+ ctx.closePath();
+
+ ctx.fillStyle = overlayColor;
+ ctx.fill();
+
+ ctx.restore();
+};
+
+const drawMaskOverlay = (ctx, maskImg, imgX, imgY, width, height) => {
+ ctx.save();
+ // ctx.globalAlpha = 0.8; // changing preview mask opacity made easy
+ ctx.drawImage(maskImg, imgX, imgY, width, height);
+ ctx.restore();
+};
+
+// Widget constraint handler
+const setupWidgetConstraints = (node) => {
+ const redraw = () => node.previewWidget?.parent?.graph?.setDirtyCanvas(true);
+
+ CONSTANTS.WIDGET_NAMES.forEach(name => {
+ const widget = node.widgetRefs[name];
+ if (!widget) return;
+
+ const originalCallback = widget.callback;
+ let isUpdating = false;
+
+ widget.callback = (val) => {
+ if (isUpdating) return;
+ isUpdating = true;
+
+ try {
+ const bounds = {
+ w: node.previewData?.original_width ?? node.previewWidget?.previewImage?.naturalWidth,
+ h: node.previewData?.original_height ?? node.previewWidget?.previewImage?.naturalHeight
+ };
+
+ const values = updateWidgetValues(node.widgetRefs, name, val, bounds);
+ applyWidgetValues(node.widgetRefs, values);
+
+ originalCallback?.call(widget, values[name]);
+
+ redraw();
+ } catch (err) {
+ console.warn(`Error in widget ${name} callback:`, err);
+ } finally {
+ isUpdating = false;
+ }
+ };
+ });
+
+ // Setup show_mask widget observer
+ const showMaskWidget = node.widgetRefs.show_mask;
+ if (showMaskWidget) {
+ const originalShowMaskCallback = showMaskWidget.callback;
+ showMaskWidget.callback = (val) => {
+ originalShowMaskCallback?.call(showMaskWidget, val);
+ redraw();
+ };
+ }
+};
+
+const updateWidgetValues = (widgetRefs, changedWidget, newValue, bounds) => {
+ const values = {
+ x: widgetRefs.x?.value ?? 0,
+ y: widgetRefs.y?.value ?? 0,
+ size: widgetRefs.size?.value ?? CONSTANTS.DEFAULT_VALUES.minSize,
+ [changedWidget]: newValue
+ };
+
+ if (bounds && bounds.w && bounds.h) {
+ const { w: imgW, h: imgH } = bounds;
+
+ values.size = Utils.clamp(
+ values.size,
+ CONSTANTS.DEFAULT_VALUES.minSize,
+ Math.min(CONSTANTS.DEFAULT_VALUES.maxSize, imgW, imgH)
+ );
+
+ const maxX = imgW - values.size;
+ const maxY = imgH - values.size;
+ values.x = Utils.clamp(values.x, 0, maxX);
+ values.y = Utils.clamp(values.y, 0, maxY);
+ }
+
+ return values;
+};
+
+const applyWidgetValues = (widgetRefs, values) => {
+ widgetRefs.x && (widgetRefs.x.value = values.x);
+ widgetRefs.y && (widgetRefs.y.value = values.y);
+ widgetRefs.size && (widgetRefs.size.value = values.size);
+};
+
+const setupMouseHandler = (nodeType) => {
+ const originalOnMouseDown = nodeType.prototype.onMouseDown;
+ const originalOnMouseMove = nodeType.prototype.onMouseMove;
+ const originalOnMouseUp = nodeType.prototype.onMouseUp;
+ const originalOnMouseLeave = nodeType.prototype.onMouseLeave;
+
+ nodeType.prototype.onMouseDown = function(e, localPos, graphCanvas) {
+ const area = this.imageArea;
+ const { x: xWidget, y: yWidget, size: sizeWidget } = this.widgetRefs ?? {};
+
+ if (!isClickInImageArea(area, xWidget, yWidget, sizeWidget, localPos)) {
+ return originalOnMouseDown?.call(this, e, localPos, graphCanvas);
+ }
+
+ if (e.button !== 0) {
+ return originalOnMouseDown?.call(this, e, localPos, graphCanvas);
+ }
+
+ e.stopPropagation();
+ e.preventDefault();
+
+ this.isDraggingMarker = true;
+ updateMarkerPosition(area, localPos, xWidget, yWidget, sizeWidget);
+ return true;
+ };
+
+ nodeType.prototype.onMouseMove = function(e, localPos, graphCanvas) {
+ if (this.isDraggingMarker && !(e.buttons & 1)) {
+ this.isDraggingMarker = false;
+ }
+
+ if (this.isDraggingMarker) {
+ const area = this.imageArea;
+ const { x: xWidget, y: yWidget, size: sizeWidget } = this.widgetRefs ?? {};
+
+ if (area && xWidget && yWidget && sizeWidget) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ updateMarkerPosition(area, localPos, xWidget, yWidget, sizeWidget);
+ return true;
+ }
+ }
+
+ const area = this.imageArea;
+ if (area && localPos) {
+ this.isMouseOverImage = isClickInImageArea(area, null, null, null, localPos);
+ }
+
+ return originalOnMouseMove?.call(this, e, localPos, graphCanvas);
+ };
+
+ nodeType.prototype.onMouseUp = function(e, localPos, graphCanvas) {
+ if (this.isDraggingMarker) {
+ this.isDraggingMarker = false;
+ e.stopPropagation();
+ e.preventDefault();
+ return true;
+ }
+
+ return originalOnMouseUp?.call(this, e, localPos, graphCanvas);
+ };
+
+ nodeType.prototype.onMouseLeave = function(e, graphCanvas) {
+ this.isDraggingMarker = false;
+ this.isMouseOverImage = false;
+
+ return originalOnMouseLeave?.call(this, e, graphCanvas);
+ };
+};
+
+const isClickInImageArea = (area, xWidget, yWidget, sizeWidget, localPos) => {
+ if (!area) return false;
+
+ return localPos[0] >= area.x &&
+ localPos[0] <= area.x + area.width &&
+ localPos[1] >= area.y &&
+ localPos[1] <= area.y + area.height;
+};
+
+const updateMarkerPosition = (area, localPos, xWidget, yWidget, sizeWidget) => {
+ const relativeX = localPos[0] - area.x;
+ const relativeY = localPos[1] - area.y;
+ const markerSize = sizeWidget.value * area.displayScale;
+
+ let markerX = relativeX - markerSize / 2;
+ let markerY = relativeY - markerSize / 2;
+ markerX = Utils.clamp(markerX, 0, area.width - markerSize);
+ markerY = Utils.clamp(markerY, 0, area.height - markerSize);
+
+ const origX = Math.round(markerX / area.displayScale);
+ const origY = Math.round(markerY / area.displayScale);
+
+ xWidget.value = origX;
+ yWidget.value = origY;
+ xWidget.callback?.(origX);
+ yWidget.callback?.(origY);
+};
+
+const adjustSizeForNode = (node, delta) => {
+ const sizeWidget = node.widgetRefs?.size;
+ if (!sizeWidget) return;
+
+ const bounds = {
+ w: node.previewData?.original_width,
+ h: node.previewData?.original_height
+ };
+
+ if (!bounds.w || !bounds.h) return;
+
+ const maxSize = Math.min(CONSTANTS.DEFAULT_VALUES.maxSize, bounds.w, bounds.h);
+ const newSize = Utils.clamp(sizeWidget.value + delta, CONSTANTS.DEFAULT_VALUES.minSize, maxSize);
+
+ sizeWidget.value = newSize;
+ sizeWidget.callback?.(newSize);
+
+ app.graph.setDirtyCanvas(true, true);
+};
+
+const setupUpdatePreview = (nodeType) => {
+ nodeType.prototype.updatePreview = function(imageInfo) {
+ if (!this.previewWidget) {
+ console.warn("Preview widget not found on node");
+ return;
+ }
+
+ this.previewData = {
+ scale: imageInfo.scale ?? 1,
+ original_width: imageInfo.original_width,
+ original_height: imageInfo.original_height
+ };
+
+ const url = Utils.constructImageURL(imageInfo.filename);
+ console.log("Loading image from:", url);
+ console.log("Preview data:", this.previewData);
+
+ const img = new Image();
+
+ img.onload = () => {
+ this.previewWidget.previewImage = img;
+ adjustNodeSize(this, img);
+ refreshCanvas(this);
+ };
+
+ img.onerror = (e) => {
+ console.error("Failed to load preview image:", url, e);
+ this.previewWidget.previewImage = null;
+ refreshCanvas(this);
+ };
+
+ img.src = url;
+
+ if (imageInfo.maskFilename) {
+ const maskUrl = Utils.constructImageURL(imageInfo.maskFilename);
+ console.log("Loading mask from:", maskUrl);
+
+ const maskImg = new Image();
+
+ maskImg.onload = () => {
+ this.previewWidget.previewMask = maskImg;
+ console.log("Mask loaded successfully");
+ refreshCanvas(this);
+ };
+
+ maskImg.onerror = (e) => {
+ console.error("Failed to load mask image:", maskUrl, e);
+ this.previewWidget.previewMask = null;
+ refreshCanvas(this);
+ };
+
+ maskImg.src = maskUrl;
+ } else {
+ this.previewWidget.previewMask = null;
+ }
+ };
+};
+
+const adjustNodeSize = (node, img) => {
+ if (!img?.naturalWidth || !img?.naturalHeight) return;
+
+ if (node.defaultSize &&
+ (node.size[0] !== node.defaultSize[0] || node.size[1] !== node.defaultSize[1])) {
+ return;
+ }
+
+ const widgetWidth = node.size[0] - CONSTANTS.MARGIN * 2;
+ const headerHeight = node.widgets_start_y ?? 0;
+ const totalHeight = headerHeight + 512 + CONSTANTS.MARGIN * 3;
+ const newHeight = Math.max(totalHeight, CONSTANTS.MIN_NODE_SIZE);
+
+ if (Math.abs(node.size[1] - newHeight) > 2) {
+ node.setSize([node.size[0], newHeight]);
+ node.graph?.setDirtyCanvas(true, true);
+ }
+};
+
+const refreshCanvas = (node) => {
+ node?.graph?.setDirtyCanvas(true, true);
+};
+
+const setupNodeCreation = (nodeType) => {
+ const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
+
+ nodeType.prototype.onNodeCreated = function() {
+ const result = originalOnNodeCreated?.apply(this, arguments);
+
+ this.widgetRefs = {
+ x: this.widgets.find(w => w.name === "x"),
+ y: this.widgets.find(w => w.name === "y"),
+ size: this.widgets.find(w => w.name === "size"),
+ color: this.widgets.find(w => w.name === "color"),
+ show_mask: this.widgets.find(w => w.name === "show_mask")
+ };
+
+ this.previewWidget = this.addCustomWidget(createPreviewWidget(this));
+ this.previewData = {
+ scale: 1,
+ original_width: 0,
+ original_height: 0
+ };
+
+ this.setSize([
+ Math.max(this.size[0], CONSTANTS.MIN_NODE_SIZE),
+ Math.max(this.size[1], CONSTANTS.MIN_NODE_SIZE)
+ ]);
+
+ this.defaultSize = [...this.size];
+ setupWidgetConstraints(this);
+ this.isMouseOverImage = false;
+
+ return result;
+ };
+};
+
+app.registerExtension({
+ name: CONSTANTS.EXTENSION_NAME,
+
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (nodeData.name !== CONSTANTS.NODE_NAME) return;
+
+ setupNodeCreation(nodeType);
+ setupMouseHandler(nodeType);
+ setupUpdatePreview(nodeType);
+
+ const originalOnRemoved = nodeType.prototype.onRemoved;
+ nodeType.prototype.onRemoved = function() {
+ return originalOnRemoved?.apply(this, arguments);
+ };
+ },
+
+ commands: [
+ {
+ id: "ImageCropLoop.increaseSize",
+ label: "Increase Marker Size",
+ function: () => {
+ const node = app.graph._nodes.find(n =>
+ n.type === CONSTANTS.NODE_NAME && n.isMouseOverImage
+ );
+ node && adjustSizeForNode(node, CONSTANTS.SIZE_INCREMENT);
+ }
+ },
+ {
+ id: "ImageCropLoop.decreaseSize",
+ label: "Decrease Marker Size",
+ function: () => {
+ const node = app.graph._nodes.find(n =>
+ n.type === CONSTANTS.NODE_NAME && n.isMouseOverImage
+ );
+ node && adjustSizeForNode(node, -CONSTANTS.SIZE_INCREMENT);
+ }
+ }
+ ],
+
+ keybindings: [
+ {
+ combo: { key: "PageUp" },
+ commandId: "ImageCropLoop.increaseSize"
+ },
+ {
+ combo: { key: "PageDown" },
+ commandId: "ImageCropLoop.decreaseSize"
+ }
+ ]
+});
\ No newline at end of file
diff --git a/js/loop_loop-any.js b/js/loop_loop-any.js
new file mode 100644
index 0000000..a732c44
--- /dev/null
+++ b/js/loop_loop-any.js
@@ -0,0 +1,473 @@
+/* A simple image loop for your workflow. MIT License. version 0.2
+https://github.com/Hullabalo/ComfyUI-Loop/
+Thanks to rgthree, chrisgoringe, pythongosssss and many, many many others for their contributions, how-to's, code snippets etc.
+ */
+
+import { app } from "../../../scripts/app.js";
+
+const NODES_LIST = ["LoopAny", "SaveAny"];
+const FIXED_INPUTS = ["mask"];
+const FIXED_OUTPUTS = ["path", "width", "height", "mask"];
+
+app.registerExtension({
+ name: "loop.LoopAny.InputOutput",
+
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (!NODES_LIST.includes(nodeData.name)) return;
+
+ // ============================================
+ // 'any' input and output helper methods
+ // ============================================
+
+ nodeType.prototype.getDefaultLabelForType = type => type === "*" ? "any" : type;
+
+ nodeType.prototype.updateInputTypeAndLabel = function(input, type, label) {
+ input.type = type;
+ input.label = label ?? this.getDefaultLabelForType(type);
+ if (app.canvas?.default_connection_color_byType) {
+ input.color_on = app.canvas.default_connection_color_byType[type];
+ }
+ };
+
+ nodeType.prototype.updateOutputTypeAndLabel = function(output, type, label) {
+ output.type = type;
+ output.label = label ?? this.getDefaultLabelForType(output.type);
+ };
+
+ // Check if two types are incompatible (neither is wildcard and they differ)
+ nodeType.prototype.areTypesIncompatible = (type1, type2) =>
+ type1 !== type2 && type1 !== "*" && type2 !== "*";
+
+ // Disconnect a target node's input if types are incompatible
+ nodeType.prototype.disconnectIfIncompatible = function(targetNode, targetSlot, newType) {
+ if (!targetNode?.inputs?.[targetSlot]) return false;
+
+ const targetInput = targetNode.inputs[targetSlot];
+ if (this.areTypesIncompatible(targetInput.type, newType)) {
+ targetNode.disconnectInput(targetSlot);
+ return true;
+ }
+ return false;
+ };
+
+ // ============================================
+ // Widget Management (Proper disable/hide)
+ // ============================================
+
+ nodeType.prototype.manageLoopMaskWidget = function() {
+ // Only manage widgets for LoopAny node
+ if (this.type !== "LoopAny") return;
+
+ const anyInput = this.inputs?.find(i => i.name === "input");
+ if (!anyInput || !this.widgets) return;
+
+ const hasConnection = anyInput.link != null;
+ const shouldShowLoopMask = hasConnection && anyInput.label?.toUpperCase() === "IMAGE";
+
+ const loopMaskWidget = this.widgets?.find(w => w.name === "loop_mask");
+ const loopMaskInput = this.inputs?.find(i => i.name === "loop_mask");
+
+ if (loopMaskWidget) {
+ loopMaskWidget.hidden = !shouldShowLoopMask;
+
+ // Handle the input connection point
+ if (!shouldShowLoopMask && loopMaskInput) {
+ const index = this.inputs.findIndex(i => i.name === "loop_mask");
+ if (index !== -1) {
+ this.removeInput(index);
+ }
+ }
+ const size = this.computeSize();
+ size[1] += 17;
+ this.setSize(size);
+ }
+ };
+
+ // ============================================
+ // Dynamic Input/Output Management
+ // ============================================
+
+ nodeType.prototype.removeByName = function(collection, names, removeFn) {
+ names.forEach(name => {
+ const index = collection.findIndex(o => o.name === name);
+ if (index !== -1) {
+ removeFn.call(this, index);
+ }
+ });
+ };
+
+ nodeType.prototype.manageInputOutputVisibility = function() {
+ const anyInput = this.inputs?.find(i => i.name === "input");
+ if (!anyInput) return;
+
+ const hasConnection = anyInput.link != null;
+ const hasOutputs = this.outputs?.length > 0;
+ const hasMaskInput = this.inputs?.some(o => o.name === "mask");
+ const shouldShowMask = hasConnection && anyInput.label?.toUpperCase() === "IMAGE";
+
+ // Add/remove mask input according to 'guessed' type
+ if (shouldShowMask && !hasMaskInput) {
+ this.addInput("mask", "MASK");
+ } else if (!shouldShowMask && hasMaskInput) {
+ this.removeByName(this.inputs, ["mask"], this.removeInput);
+ }
+
+ // Only manage outputs if the node has them
+ if (hasOutputs) {
+ const hasMaskOutput = this.outputs?.some(o => o.name === "mask");
+ const hasWidthOutput = this.outputs?.some(o => o.name === "width");
+ const hasHeightOutput = this.outputs?.some(o => o.name === "height");
+
+ const shouldShowDimensions = hasConnection &&
+ ["IMAGE", "MASK", "LATENT"].includes(anyInput.label?.toUpperCase());
+
+ // Mask output (only for nodes with outputs)
+ if (shouldShowMask && !hasMaskOutput) {
+ this.addOutput("mask", "MASK");
+ } else if (!shouldShowMask && hasMaskOutput) {
+ this.removeByName(this.outputs, ["mask"], this.removeOutput);
+ }
+
+ // Width and height outputs
+ if (shouldShowDimensions && !hasWidthOutput && !hasHeightOutput) {
+ if (!hasWidthOutput) this.addOutput("width", "INT");
+ if (!hasHeightOutput) this.addOutput("height", "INT");
+ } else if (!shouldShowDimensions && (hasWidthOutput || hasHeightOutput)) {
+ this.removeByName(this.outputs, ["width", "height"], this.removeOutput);
+ }
+
+ // Force output order
+ const orderMap = new Map(
+ ["output", ...FIXED_OUTPUTS].map((name, i) => [name, i])
+ );
+ this.outputs.sort((a, b) =>
+ (orderMap.get(a.name) ?? 999) - (orderMap.get(b.name) ?? 999)
+ );
+ }
+
+ this.setSize?.(this.computeSize());
+ this.manageLoopMaskWidget?.();
+
+ // update visual connections
+ if (app.canvas) {
+ app.canvas.setDirty(true);
+ app.canvas.draw(true, true);
+ }
+ };
+
+ // ============================================
+ // Reroute Node Handling (pysssss, rgthree and native reroute)
+ // ============================================
+
+ nodeType.prototype.updateRerouteNode = function(rerouteNode, slot, type, label) {
+ const input = rerouteNode.inputs?.[slot];
+ const output = rerouteNode.outputs?.[slot];
+
+ if (!input || !output) return;
+
+ // Update input and output types
+ if (input.type !== type) {
+ this.updateInputTypeAndLabel(input, type, label);
+ }
+
+ if (output.type !== type) {
+ this.updateOutputTypeAndLabel(output, type, label);
+
+ // Disconnect incompatible outputs
+ const outputLinks = output.links ?? [];
+ for (const outputLinkId of [...outputLinks]) {
+ const outputLink = app.graph.links?.[outputLinkId];
+ if (!outputLink) continue;
+
+ const targetNode = app.graph.getNodeById(outputLink.target_id);
+ this.disconnectIfIncompatible(targetNode, outputLink.target_slot, type);
+ }
+ }
+
+ rerouteNode.setDirtyCanvas(true, true);
+ };
+
+ // ============================================
+ // Type Propagation
+ // ============================================
+
+ nodeType.prototype.propagateTypeToConnectedNode = function(linkId, type, label, visited, depth, maxDepth) {
+ try {
+ const graph = app.graph;
+ if (!graph?.links) return;
+
+ const link = graph.links[linkId];
+ if (!link) return;
+
+ const node = graph.getNodeById(link.target_id);
+ if (!node) return;
+
+ const targetSlot = link.target_slot;
+
+ // Handle Reroute nodes
+ if (node.type.includes("Reroute")) {
+ this.updateRerouteNode(node, targetSlot, type, label);
+ return;
+ }
+
+ // Handle nodes with updateChainType capability
+ if (node.updateChainType) {
+ const targetInput = node.inputs?.[targetSlot];
+ if (targetInput) {
+ const typeChanged = targetInput.type !== type;
+ this.updateInputTypeAndLabel(targetInput, type, label);
+
+ if (typeChanged && node.outputs?.[targetSlot]) {
+ node.updateChainType(targetSlot, type, label, visited, depth + 1, maxDepth);
+ }
+ node.setDirtyCanvas(true, true);
+
+ // Trigger input/output visibility management for the downstream node
+ setTimeout(() => {
+ node.manageInputOutputVisibility?.();
+ }, 10);
+ }
+ } else {
+ // Handle standard nodes --> disconnect if incompatible
+ this.disconnectIfIncompatible(node, targetSlot, type);
+ }
+ } catch (linkError) {
+ console.warn('Error processing link in propagateTypeToConnectedNode:', linkError);
+ }
+ };
+
+ nodeType.prototype.updateChainType = function(slot, type, label, visited = new Set(), depth = 0, maxDepth = 50) {
+ try {
+ if (depth > maxDepth) {
+ console.warn('Recursion depth exceeded in updateChainType');
+ return;
+ }
+
+ if (!this?.outputs?.length) return;
+
+ if (visited.has(this.id)) return;
+ visited.add(this.id);
+
+ const output = this.outputs[slot];
+ if (!output) return;
+
+ // Do not relabel the fixed output if exists
+ if (["mask", "width", "height"].includes(output.name)) return;
+
+ // Update output type and label
+ this.updateOutputTypeAndLabel(output, type, label);
+
+ const graph = app?.graph;
+ if (!graph?.links) return;
+
+ const links = output.links ?? [];
+ for (const linkId of [...links]) {
+ const link = graph.links?.[linkId];
+ if (!link) continue;
+
+ const targetNode = graph.getNodeById(link.target_id);
+
+ // Disconnect if incompatible, otherwise propagate
+ if (!this.disconnectIfIncompatible(targetNode, link.target_slot, type)) {
+ this.propagateTypeToConnectedNode(linkId, type, label, visited, depth, maxDepth);
+ }
+ }
+ } catch (e) {
+ console.warn('Error in updateChainType:', e);
+ }
+ };
+
+ // ============================================
+ // Lifecycle Methods
+ // ============================================
+
+ const Configure = nodeType.prototype.configure ?? LGraphNode.prototype.configure;
+ nodeType.prototype.configure = function() {
+ this.configuring = true;
+ const r = Configure?.apply(this, arguments);
+ this.configuring = false;
+
+ setTimeout(() => {
+ this.inputs?.forEach((input, slot) => {
+ // Ignore fixed inputs
+ if (this.fixedInputs?.includes(input.name)) return;
+
+ // If input is connected, simulate a connection change
+ if (input.link != null) {
+ const graph = app?.graph;
+ if (!graph?.links) return;
+
+ const link = graph.links[input.link];
+ if (!link) return;
+
+ const originNode = graph._nodes_by_id?.[link.origin_id];
+ const connectedType = originNode?.outputs?.[link.origin_slot]?.type;
+
+ if (connectedType) {
+ const label = this.getDefaultLabelForType(connectedType);
+
+ // input update (we don't change the type)
+ input.label = label;
+ if (app.canvas?.default_connection_color_byType) {
+ input.color_on = app.canvas.default_connection_color_byType[connectedType];
+ }
+
+ // propagate the type to the node connected forward
+ if (this.outputs?.length && this.outputs[slot]) {
+ this.updateChainType(slot, connectedType, label, new Set(), 0, 50);
+ }
+ }
+ }
+ });
+
+ this.manageInputOutputVisibility?.();
+ }, 10);
+
+ return r;
+ };
+
+ // Init types from existing connections
+ nodeType.prototype.initializeTypesFromConnections = function() {
+ this.inputs?.forEach((input, inputSlot) => {
+ // Ignore fixed inputs
+ if (this.fixedInputs?.includes(input.name)) return;
+
+ // if input is connected
+ if (input.link != null) {
+ const graph = app?.graph;
+ if (!graph?.links) return;
+
+ const link = graph.links[input.link];
+ if (!link) return;
+
+ const originNode = graph._nodes_by_id?.[link.origin_id];
+ const connectedType = originNode?.outputs?.[link.origin_slot]?.type;
+
+ if (connectedType) {
+ const label = this.getDefaultLabelForType(connectedType);
+
+ // ONLY update the 'any' type in init (type === "*")
+ // allow further reconnections with different types
+ if (input.type === "*") {
+ input.type = connectedType;
+ }
+
+ // ALWAYS update label and color
+ input.label = label;
+ if (app.canvas?.default_connection_color_byType) {
+ input.color_on = app.canvas.default_connection_color_byType[connectedType];
+ }
+
+ // Propagate the type on node forward
+ if (this.outputs?.length && this.outputs[inputSlot]) {
+ this.updateChainType(inputSlot, connectedType, label, new Set(), 0, 50);
+ }
+ }
+ }
+ });
+ };
+
+ const OnConnectionsChange = nodeType.prototype.onConnectionsChange;
+ nodeType.prototype.onConnectionsChange = function(side, slot, connect, link_info) {
+ if (this.configuring) return;
+
+ // Don't do anything to the fixed inputs
+ if (FIXED_INPUTS.includes(this.inputs[slot]?.name)) {
+ return OnConnectionsChange?.apply(this, arguments);
+ }
+
+ // Change only the 'any' input
+ if (side === 1 && this.inputs) {
+ const input = this.inputs[slot];
+
+ if (connect && link_info) {
+ const connectedType = this.getConnectedOutputType(link_info);
+
+ if (connectedType) {
+ // CRITICAL: we don't change input.type to allow reconnection with different node input types
+ // updating label and color only
+ input.label = this.getDefaultLabelForType(connectedType);
+ if (app.canvas?.default_connection_color_byType) {
+ input.color_on = app.canvas.default_connection_color_byType[connectedType];
+ }
+
+ const label = this.getDefaultLabelForType(connectedType);
+
+ // Only call updateChainType if the node has outputs
+ if (this.outputs?.length && this.outputs[slot]) {
+ this.updateChainType(slot, connectedType, label, new Set(), 0, 50);
+ }
+ }
+ } else {
+ // Reset if disconnected
+ input.type = "*";
+ input.label = this.getDefaultLabelForType("*");
+ input.color_on = undefined;
+
+ const label = this.getDefaultLabelForType("*");
+
+ // Only call updateChainType if the node has outputs
+ if (this.outputs?.length && this.outputs[slot]) {
+ this.updateChainType(slot, "*", label, new Set(), 0, 50);
+ }
+ }
+
+ this.setDirtyCanvas(true, true);
+ }
+
+ const result = OnConnectionsChange?.apply(this, arguments);
+
+ // inputs/outputs visibility after connection change
+ if (side === 1) { // INPUT change
+ setTimeout(() => {
+ this.manageInputOutputVisibility?.();
+ }, 10);
+ }
+ return result;
+ };
+
+ // Helper to get the connected output type
+ nodeType.prototype.getConnectedOutputType = link_info => {
+ const graph = app?.graph;
+ if (!graph?._nodes_by_id || !link_info.origin_id) return null;
+
+ return graph._nodes_by_id[link_info.origin_id]?.outputs?.[link_info.origin_slot]?.type ?? null;
+ };
+
+ const OnNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function() {
+ const r = OnNodeCreated ? OnNodeCreated.apply(this, arguments) : undefined;
+
+ this.configuring = false;
+ this.fixedInputs = this.fixedInputs ?? FIXED_INPUTS;
+ this.fixedOutputs = this.fixedOutputs ?? FIXED_OUTPUTS;
+
+ // Initialize inputs
+ this.inputs?.forEach(input => {
+ if (input.type === "*" && !input.label) {
+ input.label = this.getDefaultLabelForType(input.type);
+ }
+ });
+
+ // Initialize outputs
+ this.outputs?.forEach(output => {
+ if (output.type === "*" && !output.label) {
+ output.label = this.getDefaultLabelForType(output.type);
+ }
+ });
+
+ // Init inputs/outputs visibility
+ setTimeout(() => {
+ this.manageInputOutputVisibility?.();
+ }, 10);
+
+ return r;
+ };
+
+ const OnRemoved = nodeType.prototype.onRemoved ?? LGraphNode.prototype.onRemoved;
+ nodeType.prototype.onRemoved = function() {
+ delete this.configuring;
+ return OnRemoved?.apply(this, arguments);
+ };
+ }
+});
\ No newline at end of file
diff --git a/nodes.py b/nodes.py
new file mode 100644
index 0000000..9250402
--- /dev/null
+++ b/nodes.py
@@ -0,0 +1,443 @@
+import torch
+import folder_paths
+import os
+import time
+import shutil
+from comfy.comfy_types.node_typing import IO
+from server import PromptServer
+from .utils.loop_img_utils import LoopImageUtils as IU
+from .utils.loop_latent_utils import LoopLatentUtils as LU
+from .utils.loop_audio_utils import LoopAudioUtils as AU
+from .utils.loop_string_utils import LoopStringUtils as SU
+from .utils.loop_path_utils import LoopPathUtils as PU
+from .utils.error_handler import ErrorHandler
+
+"""
+A simple image loop for your workflow. MIT License. version 0.2
+https://github.com/Hullabalo/ComfyUI-Loop/
+Thanks to rgthree, chrisgoringe, pythongosssss and many, many many others for their contributions, how-to's, code snippets etc.
+
+last changes 10/23/2025 :
+Better integration with last ComfyUI version. Better code structure.
+- Now there's only four nodes for two main usages, looping files and visual cutting-pasting:
+ LoopAny -> SaveAny
+ loop any file type : image (png), mask (png), latent (image/audio/whatever), audio (flac), string (or int/float) saved as text file.
+
+ ImageCrop -> ImagePaste
+ ImageCrop now works with last Comfy frontend, lastly tested with ComfyUI v0.3.64, ComfyUI_Frontend v1.27.10
+
+TL;DR : Revisited code from A to Z. Crop your images and masks, loop your files, the fun way.
+"""
+
+class ImageCropLoop:
+ """
+ Crop an image and mask at x,y coordinates with a given size . Generate image and mask preview for interactive cropping.
+ Keyboard shortcuts (with mouse pointer on preview Image) : PageUp/PageDown to Increase/decrease size
+ """
+
+ def __init__(self):
+ self.output_dir = folder_paths.get_temp_directory()
+ self.preview_dim = 1024 # change this if you need a detailed preview.
+ self.last_img_hash = None
+ self.last_filename = None
+ self.last_mask_hash = None
+ self.last_maskname = None
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "x": ("INT", {"default": 0, "min": 0, "max": 32768, "tooltip": "x origin of the crop."}), # enough for 1073 Megapixels images. Much more than what you would/could/should process :)
+ "y": ("INT", {"default": 0, "min": 0, "max": 32768, "tooltip": "y origin of the crop."}),
+ "size": ("INT", {"default":512, "min": 256, "max": 2048, "step": 8, "tooltip": "size of the crop. By 8 pixels increments."}),
+ "color": (["black","grey","red","green","blue"], {"default": "black", "tooltip": "Color of the crop rectangle in the preview."}),
+ "show_mask": ("BOOLEAN", {"default": True, "tooltip": "Show or hide preview mask"}),
+ },
+ "optional": {"mask": ("MASK",)},
+ "hidden": {"id": "UNIQUE_ID"}
+ }
+
+ CATEGORY = "LOOP"
+ DESCRIPTION = "Crop an image and mask at x,y coordinates with given size. Generate image and mask preview for interactive cropping."
+ FUNCTION = "click_and_crop"
+ RETURN_TYPES = ("IMAGE","IMAGE","INT","INT","INT","MASK")
+ RETURN_NAMES = ("source","cut","size","x","y","cut_mask")
+
+ def click_and_crop(self, image, x, y, size, color, show_mask, id, mask=None):
+
+ _, h, w, _ = image.shape
+ size = min(size, h, w) # max crop size to image boundaries
+ x, y = abs(x), abs(y)
+ x, y = max(0, min(x, w - size)), max(0, min(y, h - size)) # keep crop into image limits
+
+ cut = image[:, y:y+size, x:x+size, :]
+
+ # mask management
+ if mask is not None:
+ mask = IU.resize_mask(mask, h, w) # resize to image dimensions
+ cut_mask = mask[:, y:y+size, x:x+size]
+ else:
+ cut_mask = IU.get_default_mask(size, size)
+
+ # define preview scale
+ scale = self.preview_dim / w if self.preview_dim < w else 1.0
+
+ # image preview management
+ current_img_hash = IU.compute_image_hash(image)
+ # print(f"image_hash_changed: {current_img_hash != self.last_img_hash}") # debug
+
+ if current_img_hash != self.last_img_hash or self.last_filename is None:
+ filename = IU.save_preview_image(image, self.output_dir, scale)
+ self.last_img_hash = current_img_hash
+ self.last_filename = filename
+ else:
+ filename = self.last_filename
+
+ # mask preview management
+ if mask is not None:
+ current_mask_hash = IU.compute_mask_hash(mask)
+ # print(f"mask_hash_changed: {current_mask_hash != self.last_mask_hash}") # debug
+
+ if current_mask_hash != self.last_mask_hash or self.last_maskname is None:
+ maskname = IU.save_preview_mask(mask, self.output_dir, scale)
+ self.last_mask_hash = current_mask_hash
+ self.last_maskname = maskname
+ else:
+ maskname = self.last_maskname
+ else:
+ maskname = None
+ self.last_mask_hash = None
+ self.last_maskname = None
+
+ # update preview
+ try:
+ # print(f"Sending crop-bridge-proxy event (id={id}, filename={filename})") # debug
+ PromptServer.instance.send_sync("crop-bridge-proxy", {
+ "id": id,
+ "name": filename,
+ "mask": maskname,
+ "scale": scale,
+ "original_width": w,
+ "original_height": h
+ })
+ except Exception as e:
+ ErrorHandler.handle_communication_error(e, "click_and_crop")
+
+ return (image, cut, size, x, y, cut_mask)
+
+ def IS_CHANGED(**kwargs):
+ return float("NaN")
+
+class ImagePasteLoop:
+ """
+ Paste an image cut into a source image at x,y coordinates, with optional mask and blending.
+ """
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "source": ("IMAGE",),
+ "cut": ("IMAGE",),
+ "x": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8, "forceInput": True}),
+ "y": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8, "forceInput": True}),
+ },
+ "optional": {
+ "cut_mask": ("MASK", {"tooltip": "Optional cutting mask."}),
+ },
+ }
+
+ CATEGORY = "LOOP"
+ DESCRIPTION = "Paste an image cut into a source image at x,y coordinates, with optional mask and blending."
+ FUNCTION = "paste_and_forget"
+ RETURN_TYPES = ("IMAGE",)
+ RETURN_NAMES = ("image",)
+
+ def paste_and_forget(self, source, cut, x, y, cut_mask=None):
+
+ _, sh, sw, _ = source.shape
+ _, ch, cw, _ = cut.shape
+
+ # clamp coordinates and adjust if out of bounds
+ x, y = max(0, min(x, sw - 1)), max(0, min(y, sh - 1))
+ w, h = min(cw, sw - x), min(ch, sh - y)
+
+ result = source.clone()
+
+ # no mask? direct paste.
+ if cut_mask is None or cut_mask.max() == 0:
+ result[:, y:y+h, x:x+w, :] = cut[:, :h, :w, :]
+ return (result,)
+
+ # adjust and normalize mask
+ cut_mask = cut_mask[:, :h, :w].unsqueeze(-1).float()
+ cut_mask /= cut_mask.max() if cut_mask.max() > 0 else 1.0
+
+ # blend
+ region = result[:, y:y+h, x:x+w, :]
+ result[:, y:y+h, x:x+w, :] = cut[:, :h, :w, :] * cut_mask + region * (1 - cut_mask)
+
+ return (result,)
+
+class LoopAny:
+ """
+ Loop any input (image, mask, latent, audio, string...) from /output folder or one of its subfolders.
+ """
+ def __init__(self):
+ self.output_dir = folder_paths.get_output_directory()
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "input": (IO.ANY, ),
+ "loop_file": ("BOOLEAN", {"default": False, "tooltip": "Enable image loop mode. Disable to load from image input"}),
+ "filename": ("STRING",{"default": "loop_file", "tooltip": "(fac.) Filename of loop file without extension. Define/Use an existing file to load from /output or its subfolders."}),
+ "subfolder": ("STRING",{"default": "", "tooltip": "(fac.) Subfolder to load or copy input file. Default root to /output"}),
+ "loop_mask": ("BOOLEAN", {"default": False, "tooltip": "Enable mask loop mode. Disable to load from mask input"}),
+ },
+ "optional": {
+ "mask": ("MASK", {})
+ },
+ "hidden": {"id": "UNIQUE_ID"}
+ }
+
+ CATEGORY = "LOOP"
+ DESCRIPTION = "Loop any input (image, mask, latent, audio, string...) from /output folder or one of its subfolders."
+ FUNCTION = "loop_that_thing"
+ RETURN_TYPES = (IO.ANY, "STRING", "INT", "INT", "MASK")
+ RETURN_NAMES = ("output", "path", "width", "height", "mask")
+
+
+ def loop_that_thing(self, input, loop_file, loop_mask, subfolder, id, mask=None, filename = "loop_file"):
+
+ w, h = 1, 1
+
+ path = os.path.join(self.output_dir, subfolder.strip("/\\")) if subfolder else self.output_dir
+ os.makedirs(path, exist_ok=True)
+ full_path = os.path.join(path, filename)
+
+ match input:
+ # --- IMAGE ---
+ case _ if isinstance(input, torch.Tensor) and input.ndim == 4 and input.shape[1] != 4:
+ print("IMAGE TENSOR")
+ full_path += ".png"
+
+ if os.path.exists(full_path) and loop_file:
+ img_out = IU.load_existing_image(full_path)
+ else:
+ img_out = IU.save_new_image(input, full_path)
+
+ _, h, w, _ = img_out.shape
+
+ if loop_mask:
+ mask_out = (
+ IU.get_mask_from_image_alpha(full_path, h, w)
+ if os.path.exists(full_path)
+ else IU.get_default_mask(h, w)
+ )
+ else:
+ mask_out = IU.resize_mask(mask, h, w) if mask is not None else IU.get_default_mask(h, w)
+
+ return (img_out, full_path, w, h, mask_out)
+
+ # --- MASK ---
+ case _ if isinstance(input, torch.Tensor) and input.ndim == 3:
+ print("MASK TENSOR")
+ full_path += ".png"
+
+ if os.path.exists(full_path) and loop_file:
+ mask_out = IU.load_existing_mask(full_path)
+ else:
+ mask_out = IU.save_new_mask(input, full_path)
+
+ w, h = IU.get_mask_size(mask_out)
+
+ return (mask_out, full_path, w, h, None)
+
+ # --- LATENT ---
+ case _ if isinstance(input, dict) and "samples" in input:
+ samples = input["samples"]
+ latent_type = input.get("type", None)
+ # print("Checking LATENT : ", samples.mean(), samples.std())
+ full_path += ".latent"
+ if isinstance(samples, torch.Tensor):
+ s_ndim = getattr(samples, "ndim", None)
+
+ if s_ndim == 5 and samples.shape[1] == 16:
+ print("LATENT (QWEN, WAN)")
+ latent_out, w, h = LU.load_or_create_latent(input, full_path, loop_file)
+
+ elif s_ndim == 4 and samples.shape[1] == 4:
+ print("LATENT (SD 1.x / 2.x / SDXL)")
+ latent_out, w, h = LU.load_or_create_latent(input, full_path, loop_file)
+
+ elif s_ndim == 4 and samples.shape[1] == 16:
+ print("LATENT (Flux.1, SD3, Chroma)")
+ latent_out, w, h = LU.load_or_create_latent(input, full_path, loop_file)
+
+ elif s_ndim == 3 and latent_type == "audio":
+ print("LATENT AUDIO (stable audio 1.0)")
+ latent_out = LU.load_or_create_audio_latent(input, full_path, loop_file)
+
+ elif s_ndim == 4 and latent_type == "audio":
+ print("LATENT AUDIO (ACE Step)")
+ latent_out = LU.load_or_create_audio_latent(input, full_path, loop_file)
+
+ else:
+ print(f"LATENT AUDIO or Unknown LATENT (shape={samples.shape}, type={latent_type})")
+ latent_out, w, h = LU.load_or_create_latent(input, full_path, loop_file)
+
+ return (latent_out, full_path, w, h, None)
+ else:
+ print("LATENT (non-tensor samples)")
+ return (input, full_path, w, h, None)
+
+ # --- AUDIO ---
+ case _ if isinstance(input, dict) and "waveform" in input and "sample_rate" in input:
+ print("AUDIO")
+ full_path += ".flac"
+ audio_out = AU.load_or_create_audio(input, full_path, loop_file)
+ return (audio_out, full_path, w, h, None)
+
+ # --- STRING OR INT/FLOAT ---
+ case _ if isinstance(input, (str, dict, int, float)):
+ if isinstance(input, (dict, int, float)):
+ input = str(input)
+ print("STRING")
+ full_path += ".txt"
+ string_out = SU.load_or_create_text_file(input, full_path, loop_file)
+ return (string_out, full_path, w, h, None)
+
+ # --- FALLBACK / UNEXPECTED TYPE ---
+ case _:
+ t = type(input)
+ module_name = t.__module__
+ type_name = t.__name__
+ print(f"[WARN] unexpected type : {module_name}.{type_name}")
+ return (input, full_path, w, h, None)
+
+ return (input, path, w, h, mask)
+
+ @classmethod
+ def IS_CHANGED(cls, **kwargs):
+ return float("NaN")
+
+ @classmethod
+ def VALIDATE_INPUTS(s, **kwargs):
+ return True
+
+class SaveAny:
+ """
+ Save any input (image, mask, latent, audio, string...) to /output or a specified subfolder as .png, .latent, .flac, .txt...
+ """
+ def __init__(self):
+ IU.ensure_blank_image(folder_paths.get_temp_directory())
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "input": (IO.ANY, ),
+ "path": ("STRING", {"default": "/path/to/file.ext", "tooltip": "Full path of the saved file."}),
+ "save_steps": ("BOOLEAN", {"default": False, "tooltip": "Save a copy next to the saved image with a timestamp as suffix."}),
+ "save_metadata": ("BOOLEAN", {"default": False, "tooltip": "Save metadatas for compatible file formats."}),
+ "preview": ("BOOLEAN", {"default": True, "tooltip": "Display the preview in node."}),
+ },
+ "optional": {
+ "mask": ("MASK", {})
+ },
+ "hidden": {
+ "id": "UNIQUE_ID",
+ "prompt": "PROMPT",
+ "extra_pnginfo": "EXTRA_PNGINFO"
+ }
+ }
+
+ CATEGORY = "LOOP"
+ DESCRIPTION = "Save any input (image, mask, latent, audio, string...) to /output or a specified subfolder as .png (opt. mask as alpha), .latent, .flac, .txt... "
+ FUNCTION = "save_that_thing"
+ RETURN_TYPES = ()
+ OUTPUT_NODE = True
+
+ def save_that_thing(self, input, path, save_steps, save_metadata, preview, id, prompt=None, extra_pnginfo=None, mask=None):
+
+ type = "output"
+ filename, subfolders, base = PU.parse_path(path, type)
+
+ if save_steps:
+ timestamp = f"{time.time():.6f}".replace(".", "")
+ name, ext = os.path.splitext(filename)
+ step_filename = f"{name}_{timestamp}{ext}"
+ step_path = os.path.join(base, subfolders, step_filename)
+ shutil.copy(path, step_path)
+
+ match input:
+ # --- IMAGE ---
+ case _ if isinstance(input, torch.Tensor) and input.ndim == 4 and input.shape[1] != 4:
+ metadata = IU.prepare_metadata(prompt, extra_pnginfo) if save_metadata else None
+ if mask is not None:
+ print(f"Saving IMAGE (with mask as alpha channel)")
+ IU.save_image_with_alpha_mask(input, mask, path, metadata)
+ else:
+ print(f"Saving IMAGE")
+ IU.save_new_image(input, path, metadata)
+
+ # --- MASK ---
+ case _ if isinstance(input, torch.Tensor) and input.ndim == 3:
+ metadata = IU.prepare_metadata(prompt, extra_pnginfo) if save_metadata else None
+ print(f"Saving MASK")
+ IU.save_new_mask(input, path, metadata)
+
+ # --- LATENT ---
+ case _ if isinstance(input, dict) and "samples" in input:
+ samples = input["samples"]
+ if isinstance(samples, torch.Tensor):
+ metadata = LU.prepare_metadata(prompt, extra_pnginfo) if save_metadata else None
+ print(f"Saving LATENT")
+ LU.save_new_latent(input, path, metadata)
+ filename, subfolders, type = "latent.svg", "", "temp"
+ else:
+ print("NOT SAVED - LATENT (non-tensor samples)")
+ filename, subfolders, type = "error.svg", "", "temp"
+
+ # --- AUDIO ---
+ case _ if isinstance(input, dict) and "waveform" in input and "sample_rate" in input:
+ metadata = AU.prepare_metadata(prompt, extra_pnginfo) if save_metadata else None
+ print("Saving AUDIO")
+ AU.save_audio(input, path, metadata)
+ filename, subfolders, type = "audio.svg", "", "temp"
+
+ # --- STRING OR INT/FLOAT ---
+ case _ if isinstance(input, (str, dict, int, float)):
+ print("Saving TEXT")
+ SU.save_text_file(input, path)
+ filename, subfolders, type = "text.svg", "", "temp"
+
+ # --- FALLBACK / UNEXPECTED TYPE ---
+ case _:
+ t = type(input)
+ module_name = t.__module__
+ type_name = t.__name__
+ print(f"NOT SAVED - unexpected type : {module_name}.{type_name}")
+ filename, subfolders, type = "error.svg", "", "temp"
+
+ if not preview:
+ filename, subfolders, type = "blank.png", "", "temp"
+
+ return {"ui": {"images": [{"filename": filename, "subfolder": subfolders, "type": type}]}}
+
+
+NODE_CLASS_MAPPINGS = {
+ "ImageCropLoop": ImageCropLoop,
+ "ImagePasteLoop": ImagePasteLoop,
+ "LoopAny": LoopAny,
+ "SaveAny": SaveAny,
+}
+
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "ImageCropLoop": "♾️ Image Crop",
+ "ImagePasteLoop": "♾️ Paste Image",
+ "LoopAny": "♾️ Loop Any",
+ "SaveAny": "♾️ Save Any",
+}
\ No newline at end of file
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/utils/communication_manager.py b/utils/communication_manager.py
new file mode 100644
index 0000000..0b5773a
--- /dev/null
+++ b/utils/communication_manager.py
@@ -0,0 +1,62 @@
+import asyncio
+import json
+from typing import Dict, Any
+from aiohttp import web
+from server import PromptServer
+
+class CommunicationManager:
+ def __init__(self):
+ self.message_store = {}
+ self.response_callbacks = {}
+ self.app = PromptServer.instance.app
+ self.setup_routes()
+
+ def setup_routes(self):
+ """
+ Setup all communication routes
+ """
+ self.app.router.add_post('/api/bridge/response', self.handle_response)
+
+ async def send_message(self, event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Send message to frontend and wait for response
+ """
+ message_id = data.get('id', str(hash(json.dumps(data))))
+
+ # Store callback for response
+ future = asyncio.Future()
+ self.response_callbacks[message_id] = future
+
+ # Send message to frontend
+ PromptServer.instance.send_sync(event_type, data)
+
+ # Wait for response with timeout
+ try:
+ response = await asyncio.wait_for(future, timeout=30.0)
+ return response
+ except asyncio.TimeoutError:
+ raise Exception(f"Timeout waiting for response for message {message_id}")
+ finally:
+ self.response_callbacks.pop(message_id, None)
+
+ async def handle_response(self, request):
+ """
+ Handle response from frontend
+ """
+ try:
+ data = await request.json()
+ message_id = data.get('id')
+
+ if message_id in self.response_callbacks:
+ future = self.response_callbacks[message_id]
+ if not future.done():
+ future.set_result(data)
+ return web.json_response({"status": "ok"})
+ else:
+ return web.json_response({"status": "error", "message": "Unknown message ID"}, status=400)
+
+ except Exception as e:
+ return web.json_response({"status": "error", "message": str(e)}, status=500)
+
+# Global instance
+comm_manager = CommunicationManager()
\ No newline at end of file
diff --git a/utils/error_handler.py b/utils/error_handler.py
new file mode 100644
index 0000000..8cc6de2
--- /dev/null
+++ b/utils/error_handler.py
@@ -0,0 +1,13 @@
+import logging
+
+logger = logging.getLogger(__name__)
+
+class ErrorHandler:
+ @staticmethod
+ def handle_communication_error(error: Exception, context: str = ""):
+ """
+ Handle communication errors with proper logging
+ """
+ error_msg = f"Communication error in {context}: {str(error)}"
+ logger.error(error_msg)
+ return error_msg
diff --git a/utils/loop_audio_utils.py b/utils/loop_audio_utils.py
new file mode 100644
index 0000000..4b94b02
--- /dev/null
+++ b/utils/loop_audio_utils.py
@@ -0,0 +1,117 @@
+import os
+import torch
+import torchaudio
+from torchaudio.functional import resample
+import json
+import av
+import io
+
+class LoopAudioUtils:
+ """Utility class for managing audio files"""
+
+ DEFAULT_SAMPLE_RATE = 44100
+
+ @staticmethod
+ def load_or_create_audio(audio: dict | None, path: str, load: bool = True, target_sample_rate: int = DEFAULT_SAMPLE_RATE) -> dict:
+ """
+ Load an existing audio file or create the file.
+ """
+ if os.path.exists(path) and load:
+ return LoopAudioUtils.load_audio(path, target_sample_rate)
+ else:
+ if audio is None:
+ waveform = torch.zeros((1, 1, target_sample_rate), dtype=torch.float32)
+ audio = {"waveform": waveform, "sample_rate": target_sample_rate}
+ LoopAudioUtils.save_audio(audio, path)
+ return audio
+
+ @staticmethod
+ def load_audio(path: str, target_sample_rate: int = DEFAULT_SAMPLE_RATE) -> dict:
+ """
+ Load an existing audio file and return a dict with 'waveform' and 'sample_rate'.
+ eventually resample to target_sample_rate.
+ """
+ waveform, sample_rate = torchaudio.load(path)
+ if waveform.ndim == 2:
+ waveform = waveform.unsqueeze(0)
+ if sample_rate != target_sample_rate:
+ waveform = resample(waveform, sample_rate, target_sample_rate)
+ sample_rate = target_sample_rate
+ return {"waveform": waveform, "sample_rate": sample_rate}
+
+ # @staticmethod
+ # def save_audio(audio: dict, path: str, metadata: dict | None = None) -> str:
+ # """
+ # Save an audio file as wav and return input path.
+ # """
+ # waveform = audio["waveform"]
+ # sample_rate = audio["sample_rate"]
+ # if waveform.ndim == 3:
+ # waveform = waveform[0]
+ # os.makedirs(os.path.dirname(path), exist_ok=True)
+ # torchaudio.save(path, waveform.cpu(), sample_rate)
+ # return path
+
+ @staticmethod
+ def save_audio(audio: dict, path: str, metadata: dict | None = None) -> str:
+ """
+ Save an audio file as flac (mono to up to 6 channels). return input path.
+ """
+ waveform = audio["waveform"]
+ sample_rate = audio["sample_rate"]
+
+ if waveform.ndim == 3:
+ waveform = waveform[0] # Shape: (channels, samples)
+
+ num_channels = waveform.shape[0]
+
+ # layout
+ if num_channels == 1:
+ layout = 'mono'
+ elif num_channels == 2:
+ layout = 'stereo'
+ else:
+ layout = f'{num_channels}.0' # up to 6 channels
+
+ # Transform for PyAV
+ if num_channels == 1:
+ frame_tensor = waveform # (1, samples)
+ else:
+ frame_tensor = waveform.movedim(0, 1).reshape(1, -1) # (channels, samples) → (1, samples * channels)
+
+ frame_data = frame_tensor.cpu().numpy()
+
+ output_buffer = io.BytesIO()
+ output_container = av.open(output_buffer, mode='w', format="flac")
+
+ if metadata:
+ for key, value in metadata.items():
+ output_container.metadata[key] = str(value)
+
+ out_stream = output_container.add_stream("flac", rate=sample_rate)
+
+ frame = av.AudioFrame.from_ndarray(frame_data, format='flt', layout=layout)
+ frame.sample_rate = sample_rate
+ frame.pts = 0
+
+ output_container.mux(out_stream.encode(frame))
+ output_container.mux(out_stream.encode(None))
+ output_container.close()
+
+ with open(path, 'wb') as f:
+ f.write(output_buffer.getbuffer())
+
+ return path
+
+ @staticmethod
+ def prepare_metadata(prompt: str, extra_pnginfo: str) -> dict[str, str]:
+ """
+ Feed a dict with metadata.
+ """
+ metadata = {}
+ if prompt is not None:
+ metadata["prompt"] = json.dumps(prompt)
+ if extra_pnginfo is not None:
+ for x in extra_pnginfo:
+ metadata[x] = json.dumps(extra_pnginfo[x])
+ return metadata
diff --git a/utils/loop_img_utils.py b/utils/loop_img_utils.py
new file mode 100644
index 0000000..f491213
--- /dev/null
+++ b/utils/loop_img_utils.py
@@ -0,0 +1,332 @@
+from PIL import Image, ImageOps
+from PIL.PngImagePlugin import PngInfo
+import os
+import numpy as np
+import torch
+import torch.nn.functional as F
+import re
+import json
+from itertools import count
+import hashlib
+
+class LoopImageUtils:
+ """Utility class for managing images"""
+
+ @staticmethod
+ def save_preview_image(image: torch.Tensor, dir: str, scale: float) -> str:
+ """
+ Save an image tensor as JPEG in the specified folder and return filename.
+ """
+ img = Image.fromarray((image[0].detach().cpu().numpy() * 255).astype(np.uint8))
+
+ if scale != 1.0:
+ _, h, w, _ = image.shape
+ img = img.resize((int(w*scale), int(h*scale)), Image.LANCZOS)
+
+ existing = [p for p in os.listdir(dir) if p.startswith("preview_") and p.endswith(".jpeg")]
+ pattern = re.compile(r"preview_(\d{5})\.jpeg")
+ numbers = {int(pattern.match(f).group(1)) for f in existing if pattern.match(f)}
+ counter = next(c for c in count() if c not in numbers)
+
+ filename = f"preview_{counter:05d}.jpeg"
+ file_path = os.path.join(dir, filename)
+ img.save(file_path, format="JPEG", quality=60, optimize=False, progressive=False)
+
+ return filename
+
+ @staticmethod
+ def save_preview_mask(mask: torch.Tensor, dir: str, scale: float = 1.0) -> str:
+ """
+ Save a mask image tensor as binary PNG in the specified folder and return filename.
+ """
+ mask_proc = mask[0] if mask.ndim == 3 else mask
+
+ # # precise mask and super-slow preview
+ # mask_np = (mask_proc.detach().clamp(0.0, 1.0).mul_(255)
+ # .byte().cpu().numpy())
+
+ # binary mask for fast preview
+ mask_proc = mask_proc.detach().clamp(0.0, 1.0)
+ mask_binary = (mask_proc > 0.5).float()
+ mask_np = (mask_binary * 255).byte().cpu().numpy()
+
+ h, w = mask_np.shape
+ rgba = np.zeros((h, w, 4), dtype=np.uint8)
+ rgba[..., 3] = mask_np # alpha = intensité du masque
+
+ pil_mask = Image.fromarray(rgba, mode="RGBA")
+
+ if scale != 1.0:
+ new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
+ pil_mask = pil_mask.resize(new_size, Image.Resampling.BILINEAR)
+
+ base = "mask_preview_"
+ existing = [f for f in os.listdir(dir) if f.startswith(base)]
+ num = len(existing)
+ filename = f"{base}{num:05d}.png"
+ file_path = os.path.join(dir, filename)
+
+ pil_mask.save(file_path, format="PNG", compress_level=3, optimize=True)
+
+ return filename
+
+ @staticmethod
+ def load_existing_image(path: str) -> torch.Tensor:
+ """
+ Load an existing image from path and return a tensor (1, H, W, 3)
+ """
+ img = Image.open(path)
+ img = ImageOps.exif_transpose(img)
+
+ if img.mode == 'I':
+ img = img.point(lambda i: i * (1 / 255))
+ img = img.convert("RGB")
+
+ tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0)[None, ...]
+
+ return tensor
+
+ @staticmethod
+ def save_new_image(image: torch.Tensor, path: str, metadata: PngInfo | None = None) -> torch.Tensor:
+ """
+ Save image to path and return input tensor.
+ """
+ img = Image.fromarray((image[0].cpu().numpy() * 255).astype(np.uint8))
+ img.save(path, pnginfo=metadata, compress_level=0)
+
+ return image
+
+ @staticmethod
+ def save_image_with_alpha_mask(image: torch.Tensor, mask: torch.Tensor, path: str, metadata: PngInfo | None = None) -> torch.Tensor:
+ """
+ Save image with mask as alpha channel to path and return input image tensor.
+ """
+ # keep first batch element for image [H, W, C]
+ image_single = image[0] if image.ndim == 4 else image
+
+ # # first batch element for mask
+ if mask.ndim == 2: # [H, W]
+ mask_single = mask.unsqueeze(0)
+ elif mask.ndim == 3: # [B, H, W] ou [1, H, W]
+ mask_single = mask[:1] # keep first mask in batch [1, H, W]
+ else:
+ raise ValueError(f"Mask must have shape [H, W] or [B, H, W], got {mask.shape}")
+
+ # Get image dimensions
+ h, w, c = image_single.shape
+
+ # Resize mask to match image dimensions if needed
+ mask_resized = LoopImageUtils.resize_mask(mask_single, h, w)
+
+ # Clamp and prepare mask for alpha channel
+ mask_clamped = mask_resized.clamp(0.0, 1.0)
+ alpha_np = ((1.0 - mask_clamped[0]) * 255).cpu().numpy().astype(np.uint8) # [0] remove batch dim and invert mask for alpha
+
+ image_clamped = image_single.clamp(0.0, 1.0)
+ image_np = (image_clamped.cpu().numpy() * 255).astype(np.uint8)
+
+ # Convert image numpy array to PIL Image
+ if c == 1: # Grayscale
+ img_pil = Image.fromarray(image_np[:, :, 0], mode='L')
+ img_pil = img_pil.convert('RGB')
+ elif c == 3: # RGB
+ img_pil = Image.fromarray(image_np, mode='RGB')
+ else:
+ raise ValueError(f"Unsupported number of channels: {c}")
+
+ # Add alpha channel
+ img_with_alpha = img_pil.copy()
+ img_with_alpha.putalpha(Image.fromarray(alpha_np, mode='L'))
+
+ img_with_alpha.save(path, pnginfo=metadata, compress_level=0)
+
+ return image
+
+ @staticmethod
+ def prepare_metadata(prompt: str, extra_pnginfo: str) -> PngInfo:
+ """
+ Feed a pngInfo object with metadata.
+ """
+ # metadata = None
+ metadata = PngInfo()
+ if prompt is not None:
+ metadata.add_text("prompt", json.dumps(prompt))
+ if extra_pnginfo is not None:
+ for x in extra_pnginfo:
+ metadata.add_text(x, json.dumps(extra_pnginfo[x]))
+ return metadata
+
+ @staticmethod
+ def load_existing_mask(path: str) -> torch.Tensor:
+ """
+ Load an existing mask (.png) and return a mask tensor (1, H, W)
+ """
+ img = Image.open(path).convert("L") # 8-bit grayscale
+ img = ImageOps.exif_transpose(img) # EXIF rotation
+
+ mask_np = np.array(img).astype(np.float32) / 255.0
+ mask_tensor = torch.from_numpy(mask_np)[None, ...]
+
+ return mask_tensor
+
+ @staticmethod
+ def save_new_mask(mask: torch.Tensor, path: str, metadata: PngInfo | None = None) -> torch.Tensor:
+ """
+ save a mask (1, H, W) in a .png file and return mask tensor input
+ """
+ # Check shape
+ assert mask.ndim == 3 and mask.shape[0] == 1, \
+ f"Mask must be of shape (1, H, W), received {mask.shape}"
+
+ # Clamp
+ mask_clamped = mask.clamp(0.0, 1.0)
+ mask_np = (mask_clamped[0].cpu().numpy() * 255).astype(np.uint8)
+ pil_mask = Image.fromarray(mask_np, mode="L")
+ # pil_mask.save(path)
+ pil_mask.save(path, pnginfo=metadata, compress_level=0)
+
+ return mask
+
+ @staticmethod
+ def get_mask_from_image_alpha(path: str, h: int, w: int) -> torch.Tensor:
+ """
+ Return a mask from alpha channel of an image file path, or empty mask
+ """
+ img = Image.open(path)
+ img = ImageOps.exif_transpose(img)
+
+ if 'A' in img.getbands():
+ mask_out = np.array(img.getchannel('A')).astype(np.float32) / 255.0
+ mask_out = 1. - torch.from_numpy(mask_out)
+ return mask_out.unsqueeze(0)
+ else:
+ return torch.zeros((1, h, w), dtype=torch.float32, device="cpu")
+
+ @staticmethod
+ def get_mask_from_image_tensor(image: torch.Tensor, h: int, w: int) -> torch.Tensor:
+ """
+ Return a mask from alpha of an Image tensor, or empty mask
+ """
+ if image.shape[3] == 4: # if RGBA
+ return image[0, :, :, 3:4].permute(2, 0, 1)
+ else:
+ return torch.zeros((1, h, w), dtype=torch.float32, device="cpu")
+
+ @staticmethod
+ def resize_mask(mask: torch.Tensor, h: int, w: int) -> torch.Tensor:
+ """
+ Resize a mask to h,w if != than h, w
+ """
+ if mask.shape[1] != h or mask.shape[2] != w:
+ mask = F.interpolate(
+ mask.unsqueeze(0),
+ size=(h, w),
+ mode='nearest'
+ ).squeeze(0)
+ return mask
+
+ @staticmethod
+ def get_mask_size(mask: torch.Tensor) -> tuple:
+ """
+ return width and height of a mask
+ """
+ w, h = mask.shape[-1], mask.shape[-2]
+ return (w, h)
+
+ @staticmethod
+ def get_default_mask(h: int, w: int) -> torch.Tensor:
+ """
+ return a blank mask tensor of h and w size
+ """
+ return torch.zeros((1, h, w), dtype=torch.float32, device="cpu")
+
+ @staticmethod
+ def ensure_blank_image(path: str):
+ """
+ Create a 1x1 transparent png file if it doesn't exist.
+ """
+ full_path = os.path.join(path, "blank.png")
+ if not os.path.exists(full_path):
+ img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) # pixel transparent
+ img.save(full_path, format="PNG")
+
+ @staticmethod
+ def compute_image_hash(image: torch.Tensor) -> str:
+ """
+ Compute a compact perceptual hash for an image tensor.
+
+ generates a lightweight, content-based signature used to
+ quickly compare or detect changes between images. designed for speed:
+ Small visual variations (color, brightness, or minor cropping)
+ typically yield similar hashes, while distinct images produce distinct
+ values.
+ """
+ try:
+ if image.is_cuda:
+ image = image.detach().to("cpu")
+
+ if image.dtype == torch.float32:
+ i = (image.clamp(0, 1) * 255).byte()
+ else:
+ i = image.byte()
+
+ # agressive downsample (16x16)
+ h, w = i.shape[-3:-1]
+ step_h = max(1, h // 16)
+ step_w = max(1, w // 16)
+ small = i[..., ::step_h, ::step_w, :]
+
+ mean_val = torch.round(small.float().mean(dim=(0, 1)) / 32).to(torch.uint8)
+
+ # fast stats
+ arr_float = small.float()
+ mean_global = torch.round(arr_float.mean())
+ max_val = arr_float.max()
+ min_val = arr_float.min()
+
+ stats = torch.tensor([mean_global, max_val, min_val], dtype=torch.float32)
+
+ data = mean_val.numpy().tobytes() + stats.numpy().tobytes()
+ return hashlib.md5(data).hexdigest()
+
+ except Exception as e:
+ print(f"[HASH ERROR image] {e}")
+ return None
+
+ @staticmethod
+ def compute_mask_hash(mask: torch.Tensor) -> str:
+ """
+ Compute a compact perceptual hash for a mask tensor.
+
+ generates a lightweight, content-based signature used to
+ quickly compare or detect changes between masks. designed for speed:
+ Small visual variations typically yield similar hashes.
+ """
+ try:
+ if mask.is_cuda:
+ mask = mask.detach().to("cpu")
+
+ if mask.dtype == torch.float32:
+ m = (mask.clamp(0, 1) * 255).to(torch.uint8)
+ else:
+ m = mask.to(torch.uint8)
+
+ # agressive downsample (16x16) --> precise (32x32)
+ h, w = m.shape[-2:]
+ step_h = max(1, h // 32)
+ step_w = max(1, w // 32)
+ small = m[..., ::step_h, ::step_w]
+
+ mean_val = torch.round(small.float().mean())
+
+ # 12 bins histogram
+ small_binned = (small.float() / 255 * 7).to(torch.long).clamp(0, 7)
+ hist = torch.bincount(small_binned.flatten(), minlength=12)
+ hist = torch.round(hist.float() / hist.sum() * 255).to(torch.uint8)
+
+ data = mean_val.numpy().tobytes() + hist.numpy().tobytes()
+ return hashlib.md5(data).hexdigest()
+
+ except Exception as e:
+ print(f"[HASH ERROR mask] {e}")
+ return None
diff --git a/utils/loop_latent_utils.py b/utils/loop_latent_utils.py
new file mode 100644
index 0000000..fda3e4a
--- /dev/null
+++ b/utils/loop_latent_utils.py
@@ -0,0 +1,104 @@
+import torch
+import safetensors.torch
+import os
+import json
+
+
+class LoopLatentUtils:
+ """Utility class for managing latents"""
+
+ @staticmethod
+ def _load_or_create(latent: torch.Tensor, path: str, load: bool) -> torch.Tensor:
+ """
+ Internal method to load or create a latent.
+ """
+ if os.path.exists(path) and load:
+ return LoopLatentUtils.load_existing_latent(path)
+ else:
+ return LoopLatentUtils.save_new_latent(latent, path)
+
+ @staticmethod
+ def load_or_create_latent(latent: torch.Tensor, path: str, load: bool) -> tuple[torch.Tensor, int, int]:
+ """
+ Load an existing latent or save it and return its size
+ """
+ latent_out = LoopLatentUtils._load_or_create(latent, path, load)
+ w, h = LoopLatentUtils.get_latent_size(latent_out)
+ return latent_out, w, h
+
+ @staticmethod
+ def load_or_create_audio_latent(latent: torch.Tensor, path: str, load: bool) -> torch.Tensor:
+ """
+ Load an existing latent or save it.
+ """
+ return LoopLatentUtils._load_or_create(latent, path, load)
+
+ @staticmethod
+ def load_existing_latent(path: str) -> dict[str, torch.Tensor]:
+ """
+ load a .latent and return a dict {'samples': tensor 4D}.
+ Apply multiplier if the file is ancient.
+ """
+ latent = safetensors.torch.load_file(path, device="cpu") # version code comfyui
+ multiplier = 1.0
+ if "latent_format_version_0" not in latent:
+ multiplier = 1.0 / 0.18215
+ samples = {"samples": latent["latent_tensor"].float() * multiplier}
+ return samples
+
+ @staticmethod
+ def save_new_latent(latent: torch.Tensor, path: str, metadata: dict | None = None) -> torch.Tensor:
+ """
+ Save latent to path and return
+ """
+ output = {
+ "latent_tensor": latent["samples"].contiguous()
+ if isinstance(latent, dict) and "samples" in latent
+ else latent.contiguous(),
+ "latent_format_version_0": torch.tensor([]),
+ }
+ safetensors.torch.save_file(output, path, metadata=metadata)
+
+ return latent
+
+ @staticmethod
+ def get_latent_size(latent: torch.Tensor, latent_scale: int = 8) -> tuple[int, int]:
+ """
+ Return latent width and height.
+ """
+ samples = latent["samples"] if isinstance(latent, dict) and "samples" in latent else latent
+
+ # torch.Tensor case
+ if isinstance(samples, torch.Tensor):
+ if samples.ndim >= 2:
+ h = int(samples.shape[-2])
+ w = int(samples.shape[-1])
+ return (w * latent_scale, h * latent_scale)
+
+ # numpy / array-like case (attempt)
+ shape = getattr(samples, "shape", None)
+ if shape and len(shape) >= 2:
+ try:
+ h = int(shape[-2])
+ w = int(shape[-1])
+ return (w * latent_scale, h * latent_scale)
+ except Exception:
+ pass
+
+ return (0, 0)
+
+ @staticmethod
+ def prepare_metadata(prompt: str, extra_pnginfo: str) -> dict[str, str]:
+ """
+ Feed a dict with metadata.
+ """
+ prompt_info = ""
+ if prompt is not None:
+ prompt_info = json.dumps(prompt)
+
+ metadata = None
+ metadata = {"prompt": prompt_info}
+ if extra_pnginfo is not None:
+ for x in extra_pnginfo:
+ metadata[x] = json.dumps(extra_pnginfo[x])
+ return metadata
\ No newline at end of file
diff --git a/utils/loop_path_utils.py b/utils/loop_path_utils.py
new file mode 100644
index 0000000..f9e5e6f
--- /dev/null
+++ b/utils/loop_path_utils.py
@@ -0,0 +1,43 @@
+from pathlib import Path
+import shutil
+
+class LoopPathUtils:
+ """Utility class for files and path management"""
+
+ @staticmethod
+ def parse_path(full_path: str, base_dir: str) -> tuple[str | None, str | None, str | None]:
+ """
+ Given a full path, returns filename, relative_subfolders, base_path.
+ """
+ try:
+ full_path = Path(full_path)
+ filename = full_path.name
+ parent_dir = full_path.parent
+
+ parts = parent_dir.parts
+ if base_dir in parts:
+ base_index = parts.index(base_dir)
+ base_path = Path(*parts[:base_index + 1])
+ relative = parent_dir.relative_to(base_path)
+ else:
+ base_path = parent_dir
+ relative = Path("")
+
+ return filename, str(relative) if str(relative) != '.' else '', str(base_path)
+ except Exception:
+ return None, None, None
+
+ @staticmethod
+ def copy_tree(source: str, dest: str) :
+ """
+ Copy relative source folder content to relative dest folder. I.E. ComfyUI folder as base folder
+ """
+ source = Path.cwd() / source
+ dest = Path.cwd() /dest
+
+ try:
+ shutil.copytree(source, dest, dirs_exist_ok=True)
+ except FileNotFoundError:
+ print(f"Error: Cannot find source directory")
+ except Exception as e:
+ print(f"Error while copying: {e}")
\ No newline at end of file
diff --git a/utils/loop_string_utils.py b/utils/loop_string_utils.py
new file mode 100644
index 0000000..ed2076c
--- /dev/null
+++ b/utils/loop_string_utils.py
@@ -0,0 +1,37 @@
+import os
+
+class LoopStringUtils:
+ """Utility class for string and text files management"""
+
+ @staticmethod
+ def load_or_create_text_file(input: str | int |float, path: str, load: bool = True) -> str:
+ """
+ Load an existing text file or create it.
+ """
+ if os.path.exists(path) and load:
+ return LoopStringUtils.load_text_file(path)
+ else:
+ if input is None:
+ input = ""
+ LoopStringUtils.save_text_file(input, path)
+ return input
+
+ @staticmethod
+ def load_text_file(path: str) -> str | None:
+ """
+ Load an existing text file and return its content.
+ """
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+ return content
+
+ @staticmethod
+ def save_text_file(input: str | int | float, path: str) -> str:
+ """
+ Save a text file and return input path.
+ """
+ if isinstance(input, (int, float)):
+ input = str(input)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(input)
+ return path