This commit patches the `SessionImageSender`, `AutoLoopCalculatorWan`, and `AutoLoopCalculatorLTX` nodes to prevent infinite loops caused by extreme truncation at the end of video sequences. - `image.py`: Ensures `SessionImageSender` accepts the entire batch if no faces are detected, rather than defaulting to frame 0, which previously trapped the workflow in a 1-frame micro-cycle loop. - `video.py`: Updates `AutoLoopCalculatorWan` to always round the final chunk size up to the nearest multiple of 4, removing the strict cropping logic that broke the 3D VAE rule. - `loop.py`: Updates `AutoLoopCalculatorLTX` to always round the final chunk size up to the nearest `8n + 1` value, removing the strict cropping logic that broke the DiT rule.
319 lines
13 KiB
Python
319 lines
13 KiB
Python
import random
|
|
import time
|
|
import urllib.request
|
|
import json
|
|
import gc
|
|
import torch
|
|
import comfy.model_management as mm
|
|
from . import register_node
|
|
|
|
global_loop_index = 0
|
|
global_accumulated_frames = 0
|
|
global_source_frame_count = 1
|
|
global_select_every_nth = 1
|
|
global_server_port = 8188 # 💡 NUEVO: Única fuente de la verdad para el puerto
|
|
global_ltx_mode = False
|
|
|
|
@register_node
|
|
class SequentialLoopStart:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"reset_loop": ("BOOLEAN", {"default": False}),
|
|
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
|
"port": ("INT", {"default": 8188, "min": 1, "max": 65535}), # 💡 FAIL-FAST: Pedimos el puerto aquí
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("INT",)
|
|
RETURN_NAMES = ("current_loop_index",)
|
|
FUNCTION = "get_index"
|
|
CATEGORY = "🔁 Sequential Batcher/Loop"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return time.time()
|
|
|
|
def get_index(self, reset_loop, loop_idx, port):
|
|
global global_loop_index
|
|
global global_accumulated_frames
|
|
global global_server_port
|
|
global global_ltx_mode
|
|
|
|
print(f"\n{'='*50}")
|
|
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
|
|
|
|
# --- PRE-FLIGHT CHECK (FAIL-FAST) ---
|
|
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto {port}...")
|
|
try:
|
|
# Ping ultrarrápido a la API. Si falla, colapsa en 0.1 segundos.
|
|
req = urllib.request.Request(f"http://127.0.0.1:{port}/system_stats")
|
|
urllib.request.urlopen(req, timeout=2)
|
|
global_server_port = port # Guardamos el puerto correcto para el Trigger
|
|
print(f" -> ✅ Conexión establecida. Puerto blindado.")
|
|
except Exception as e:
|
|
print(f" -> ❌ ERROR FATAL: No se pudo conectar al puerto {port}.")
|
|
raise ValueError(f"🚨 EL PUERTO {port} ES INCORRECTO O COMFYUI NO RESPONDE. "
|
|
f"Cambia el puerto en el nodo 'Loop Start' antes de procesar. (Error: {e})")
|
|
# ------------------------------------
|
|
|
|
is_reset = str(reset_loop).lower() in ['true', '1', 't', 'y']
|
|
if is_reset or loop_idx == 0:
|
|
global_loop_index = 0
|
|
global_accumulated_frames = 0
|
|
global_ltx_mode = False # 💡 REINICIO DE SEGURIDAD PARA LTX
|
|
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
|
|
else:
|
|
global_loop_index = loop_idx
|
|
|
|
print(f" -> 📍 Índice actual de bucle: {global_loop_index}")
|
|
print(f"{'='*50}\n")
|
|
return (global_loop_index,)
|
|
|
|
@register_node
|
|
class AutoLoopCalculatorLTX:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"source_frame_count": ("INT", {"forceInput": True}),
|
|
"target_frames_per_loop": ("INT", {"default": 81, "min": 9, "max": 257, "step": 8}),
|
|
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
|
|
"current_loop_index": ("INT", {"forceInput": True}),
|
|
},
|
|
"optional": {
|
|
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("INT", "INT", "INT")
|
|
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth")
|
|
FUNCTION = "calculate"
|
|
CATEGORY = "🔁 Sequential Batcher/Loop"
|
|
|
|
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None):
|
|
# --- Ajuste Proporcional LTX (Regla DiT 8n + 1) ---
|
|
import math
|
|
from . import loop
|
|
|
|
potential_effective_frames = source_frame_count // select_every_nth
|
|
|
|
# Truncar al (8n + 1) más cercano hacia abajo
|
|
if potential_effective_frames < 9:
|
|
safe_effective_frames = 9 # Too short, bypass
|
|
else:
|
|
safe_effective_frames = ((potential_effective_frames - 1) // 8) * 8 + 1
|
|
|
|
safe_source_frame_count = safe_effective_frames * select_every_nth
|
|
loop.global_source_frame_count = safe_source_frame_count
|
|
effective_loss = potential_effective_frames - safe_effective_frames
|
|
|
|
# Ajuste proporcional forzando regla 8n + 1
|
|
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
|
|
if estimated_loops > 0:
|
|
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
|
|
# Encontrar el 8n + 1 más cercano hacia arriba para cubrir el total
|
|
adjusted_target = ((optimal_target - 1 + 7) // 8) * 8 + 1
|
|
target_frames_per_loop = adjusted_target
|
|
print(f" -> ⚖️ Ajuste Proporcional LTX: Target recalculado a {adjusted_target} frames por ciclo.")
|
|
|
|
# --- LOGS MEJORADOS Y TRANSPARENTES ---
|
|
print(f" -> 🎞️ Capacidad del video: {potential_effective_frames} frames procesables (Nth: {select_every_nth})")
|
|
if effective_loss > 0:
|
|
print(f" -> 🛡️ Ajuste VAE: Se usarán {safe_effective_frames} frames (Descarte técnico: {effective_loss} frame/s de proceso)")
|
|
else:
|
|
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
|
|
|
|
print(f" -> 📊 Timeline final: 0 a {safe_source_frame_count} (de {source_frame_count} totales)")
|
|
# -------------------------------------------------------------
|
|
|
|
loop.global_select_every_nth = select_every_nth
|
|
|
|
# Indicador global para el Stitcher y Sender (LTX mode activo)
|
|
global global_ltx_mode
|
|
global_ltx_mode = True
|
|
|
|
current_pos = loop.global_accumulated_frames
|
|
|
|
print(f"\n{'='*50}")
|
|
print(f"📊 [DEBUG] NODO: Auto Loop Calculator (LTX 2.3)")
|
|
print(f" -> Timeline seguro ajustado a LTX: {current_pos} / {safe_source_frame_count} (Original: {source_frame_count})")
|
|
|
|
if current_pos >= safe_source_frame_count:
|
|
return (1, current_pos, select_every_nth)
|
|
|
|
frames_left = safe_source_frame_count - current_pos
|
|
|
|
equitable_target = target_frames_per_loop * select_every_nth
|
|
|
|
ideal_cut = current_pos + equitable_target
|
|
|
|
if frames_left <= equitable_target:
|
|
best_cut = safe_source_frame_count
|
|
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
|
|
else:
|
|
best_cut = ideal_cut
|
|
if safe_faces_list and len(safe_faces_list) > 0:
|
|
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
|
|
best_cut = closest_face
|
|
print(f" -> ✂️ Corte Inteligente proyectado: {best_cut} (Meta equitativa: {ideal_cut})")
|
|
else:
|
|
print(f" -> ⚖️ Sin caras detectadas. Forzando corte equitativo: {ideal_cut}")
|
|
|
|
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
|
|
|
# Redondear siempre hacia ARRIBA al (8n + 1) más cercano
|
|
effective_chunk_frames = ((effective_chunk_frames + 6) // 8) * 8 + 1
|
|
|
|
if effective_chunk_frames < 9:
|
|
effective_chunk_frames = 9 # Mínimo vital LTX
|
|
|
|
# ELIMINAMOS el recorte final que rompe la regla 8n + 1
|
|
if current_pos + (effective_chunk_frames * select_every_nth) >= safe_source_frame_count:
|
|
print(f" -> 🏁 Chunk final LTX detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 8n+1.")
|
|
|
|
skip_frames = current_pos
|
|
|
|
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)
|
|
|
|
@register_node
|
|
class SequentialLoopTrigger:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"trigger_dependency": ("*", ),
|
|
},
|
|
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
RETURN_NAMES = ()
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "trigger_next"
|
|
CATEGORY = "🔁 Sequential Batcher/Loop"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return time.time()
|
|
|
|
def trigger_next(self, trigger_dependency, prompt=None, extra_pnginfo=None):
|
|
global global_loop_index
|
|
global global_accumulated_frames
|
|
global global_source_frame_count
|
|
global global_server_port
|
|
|
|
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" -> Progreso del vídeo: {global_accumulated_frames} / {global_source_frame_count} frames.")
|
|
|
|
if not is_final:
|
|
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
|
|
if prompt is not None:
|
|
# 🧠 HACKER MODE: Estimamos si el ciclo que vamos a encolar es el final
|
|
estimated_chunk = 50
|
|
for node_id, node_data in prompt.items():
|
|
if "AutoLoopCalculator" in node_data.get("class_type", ""):
|
|
# Rescata el chunk estimado del calculador en uso
|
|
estimated_chunk = node_data.get("inputs", {}).get("target_frames_per_loop", 50)
|
|
break
|
|
|
|
is_next_final = (global_accumulated_frames + estimated_chunk) >= global_source_frame_count
|
|
|
|
m_seeds = 0
|
|
for node_id, node_data in prompt.items():
|
|
inputs = node_data.get("inputs", {})
|
|
|
|
# Mutación de 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
|
|
|
|
# Mutación de índice
|
|
if node_data.get("class_type") == "SequentialLoopStart":
|
|
inputs["loop_idx"] = next_loop
|
|
inputs["reset_loop"] = False
|
|
|
|
# 🔪 CIRUGÍA DE GRAFO EN CALIENTE
|
|
if node_data.get("class_type") == "MasterSwitch":
|
|
print(f" -> 🔀 [Cirugía de Grafo] Mutando Master Switch para Ciclo {next_loop}...")
|
|
# Sobrescribimos el cable que viene del Stitcher por un booleano estático
|
|
inputs["is_final_cycle"] = is_next_final
|
|
|
|
if is_next_final:
|
|
if "on_false" in inputs:
|
|
del inputs["on_false"]
|
|
print(f" ✂️ Cable 'on_false' eliminado (Ahorro de VRAM en ruta inactiva).")
|
|
else:
|
|
if "on_true" in inputs:
|
|
del inputs["on_true"]
|
|
print(f" ✂️ Cable 'on_true' eliminado (Ruta pesada desconectada).")
|
|
|
|
print(f" -> 🎲 Semillas mutadas: {m_seeds}")
|
|
|
|
p = {"prompt": prompt}
|
|
if extra_pnginfo:
|
|
p["extra_data"] = {"extra_pnginfo": extra_pnginfo}
|
|
data = json.dumps(p).encode('utf-8')
|
|
req = urllib.request.Request(f"http://127.0.0.1:{global_server_port}/prompt", data=data, headers={'Content-Type': 'application/json'})
|
|
try:
|
|
urllib.request.urlopen(req, timeout=5)
|
|
print(f" -> ✅ Ciclo {next_loop} inyectado en la cola.")
|
|
except Exception as e:
|
|
print(f" -> ❌ Error HTTP: {e}")
|
|
|
|
# Limpieza ligera por ciclo para evitar fragmentación
|
|
import gc
|
|
import torch
|
|
import comfy.model_management as mm
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
else:
|
|
print(f" -> 🏁 ¡Generación Finalizada! Todos los frames ensamblados.")
|
|
global_loop_index = 0
|
|
global_accumulated_frames = 0
|
|
|
|
# --- LIMPIEZA EXTREMA DE VRAM AUTOMÁTICA (Multi-Plataforma) ---
|
|
print(f" -> 🧹 Iniciando vaciado automático de VRAM...")
|
|
import gc
|
|
import torch
|
|
import comfy.model_management as mm
|
|
try:
|
|
# 1. Obligamos al motor interno de ComfyUI a soltar los modelos
|
|
mm.unload_all_models()
|
|
mm.soft_empty_cache()
|
|
except Exception as e:
|
|
print(f" -> ⚠️ Aviso: No se pudo usar model_management: {e}")
|
|
|
|
# 2. Forzamos al recolector de basura de Python
|
|
gc.collect()
|
|
|
|
# 3. Le arrancamos a PyTorch la memoria reservada (CUDA, ROCm y Mac MPS)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
|
|
# HACK: Forzar la desfragmentación de la memoria caché de PyTorch
|
|
import ctypes
|
|
try:
|
|
ctypes.CDLL('libc.so.6').malloc_trim(0)
|
|
except Exception:
|
|
pass
|
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
torch.mps.empty_cache()
|
|
|
|
print(f" -> ✨ VRAM liberada con éxito. Gráfica lista para nuevos flujos.")
|
|
|
|
print(f"{'='*50}\n")
|
|
return ()
|