refactor: clean up repository to v1.0.0 and implement recursive self-queuing architecture
- Delete deprecated modules: `sequence.py`, `paths.py`, `batch.py`, `debug.py`. - Clean `image.py`, retaining only SessionImageReceiver, SessionImageSender, and auxiliary function. - Clean `video.py`, retaining only IncrementalVideoStitcher and WanFrameValidator. - Clean `loop.py`, retaining only SequentialLoopStart and SequentialLoopTrigger. - Rewrite `__init__.py` mapping explicitly to the 6 core components and bump version to 1.0.0.
This commit is contained in:
+6
-57
@@ -1,9 +1,8 @@
|
||||
__version__ = "0.9.3-beta"
|
||||
__version__ = "1.0.0" # Subimos a versión 1.0 ya que es una arquitectura nueva
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
|
||||
def register_node(c, display_name=None):
|
||||
assert not isinstance(c.RETURN_TYPES, str), "Error: string found instead of tuple."
|
||||
assert not isinstance(c.RETURN_NAMES, str), "Error: string found instead of tuple."
|
||||
@@ -11,71 +10,21 @@ def register_node(c, display_name=None):
|
||||
NODE_DISPLAY_NAME_MAPPINGS[c.__name__] = display_name or c.__name__
|
||||
return c
|
||||
|
||||
|
||||
from . import sequence, paths, batch, image, debug, loop, video
|
||||
from . import image, loop, video
|
||||
|
||||
# Display name mappings with emojis
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
# Sequence
|
||||
"Range": "🔢 Range",
|
||||
"Literal": "📝 Literal",
|
||||
"Reorder": "🔄 Reorder",
|
||||
"Combinations": "🧬 Combinations",
|
||||
"Permutations": "🔀 Permutations",
|
||||
"Slice": "✂️ Slice",
|
||||
"Join": "🔗 Join",
|
||||
"ModelFinder": "🔍 Model Finder",
|
||||
|
||||
# Batch
|
||||
"MakeBatch": "🛠️ Make Batch",
|
||||
"CombineBatches": "🖇️ Combine Batches",
|
||||
"EnumerateBatch": "🔢 Enumerate Batch",
|
||||
"GetBatchStep": "📍 Get Batch Step",
|
||||
"FormatAttributes": "📝 Format Attributes",
|
||||
"GetAttribute": "📥 Get Attribute",
|
||||
"GetAttributeInt": "📥 Get Attribute (Int)",
|
||||
"GetAttributeFloat": "📥 Get Attribute (Float)",
|
||||
"GetAttributeString": "📥 Get Attribute (String)",
|
||||
"BatchToList": "🔄 Batch To List",
|
||||
"LoadCSV": "📂 Load CSV",
|
||||
"PreviewBatch": "📊 Preview Batch",
|
||||
|
||||
# Image
|
||||
"JoinImageBatch": "🖼️ Join Image Batch",
|
||||
"JoinImages": "🖼️ Join Images",
|
||||
"SelectImageBatch": "🎯 Select Image Batch",
|
||||
"SelectImageList": "🎯 Select Image List",
|
||||
"GetImageSize": "📏 Get Image Size",
|
||||
"StringToImage": "🔤 String To Image",
|
||||
"ProgressBar": "⏳ Progress Bar",
|
||||
"ImageBatchToList": "🖼️ Image Batch To List",
|
||||
"ImageListToBatch": "🖼️ Image List To Batch",
|
||||
"LatentBatchToList": "🎞️ Latent Batch To List",
|
||||
"LatentListToBatch": "🎞️ Latent List To Batch",
|
||||
# Image (Memoria de Sesión)
|
||||
"SessionImageReceiver": "📥 Session Image Receiver",
|
||||
"SessionImageSender": "📤 Session Image Sender",
|
||||
|
||||
# Video
|
||||
"FFmpegVideoStitcher": "🎞️ FFmpeg Video Stitcher",
|
||||
# Video (Ensamblaje y Validación)
|
||||
"IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher",
|
||||
"WanFrameValidator": "🛡️ Wan Frame Validator",
|
||||
|
||||
# Debug
|
||||
"Stringify": "🧵 Stringify",
|
||||
"Interact": "⌨️ Interact",
|
||||
|
||||
# Loop
|
||||
"LoopIndex": "🔁 Sequential Loop Index",
|
||||
"Repeat": "🔁 Repeat",
|
||||
# Loop (Orquestación Autónoma)
|
||||
"SequentialLoopStart": "🏁 Loop Start (Index)",
|
||||
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
|
||||
})
|
||||
|
||||
# Backward compatibility for "Job" naming
|
||||
NODE_CLASS_MAPPINGS.update({
|
||||
"MakeJob": batch.MakeBatch,
|
||||
"CombineJobs": batch.CombineBatches,
|
||||
"EnumerateJob": batch.EnumerateBatch,
|
||||
"GetJobStep": batch.GetBatchStep,
|
||||
"JobToList": batch.BatchToList,
|
||||
})
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
@@ -1,305 +0,0 @@
|
||||
import collections
|
||||
import itertools
|
||||
import csv
|
||||
import os
|
||||
import folder_paths
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
def format_batch_as_table(batch, current_index=-1, max_rows=20):
|
||||
if not batch:
|
||||
return "(Empty Batch)"
|
||||
|
||||
# Get all unique keys across all rows
|
||||
keys = list(dict.fromkeys(itertools.chain.from_iterable(row.keys() for row in batch)))
|
||||
|
||||
if not keys:
|
||||
return "(Empty rows)"
|
||||
|
||||
# Truncate values for display
|
||||
def trunc(v, l=20):
|
||||
s = str(v).replace('\n', ' ')
|
||||
return (s[:l-3] + "...") if len(s) > l else s
|
||||
|
||||
# Calculate widths
|
||||
widths = {k: len(str(k)) for k in keys}
|
||||
rows_to_show = batch[:max_rows]
|
||||
for row in rows_to_show:
|
||||
for k in keys:
|
||||
widths[k] = max(widths[k], len(trunc(row.get(k, ""))))
|
||||
|
||||
# Build header
|
||||
header = "| " + " | ".join(str(k).ljust(widths[k]) for k in keys) + " |"
|
||||
sep = "|-" + "-|-".join("-" * widths[k] for k in keys) + "-|"
|
||||
|
||||
idx_to_highlight = (current_index % len(batch)) if (len(batch) > 0 and current_index != -1) else (0 if len(batch) > 0 else -1)
|
||||
|
||||
lines = [header, sep]
|
||||
for i, row in enumerate(rows_to_show):
|
||||
prefix = ">" if i == idx_to_highlight else " "
|
||||
line = f"{prefix}| " + " | ".join(trunc(row.get(k, "")).ljust(widths[k]) for k in keys) + " |"
|
||||
lines.append(line)
|
||||
|
||||
if len(batch) > max_rows:
|
||||
lines.append(f"... and {len(batch) - max_rows} more rows")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@register_node
|
||||
class MakeBatch:
|
||||
"""Turns a sequence into a batch with one attribute."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"name": ("STRING", {"default": ''}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BATCH", "INT")
|
||||
RETURN_NAMES = ("batch", "count")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def merge_dicts(self, *dicts):
|
||||
#return collections.ChainMap(*reversed(dicts))
|
||||
return dict(itertools.chain.from_iterable(d.items() for d in dicts))
|
||||
|
||||
def go(self, sequence, name):
|
||||
result = [{name: value} for value in sequence]
|
||||
return (result, len(result))
|
||||
|
||||
|
||||
@register_node
|
||||
class CombineBatches(MakeBatch):
|
||||
"""Combines multiple batches."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"a": ("BATCH", ),
|
||||
"method": (("zip", "product"), {"default": "zip"}),
|
||||
},
|
||||
"optional": {
|
||||
x: ("BATCH", ) for x in ('b', 'c', 'd', 'e')
|
||||
}
|
||||
}
|
||||
|
||||
def go(self, method, **kwargs):
|
||||
method = {'product': itertools.product, 'zip': zip}[method]
|
||||
result = [self.merge_dicts(*steps) for steps in method(*kwargs.values())]
|
||||
return (result, len(result))
|
||||
|
||||
|
||||
@register_node
|
||||
class EnumerateBatch(MakeBatch):
|
||||
"""Combines multiple batches."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH", ),
|
||||
"name": ("STRING", {"default": ''}),
|
||||
},
|
||||
}
|
||||
|
||||
def go(self, batch, name):
|
||||
result = [self.merge_dicts(step, {name: n}) for n, step in enumerate(batch)]
|
||||
return (result, len(result))
|
||||
|
||||
|
||||
@register_node
|
||||
class GetBatchStep:
|
||||
"""Gets the batch step by number."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH", ),
|
||||
"step": ("INT", {"default": 0}),
|
||||
"wrap": (("repeat", "clamp"), {"default": "repeat"}),
|
||||
},
|
||||
}
|
||||
RETURN_TYPES = ("ATTRIBUTES", )
|
||||
RETURN_NAMES = ("attributes", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, batch, step, wrap):
|
||||
if wrap == 'repeat':
|
||||
while step < 0:
|
||||
step += len(batch)
|
||||
step = step % len(batch)
|
||||
elif wrap == 'clamp':
|
||||
step = max(min(step, len(batch)-1), 0)
|
||||
return (batch[step], )
|
||||
|
||||
|
||||
@register_node
|
||||
class FormatAttributes:
|
||||
"""Applies attributes to a format string."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"attributes": ("ATTRIBUTES",),
|
||||
"format": ("STRING", {'default': '', 'multiline': True, "dynamicPrompts": False})
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", )
|
||||
RETURN_NAMES = ("string", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, attributes, format):
|
||||
return (format.format(**attributes), )
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@register_node
|
||||
class GetAttribute:
|
||||
"""Gets a named attribute from a step."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"attributes": ("ATTRIBUTES", ),
|
||||
"name": ("STRING", {"default": ''}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (AnyType("*"), )
|
||||
RETURN_NAMES = ("value", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, attributes, name):
|
||||
return (attributes[name], )
|
||||
|
||||
|
||||
# Dynamically register typed attribute getters to avoid wildcard bug
|
||||
# https://github.com/comfyanonymous/ComfyUI/pull/770
|
||||
for t in ('INT', 'FLOAT', 'STRING'):
|
||||
register_node(type(
|
||||
'GetAttribute'+t.title(),
|
||||
(GetAttribute, ),
|
||||
{
|
||||
'RETURN_TYPES': (t, ),
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
@register_node
|
||||
class LoadCSV:
|
||||
"""Loads a CSV file into a batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"path": ("STRING", {"default": "prompt.csv"}),
|
||||
"delimiter": (["comma", "tab", "semicolon"], {"default": "comma"}),
|
||||
"quotechar": (["\"", "'"], {"default": "\""}),
|
||||
},
|
||||
"optional": {
|
||||
"index": ("INT", {"default": -1, "min": -1, "max": 999999}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BATCH", "ATTRIBUTES", "INT")
|
||||
RETURN_NAMES = ("batch", "current_attributes", "count")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, path, delimiter, quotechar, index=-1):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
if input_dir is None:
|
||||
raise ValueError("Input directory not found in ComfyUI configuration.")
|
||||
|
||||
if os.path.isabs(path):
|
||||
full_path = os.path.normpath(path)
|
||||
else:
|
||||
full_path = os.path.normpath(os.path.join(input_dir, path))
|
||||
|
||||
if os.path.commonpath([input_dir, full_path]) != os.path.normpath(input_dir):
|
||||
raise ValueError(f"Access denied: Path '{path}' is outside the input directory.")
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise FileNotFoundError(f"CSV file not found: {path}")
|
||||
|
||||
delim = {
|
||||
"comma": ",",
|
||||
"tab": "\t",
|
||||
"semicolon": ";",
|
||||
}[delimiter]
|
||||
|
||||
batch = []
|
||||
with open(full_path, mode='r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f, delimiter=delim, quotechar=quotechar)
|
||||
for row in reader:
|
||||
# Clean up keys and values
|
||||
clean_row = {k.strip() if k else k: v.strip() if v else v for k, v in row.items()}
|
||||
batch.append(clean_row)
|
||||
|
||||
count = len(batch)
|
||||
current_attributes = batch[index % count] if count > 0 and index != -1 else (batch[0] if count > 0 else {})
|
||||
|
||||
table = format_batch_as_table(batch, index)
|
||||
|
||||
return {"ui": {"text": [table]}, "result": (batch, current_attributes, count)}
|
||||
|
||||
|
||||
@register_node
|
||||
class PreviewBatch:
|
||||
"""Shows a table of the batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH",),
|
||||
},
|
||||
"optional": {
|
||||
"index": ("INT", {"default": -1, "min": -1, "max": 999999}),
|
||||
"max_rows": ("INT", {"default": 20, "min": 1, "max": 1000}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BATCH",)
|
||||
RETURN_NAMES = ("batch",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, batch, index=-1, max_rows=20):
|
||||
table = format_batch_as_table(batch, index, max_rows)
|
||||
return {"ui": {"text": [table]}, "result": (batch,)}
|
||||
|
||||
|
||||
@register_node
|
||||
class BatchToList:
|
||||
"""Converts a batch into a list."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("ATTRIBUTES",)
|
||||
RETURN_NAMES = ("attributes",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, batch):
|
||||
return (batch,)
|
||||
@@ -1,106 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import textwrap
|
||||
import code
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
@register_node
|
||||
class Stringify:
|
||||
"""Convert any input to str/repr."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"x": ("*", ),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("str", "repr")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Debug"
|
||||
|
||||
def go(self, x):
|
||||
return (str(x), repr(x))
|
||||
|
||||
|
||||
class RestoreStdStreams(object):
|
||||
# ComfyUI-Manager patches sys.stdout and sys.stder
|
||||
# which breaks GNU Readline support and makes the
|
||||
# REPL annoying to use. This context manager temporarily
|
||||
# puts back the originals.
|
||||
|
||||
def __enter__(self):
|
||||
self._stdout, self._stderr = sys.stdout, sys.stderr
|
||||
sys.stdout, sys.stderr = sys.__stdout__, sys.__stderr__
|
||||
self._stdout.flush(); self._stderr.flush()
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
_stdout, _stderr = sys.stdout, sys.stderr
|
||||
sys.stdout, sys.stderr = self._stdout, self._stderr
|
||||
_stdout.flush(); _stderr.flush()
|
||||
|
||||
|
||||
EOF = 'Ctrl-Z plus Return' if os.sep == '\\' else 'Ctrl-D (i.e. EOF)'
|
||||
MESSAGE = f"Press {EOF} to continue processing."
|
||||
|
||||
|
||||
class Quitter:
|
||||
# Replace exit() and quit() to make sure the user exits with EOF
|
||||
# otherwise the whole server gets terminated.
|
||||
|
||||
def __repr__(self):
|
||||
return MESSAGE
|
||||
|
||||
def __call__(self):
|
||||
print(MESSAGE)
|
||||
|
||||
|
||||
@register_node
|
||||
class Interact:
|
||||
"""
|
||||
Opens an interactive REPL whenever the node is evaluated.
|
||||
|
||||
SECURITY WARNING: This node allows arbitrary code execution.
|
||||
To enable it, you must set the environment variable:
|
||||
COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG=1
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {x: "*" for x in ('a', 'b', 'c', 'd')},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "interact"
|
||||
CATEGORY = "🔁 Sequential Batcher/Debug"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def interact(self, **kwargs):
|
||||
if os.environ.get("COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG", "").lower() not in ("true", "1", "yes"):
|
||||
print(f"[{self.__class__.__name__}] Interactive debugging is disabled. Set COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG=1 to enable.")
|
||||
return ()
|
||||
|
||||
if sys.__stdout__.isatty():
|
||||
with RestoreStdStreams():
|
||||
code.interact(
|
||||
banner=textwrap.dedent(f"""
|
||||
Interactive debugging started.
|
||||
Try `print(a)`.
|
||||
{MESSAGE}
|
||||
"""),
|
||||
exitmsg="Resuming workflow...",
|
||||
local={
|
||||
**kwargs,
|
||||
'quit': Quitter(),
|
||||
'exit': Quitter()
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Don't block the server if there is no tty.
|
||||
print("Skipping interactive prompt because there is no tty.")
|
||||
return ()
|
||||
@@ -2,264 +2,12 @@ import os
|
||||
import random
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image
|
||||
import folder_paths
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
@register_node
|
||||
class JoinImageBatch:
|
||||
"""Turns an image batch into one big image."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"mode": (("horizontal", "vertical"), {"default": "horizontal"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "join"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def join(self, images, mode):
|
||||
# mode will be a list of strings, e.g. ["horizontal", "horizontal", ...]
|
||||
mode = mode[0]
|
||||
if isinstance(images, list):
|
||||
# images is a list of [1, H, W, C] tensors or similar
|
||||
images = torch.cat(images, dim=0)
|
||||
n, h, w, c = images.shape
|
||||
image = None
|
||||
if mode == "vertical":
|
||||
# for vertical we can just reshape
|
||||
image = images.reshape(1, n * h, w, c)
|
||||
elif mode == "horizontal":
|
||||
# for horizontal we have to swap axes
|
||||
image = torch.transpose(torch.transpose(images, 1, 2).reshape(1, n * w, h, c), 1, 2)
|
||||
return (image,)
|
||||
|
||||
|
||||
@register_node
|
||||
class JoinImages:
|
||||
"""Turns joins two images into one big image."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image_a": ("IMAGE",),
|
||||
"image_b": ("IMAGE",),
|
||||
"mode": (("horizontal", "vertical"), {"default": "horizontal"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "join"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def join(self, image_a, image_b, mode):
|
||||
dim = {'horizontal': 2, 'vertical': 1}[mode]
|
||||
return (torch.concat((image_a, image_b), dim), )
|
||||
|
||||
|
||||
@register_node
|
||||
class SelectImageBatch:
|
||||
"""Selects one image from an image batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"select": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def select(self, images, select):
|
||||
n, h, w, c = images.shape
|
||||
if select >= n:
|
||||
select = n - 1
|
||||
return (images[select].reshape(1, h, w, c),)
|
||||
|
||||
|
||||
@register_node
|
||||
class SelectImageList:
|
||||
"""Selects one image from an image list."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"select": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def select(self, images, select):
|
||||
select = select[0]
|
||||
n = len(images)
|
||||
if select >= n:
|
||||
select = n - 1
|
||||
return (images[select],)
|
||||
|
||||
|
||||
@register_node
|
||||
class GetImageSize:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "INT")
|
||||
RETURN_NAMES = ("width", "height", "batch_size")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def go(self, images):
|
||||
return (images.shape[2], images.shape[1], images.shape[0])
|
||||
|
||||
|
||||
@register_node
|
||||
class StringToImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "Hello world!"}),
|
||||
"width": ("INT", {"default": 384}),
|
||||
"height": ("INT", {"default": 16}),
|
||||
"colour": ("COLOR", {"default": "white"}),
|
||||
"background": ("COLOR", {"default": "black"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "render"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def render(self, text, width, height, colour, background):
|
||||
font = ImageFont.load_default()
|
||||
img = Image.new("RGB", (width, height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
_, _, w, h = draw.textbbox((0, 0), text, font=font)
|
||||
draw.text(((width - w) / 2, ((height - h) / 2) - 1), text, font=font, fill=colour)
|
||||
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
|
||||
@register_node
|
||||
class ProgressBar:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"progress": ("FLOAT", {'default': 0, 'forceInput': True}),
|
||||
"padding": ("INT", {'default': 3}),
|
||||
"width": ("INT", {"default": 384}),
|
||||
"height": ("INT", {"default": 16}),
|
||||
"colour": ("COLOR", {"default": "white"}),
|
||||
"background": ("COLOR", {"default": "black"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "render"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def render(self, progress, padding, width, height, colour, background):
|
||||
img = Image.new("RGB", (width, height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle((padding, padding, width - padding - 1, height - padding - 1), outline=colour)
|
||||
if progress > 0:
|
||||
ip = padding + 2
|
||||
draw.rectangle((ip, ip, max(ip+1, (width - ip - 1) * progress), height - ip - 1), outline=colour, fill=colour)
|
||||
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
|
||||
@register_node
|
||||
class ImageBatchToList:
|
||||
"""Splits an image batch into a list for iteration."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"images": ("IMAGE",)}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def go(self, images):
|
||||
return (list(torch.split(images, 1, dim=0)),)
|
||||
|
||||
|
||||
@register_node
|
||||
class ImageListToBatch:
|
||||
"""Gathers an iterated list of images into a single batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"images": ("IMAGE",)}}
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def go(self, images):
|
||||
return (torch.cat(images, dim=0),)
|
||||
|
||||
|
||||
@register_node
|
||||
class LatentBatchToList:
|
||||
"""Splits a latent batch into a list for iteration."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"latents": ("LATENT",)}}
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("latent",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Latent"
|
||||
|
||||
def go(self, latents):
|
||||
samples = latents["samples"]
|
||||
return ([{"samples": samples[i:i+1]} for i in range(samples.shape[0])],)
|
||||
|
||||
|
||||
@register_node
|
||||
class LatentListToBatch:
|
||||
"""Gathers an iterated list of latents into a single batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"latents": ("LATENT",)}}
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("latents",)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Latent"
|
||||
|
||||
def go(self, latents):
|
||||
return ({"samples": torch.cat([l["samples"] for l in latents], dim=0)},)
|
||||
|
||||
|
||||
# Variable global para almacenar el último fotograma de la sesión
|
||||
global_session_image = None
|
||||
|
||||
|
||||
@@ -5,43 +5,6 @@ from . import register_node
|
||||
# Variable global para mantener el estado del bucle entre ejecuciones
|
||||
global_loop_index = 0
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
@register_node
|
||||
class LoopIndex:
|
||||
"""Iterates a number of times."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"count": ("INT", {"default": 2, "min": 1, "max": 1000000})}}
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("index",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Loop"
|
||||
def go(self, count):
|
||||
return (list(range(count)),)
|
||||
|
||||
@register_node
|
||||
class Repeat:
|
||||
"""Repeats an input N times."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"input": (AnyType("*"),),
|
||||
"count": ("INT", {"default": 2, "min": 1, "max": 1000000}),
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = (AnyType("*"),)
|
||||
RETURN_NAMES = ("output",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Loop"
|
||||
def go(self, input, count):
|
||||
return ([input] * count,)
|
||||
|
||||
@register_node
|
||||
class SequentialLoopStart:
|
||||
"""Inicia el bucle y provee el índice actual a los nodos de imagen y vídeo."""
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import pathlib
|
||||
import glob
|
||||
from folder_paths import folder_names_and_paths
|
||||
from . import register_node
|
||||
|
||||
|
||||
folder_types = tuple(sorted(folder_names_and_paths.keys()))
|
||||
folder_default = "checkpoint" if "checkpoint" in folder_types else folder_types[0]
|
||||
|
||||
@register_node
|
||||
class ModelFinder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"filenames": ("SEQUENCE", ),
|
||||
"model_type": (folder_types, {"default": folder_default}),
|
||||
"recursive": ("BOOLEAN", {"default": True}),
|
||||
"skip_missing": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", "SEQUENCE")
|
||||
RETURN_NAMES = ("paths", "stems")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def find_models(self, fn, paths, recursive):
|
||||
for p in paths:
|
||||
for f in (p.rglob(fn) if recursive else p.glob(fn)):
|
||||
yield f.relative_to(p)
|
||||
|
||||
def go(self, filenames, model_type, recursive, skip_missing):
|
||||
paths = [pathlib.Path(folder) for folder in folder_names_and_paths[model_type][0]]
|
||||
|
||||
available_files = []
|
||||
file_index = {}
|
||||
for p in paths:
|
||||
for f in (p.rglob('*') if recursive else p.glob('*')):
|
||||
try:
|
||||
rel = f.relative_to(p)
|
||||
available_files.append(rel)
|
||||
if rel.name not in file_index:
|
||||
file_index[rel.name] = []
|
||||
file_index[rel.name].append(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
result = []
|
||||
for fn in filenames:
|
||||
if '..' in fn:
|
||||
raise Exception(f'".." is not allowed: {fn}.')
|
||||
|
||||
candidates = []
|
||||
if glob.has_magic(fn):
|
||||
for f in available_files:
|
||||
if f.match(fn):
|
||||
candidates.append(f)
|
||||
else:
|
||||
potential = file_index.get(pathlib.Path(fn).name, [])
|
||||
for f in potential:
|
||||
if f.match(fn):
|
||||
candidates.append(f)
|
||||
|
||||
if candidates:
|
||||
result.extend(candidates)
|
||||
elif not skip_missing:
|
||||
raise Exception(f'Could not find file: {fn}')
|
||||
|
||||
return ([str(f) for f in result], [f.stem for f in result])
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
import ast
|
||||
import functools
|
||||
import itertools
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
# Sequence creation.
|
||||
|
||||
|
||||
@register_node
|
||||
class Range:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"start": ("INT", {"default": 0, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"stop": ("INT", {"default": 10, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"step": ("INT", {"default": 1, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, start, stop, step):
|
||||
return (range(start, stop, step), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Literal:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"literal": ("STRING", {'default': '', 'multiline': True})
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, literal):
|
||||
return (ast.literal_eval(literal), )
|
||||
|
||||
|
||||
# Processing of existing sequences.
|
||||
|
||||
|
||||
@register_node
|
||||
class Reorder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"method": (("reverse", "sort", "reverse sort"), {"default": "sort"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, method):
|
||||
f = {"reverse": reversed, "sort": sorted, "reverse_sort": lambda x: sorted(x, reverse=True)}[method]
|
||||
return (f(sequence), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Combinations:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"count": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
|
||||
"replacement": ("BOOLEAN", {"default": False, "label_on": "Yes", "label_off": "No"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, count, replacement):
|
||||
if replacement:
|
||||
return (itertools.combinations_with_replacement(sequence, count), )
|
||||
else:
|
||||
return (itertools.combinations(sequence, count), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Permutations:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"count": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, count):
|
||||
return (itertools.permutations(sequence, count), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Slice:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE",),
|
||||
"start": ("INT", {"default": 0, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"stop": ("INT", {"default": 9999999, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"step": ("INT", {"default": 1, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, start, stop, step):
|
||||
return (sequence[start:stop:step], )
|
||||
|
||||
|
||||
|
||||
@register_node
|
||||
class Join:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"join_str": ("STRING", {'default': ', '}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, join_str):
|
||||
return ([join_str.join(x) for x in sequence], )
|
||||
@@ -5,92 +5,6 @@ import subprocess
|
||||
import folder_paths
|
||||
from . import register_node
|
||||
|
||||
@register_node
|
||||
class FFmpegVideoStitcher:
|
||||
"""Nodo final: Espera a que termine todo el lote secuencial y une los vídeos con FFmpeg."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"video_paths": ("VHS_FILENAMES", ),
|
||||
"output_filename": ("STRING", {"default": "SCAIL_Final_Completo.mp4"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", )
|
||||
RETURN_NAMES = ("final_video_path", )
|
||||
|
||||
# CRÍTICO: Obliga a ComfyUI a recolectar todas las ejecuciones del loop antes de actuar.
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "stitch_videos"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
def stitch_videos(self, video_paths, output_filename):
|
||||
# 1. Comprobación estricta de dependencias
|
||||
if not shutil.which("ffmpeg"):
|
||||
raise Exception("FFmpeg no está instalado o no se encuentra en el PATH del sistema. Por favor, instálalo para unir los vídeos.")
|
||||
|
||||
# Ensure video_paths is a valid list before iteration
|
||||
if not video_paths or not isinstance(video_paths, list):
|
||||
raise Exception("Error: 'video_paths' inválido. Se esperaba una lista.")
|
||||
|
||||
# 2. Aplanar la lista de rutas generadas (VHS_FILENAMES genera listas anidadas)
|
||||
flat_paths = []
|
||||
for path_group in video_paths:
|
||||
if not isinstance(path_group, list):
|
||||
# Sometimes path_group might be a string directly depending on how it's passed
|
||||
if isinstance(path_group, str) and path_group.endswith(".mp4"):
|
||||
flat_paths.append(path_group)
|
||||
continue
|
||||
|
||||
for p in path_group:
|
||||
if isinstance(p, str) and p.endswith(".mp4"):
|
||||
flat_paths.append(p)
|
||||
|
||||
# Ensure output_filename is safe to read
|
||||
if isinstance(output_filename, list):
|
||||
if not output_filename:
|
||||
out_name = "SCAIL_Final_Completo.mp4"
|
||||
else:
|
||||
out_name = output_filename[0]
|
||||
else:
|
||||
out_name = output_filename
|
||||
|
||||
if not isinstance(out_name, str) or not out_name.strip():
|
||||
out_name = "SCAIL_Final_Completo.mp4"
|
||||
|
||||
# Add .mp4 extension if not present
|
||||
if not out_name.lower().endswith('.mp4'):
|
||||
out_name += '.mp4'
|
||||
|
||||
if not flat_paths:
|
||||
return ("Error: No se encontraron vídeos .mp4 para unir.", )
|
||||
|
||||
# 3. Preparar directorio de salida estándar
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
list_file_path = os.path.join(out_dir, "batch_concat_list.txt")
|
||||
|
||||
# 4. Crear archivo de concatenación seguro para FFmpeg
|
||||
with open(list_file_path, 'w', encoding='utf-8') as f:
|
||||
for video in sorted(flat_paths):
|
||||
# Usar rutas absolutas aseguradas
|
||||
f.write(f"file '{os.path.abspath(video)}'\n")
|
||||
|
||||
# 5. Ejecutar unión sin recodificación (Copy codec)
|
||||
final_output = os.path.join(out_dir, out_name)
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", list_file_path, "-c", "copy", final_output
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
print(f"[Sequential Batcher] Vídeo ensamblado exitosamente en: {final_output}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(f"Error crítico de FFmpeg al unir los fragmentos: {e.stderr.decode()}")
|
||||
|
||||
return (final_output, )
|
||||
|
||||
@register_node
|
||||
class WanFrameValidator:
|
||||
|
||||
Reference in New Issue
Block a user