This commit introduces a new dropdown to the `AutoLoopCalculatorTTS` node inside `video.py`, allowing text to be split by either "Párrafos (Saltos de línea)" or "Frases (Puntos)". When splitting by sentences, it cleanly preserves the periods to maintain proper intonation in TTS systems. Output pins are renamed (`current_text`, `total_chunks`) for better semantics.
887 lines
40 KiB
Python
887 lines
40 KiB
Python
import os
|
|
import sys
|
|
import logging
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import redirect_stdout, redirect_stderr
|
|
from tqdm import tqdm
|
|
import comfy.utils
|
|
import math
|
|
import torch
|
|
import torchaudio
|
|
import folder_paths
|
|
import nodes
|
|
import time
|
|
import uuid
|
|
import subprocess
|
|
from . import register_node
|
|
|
|
# Aseguramos que torch esté disponible globalmente para los bloques de limpieza
|
|
import torch
|
|
|
|
# Intento de carga de OpenCV para el Director de Fotografía
|
|
|
|
# Caché persistente para evitar re-escaneos pesados de vídeo
|
|
VIDEO_ANALYSIS_CACHE = {}
|
|
|
|
# 🎵 Helper para estandarizar audio a 44100Hz y 2 Canales (Estéreo)
|
|
def extract_and_standardize_audio(video_path, target_sr=44100):
|
|
import torchaudio.transforms as T
|
|
try:
|
|
waveform, sample_rate = torchaudio.load(video_path)
|
|
|
|
# 1. Resampling si la frecuencia es distinta
|
|
if sample_rate != target_sr:
|
|
resampler = T.Resample(orig_freq=sample_rate, new_freq=target_sr)
|
|
waveform = resampler(waveform)
|
|
sample_rate = target_sr
|
|
|
|
# 2. Downmix / Upmix a Estéreo (2 canales) estrictamente
|
|
channels = waveform.shape[0]
|
|
if channels > 2:
|
|
# Si tiene más de 2 canales (ej. 5.1), mezclamos a mono y duplicamos a estéreo
|
|
mono = torch.mean(waveform, dim=0, keepdim=True)
|
|
waveform = mono.repeat(2, 1)
|
|
elif channels == 1:
|
|
# Si es mono, duplicamos la pista para que sea estéreo
|
|
waveform = waveform.repeat(2, 1)
|
|
|
|
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
|
|
except Exception as e:
|
|
raise e
|
|
|
|
# 🎬 Helper para crear un Clon de Vídeo Físico Estandarizado (Soluciona el desvío VHS)
|
|
def standardize_video_file(video_path, _log):
|
|
import folder_paths
|
|
|
|
if "_std_audio.mp4" in video_path:
|
|
return video_path
|
|
|
|
filename = os.path.basename(video_path)
|
|
name, _ = os.path.splitext(filename)
|
|
out_filename = f"{name}_std_audio.mp4"
|
|
out_path = os.path.join(folder_paths.get_temp_directory(), out_filename)
|
|
|
|
if os.path.exists(out_path):
|
|
_log(f" -> ♻️ Usando clon de vídeo estandarizado existente en disco: {out_filename}")
|
|
return out_path
|
|
|
|
# Averiguar info original del audio
|
|
target_sr = None
|
|
target_ac = None
|
|
try:
|
|
info = torchaudio.info(video_path)
|
|
# Solo aplicamos limitador si se superan los umbrales máximos de ComfyUI
|
|
if info.sample_rate > 44100:
|
|
target_sr = "44100"
|
|
if info.num_channels > 2:
|
|
target_ac = "2"
|
|
except Exception as e:
|
|
_log(f" -> ⚠️ No se pudo leer metadata de audio con torchaudio. Forzando límites de seguridad. (Error: {e})")
|
|
target_sr = "44100"
|
|
target_ac = "2"
|
|
|
|
# Si el audio ya es seguro, no gastamos recursos en clonar
|
|
if target_sr is None and target_ac is None:
|
|
_log(f" -> ✅ El audio original ya está en parámetros seguros (<=44100Hz, <=2 Canales). Omitiendo FFmpeg.")
|
|
return video_path
|
|
|
|
_log(f" -> ⚙️ Reestructurando contenedor físico con FFmpeg (Vídeo: Copy | Audio: Limitando a parámetros seguros)...")
|
|
try:
|
|
cmd = [
|
|
"ffmpeg", "-y", "-i", video_path,
|
|
"-c:v", "copy",
|
|
"-c:a", "aac"
|
|
]
|
|
|
|
# Añadimos los filtros solo si son necesarios
|
|
if target_sr:
|
|
cmd.extend(["-ar", target_sr])
|
|
if target_ac:
|
|
cmd.extend(["-ac", target_ac])
|
|
|
|
cmd.extend(["-map", "0:v?", "-map", "0:a?", out_path])
|
|
|
|
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
_log(f" -> ✅ Contenedor clonado y blindado con éxito para los nodos de VHS.")
|
|
return out_path
|
|
except Exception as e:
|
|
raise Exception(f"❌ [Secuencial Batcher] Error crítico: Falló la reestructuración del vídeo. Asegúrate de tener FFmpeg instalado y accesible en el PATH del sistema. Detalles: {e}")
|
|
|
|
try:
|
|
import cv2
|
|
import numpy as np
|
|
HAS_OPENCV = True
|
|
except ImportError:
|
|
HAS_OPENCV = False
|
|
|
|
@register_node
|
|
class VideoAnalyzerFaceDetector:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# 1. Obtenemos los archivos locales igual que VHS
|
|
input_dir = folder_paths.get_input_directory()
|
|
files = []
|
|
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov'] # Mismos que VHS
|
|
if os.path.exists(input_dir):
|
|
for f in os.listdir(input_dir):
|
|
if os.path.isfile(os.path.join(input_dir, f)):
|
|
file_parts = f.split('.')
|
|
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
|
|
files.append(f)
|
|
|
|
# 2. El truco VHS: Una tupla con (lista_de_archivos,) pero configurado como STRING y con forceInput=False
|
|
return {
|
|
"required": {
|
|
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
|
|
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
|
|
"use_face_detector": ("BOOLEAN", {"default": True}),
|
|
"blur_threshold": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 1000.0, "step": 1.0}),
|
|
"unload_detector_after_analysis": ("BOOLEAN", {"default": True}),
|
|
"unload_detector": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"optional": {
|
|
"bbox_detector": ("BBOX_DETECTOR", ), # 💡 Puerto para YOLO/ONNX
|
|
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "FACE_CUTS", "IMAGE", "STRING")
|
|
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "safe_faces_list", "reference_frame", "log")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "analyze"
|
|
CATEGORY = "🔁 Sequential Batcher/Video"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, video, reference_frame_idx, use_face_detector, blur_threshold, **kwargs):
|
|
if isinstance(video, list):
|
|
video_str = "".join(video)
|
|
else:
|
|
video_str = str(video)
|
|
return f"{video_str}_{reference_frame_idx}_{use_face_detector}_{blur_threshold}"
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(cls, **kwargs):
|
|
# Bypass para permitir conexiones dinámicas (como descargas en curso)
|
|
return True
|
|
|
|
def analyze(self, video, reference_frame_idx, use_face_detector, blur_threshold, unload_detector_after_analysis=True, bbox_detector=None, current_loop_index=0, **kwargs):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
# Resolución unificada de la ruta, tal como hace VHS
|
|
if os.path.exists(video):
|
|
video_path = video
|
|
else:
|
|
video_path = folder_paths.get_annotated_filepath(video)
|
|
|
|
# 🚀 CLONACIÓN FÍSICA PARA VHS
|
|
video_path = standardize_video_file(video_path, _log)
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"🕵️ [Secuencial Batcher] NODO: Video Analyzer (Explorador)")
|
|
_log(f" -> Archivo resuelto: {video_path}")
|
|
|
|
cache_key = f"{video_path}_face"
|
|
|
|
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
|
|
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
|
|
_log(f"♻️ [Face Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
|
|
cached = VIDEO_ANALYSIS_CACHE[cache_key]
|
|
frame_count, source_fps, source_audio, safe_faces = cached
|
|
else:
|
|
# Análisis completo (Solo Ciclo 0 o primer arranque)
|
|
_log(f"🎬 [Face Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
|
|
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
|
|
source_audio = None
|
|
try:
|
|
source_audio = extract_and_standardize_audio(video_path)
|
|
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
|
|
except Exception as e:
|
|
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
|
|
|
|
# 2. Escaneo de OpenCV (Frames, Rostros y Referencia)
|
|
frame_count = 0
|
|
source_fps = 0.0
|
|
safe_faces = []
|
|
|
|
if not HAS_OPENCV:
|
|
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
|
|
else:
|
|
cap = cv2.VideoCapture(video_path)
|
|
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
|
|
_log(f" -> 🎞️ Total Frames detectados: {frame_count}")
|
|
_log(f" -> ⏱️ FPS detectados: {source_fps}")
|
|
|
|
# Escaneo de Rostros
|
|
if bbox_detector is not None or use_face_detector:
|
|
_log(f" -> 🤖 Iniciando escaneo de rostros (Umbral: {blur_threshold})...")
|
|
if bbox_detector is not None:
|
|
_log(f" -> ⚡ Usando detector de rostros por GPU (YOLO/ONNX).")
|
|
else:
|
|
_log(f" -> 🐢 Usando detector de rostros por CPU (OpenCV).")
|
|
|
|
if HAS_OPENCV:
|
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Volver al inicio
|
|
|
|
_log(f" -> 🤖 Iniciando escaneo de rostros por GPU...")
|
|
|
|
# Silenciamos el logger de ultralytics antes de empezar
|
|
logging.getLogger("ultralytics").setLevel(logging.ERROR)
|
|
|
|
# Barra de progreso profesional (desc=Descripción, unit=unidad, leave=True para que no desaparezca)
|
|
pbar = tqdm(total=frame_count, desc="🔍 Analizando Rostros", unit="frame", dynamic_ncols=True)
|
|
comfy_pbar = comfy.utils.ProgressBar(frame_count)
|
|
|
|
for idx in range(frame_count):
|
|
ret, frame = cap.read()
|
|
if not ret: break
|
|
|
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
|
|
|
|
faces_found = False
|
|
if variance > blur_threshold:
|
|
|
|
if bbox_detector is not None:
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
img_tensor = torch.from_numpy(frame_rgb.astype(np.float32) / 255.0).unsqueeze(0).cpu()
|
|
|
|
try:
|
|
# 🤐 CÁPSULA DE VACÍO: Silenciamos TODO el ruido de la consola
|
|
with open(os.devnull, 'w') as fnull:
|
|
with redirect_stdout(fnull), redirect_stderr(fnull):
|
|
# Inferencia
|
|
res = bbox_detector.detect(img_tensor, 0.5, 10, 1.0, 10)
|
|
if isinstance(res, tuple):
|
|
segs = res[0]
|
|
else:
|
|
segs = res
|
|
if isinstance(segs, tuple) and len(segs) > 1 and isinstance(segs[1], list):
|
|
faces_found = len(segs[1]) > 0
|
|
elif isinstance(segs, list):
|
|
faces_found = len(segs) > 0
|
|
elif hasattr(segs, '__len__'):
|
|
faces_found = len(segs) > 0
|
|
except Exception as e:
|
|
_log(f" ❌ ERROR BBOX en frame {idx}: {e}")
|
|
|
|
else:
|
|
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
|
|
faces_found = len(faces) > 0
|
|
|
|
if faces_found:
|
|
safe_faces.append(idx)
|
|
# _log(f" ✨ Rostro nítido detectado en frame [{idx}] (Varianza: {variance:.2f})")
|
|
|
|
pbar.update(1)
|
|
comfy_pbar.update(1)
|
|
|
|
pbar.close()
|
|
_log(f" -> ✅ Encontrados {len(safe_faces)} frames válidos con rostros.")
|
|
|
|
cap.release()
|
|
|
|
# Guardar en caché
|
|
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, safe_faces)
|
|
|
|
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
|
|
ui_result = {}
|
|
if HAS_OPENCV:
|
|
cap = cv2.VideoCapture(video_path)
|
|
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1))
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
|
|
ret, frame = cap.read()
|
|
if ret:
|
|
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
|
|
ref_tensor = ref_tensor.unsqueeze(0)
|
|
_log(f" -> 🖼️ Frame de referencia extraído (Índice: {safe_ref_idx})")
|
|
|
|
from PIL import Image
|
|
preview_dir = folder_paths.get_temp_directory()
|
|
preview_name = f"preview_ref_{uuid.uuid4().hex[:5]}.png"
|
|
Image.fromarray(ref_frame_rgb).save(os.path.join(preview_dir, preview_name))
|
|
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
|
|
else:
|
|
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
|
cap.release()
|
|
else:
|
|
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
|
|
|
# Liberar el detector de VRAM si se solicita y si se usó
|
|
if unload_detector_after_analysis and bbox_detector is not None and getattr(kwargs, 'unload_detector', True):
|
|
_log(f" -> 🧹 Limpiando BBOX_DETECTOR de la VRAM para ahorrar memoria...")
|
|
import gc
|
|
import comfy.model_management as mm
|
|
# Romper referencias
|
|
del bbox_detector
|
|
bbox_detector = None
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
mm.soft_empty_cache()
|
|
_log(f"{'='*50}\n")
|
|
|
|
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, safe_faces, ref_tensor, "\n".join(log_output))}
|
|
|
|
|
|
@register_node
|
|
class VideoAnalyzerSceneDetector:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
input_dir = folder_paths.get_input_directory()
|
|
files = []
|
|
if os.path.exists(input_dir):
|
|
files = [f for f in os.listdir(input_dir) if f.split('.')[-1].lower() in ['webm', 'mp4', 'mkv', 'gif', 'mov']]
|
|
return {
|
|
"required": {
|
|
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
|
|
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000}),
|
|
"scene_threshold": ("FLOAT", {"default": 25.0, "min": 5.0, "max": 150.0}),
|
|
},
|
|
"optional": {
|
|
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "SCENE_CUTS", "IMAGE", "STRING")
|
|
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "scene_cuts_list", "reference_frame", "log")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "analyze"
|
|
CATEGORY = "🔁 Sequential Batcher/Video"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, video, reference_frame_idx, scene_threshold, **kwargs):
|
|
if isinstance(video, list):
|
|
video_str = "".join(video)
|
|
else:
|
|
video_str = str(video)
|
|
return f"{video_str}_{reference_frame_idx}_{scene_threshold}"
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(cls, **kwargs):
|
|
return True
|
|
|
|
def analyze(self, video, reference_frame_idx, scene_threshold, current_loop_index=0, **kwargs):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
|
|
video_path = video if os.path.exists(video) else folder_paths.get_annotated_filepath(video)
|
|
|
|
# 🚀 CLONACIÓN FÍSICA PARA VHS
|
|
video_path = standardize_video_file(video_path, _log)
|
|
|
|
cache_key = f"{video_path}_scene"
|
|
|
|
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
|
|
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
|
|
_log(f"♻️ [Scene Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
|
|
cached = VIDEO_ANALYSIS_CACHE[cache_key]
|
|
frame_count, source_fps, source_audio, scene_cuts = cached
|
|
else:
|
|
# Análisis completo (Solo Ciclo 0 o primer arranque)
|
|
_log(f"🎬 [Scene Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
|
|
|
|
frame_count = 0
|
|
source_fps = 0.0
|
|
source_audio = None
|
|
scene_cuts = []
|
|
|
|
if HAS_OPENCV:
|
|
cap = cv2.VideoCapture(video_path)
|
|
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
|
|
|
|
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
|
|
source_audio = None
|
|
try:
|
|
source_audio = extract_and_standardize_audio(video_path)
|
|
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
|
|
except Exception as e:
|
|
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
|
|
|
|
# Detección de cortes
|
|
_log(f" -> 🎬 Iniciando escaneo de escenas (Umbral Diferencia: {scene_threshold})...")
|
|
prev_gray = None
|
|
pbar = tqdm(total=frame_count, desc="🎬 Analizando Escenas")
|
|
comfy_pbar = comfy.utils.ProgressBar(frame_count)
|
|
|
|
for idx in range(frame_count):
|
|
ret, frame = cap.read()
|
|
if not ret: break
|
|
small = cv2.resize(frame, (128, 128))
|
|
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
|
if prev_gray is not None:
|
|
score = np.mean(cv2.absdiff(gray, prev_gray))
|
|
if score > scene_threshold:
|
|
scene_cuts.append(idx)
|
|
# _log(f" 🎞️ Corte de escena detectado en frame [{idx}] (Puntuación: {score:.2f})")
|
|
prev_gray = gray
|
|
pbar.update(1)
|
|
comfy_pbar.update(1)
|
|
pbar.close()
|
|
cap.release()
|
|
_log(f" -> ✅ Encontrados {len(scene_cuts)} cambios de escena fuertes.")
|
|
else:
|
|
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
|
|
|
|
# Guardar en caché
|
|
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, scene_cuts)
|
|
|
|
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
|
|
ui_result = {}
|
|
if HAS_OPENCV:
|
|
cap = cv2.VideoCapture(video_path)
|
|
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1)) if frame_count > 0 else 0
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
|
|
ret, frame = cap.read()
|
|
if ret:
|
|
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
|
|
ref_tensor = ref_tensor.unsqueeze(0)
|
|
|
|
from PIL import Image
|
|
preview_name = f"preview_scene_{uuid.uuid4().hex[:5]}.png"
|
|
Image.fromarray(ref_frame_rgb).save(os.path.join(folder_paths.get_temp_directory(), preview_name))
|
|
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
|
|
else:
|
|
ref_tensor = torch.zeros((1, 64, 64, 3))
|
|
cap.release()
|
|
else:
|
|
ref_tensor = torch.zeros((1, 64, 64, 3))
|
|
|
|
_log(f"{'='*50}\n")
|
|
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, scene_cuts, ref_tensor, "\n".join(log_output))}
|
|
|
|
|
|
|
|
@register_node
|
|
class AutoLoopCalculator:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"source_frame_count": ("INT", {"forceInput": True}),
|
|
"target_frames_per_loop": ("INT", {"default": 50, "min": 1, "max": 10000}),
|
|
"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", "STRING")
|
|
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
|
|
FUNCTION = "calculate"
|
|
CATEGORY = "🔁 Sequential Batcher/Video"
|
|
|
|
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
# --- Ajuste Proporcional Base (Sin restricciones VAE) ---
|
|
import math
|
|
from . import loop
|
|
|
|
potential_effective_frames = source_frame_count // select_every_nth
|
|
safe_effective_frames = potential_effective_frames # Sin restricciones
|
|
|
|
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
|
|
|
|
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
|
|
if estimated_loops > 0:
|
|
target_frames_per_loop = math.ceil(safe_effective_frames / estimated_loops)
|
|
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado a {target_frames_per_loop} frames por ciclo.")
|
|
|
|
# --- LOGS MEJORADOS Y TRANSPARENTES ---
|
|
_log(f" -> 🎞️ Capacidad del video: {potential_effective_frames} frames procesables (Nth: {select_every_nth})")
|
|
if effective_loss > 0:
|
|
_log(f" -> 🛡️ Ajuste VAE: Se usarán {safe_effective_frames} frames (Descarte técnico: {effective_loss} frame/s de proceso)")
|
|
else:
|
|
_log(f" -> ✅ Ajuste VAE: Perfecto. Múltiplo de 4 detectado.") # Manteniendo el mismo string por ahora o podemos omitirlo, lo adapto a que es genérico
|
|
|
|
_log(f" -> 📊 Timeline final: 0 a {safe_source_frame_count} (de {source_frame_count} totales)")
|
|
# -------------------------------------------------------------
|
|
|
|
loop.global_select_every_nth = select_every_nth
|
|
|
|
current_pos = loop.global_accumulated_frames
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (Motor Dinámico)")
|
|
_log(f" -> Posición en el timeline original: {current_pos} / {source_frame_count}")
|
|
|
|
if current_pos >= safe_source_frame_count:
|
|
return (1, current_pos, select_every_nth, "\n".join(log_output))
|
|
|
|
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
|
|
_log(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
|
|
else:
|
|
if scene_cuts_list and len(scene_cuts_list) > 0:
|
|
# 🎬 LÓGICA DE ESCENAS ESTRICTA (1 Escena = 1 Bucle)
|
|
# Buscar el PRIMER corte que venga inmediatamente después de la posición actual
|
|
future_cuts = [c for c in scene_cuts_list if c > current_pos]
|
|
|
|
if future_cuts:
|
|
next_cut = min(future_cuts)
|
|
|
|
# Verificamos si esta escena entera cabe en la VRAM
|
|
if next_cut <= ideal_cut:
|
|
best_cut = next_cut
|
|
_log(f" -> 🎬 Bucle aislado por escena. Cortando exacto en el frame: {best_cut}")
|
|
else:
|
|
best_cut = ideal_cut
|
|
_log(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
|
|
_log(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:
|
|
# 👤 LÓGICA DE ROSTROS (Fallback si no hay escenas conectadas)
|
|
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
|
|
best_cut = closest_face
|
|
_log(f" -> ✂️ Corte Facial Inteligente: {best_cut} (Meta: {ideal_cut})")
|
|
|
|
else:
|
|
best_cut = ideal_cut
|
|
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo: {ideal_cut}")
|
|
|
|
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
|
if current_pos + (effective_chunk_frames * select_every_nth) > safe_source_frame_count:
|
|
effective_chunk_frames = (safe_source_frame_count - current_pos) // select_every_nth
|
|
|
|
skip_frames = current_pos
|
|
|
|
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
|
|
_log(f"{'='*50}\n")
|
|
|
|
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
|
|
|
|
@register_node
|
|
class AutoLoopCalculatorTTS:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"text": ("STRING", {"multiline": True, "dynamicPrompts": False}),
|
|
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
|
|
"current_loop_index": ("INT", {"forceInput": True}),
|
|
}
|
|
}
|
|
|
|
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, split_mode, current_loop_index):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
|
|
from . import loop
|
|
|
|
# 1. Separar el texto según el modo elegido
|
|
if split_mode == "Frases (Puntos)":
|
|
# Dividimos por punto, eliminamos vacíos y restauramos el punto final
|
|
raw_chunks = [p.strip() + "." for p in text.split('.') if p.strip()]
|
|
chunk_type_name = "Frases"
|
|
else:
|
|
# Comportamiento original: Dividir por párrafos (saltos de línea)
|
|
raw_chunks = [p.strip() for p in 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
|
|
|
|
# 2. Seguridad de índice
|
|
safe_index = min(current_loop_index, total_chunks - 1)
|
|
current_text = raw_chunks[safe_index]
|
|
|
|
# 3. 🧠 HACK CORE: Inyectamos los bloques como si fueran frames
|
|
loop.global_source_frame_count = total_chunks
|
|
loop.global_accumulated_frames = safe_index + 1
|
|
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS)")
|
|
_log(f" -> Modo de división: {split_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_text[:75]}...")
|
|
_log(f"{'='*50}\n")
|
|
|
|
return (current_text, safe_index, total_chunks, "\n".join(log_output))
|
|
|
|
@register_node
|
|
class IncrementalVideoStitcher:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"audio": ("AUDIO",),
|
|
"current_loop_index": ("INT", {"default": 0}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "AUDIO", "BOOLEAN", "STRING")
|
|
RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT", "IS_FINAL_CYCLE", "log")
|
|
FUNCTION = "stitch"
|
|
CATEGORY = "🔁 Sequential Batcher/Video"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
import time
|
|
return time.time()
|
|
|
|
def stitch(self, images, audio, current_loop_index):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
from . import loop
|
|
import os, folder_paths, torch, shutil, time
|
|
from PIL import Image
|
|
import numpy as np
|
|
|
|
cache_dir = os.path.join(folder_paths.get_temp_directory(), "meisoft_video_cache")
|
|
|
|
if current_loop_index == 0:
|
|
if os.path.exists(cache_dir):
|
|
try: shutil.rmtree(cache_dir)
|
|
except Exception as e: pass
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
_log(f"\n🧹 [Stitcher] Ciclo 0 detectado. Subcarpeta temporal limpiada.")
|
|
else:
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
|
|
timestamp = int(time.time() * 1000)
|
|
|
|
# 🖼️ GUARDAR IMÁGENES (PNG)
|
|
_log(f"📦 [Stitcher] Guardando {images.shape[0]} frames como PNGs de alta calidad...")
|
|
for i in range(images.shape[0]):
|
|
filename = f"frame_{current_loop_index:04d}_{timestamp}_{i:04d}.png"
|
|
path = os.path.join(cache_dir, filename)
|
|
img_array = 255. * images[i].cpu().numpy()
|
|
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
|
img.save(path, format="PNG")
|
|
|
|
# 🎵 GUARDAR AUDIO EN CACHÉ (.pt)
|
|
if audio is not None:
|
|
audio_path = os.path.join(cache_dir, f"audio_{current_loop_index:04d}_{timestamp}.pt")
|
|
torch.save(audio, audio_path)
|
|
|
|
source_total = getattr(loop, 'global_source_frame_count', 1)
|
|
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
|
|
|
|
if is_final_chunk or loop.global_accumulated_frames >= source_total:
|
|
_log(f" -> 🏁 ¡Último ciclo detectado! Ensamblando recursos desde la caché temporal...")
|
|
|
|
# 1. ENSAMBLAR VÍDEO
|
|
png_files = sorted([f for f in os.listdir(cache_dir) if f.endswith('.png')])
|
|
if not png_files:
|
|
_log(" -> ❌ ERROR: No se encontraron frames en la subcarpeta.")
|
|
return (images, audio, True, "\n".join(log_output))
|
|
|
|
first_img = Image.open(os.path.join(cache_dir, png_files[0]))
|
|
H, W = first_img.height, first_img.width
|
|
total_frames = len(png_files)
|
|
|
|
_log(f" -> 🧩 Reservando bloque en RAM para {total_frames} frames de vídeo...")
|
|
final_tensor = torch.empty((total_frames, H, W, 3), dtype=torch.float32, device="cpu")
|
|
|
|
def load_and_process(idx, filename):
|
|
img = Image.open(os.path.join(cache_dir, filename)).convert("RGB")
|
|
img_np = np.array(img).astype(np.float32) / 255.0
|
|
final_tensor[idx] = torch.from_numpy(img_np)
|
|
|
|
with ThreadPoolExecutor() as executor:
|
|
list(executor.map(lambda p: load_and_process(*p), enumerate(png_files)))
|
|
|
|
# 2. ENSAMBLAR AUDIO
|
|
audio_files = sorted([f for f in os.listdir(cache_dir) if f.startswith('audio_') and f.endswith('.pt')])
|
|
final_audio = None
|
|
if audio_files:
|
|
_log(f" -> 🎵 Ensamblando {len(audio_files)} fragmentos de audio...")
|
|
waveforms = []
|
|
sample_rate = 44100
|
|
for af in audio_files:
|
|
chunk_audio = torch.load(os.path.join(cache_dir, af))
|
|
waveforms.append(chunk_audio["waveform"])
|
|
sample_rate = chunk_audio["sample_rate"]
|
|
|
|
# Concatenamos los audios en el eje del tiempo (dimensión -1)
|
|
final_waveform = torch.cat(waveforms, dim=-1)
|
|
final_audio = {"waveform": final_waveform, "sample_rate": sample_rate}
|
|
_log(f" -> ✅ Audio unificado. Duración total: {final_waveform.shape[-1] / sample_rate:.2f} segundos.")
|
|
else:
|
|
final_audio = audio
|
|
|
|
_log(f"✅ [Stitcher] VÍDEO COMPLETADO: {final_tensor.shape[0]} frames ensamblados con éxito.")
|
|
|
|
try:
|
|
shutil.rmtree(cache_dir)
|
|
_log(f"🧹 [Stitcher] Subcarpeta temporal destruida.")
|
|
except:
|
|
pass
|
|
|
|
return (final_tensor, final_audio, True, "\n".join(log_output))
|
|
else:
|
|
_log(f" -> ⏳ Ciclo intermedio. Recursos almacenados de forma segura. Pasando frames dummy...")
|
|
dummy_frame = images[-1:].clone()
|
|
return (dummy_frame, None, False, "\n".join(log_output))
|
|
|
|
@register_node
|
|
class AutoLoopCalculatorWan:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"source_frame_count": ("INT", {"forceInput": True}),
|
|
"target_frames_per_loop": ("INT", {"default": 48, "min": 4, "max": 10000, "step": 4}),
|
|
"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", "STRING")
|
|
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
|
|
FUNCTION = "calculate"
|
|
CATEGORY = "🔁 Sequential Batcher/Video"
|
|
|
|
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
import math
|
|
from . import loop
|
|
|
|
# --- MEISOFT PATCH: Sincronización Matemática (Hold Last Frame) ---
|
|
# 1. Redondear hacia ARRIBA a la regla 4n+1 (Acolchado WanVideo)
|
|
potential_effective_frames = source_frame_count // select_every_nth
|
|
|
|
if potential_effective_frames < 5:
|
|
safe_effective_frames = 5
|
|
else:
|
|
safe_effective_frames = ((potential_effective_frames + 2) // 4) * 4 + 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
|
|
|
|
loop.global_select_every_nth = select_every_nth
|
|
|
|
# 2. Ajuste Proporcional Real (Evitar micro-ciclos al final)
|
|
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 + 2) // 4) * 4 + 1
|
|
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado de {target_frames_per_loop} a {adjusted_target} frames por ciclo (para {estimated_loops} ciclos)")
|
|
target_frames_per_loop = adjusted_target
|
|
|
|
_log(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
|
|
if effective_padding > 0:
|
|
_log(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
|
|
else:
|
|
_log(f" -> ✅ Ajuste VAE: Perfecto. Regla 4n+1 detectada.")
|
|
|
|
_log(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
|
|
|
|
current_pos = getattr(loop, 'global_accumulated_frames', 0)
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (WanVideo 3D VAE)")
|
|
_log(f" -> Timeline físico ajustado: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
|
|
|
|
# Prevención de desbordamiento de bucle (Usando límite físico)
|
|
if current_pos >= physical_source_frame_count:
|
|
return (4, current_pos, select_every_nth, "\n".join(log_output))
|
|
|
|
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
|
|
_log(f" -> 🧮 Absorbiendo resto final seguro: 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 // 4) * 4
|
|
if safe_chunk_from_scene < 4: safe_chunk_from_scene = 4
|
|
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
|
|
_log(f" -> 🎬 Bucle aislado por escena (Ajustado x4). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
|
|
else:
|
|
best_cut = ideal_cut
|
|
_log(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
|
|
_log(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 // 4) * 4
|
|
if safe_chunk_from_face < 4: safe_chunk_from_face = 4
|
|
|
|
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
|
|
_log(f" -> ✂️ Corte Facial Inteligente (Ajustado x4): {best_cut} (Cara original detectada: {closest_face})")
|
|
else:
|
|
best_cut = ideal_cut
|
|
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x4: {ideal_cut}")
|
|
|
|
# 4. Cálculo final del chunk consolidado a regla 4n+1
|
|
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
|
|
|
effective_chunk_frames = ((effective_chunk_frames + 2) // 4) * 4 + 1
|
|
|
|
if effective_chunk_frames < 5:
|
|
effective_chunk_frames = 5
|
|
|
|
# Comprobación contra el límite físico para evitar pedir más allá del final
|
|
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
|
|
_log(f" -> 🏁 Chunk final detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 4n+1.")
|
|
loop.global_is_final_chunk = True
|
|
else:
|
|
loop.global_is_final_chunk = False
|
|
|
|
skip_frames = current_pos
|
|
|
|
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos a VHS (Saltando {skip_frames})")
|
|
_log(f"{'='*50}\n")
|
|
|
|
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
|