import math import time import torch from . import register_node class AnyType(str): def __ne__(self, __value: object) -> bool: return False any_type = AnyType("*") @register_node class PrimitiveDelay: @classmethod def INPUT_TYPES(s): return { "required": { "delay_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3600.0, "step": 0.1, "tooltip": "Tiempo de pausa en segundos"}), }, "optional": { "any_in": (any_type, {"tooltip": "Conecta aquí el cable que quieres retrasar"}), } } RETURN_TYPES = (any_type,) RETURN_NAMES = ("* (passthrough)",) FUNCTION = "execute_delay" CATEGORY = "🔁 Sequential Batcher/Tools" def execute_delay(self, delay_seconds, any_in=None): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) _log(f"\n{'='*50}") _log(f"⏱️ [Secuencial Batcher] NODO: Primitive Delay") _log(f" -> Congelando la ejecución durante {delay_seconds} segundos...") time.sleep(delay_seconds) _log(f" -> ✅ Pausa terminada. Reanudando flujo.") _log(f"{'='*50}\n") return (any_in,) # 🧠 Clase Base Invisible (El molde matemático por Megapíxeles) class BaseResolutionTool: @classmethod def INPUT_TYPES(cls): return { "required": { "aspect_ratio": (["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "2:3"],), # 💡 Cambio clave: Dejamos de usar combo y pasamos a un campo INT libre "base_resolution": ("INT", {"default": 1024, "min": 128, "max": 8192, "step": 8}), } } RETURN_TYPES = ("INT", "INT", "STRING", "STRING") RETURN_NAMES = ("width", "height", "debug_info", "log") FUNCTION = "get_resolution" CATEGORY = "🔁 Sequential Batcher/Tools" def get_resolution(self, aspect_ratio, base_resolution): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) div = self.DIVISOR min_pixels = getattr(self, "MIN_PIXELS", 0) longest_side = int(base_resolution) # Mantenemos el casting por seguridad w_str, h_str = aspect_ratio.split(':') w_ratio = float(w_str) h_ratio = float(h_str) ratio = w_ratio / h_ratio _log(f"\n{'='*50}") _log(f"🛠️ [Secuencial Batcher] NODO: Resolution Tool {div}x") _log(f" -> Petición Original: Lado mayor {longest_side}px | Ratio {aspect_ratio}") # 1. Calcular resolución inicial teórica if w_ratio >= h_ratio: ideal_w = longest_side ideal_h = ideal_w / ratio else: ideal_h = longest_side ideal_w = ideal_h * ratio current_pixels = ideal_w * ideal_h # 2. 🛡️ Protección de Suelo por ÁREA TOTAL (Training Floor) if current_pixels < min_pixels: _log(f" -> ⚠️ ALERTA: La resolución pedida ({int(current_pixels)} píxeles) es inferior al mínimo vital del modelo ({min_pixels} píxeles).") _log(f" -> 🛡️ Escalando proporcionalmente para evitar artefactos...") # Factor de escala basado en área scale_factor = math.sqrt(min_pixels / current_pixels) ideal_w *= scale_factor ideal_h *= scale_factor # 3. Ajuste final de divisibilidad estricta # Usamos round para asegurar que nos quedamos lo más cerca posible del área ideal width = max(div, round(ideal_w / div) * div) height = max(div, round(ideal_h / div) * div) debug_msg = f"{width}x{height} (Div {div})" _log(f" -> 🎯 Resultado Final Seguro: Ancho {width} | Alto {height} | Píxeles totales: {width*height}") _log(f"{'='*50}\n") return (width, height, debug_msg, "\n".join(log_output)) # 📦 Nodos Específicos Blindados por Píxeles @register_node class ResTool8x(BaseResolutionTool): DIVISOR = 8 MIN_PIXELS = 262144 # SD1.5 (Equivale a 512x512) @register_node class ResTool16x(BaseResolutionTool): DIVISOR = 16 MIN_PIXELS = 1048576 # SDXL (Equivale a 1024x1024) @register_node class ResTool32x(BaseResolutionTool): DIVISOR = 32 MIN_PIXELS = 399360 # WanVideo (Equivale a 832x480) @register_node class ResTool64x(BaseResolutionTool): DIVISOR = 64 MIN_PIXELS = 921600 # Hunyuan (Equivale a 1280x720) @register_node class ResTool64xLTX(BaseResolutionTool): DIVISOR = 64 MIN_PIXELS = 393216 # LTX 2.3 (Equivale a 768x512) @register_node class AutoFPSLimiter: @classmethod def INPUT_TYPES(s): return { "required": { "source_fps": ("FLOAT", {"forceInput": True}), "target_max_fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}), } } RETURN_TYPES = ("INT", "FLOAT", "STRING") RETURN_NAMES = ("select_every_nth", "new_fps", "log") FUNCTION = "calculate_fps" CATEGORY = "🔁 Sequential Batcher/Tools" def calculate_fps(self, source_fps, target_max_fps): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) import math _log(f"\n{'='*50}") _log(f"⏱️ [Secuencial Batcher] NODO: Auto FPS Limiter") _log(f" -> FPS Originales: {source_fps:.2f} | Meta Máxima: {target_max_fps:.2f}") if source_fps <= target_max_fps: nth = 1 new_fps = source_fps _log(f" -> ✅ Los FPS originales ya están dentro del límite.") else: # Calculamos el salto matemático (hacia arriba) para garantizar no superar la meta nth = math.ceil(source_fps / target_max_fps) new_fps = source_fps / nth _log(f" -> ✂️ Reducción necesaria. Procesando 1 de cada {nth} frames.") _log(f" -> 🎯 Resultado: {new_fps:.2f} FPS finales (Nth: {nth})") _log(f"{'='*50}\n") return (nth, new_fps, "\n".join(log_output)) @register_node class ConditionalAudioRouter: @classmethod def INPUT_TYPES(s): return { "required": { "audio": ("AUDIO",), "is_final_cycle": ("BOOLEAN", {"forceInput": True}), } } RETURN_TYPES = ("AUDIO", "STRING") RETURN_NAMES = ("routed_audio", "log") FUNCTION = "route" CATEGORY = "🔁 Sequential Batcher/Tools" def route(self, audio, is_final_cycle): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) if is_final_cycle: _log(" -> 🔊 [Audio Router] Ciclo Final: Enviando pista de audio completa al Lip Sync.") return (audio, "\n".join(log_output)) else: _log(" -> 🔇 [Audio Router] Ciclo Intermedio: Generando micro-silencio para bypasear el Lip Sync...") # Detectar el sample rate del audio original (estándar de ComfyUI es un dict) sample_rate = 44100 if isinstance(audio, dict) and "sample_rate" in audio: sample_rate = audio["sample_rate"] # Creamos 0.1 segundos de silencio absoluto (1 canal, mínimos samples) samples = int(sample_rate * 0.1) dummy_waveform = torch.zeros((1, 1, samples), dtype=torch.float32) dummy_audio = {"waveform": dummy_waveform, "sample_rate": sample_rate} return (dummy_audio, "\n".join(log_output)) @register_node class VAESafeFramePadder: @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), "rule": (["WanVideo (Regla 4n+1)", "LTX (Regla 8n+1)"],), } } RETURN_TYPES = ("IMAGE", "INT", "STRING") RETURN_NAMES = ("PADDED_IMAGES", "frames_added", "log") FUNCTION = "pad" CATEGORY = "🔁 Sequential Batcher/Tools" def pad(self, images, rule): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) if not isinstance(images, torch.Tensor): raise ValueError("[VAESafeFramePadder] Input is not a valid tensor.") n_frames = images.shape[0] target_frames = n_frames if "WanVideo" in rule: # 🚀 FIX: Regla 4n+1 para WanVideo (Evita la pérdida de 3 frames en el VAE) if n_frames < 5: target_frames = 5 else: target_frames = ((n_frames + 2) // 4) * 4 + 1 elif "LTX" in rule: if n_frames < 9: target_frames = 9 else: target_frames = ((n_frames + 6) // 8) * 8 + 1 diff = target_frames - n_frames _log(f"\n{'='*50}") _log(f"🛡️ [Secuencial Batcher] NODO: VAE Safe Frame Padder") _log(f" -> Frames recibidos: {n_frames}") _log(f" -> Regla aplicada: {rule}") if diff > 0: _log(f" -> 🧱 Tensor incompleto. Clonando el último frame {diff} vez/veces...") last_frame = images[-1:] # Extraemos el último frame padding = last_frame.repeat(diff, 1, 1, 1) # Lo multiplicamos images = torch.cat([images, padding], dim=0) # Lo fusionamos al final _log(f" -> ✅ Tensor final acolchado a: {target_frames} frames.") else: _log(f" -> ✅ Tensor perfecto. No requiere acolchado.") _log(f"{'='*50}\n") return (images, diff, "\n".join(log_output)) @register_node class VHS_Path_Selector: @classmethod def INPUT_TYPES(s): return { "required": { "vhs_filenames": ("VHS_FILENAMES",), "select_file": (["video_with_audio", "video_muted", "png_thumbnail"],), } } # 🔄 Ahora devuelve la cadena y el objeto VHS reordenado RETURN_TYPES = ("STRING", "VHS_FILENAMES", "STRING") RETURN_NAMES = ("file_path", "vhs_filenames", "log") FUNCTION = "select" CATEGORY = "🔁 Sequential Batcher/Tools" # 📍 Ubicación corregida def select(self, vhs_filenames, select_file): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) try: if vhs_filenames is None or not isinstance(vhs_filenames, (list, tuple)) or len(vhs_filenames) < 2: _log("⚠️ [Path Selector] Entrada vhs_filenames vacía o inválida.") return ("", vhs_filenames, "\n".join(log_output)) paths = vhs_filenames[1] if not isinstance(paths, list) or not paths: return ("", vhs_filenames, "\n".join(log_output)) # Identificación de archivos audio_path = next((s for s in paths if isinstance(s, str) and s.endswith("-audio.mp4")), None) muted_path = next((s for s in paths if isinstance(s, str) and s.endswith(".mp4") and not s.endswith("-audio.mp4")), None) png_path = next((s for s in paths if isinstance(s, str) and s.endswith(".png")), None) # Selección del archivo principal (result) if select_file == "video_with_audio": result = audio_path if audio_path else (muted_path if muted_path else paths[0]) elif select_file == "video_muted": result = muted_path if muted_path else (audio_path if audio_path else paths[0]) else: # png_thumbnail result = png_path if png_path else paths[0] # 🪄 REORDENADO: Ponemos el elegido en la posición 0 new_paths = list(paths) if result in new_paths: new_paths.remove(result) new_paths.insert(0, result) # Creamos el nuevo paquete VHS [bool, [lista_reordenada]] reordered_vhs = [vhs_filenames[0], new_paths] _log(f"📂 [Path Selector] Selección: {select_file} -> {result}") return (str(result), reordered_vhs, "\n".join(log_output)) except Exception as e: _log(f"❌ [Path Selector] Error crítico: {e}") return ("", vhs_filenames, "\n".join(log_output)) @register_node class AudioDurationCalculator: @classmethod def INPUT_TYPES(s): return { "required": { "audio": ("AUDIO",), } } RETURN_TYPES = ("FLOAT", "STRING") RETURN_NAMES = ("duration_seconds", "log") FUNCTION = "calculate" CATEGORY = "🔁 Sequential Batcher/Tools" def calculate(self, audio): log_output = [] def _log(msg): print(msg) log_output.append(str(msg)) _log(f"\n{'='*50}") _log(f"⏱️ [Secuencial Batcher] NODO: Audio Duration Calculator") # 1. Validación de seguridad para evitar crashes if not isinstance(audio, dict): _log(" -> ⚠️ No se detectó un diccionario de audio válido. Devolviendo 0.0") _log(f"{'='*50}\n") return (0.0, "\n".join(log_output)) waveform = audio.get("waveform") sample_rate = audio.get("sample_rate", 44100) if waveform is None: _log(" -> ⚠️ No se detectó 'waveform' en el audio. Devolviendo 0.0") _log(f"{'='*50}\n") return (0.0, "\n".join(log_output)) # 2. Cálculo matemático samples = waveform.shape[-1] duration = float(samples) / float(sample_rate) _log(f" -> ✅ Duración calculada: {duration:.3f} segundos ({samples} samples @ {sample_rate}Hz)") _log(f"{'='*50}\n") return (duration, "\n".join(log_output)) @register_node class PreciseAudioSlicer: @classmethod def INPUT_TYPES(s): return { "required": { "audio": ("AUDIO",), "skip_frames": ("INT", {"forceInput": True}), "chunk_frames": ("INT", {"forceInput": True}), "fps": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 120.0, "step": 1.0}), } } RETURN_TYPES = ("AUDIO", "STRING") RETURN_NAMES = ("sliced_audio", "log") FUNCTION = "slice_audio" CATEGORY = "🔁 Sequential Batcher/Tools" def slice_audio(self, audio, skip_frames, chunk_frames, fps): log_output = [] def _log(msg): print(msg); log_output.append(str(msg)) import torch from . import loop # 🧠 COMPENSACIÓN MULTI-ESCENA global_offset = getattr(loop, 'global_audio_offset_frames', 0) absolute_skip_frames = skip_frames + global_offset _log(f"\n{'='*50}") _log(f"✂️ [Secuencial Batcher] NODO: Precise Audio Slicer") if not isinstance(audio, dict) or "waveform" not in audio: _log(" -> ⚠️ Audio no válido. Generando silencio...") return ({"waveform": torch.zeros((1, 2, int((chunk_frames/fps)*44100)), dtype=torch.float32), "sample_rate": 44100}, "\n".join(log_output)) waveform = audio["waveform"] sample_rate = audio.get("sample_rate", 44100) start_sec = absolute_skip_frames / fps duration_sec = chunk_frames / fps start_sample = int(start_sec * sample_rate) chunk_samples = int(duration_sec * sample_rate) end_sample = start_sample + chunk_samples total_samples = waveform.shape[-1] _log(f" -> Sincronizando audio ({chunk_frames} frames a {fps} FPS)") _log(f" -> Tramo de tiempo absoluto: {start_sec:.3f}s hasta {start_sec + duration_sec:.3f}s") if start_sample >= total_samples: sliced_waveform = torch.zeros((*waveform.shape[:-1], chunk_samples), dtype=waveform.dtype, device=waveform.device) elif end_sample > total_samples: valid_audio = waveform[..., start_sample:total_samples] pad_tensor = torch.zeros((*waveform.shape[:-1], end_sample - total_samples), dtype=waveform.dtype, device=waveform.device) sliced_waveform = torch.cat([valid_audio, pad_tensor], dim=-1) else: sliced_waveform = waveform[..., start_sample:end_sample] sliced_waveform = sliced_waveform.contiguous() _log(f"{'='*50}\n") return ({"waveform": sliced_waveform, "sample_rate": sample_rate}, "\n".join(log_output)) @register_node class SaveSceneKeyframe: @classmethod def INPUT_TYPES(s): return { "required": { "image": ("IMAGE",), "file_path": ("STRING", {"forceInput": True}), } } RETURN_TYPES = ("IMAGE", "STRING") RETURN_NAMES = ("image", "file_path") # 💡 Eliminado OUTPUT_NODE = True para permitir evaluación perezosa FUNCTION = "save" CATEGORY = "🔁 Sequential Batcher/Tools" def save(self, image, file_path): import os, numpy as np from PIL import Image from . import loop img_array = 255. * image[0].cpu().numpy() img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)) if os.path.isdir(file_path): os.rmdir(file_path) # Borra la carpeta fantasma creada por WAS os.makedirs(os.path.dirname(file_path), exist_ok=True) img.save(file_path) # Ahora sí, guarda la imagen (sobrescribiendo si existe) # 🧠 HACK DE RELOJ (Art Flow): Marca la escena como terminada para que el Director avance. loop.global_is_final_chunk = True print(f"💾 [Keyframe Saver] Guardado con éxito en: {file_path}") return (image, file_path) @register_node class LoadSceneKeyframe: @classmethod def INPUT_TYPES(s): return { "required": { "file_path": ("STRING", {"forceInput": True}) } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("IMAGE",) FUNCTION = "load" CATEGORY = "🔁 Sequential Batcher/Tools" def load(self, file_path): import os import torch import numpy as np from PIL import Image, ImageOps if not os.path.exists(file_path): print(f"⚠️ [Keyframe Loader] Archivo no encontrado: {file_path}. Generando tensor negro de seguridad.") return (torch.zeros((1, 512, 512, 3), dtype=torch.float32),) img = Image.open(file_path) img = ImageOps.exif_transpose(img) img = img.convert("RGB") img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0) print(f"📥 [Keyframe Loader] Imagen cargada desde: {file_path}") return (img_tensor,) @register_node class LTXVSingleFrameInjector: @classmethod def INPUT_TYPES(s): return { "required": { "vae": ("VAE",), "latent": ("LATENT",), "image": ("IMAGE",), } } RETURN_TYPES = ("LATENT",) RETURN_NAMES = ("latent",) FUNCTION = "inject" CATEGORY = "🔁 Sequential Batcher/Tools" def inject(self, vae, latent, image): import torch import comfy.utils print(f"\n{'='*50}") print(f"💉 [Secuencial Batcher] NODO: LTXV Single Frame Injector") samples = latent["samples"].clone() # Extraer factores de escala del VAE (Típicamente 8x32x32 para LTXV) scale_factors = getattr(vae, "downscale_index_formula", (8, 32, 32)) height_scale_factor = scale_factors[1] width_scale_factor = scale_factors[2] batch, channels, latent_frames, latent_height, latent_width = samples.shape width = latent_width * width_scale_factor height = latent_height * height_scale_factor if "noise_mask" in latent: mask = latent["noise_mask"].clone() else: mask = torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=samples.device) print(f" -> 📐 Adaptando resolución de la imagen ({image.shape[2]}x{image.shape[1]}) a la del Latent ({width}x{height})") # Ajustar resolución de la imagen si no coincide con el latent if image.shape[1] != height or image.shape[2] != width: pixels = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1) else: pixels = image encode_pixels = pixels[:, :, :, :3] # Codificar imagen usando el VAE de LTX t = vae.encode(encode_pixels) # Compatibilidad 4D a 5D (Si el VAE devuelve [B, C, H, W] lo pasamos a [B, C, T, H, W]) if len(t.shape) == 4: t = t.unsqueeze(2) latent_idx = 0 end_index = min(latent_idx + t.shape[2], latent_frames) # Inyección directa samples[:, :, latent_idx:end_index] = t[:, :, :end_index - latent_idx] mask[:, :, latent_idx:end_index] = 0.0 # 0.0 significa "Proteger este frame del ruido" print(f" -> ✅ Imagen inyectada y sellada en el Frame 0 (Índice Latente {latent_idx}).") print(f"{'='*50}\n") return ({"samples": samples, "noise_mask": mask},)