- Removed `INPUT_IS_LIST = True` to prevent ComfyUI from implicitly converting input values to lists and expecting lists as outputs. - Refactored `calculate` to handle list inputs natively using `isinstance` while correctly interacting with the standard Text Reader's native Python lists. - Changed the return signature to emit native primitive values (`str`, `int`) instead of wrapping them in lists, correctly accommodating downstream integration with TTS models (e.g. Fish Speech) that expect pure strings.
448 lines
19 KiB
Python
448 lines
19 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
|
|
global_step_by_chunk = False
|
|
global_batch_index = 0
|
|
global_has_more_batches = False
|
|
global_is_batch_advancing = 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
|
|
import sys
|
|
import argparse
|
|
|
|
print(f"\n{'='*50}")
|
|
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
|
|
|
|
# --- PRE-FLIGHT CHECK (SECURITY SSRF FIX) ---
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--port", type=int, default=8188)
|
|
try:
|
|
args, _ = parser.parse_known_args(sys.argv[1:])
|
|
system_port = args.port
|
|
except:
|
|
system_port = 8188
|
|
|
|
if port != system_port:
|
|
print(f" -> ⚠️ ATENCIÓN: El puerto de entrada ({port}) no coincide con el puerto del sistema ({system_port}).")
|
|
print(f" -> 🔒 Por motivos de seguridad (Prevención SSRF), se forzará el puerto real del sistema: {system_port}.")
|
|
safe_port = system_port
|
|
else:
|
|
safe_port = system_port
|
|
|
|
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto seguro {safe_port}...")
|
|
max_retries = 3
|
|
for attempt in range(max_retries):
|
|
try:
|
|
req = urllib.request.Request(f"http://127.0.0.1:{safe_port}/system_stats")
|
|
urllib.request.urlopen(req, timeout=10)
|
|
global_server_port = safe_port
|
|
print(f" -> ✅ Conexión establecida. Puerto blindado y seguro.")
|
|
break
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
print(f" -> ⏳ Reintentando conexión ({attempt + 1}/{max_retries}) por alta carga del servidor...")
|
|
time.sleep(1)
|
|
else:
|
|
error_msg = f"🚨 FATAL ERROR: EL PUERTO {safe_port} NO RESPONDE TRAS VARIOS INTENTOS. El servidor ComfyUI podría estar bloqueado o apagado. (Error original: {e})"
|
|
print(f" -> ❌ {error_msg}")
|
|
raise RuntimeError(error_msg)
|
|
# ------------------------------------
|
|
|
|
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.")
|
|
|
|
# 💡 NUEVO: Control de lotes (Batch)
|
|
global global_batch_index, global_is_batch_advancing
|
|
if not global_is_batch_advancing:
|
|
global_batch_index = 0
|
|
print(" -> 🆕 Reinicio completo detectado. Lote (Batch) reiniciado a 0.")
|
|
global_is_batch_advancing = False
|
|
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 AutoLoopCalculatorTTSBatch:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"text_list": ("STRING", {"forceInput": True}),
|
|
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
|
|
"current_loop_index": ("INT", {"forceInput": True}),
|
|
}
|
|
}
|
|
|
|
# 💡 FIX: Eliminado INPUT_IS_LIST = True para evitar que ComfyUI modifique los tipos de salida
|
|
RETURN_TYPES = ("STRING", "INT", "INT", "STRING")
|
|
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
|
|
FUNCTION = "calculate"
|
|
CATEGORY = "🔁 Sequential Batcher/Text"
|
|
|
|
def calculate(self, text_list, split_mode, current_loop_index):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg); log_output.append(str(msg))
|
|
|
|
from . import loop
|
|
import re
|
|
|
|
loop.global_step_by_chunk = True
|
|
|
|
# Como eliminamos INPUT_IS_LIST, text_list llega como la lista pura desde el Reader
|
|
texts = text_list if isinstance(text_list, list) else [text_list]
|
|
|
|
mode = split_mode
|
|
idx = current_loop_index
|
|
|
|
current_batch_idx = getattr(loop, 'global_batch_index', 0)
|
|
if current_batch_idx >= len(texts):
|
|
current_batch_idx = len(texts) - 1
|
|
|
|
current_file_text = texts[current_batch_idx]
|
|
|
|
if mode == "Frases (Puntos)":
|
|
matches = re.findall(r'[^.!?\n]+[.!?\n]*', current_file_text)
|
|
temp_chunks = [m.strip() for m in matches if m.strip()]
|
|
MIN_WORDS = 5
|
|
raw_chunks = []
|
|
buffer_text = ""
|
|
for chunk in temp_chunks:
|
|
buffer_text = (buffer_text + " " + chunk).strip()
|
|
if len(buffer_text.split()) >= MIN_WORDS:
|
|
raw_chunks.append(buffer_text)
|
|
buffer_text = ""
|
|
if buffer_text:
|
|
if raw_chunks: raw_chunks[-1] += " " + buffer_text
|
|
else: raw_chunks.append(buffer_text)
|
|
chunk_type_name = "Frases Optimizadas"
|
|
else:
|
|
raw_chunks = [p.strip() for p in current_file_text.split('\n') if p.strip()]
|
|
chunk_type_name = "Párrafos"
|
|
|
|
total_chunks = len(raw_chunks)
|
|
if total_chunks == 0:
|
|
raw_chunks = [""]
|
|
total_chunks = 1
|
|
|
|
safe_index = min(idx, total_chunks - 1)
|
|
current_chunk_text = raw_chunks[safe_index]
|
|
|
|
loop.global_source_frame_count = total_chunks
|
|
loop.global_accumulated_frames = safe_index + 1
|
|
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
|
|
loop.global_has_more_batches = (current_batch_idx < len(texts) - 1)
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS Batch)")
|
|
_log(f" -> Archivo {current_batch_idx + 1} de {len(texts)} en el lote.")
|
|
_log(f" -> Modo de división: {mode}")
|
|
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
|
|
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
|
|
_log(f" -> 📜 Texto a procesar: {current_chunk_text[:75]}...")
|
|
_log(f"{'='*50}\n")
|
|
|
|
# 💡 FIX: Devolvemos valores nativos, no listas
|
|
return (current_chunk_text, safe_index, total_chunks, "\n".join(log_output))
|
|
|
|
@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}),
|
|
"scene_cuts_list": ("SCENE_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, scene_cuts_list=None):
|
|
if source_frame_count <= 0 or target_frames_per_loop <= 0:
|
|
return (1, 0, select_every_nth)
|
|
|
|
source_frame_count = min(source_frame_count, 10000)
|
|
select_every_nth = max(1, select_every_nth)
|
|
|
|
# --- Ajuste Proporcional LTX (Regla DiT 8n + 1) ---
|
|
import math
|
|
from . import loop
|
|
|
|
loop.global_step_by_chunk = False
|
|
|
|
potential_effective_frames = source_frame_count // select_every_nth
|
|
|
|
if potential_effective_frames < 9:
|
|
safe_effective_frames = 9
|
|
else:
|
|
safe_effective_frames = ((potential_effective_frames + 6) // 8) * 8 + 1
|
|
|
|
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
|
|
physical_source_frame_count = potential_effective_frames * select_every_nth
|
|
loop.global_source_frame_count = physical_source_frame_count
|
|
effective_padding = safe_effective_frames - potential_effective_frames
|
|
|
|
# Ajuste proporcional
|
|
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
|
|
if estimated_loops > 0:
|
|
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
|
|
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.")
|
|
|
|
print(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
|
|
if effective_padding > 0:
|
|
print(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
|
|
else:
|
|
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
|
|
|
|
print(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
|
|
|
|
loop.global_select_every_nth = select_every_nth
|
|
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 físico ajustado a LTX: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
|
|
|
|
if current_pos >= physical_source_frame_count:
|
|
return (1, current_pos, select_every_nth)
|
|
|
|
frames_left = physical_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 = physical_source_frame_count
|
|
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
|
|
else:
|
|
if scene_cuts_list and len(scene_cuts_list) > 0:
|
|
future_cuts = [c for c in scene_cuts_list if c > current_pos]
|
|
if future_cuts:
|
|
next_cut = min(future_cuts)
|
|
if next_cut <= ideal_cut:
|
|
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
|
|
safe_chunk_from_scene = ((chunk_from_scene + 6) // 8) * 8 + 1
|
|
if safe_chunk_from_scene < 9: safe_chunk_from_scene = 9
|
|
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
|
|
print(f" -> 🎬 Bucle aislado por escena (Ajustado x8). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
|
|
else:
|
|
best_cut = ideal_cut
|
|
print(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
|
|
else:
|
|
best_cut = ideal_cut
|
|
print(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
|
|
elif safe_faces_list and len(safe_faces_list) > 0:
|
|
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
|
|
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
|
|
safe_chunk_from_face = ((chunk_from_face + 6) // 8) * 8 + 1
|
|
if safe_chunk_from_face < 9: safe_chunk_from_face = 9
|
|
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
|
|
print(f" -> ✂️ Corte Facial Inteligente (Ajustado x8): {best_cut} (Meta equitativa: {ideal_cut})")
|
|
else:
|
|
best_cut = ideal_cut
|
|
print(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x8: {ideal_cut}")
|
|
|
|
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
|
effective_chunk_frames = ((effective_chunk_frames + 6) // 8) * 8 + 1
|
|
|
|
if effective_chunk_frames < 9:
|
|
effective_chunk_frames = 9
|
|
|
|
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
|
|
print(f" -> 🏁 Chunk final LTX detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 8n+1.")
|
|
loop.global_is_final_chunk = True
|
|
else:
|
|
loop.global_is_final_chunk = False
|
|
|
|
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_chunk = global_accumulated_frames >= global_source_frame_count
|
|
|
|
import loop
|
|
has_more_batches = getattr(loop, 'global_has_more_batches', False)
|
|
|
|
print(f"\n{'='*50}")
|
|
print(f"🎯 [DEBUG] NODO: Loop Trigger")
|
|
print(f" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
|
|
|
|
# 💡 FIX: Reencolar si NO es el final del chunk, O si es el final pero hay más archivos
|
|
if not is_final_chunk or has_more_batches:
|
|
if prompt is not None:
|
|
estimated_chunk = 50
|
|
found_calculator = False
|
|
m_seeds = 0
|
|
master_switches = []
|
|
|
|
for node_id, node_data in prompt.items():
|
|
class_type = node_data.get("class_type", "")
|
|
inputs = node_data.get("inputs", {})
|
|
|
|
if not found_calculator and "AutoLoopCalculator" in class_type:
|
|
if "TTS" in class_type:
|
|
estimated_chunk = 1
|
|
else:
|
|
estimated_chunk = inputs.get("target_frames_per_loop", 50)
|
|
found_calculator = True
|
|
|
|
for key in ["seed", "noise_seed"]:
|
|
if key in inputs and isinstance(inputs[key], (int, float)):
|
|
inputs[key] = random.randint(1, 0x7fffffff)
|
|
m_seeds += 1
|
|
|
|
if class_type == "SequentialLoopStart":
|
|
if is_final_chunk and has_more_batches:
|
|
inputs["loop_idx"] = 0
|
|
inputs["reset_loop"] = True
|
|
else:
|
|
inputs["loop_idx"] = next_loop
|
|
inputs["reset_loop"] = False
|
|
|
|
if class_type == "MasterSwitch":
|
|
master_switches.append(inputs)
|
|
|
|
is_next_final = (global_accumulated_frames + estimated_chunk) >= global_source_frame_count
|
|
if is_final_chunk and has_more_batches:
|
|
is_next_final = False
|
|
|
|
for inputs in master_switches:
|
|
inputs["is_final_cycle"] = is_next_final
|
|
|
|
if is_final_chunk and has_more_batches:
|
|
loop.global_batch_index = getattr(loop, 'global_batch_index', 0) + 1
|
|
loop.global_is_batch_advancing = True
|
|
print(f" -> 📦 Archivo finalizado. Iniciando archivo {loop.global_batch_index + 1} del lote...")
|
|
else:
|
|
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
|
|
|
|
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" -> ✅ Siguiente ciclo inyectado en la cola.")
|
|
except Exception as e:
|
|
print(f" -> ❌ Error HTTP: {e}")
|
|
|
|
import gc, torch
|
|
gc.collect()
|
|
if torch.cuda.is_available(): torch.cuda.empty_cache()
|
|
|
|
else:
|
|
print(f" -> 🏁 ¡Generación Finalizada! Todos los archivos del lote completados.")
|
|
# ... Mantener aquí el código existente de limpieza extrema de VRAM (mm.unload_all_models, malloc_trim, etc.) ...
|
|
print(f" -> 🧹 Iniciando vaciado automático de VRAM...")
|
|
import gc
|
|
import torch
|
|
import comfy.model_management as mm
|
|
try:
|
|
mm.unload_all_models()
|
|
mm.soft_empty_cache()
|
|
except Exception as e:
|
|
pass
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
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 ()
|