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:
Meisoftcoltd
2026-03-22 19:25:11 +00:00
parent 6da4275975
commit 73581ac582
8 changed files with 7 additions and 1073 deletions
+6 -57
View File
@@ -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']
-305
View File
@@ -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,)
-106
View File
@@ -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 ()
+1 -253
View File
@@ -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
-37
View File
@@ -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."""
-70
View File
@@ -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
View File
@@ -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], )
-86
View File
@@ -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: