feat: implement Dynamic Engine and Tools suite for v1.5.0
This commit introduces the Hybrid Identity Architecture's Dynamic Engine: - `tools.py`: Adds new Math constraint nodes (`ResTool 8x, 16x, 32x, 64x`) to calculate safe resolutions dynamically, preventing VRAM overflow. - `loop.py`: Replaces the legacy static mathematical state machine with a dynamic performance-based state using `global_accumulated_frames` to progress accurately based on successful generations. - `image.py`: Overhauls `SessionImageSender` to dynamically truncate generated image tensors using OpenCV if faces are lost, saving the last valid frame, and correctly adjusting the loop progression. `SessionImageReceiver` updated to log dynamic progression. - `video.py`: Refactors `AutoLoopCalculator` and `IncrementalVideoStitcher` to work under the new dynamic state machine logic. - `__init__.py`: Registered new nodes, updated imports to include `tools`, and bumped version to v1.5.0. - `README.md` & `README_EN.md`: Updated documentation to detail the new Dynamic Engine functionality and Tools suite.
This commit is contained in:
@@ -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.
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
+10
-18
@@ -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']
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -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, )}
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user