diff --git a/README.md b/README.md index dfbd3db..1c89070 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ComfyUI Sequential Batcher (v1.4.3) +# ComfyUI Sequential Batcher (v1.5.0) Una suite altamente especializada de nodos personalizados para ComfyUI diseñada para el **Auto-Encolado Recursivo (Recursive Self-Queuing)** y el procesamiento secuencial autónomo. Esta arquitectura minimiza el uso de VRAM procesando tareas pesadas (como la generación de vídeo) de forma secuencial, lote por lote, orquestadas completamente desde dentro del propio grafo. @@ -26,9 +26,10 @@ El sistema se construye alrededor de tres categorías principales: 3. **📥 Session Image Receiver (`SessionImageReceiver`)**: Proporciona la imagen inicial o la última generada del ciclo anterior, detectando inteligentemente el inicio de una sesión en la memoria RAM. 4. **📤 Session Image Sender (`SessionImageSender`)**: Extrae la última imagen del lote y la asegura en la memoria del sistema para el siguiente ciclo. - **💾 Guardado de Keyframes (¡Nuevo en v1.1.0!):** Ahora recibe el índice actual y realiza un volcado de seguridad en el disco duro, guardando progresivamente `keyframe_XXX.png` en cada ciclo para prevenir pérdidas de datos. + - **🏎️ Motor Dinámico F1:** El `Session Image Sender` ahora trunca dinámicamente el tensor generado si la IA falla (ej. el personaje se da la vuelta), actualizando el acumulador global para que el `Auto Loop Calculator` reajuste el siguiente ciclo de extracción, garantizando una identidad perfecta sin desincronizar el audio. ### 🛠️ Herramientas (Tools) -Una suite de nodos dedicados (ResTool 8x, 16x, 32x, 64x) para calcular resoluciones seguras al instante. Selecciona tu formato (ej. 9:16) y tu resolución base (ej. 1080). El nodo correspondiente aplicará una restricción matemática estricta hacia abajo para garantizar que el ancho y el alto sean perfectamente divisibles por la arquitectura de tu modelo, protegiendo tu VRAM de errores de tensores. +**🛠️ Herramientas (Tools):** Nodos dedicados (`ResTool 8x, 16x, 32x, 64x`) para calcular resoluciones estrictamente divisibles y proteger la VRAM. Selecciona tu formato (ej. 9:16) y tu resolución base (ej. 1080). El nodo correspondiente aplicará una restricción matemática estricta hacia abajo para garantizar que el ancho y el alto sean perfectamente divisibles por la arquitectura de tu modelo, protegiendo tu VRAM de errores de tensores. ### 🎞️ Video (Ensamblaje y Validación) 5. **🕵️ Video Analyzer + Audio (`VideoAnalyzerWithAudio`)**: Es el "Explorador" de la máquina. Escanea el vídeo usando OpenCV para detectar frames con rostros nítidos, extrae la pista de audio íntegra de forma pura mediante Torchaudio, y genera un visual **Preview del Frame de Referencia** en su propia interfaz. Emite dicho frame de referencia en formato de imagen (IMAGE) para el resto del flujo de trabajo. diff --git a/README_EN.md b/README_EN.md index a3084b1..9b4c609 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,4 +1,4 @@ -# ComfyUI Sequential Batcher (v1.4.3) +# ComfyUI Sequential Batcher (v1.5.0) A highly specialized suite of custom nodes for ComfyUI designed for **Recursive Self-Queuing** and autonomous sequential processing. This architecture minimizes VRAM usage by processing heavy tasks (like video generation) sequentially, batch-by-batch, orchestrated entirely from within the graph. @@ -26,9 +26,10 @@ The system is built around three major categories: 3. **📥 Session Image Receiver (`SessionImageReceiver`)**: Retrieves the initial image or the last generated frame from the previous cycle, intelligently detecting the start of a RAM session. 4. **📤 Session Image Sender (`SessionImageSender`)**: Extracts the final image of a batch and secures it in system memory for the next cycle. - **💾 Keyframe Dumping (New in v1.1.0!):** Now receives the current index and performs a safety dump to the hard drive, progressively saving `keyframe_XXX.png` every cycle to prevent data loss. + - **✨ Dynamic Engine:** The `Session Image Sender` dynamically truncates generated tensors if the AI hallucination occurs (e.g., character turns their back), and updates the global accumulator so the `Auto Loop Calculator` accurately readjusts the next extraction cycle, ensuring flawless identity continuity without desyncing audio. ### 🛠️ Tools -A suite of dedicated nodes (ResTool 8x, 16x, 32x, 64x) to calculate safe resolutions instantly. Select your aspect ratio (e.g., 9:16) and base resolution (e.g., 1080). The specific node will apply a strict downward mathematical constraint to guarantee that the width and height are perfectly divisible by your model's architecture, shielding your VRAM from tensor errors. +**🛠️ Tools Suite:** Dedicated nodes (`ResTool 8x, 16x, 32x, 64x`) to calculate strictly divisible safe resolutions, shielding VRAM. Select your aspect ratio (e.g., 9:16) and base resolution (e.g., 1080). The specific node will apply a strict downward mathematical constraint to guarantee that the width and height are perfectly divisible by your model's architecture, shielding your VRAM from tensor errors. ### 🎞️ Video (Assembly and Validation) 5. **🕵️ Video Analyzer + Audio (`VideoAnalyzerWithAudio`)**: The "Explorer" of the machine. Scans the video using OpenCV to detect frames with sharp faces, extracts the pure, intact audio track using Torchaudio, and generates a **Reference Frame Visual Preview** on its own interface. It outputs this reference frame as an IMAGE format for the rest of the workflow. diff --git a/__init__.py b/__init__.py index 3000e11..88fa29e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,37 +1,29 @@ -__version__ = "1.0.0" # Subimos a versión 1.0 ya que es una arquitectura nueva +__version__ = "1.5.0" 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." NODE_CLASS_MAPPINGS[c.__name__] = c NODE_DISPLAY_NAME_MAPPINGS[c.__name__] = display_name or c.__name__ return c +# Importar tools from . import image, loop, video, tools -# Display name mappings with emojis NODE_DISPLAY_NAME_MAPPINGS.update({ + "SessionImageReceiver": "📥 Session Image Receiver", + "SessionImageSender": "📤 Session Image Sender", + "IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher", + "VideoAnalyzerWithAudio": "🕵️ Video Analyzer + Audio", + "AutoLoopCalculator": "📊 Auto Loop Calculator", + "SequentialLoopStart": "🏁 Loop Start (Index)", + "SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)", # Tools "ResTool8x": "📐 ResTool 8x (SD1.5)", "ResTool16x": "📏 ResTool 16x (SDXL)", "ResTool32x": "🎞️ ResTool 32x (WanVideo)", "ResTool64x": "🎬 ResTool 64x (Hunyuan)", - - # Image (Memoria de Sesión) - "SessionImageReceiver": "📥 Session Image Receiver", - "SessionImageSender": "📤 Session Image Sender", - - # Video (Ensamblaje y Validación) - "IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher", - "VideoAnalyzerWithAudio": "🕵️ Video Analyzer + Audio", - "AutoLoopCalculator": "📊 Auto Loop Calculator", - - # Loop (Orquestación Autónoma) - "SequentialLoopStart": "🏁 Loop Start (Index)", - "SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)", }) -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/image.py b/image.py index 32ac2f1..cde1d48 100644 --- a/image.py +++ b/image.py @@ -41,22 +41,25 @@ class SessionImageReceiver: def get_image(self, initial_image, current_loop_index): from . import loop - total_loops = loop.global_total_loops - global global_session_image + loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index is_first = (loop_idx == 0) + # Usamos getattr para evitar errores si el bucle aún no ha inicializado las variables + accumulated = getattr(loop, 'global_accumulated_frames', 0) + source_total = getattr(loop, 'global_source_frame_count', 1) + print(f"\n{'='*50}") print(f"📥 [DEBUG] NODO: Image Receiver") - print(f" -> Ciclo actual detectado: {loop_idx} / {total_loops - 1}") + print(f" -> Ciclo actual: {loop_idx} | Progreso global: {accumulated} / {source_total} frames") if is_first or global_session_image is None: global_session_image = initial_image.clone().cpu() print(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.") selected = initial_image else: - print(f" -> ♻️ Usando el fotograma rescatado de la RAM.") + print(f" -> ♻️ Usando el Keyframe validado y rescatado de la RAM.") selected = global_session_image print(f" -> 🖼️ Tensor shape: {selected.shape}") @@ -73,11 +76,12 @@ class SessionImageSender: "required": { "generated_images": ("IMAGE",), "current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}), + "validate_face": ("BOOLEAN", {"default": True}), }, } - RETURN_TYPES = () - RETURN_NAMES = () + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("VALIDATED_IMAGES",) OUTPUT_NODE = True FUNCTION = "set_image" CATEGORY = "🔁 Sequential Batcher/Image" @@ -86,27 +90,70 @@ class SessionImageSender: def IS_CHANGED(cls, **kwargs): return time.time() - def set_image(self, generated_images, current_loop_index): + def set_image(self, generated_images, current_loop_index, validate_face): from . import loop - total_loops = loop.global_total_loops + import cv2 + import numpy as np + import os, folder_paths, torch + from PIL import Image if generated_images is None: - raise ValueError("❌ ERROR CRÍTICO: El nodo 'Session Image Sender' no está recibiendo imágenes. Conecta la salida de tu VAE Decode o Sampler.") - - if current_loop_index is None: - raise ValueError("❌ ERROR CRÍTICO: El nodo 'Session Image Sender' necesita el cable de 'current_loop_index' desde el Loop Start para nombrar los archivos.") + raise ValueError("❌ ERROR CRÍTICO: No se recibieron imágenes en el Sender.") global global_session_image loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index + batch_size = generated_images.shape[0] + best_idx = batch_size - 1 print(f"\n{'='*50}") - print(f"📤 [DEBUG] NODO: Image Sender") - print(f" -> Ciclo actual: {loop_idx} / {total_loops - 1} | Frames recibidos: {generated_images.shape[0]}") + print(f"📤 [DEBUG] NODO: Image Sender (Filtro Dinámico)") + print(f" -> Frames recibidos de la IA: {batch_size}") - last_frame = generated_images[-1:].clone().cpu() + if validate_face: + try: + cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' + face_cascade = cv2.CascadeClassifier(cascade_path) + + print(f" -> 🕵️ Buscando el último rostro frontal en reversa (Desde frame {batch_size - 1} hasta 0)...") + + found = False + for i in range(batch_size - 1, -1, -1): + img_np = (generated_images[i].cpu().numpy() * 255.0).astype(np.uint8) + gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) + faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(30, 30)) + + if len(faces) > 0: + best_idx = i + found = True + print(f" -> ✅ Rostro detectado en el frame [{i}].") + break + + if not found: + print(f" -> ⚠️ ALERTA: No se detectó rostro frontal en NINGÚN frame. Salvando el ciclo usando el frame 0 por defecto.") + best_idx = 0 + except Exception as e: + print(f" -> ⚠️ Error en OpenCV al validar: {e}") + + # 1. TRUNCAR EL TENSOR + valid_images = generated_images[:best_idx + 1] + frames_accepted = best_idx + 1 + + # 2. AVANZAR LA MÁQUINA + stride = getattr(loop, 'global_select_every_nth', 1) + advanced_original_frames = frames_accepted * stride + + if not hasattr(loop, 'global_accumulated_frames'): + loop.global_accumulated_frames = 0 + + loop.global_accumulated_frames += advanced_original_frames + + source_total = getattr(loop, 'global_source_frame_count', 1) + print(f" -> ✂️ Tensor truncado a {frames_accepted} frames válidos.") + print(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total}") + + last_frame = valid_images[-1:].clone().cpu() global_session_image = last_frame - # Guardar disco progresivo out_dir = folder_paths.get_output_directory() filename = f"keyframe_{loop_idx:03d}.png" filepath = os.path.join(out_dir, filename) @@ -115,9 +162,8 @@ class SessionImageSender: img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)) img.save(filepath) - print(f" -> 💾 Keyframe guardado físicamente: {filename}") - print(f" -> 🧠 RAM asegurada para el próximo ciclo.") + print(f" -> 💾 Keyframe seguro guardado: {filename}") print(f"{'='*50}\n") ui_image = tensor_to_temp_image(last_frame, "sender") - return {"ui": {"images": [ui_image]}} + return {"ui": {"images": [ui_image]}, "result": (valid_images, )} diff --git a/loop.py b/loop.py index efd8f37..c34cff4 100644 --- a/loop.py +++ b/loop.py @@ -5,10 +5,9 @@ import json from . import register_node global_loop_index = 0 -global_total_loops = 1 # 🌍 NUEVA MEMORIA GLOBAL PARA LOS CICLOS -global_source_frame_count = 0 # NUEVO: Memoria del total de frames -global_target_frames = 50 # NUEVO: Memoria del target del usuario -global_stride = 1 # NUEVO: Memoria del salto +global_accumulated_frames = 0 +global_source_frame_count = 1 +global_select_every_nth = 1 @register_node class SequentialLoopStart: @@ -32,24 +31,21 @@ class SequentialLoopStart: def get_index(self, reset_loop, loop_idx): global global_loop_index - global global_total_loops - global global_source_frame_count + global global_accumulated_frames print(f"\n{'='*50}") - print(f"🚀 [DEBUG] NODO: Loop Start") - print(f" -> Input loop_idx: {loop_idx} (Total Esperado: {global_total_loops})") - print(f" -> Input reset_loop: {reset_loop}") + print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)") is_reset = str(reset_loop).lower() in ['true', '1', 't', 'y'] - if is_reset: + if is_reset or loop_idx == 0: global_loop_index = 0 - global_total_loops = 1 # Reinicio de seguridad limpia la memoria fantasma - global_source_frame_count = 0 # Reinicio limpio - print(" -> 🔄 Bucle reiniciado a 0 manualmente.") + global_accumulated_frames = 0 + print(" -> 🔄 Bucle y Acumulador reiniciados a 0.") else: global_loop_index = loop_idx print(f" -> 📤 OUTPUT current_loop_index: {global_loop_index}") + print(f" -> 📈 Frames acumulados históricamente: {global_accumulated_frames}") print(f"{'='*50}\n") return (global_loop_index,) @@ -77,35 +73,30 @@ class SequentialLoopTrigger: return time.time() def trigger_next(self, trigger_dependency, port, prompt=None, extra_pnginfo=None): - if trigger_dependency is None: - raise ValueError("❌ ERROR CRÍTICO: El nodo 'Loop Trigger' no tiene nada conectado en 'trigger_dependency'. Debes conectar la salida del Stitcher para que el bucle pueda continuar.") - global global_loop_index - global global_total_loops + global global_accumulated_frames + global global_source_frame_count - target_loops = global_total_loops next_loop = global_loop_index + 1 + is_final = global_accumulated_frames >= global_source_frame_count print(f"\n{'='*50}") print(f"🎯 [DEBUG] NODO: Loop Trigger") - print(f" -> Ciclo terminado: {global_loop_index} | Target: {target_loops}") + print(f" -> Progreso del vídeo: {global_accumulated_frames} / {global_source_frame_count} frames.") - if next_loop < target_loops: + if not is_final: print(f" -> ⚙️ Preparando Ciclo {next_loop}...") if prompt is not None: m_seeds = 0 for node_id, node_data in prompt.items(): inputs = node_data.get("inputs", {}) - # Mutar Semillas for key in ["seed", "noise_seed"]: if key in inputs and isinstance(inputs[key], (int, float)): inputs[key] = random.randint(1, 0xffffffff) m_seeds += 1 - # 💥 INYECCIÓN ANTI-CACHÉ: Forzar a Loop Start a leer el nuevo índice if node_data.get("class_type") == "SequentialLoopStart": inputs["loop_idx"] = next_loop inputs["reset_loop"] = False - print(f" -> 💉 Inyectado loop_idx={next_loop} en LoopStart (Nodo {node_id})") print(f" -> 🎲 Semillas mutadas: {m_seeds}") p = {"prompt": prompt} @@ -119,8 +110,9 @@ class SequentialLoopTrigger: except Exception as e: print(f" -> ❌ Error HTTP: {e}") else: - print(f" -> 🏁 ¡Generación finalizada! ({target_loops} ciclos)") + print(f" -> 🏁 ¡Generación Finalizada! Todos los frames ensamblados.") global_loop_index = 0 + global_accumulated_frames = 0 print(f"{'='*50}\n") return () diff --git a/tools.py b/tools.py index 6cd8af1..795b066 100644 --- a/tools.py +++ b/tools.py @@ -24,7 +24,7 @@ class BaseResolutionTool: w_ratio = float(w_str) h_ratio = float(h_str) - # Matemática segura hacia abajo (Strictly Down) simulando tabla memorizada + # Matemática segura hacia abajo (Strictly Down) actual_longest = max(div, (longest_side // div) * div) if w_ratio >= h_ratio: @@ -48,7 +48,6 @@ class BaseResolutionTool: return (width, height, debug_msg) -# 📦 Los 4 Nodos Visibles para el Usuario @register_node class ResTool8x(BaseResolutionTool): DIVISOR = 8 diff --git a/video.py b/video.py index 9847f28..bba9948 100644 --- a/video.py +++ b/video.py @@ -166,68 +166,46 @@ class AutoLoopCalculator: import math from . import loop - if source_frame_count <= 0 or target_frames_per_loop <= 0: - loop.global_total_loops = 1 - return (max(1, source_frame_count), 0, select_every_nth) + loop.global_source_frame_count = source_frame_count + loop.global_select_every_nth = select_every_nth - # 1. Parámetros base y margen del ±10% + current_pos = loop.global_accumulated_frames + + print(f"\n{'='*50}") + print(f"📊 [DEBUG] NODO: Auto Loop Calculator (Motor Dinámico)") + print(f" -> Posición en el timeline original: {current_pos} / {source_frame_count}") + + if current_pos >= source_frame_count: + return (1, current_pos, select_every_nth) + + frames_left = source_frame_count - current_pos target_original = target_frames_per_loop * select_every_nth margin = max(1, int(target_original * 0.10)) + min_chunk = target_original - margin max_chunk = target_original + margin - cuts = [0] - current_pos = 0 - - print(f"\n{'='*50}") - print(f"📊 [DEBUG] NODO: Auto Loop Calculator (Cerebro)") - print(f" -> Target: {target_original} | Margen 10%: {min_chunk} a {max_chunk} frames") - - # 2. Búsqueda de cortes - while current_pos < source_frame_count: - frames_left = source_frame_count - current_pos - - # Si lo que queda cabe en el margen máximo, cerramos el último bloque - if frames_left <= max_chunk: - cuts.append(source_frame_count) - print(f" -> 🧮 Resto final ({frames_left} frames) absorbido en el límite máximo.") - break - + if frames_left <= max_chunk: + best_cut = source_frame_count + print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}") + else: ideal_cut = current_pos + target_original min_cut = current_pos + min_chunk - max_cut = current_pos + max_chunk + max_cut = min(current_pos + max_chunk, source_frame_count) - # Asegurar que no nos pasamos del total - max_cut = min(max_cut, source_frame_count) best_cut = ideal_cut - if safe_faces_list and len(safe_faces_list) > 0: valid_cuts = [f for f in safe_faces_list if min_cut <= f <= max_cut] if valid_cuts: best_cut = min(valid_cuts, key=lambda x: abs(x - ideal_cut)) - print(f" -> ✂️ Corte Inteligente: {best_cut} (Ideal: {ideal_cut} | Ventana: {min_cut}-{max_cut})") + print(f" -> ✂️ Corte Inteligente proyectado: {best_cut} (Ventana: {min_cut}-{max_cut})") else: - print(f" -> ⚠️ Sin caras en ventana {min_cut}-{max_cut}. Forzando ideal: {ideal_cut}") - else: - print(f" -> 🧮 Corte matemático: {ideal_cut}") + print(f" -> ⚠️ Sin caras en ventana. Forzando ideal: {ideal_cut}") - cuts.append(best_cut) - current_pos = best_cut + effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth) + skip_frames = current_pos - total_loops = len(cuts) - 1 - loop.global_total_loops = total_loops - - # 3. Extracción del ciclo actual - safe_index = min(current_loop_index, total_loops - 1) - start_frame = cuts[safe_index] - end_frame = cuts[safe_index + 1] - - original_chunk_length = end_frame - start_frame - effective_chunk_frames = math.ceil(original_chunk_length / select_every_nth) - skip_frames = start_frame - - print(f" -> 🎬 Plan de Cortes: {cuts}") - print(f" -> 🚀 Ciclo {current_loop_index}: Generando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})") + print(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})") print(f"{'='*50}\n") return (effective_chunk_frames, skip_frames, select_every_nth) @@ -251,26 +229,24 @@ class IncrementalVideoStitcher: def stitch(self, images, audio, current_loop_index): from . import loop - total_loops = loop.global_total_loops # Leemos la variable global + import os, folder_paths, torch cache_dir = os.path.join(folder_paths.get_temp_directory(), "wan_stitcher_cache") os.makedirs(cache_dir, exist_ok=True) - # 1. Guardar el lote completo actual en disco path = os.path.join(cache_dir, f"batch_{current_loop_index:04d}.pt") torch.save(images.cpu(), path) - print(f"🎞️ [Stitcher] Lote {current_loop_index} guardado en disco.") + print(f"🎞️ [Stitcher] Lote {current_loop_index} guardado en disco ({images.shape[0]} frames).") - if current_loop_index < total_loops - 1: - # 🛠️ TRUCO MAESTRO: En lugar de un frame negro, enviamos el primer frame real del lote. - # Mantiene los cables vivos y establece la resolución correcta para RIFE/Upscale. + is_final = loop.global_accumulated_frames >= loop.global_source_frame_count + + if not is_final: preview_frame = images[0:1] return (preview_frame, None) - # 2. Ciclo final: Ensamblar todos los lotes print(f"📦 [Stitcher] Ensamblando todos los lotes de vídeo...") all_tensors = [] - for i in range(total_loops): + for i in range(current_loop_index + 1): p = os.path.join(cache_dir, f"batch_{i:04d}.pt") if os.path.exists(p): all_tensors.append(torch.load(p)) @@ -278,6 +254,6 @@ class IncrementalVideoStitcher: except: pass final_images = torch.cat(all_tensors, dim=0) - print(f"✅ [Stitcher] Vídeo completado: {final_images.shape[0]} frames.") + print(f"✅ [Stitcher] Vídeo completado: {final_images.shape[0]} frames ensamblados.") return (final_images, audio)