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:
Meisoftcoltd
2026-03-30 19:03:16 +00:00
parent f780a63a07
commit 1c13d37fbc
7 changed files with 128 additions and 121 deletions
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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']
+65 -19
View File
@@ -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, )}
+16 -24
View File
@@ -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 ()
+1 -2
View File
@@ -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
+30 -54
View File
@@ -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)