diff --git a/__init__.py b/__init__.py index 6e094ae..261e9cb 100644 --- a/__init__.py +++ b/__init__.py @@ -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'] \ No newline at end of file diff --git a/batch.py b/batch.py deleted file mode 100644 index afd4e48..0000000 --- a/batch.py +++ /dev/null @@ -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,) diff --git a/debug.py b/debug.py deleted file mode 100644 index a20e157..0000000 --- a/debug.py +++ /dev/null @@ -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 () diff --git a/image.py b/image.py index 268d069..22b5f3b 100644 --- a/image.py +++ b/image.py @@ -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 diff --git a/loop.py b/loop.py index 3743cac..9ec7ade 100644 --- a/loop.py +++ b/loop.py @@ -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.""" diff --git a/paths.py b/paths.py deleted file mode 100644 index e2d171b..0000000 --- a/paths.py +++ /dev/null @@ -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]) diff --git a/sequence.py b/sequence.py deleted file mode 100644 index ea434a1..0000000 --- a/sequence.py +++ /dev/null @@ -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], ) diff --git a/video.py b/video.py index 89976df..8f6d7ec 100644 --- a/video.py +++ b/video.py @@ -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: