Removes the zeroing of `global_loop_index` and `global_accumulated_frames` from the `SequentialLoopTrigger` node. This architectural shift solves a race condition where the stitcher would process the final chunk based on freshly reset counters (0) instead of the actual completed value, resulting in empty frames or structural bypassing. State cleanup is now exclusively handled by the entry start node.
317 lines
13 KiB
Python
317 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.")
|
|
|
|
# --- 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 ()
|