- Introduce `global_step_by_chunk` ghost variable initialized to `False` in `loop.py` - Set `loop.global_step_by_chunk = True` in `AutoLoopCalculatorTTS` to indicate chunk mode - Reset `loop.global_step_by_chunk = False` in video calculators (`AutoLoopCalculator`, `AutoLoopCalculatorWan`, `AutoLoopCalculatorLTX`) to ensure state resets - Update `SessionImageSender.send_image` timeline management to branch logic, using `is_chunk_mode` to calculate progress differently for text chunks versus video frames
222 lines
9.0 KiB
Python
222 lines
9.0 KiB
Python
import os
|
|
import time
|
|
import random
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
import folder_paths
|
|
from . import register_node
|
|
|
|
global_session_image = None
|
|
|
|
def tensor_to_temp_image(tensor_image, prefix="session_img"):
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
filename = f"{prefix}_{random.randint(10000, 99999)}.png"
|
|
filepath = os.path.join(temp_dir, filename)
|
|
img_array = 255. * tensor_image[0].cpu().numpy()
|
|
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
|
img.save(filepath)
|
|
return {"filename": filename, "subfolder": "", "type": "temp"}
|
|
|
|
@register_node
|
|
class SessionImageReceiver:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"initial_image": ("IMAGE",),
|
|
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("current_image", "log")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "get_image"
|
|
CATEGORY = "🔁 Sequential Batcher/Image"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return time.time()
|
|
|
|
def get_image(self, initial_image, current_loop_index):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
from . import loop
|
|
global global_session_image
|
|
|
|
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
|
is_first = (loop_idx == 0)
|
|
|
|
# Usamos getattr para evitar errores si el bucle aún no ha inicializado las variables
|
|
accumulated = getattr(loop, 'global_accumulated_frames', 0)
|
|
source_total = getattr(loop, 'global_source_frame_count', 1)
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"📥 [Secuencial Batcher] NODO: Image Receiver")
|
|
_log(f" -> Ciclo actual: {loop_idx} | Progreso global: {accumulated} / {source_total} frames")
|
|
|
|
if is_first or global_session_image is None:
|
|
global_session_image = initial_image.clone().cpu()
|
|
_log(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
|
|
selected = initial_image
|
|
else:
|
|
_log(f" -> ♻️ Usando el Keyframe validado y rescatado de la RAM.")
|
|
selected = global_session_image
|
|
|
|
_log(f" -> 🖼️ Tensor shape: {selected.shape}")
|
|
_log(f"{'='*50}\n")
|
|
|
|
ui_image = tensor_to_temp_image(selected, "receiver")
|
|
return {"ui": {"images": [ui_image]}, "result": (selected, "\n".join(log_output))}
|
|
|
|
@register_node
|
|
class SessionImageSender:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"generated_images": ("IMAGE",),
|
|
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
|
"detect_faces": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("VALIDATED_IMAGES", "log")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "set_image"
|
|
CATEGORY = "🔁 Sequential Batcher/Image"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return time.time()
|
|
|
|
def set_image(self, generated_images, current_loop_index, detect_faces):
|
|
log_output = []
|
|
def _log(msg):
|
|
print(msg)
|
|
log_output.append(str(msg))
|
|
from . import loop
|
|
import cv2
|
|
import numpy as np
|
|
import os, folder_paths, torch
|
|
from PIL import Image
|
|
|
|
if generated_images is None:
|
|
raise ValueError("❌ ERROR CRÍTICO: No se recibieron imágenes en el Sender.")
|
|
|
|
global global_session_image
|
|
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
|
batch_size = generated_images.shape[0]
|
|
best_idx = batch_size - 1
|
|
|
|
accumulated = getattr(loop, 'global_accumulated_frames', 0)
|
|
source_total = getattr(loop, 'global_source_frame_count', 1)
|
|
stride = getattr(loop, 'global_select_every_nth', 1)
|
|
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
|
|
|
|
_log(f"\n{'='*50}")
|
|
_log(f"📤 [Secuencial Batcher] NODO: Image Sender (Filtro Dinámico)")
|
|
_log(f" -> Frames recibidos de la IA: {batch_size}")
|
|
|
|
# 🧠 BYPASS IMPLACABLE: Si al sumar este lote llegamos al final del video, NO RECORTAMOS
|
|
advance_check = (batch_size - 1) * stride if ltx_mode_active else batch_size * stride
|
|
if (accumulated + advance_check) >= source_total:
|
|
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Desactivando recorte de rostros para forzar la salida de TODOS los frames.")
|
|
detect_faces = False
|
|
|
|
if detect_faces:
|
|
try:
|
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
|
|
_log(f" -> 🕵️ Buscando el último rostro frontal en reversa (Desde frame {batch_size - 1} hasta 0)...")
|
|
|
|
found = False
|
|
for i in range(batch_size - 1, -1, -1):
|
|
img_np = (generated_images[i].cpu().numpy() * 255.0).astype(np.uint8)
|
|
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
|
|
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(30, 30))
|
|
|
|
if len(faces) > 0:
|
|
best_idx = i
|
|
found = True
|
|
_log(f" -> ✅ Rostro detectado en el frame [{i}].")
|
|
break
|
|
|
|
if not found:
|
|
_log(f" -> ⚠️ ALERTA: No se detectó rostro frontal en NINGÚN frame. Aceptando todo el lote para evitar bucles infinitos.")
|
|
best_idx = batch_size - 1
|
|
except Exception as e:
|
|
_log(f" -> ⚠️ Error en OpenCV al validar: {e}")
|
|
else:
|
|
_log(f" -> ⏩ Detección de rostros desactivada. Usando el último frame absoluto del lote ({batch_size - 1}).")
|
|
best_idx = batch_size - 1
|
|
|
|
# 1. TRUNCAR EL TENSOR
|
|
valid_images = generated_images[:best_idx + 1]
|
|
frames_accepted = best_idx + 1
|
|
|
|
# 2. AVANZAR LA MÁQUINA
|
|
stride = getattr(loop, 'global_select_every_nth', 1)
|
|
advanced_original_frames = frames_accepted * stride
|
|
|
|
if not hasattr(loop, 'global_accumulated_frames'):
|
|
loop.global_accumulated_frames = 0
|
|
|
|
# LTX Mode Fix: Avoid counting the overlap frame in the progression
|
|
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
|
|
if ltx_mode_active:
|
|
# We subtract 1 from frames_accepted to account for the anchor frame
|
|
# The overlap frame shouldn't advance the global accumulated frames count
|
|
# since it will be reused as the start of the next chunk.
|
|
advanced_original_frames = max(1, (frames_accepted - 1) * stride)
|
|
|
|
source_total = getattr(loop, 'global_source_frame_count', 1)
|
|
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
|
|
|
|
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
|
|
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
|
|
|
|
if is_chunk_mode:
|
|
# En modo TTS (Texto), el progreso se mide estrictamente en frases/chunks, no en frames de video.
|
|
is_final_cycle = is_final_chunk
|
|
_log(f" -> 🧩 Modo Texto activo: Progreso gestionado por frases ({loop.global_accumulated_frames} / {source_total}).")
|
|
else:
|
|
# Lógica original por frames de video
|
|
if is_final_chunk:
|
|
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Compensando mermas del VAE (Timeline forzado al 100%).")
|
|
loop.global_accumulated_frames = source_total
|
|
else:
|
|
loop.global_accumulated_frames += advanced_original_frames
|
|
|
|
is_final_cycle = loop.global_accumulated_frames >= source_total
|
|
_log(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total} frames")
|
|
|
|
_log(f" -> ✂️ Tensor truncado a {frames_accepted} frames válidos.")
|
|
|
|
last_frame = valid_images[-1:].clone().cpu()
|
|
global_session_image = last_frame
|
|
|
|
# 🛡️ Solo guardamos el keyframe si habrá un próximo ciclo
|
|
if not is_final_cycle:
|
|
out_dir = folder_paths.get_output_directory()
|
|
filename = f"keyframe_{loop_idx:03d}.png"
|
|
filepath = os.path.join(out_dir, filename)
|
|
|
|
img_array = 255. * last_frame[0].numpy()
|
|
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
|
img.save(filepath)
|
|
|
|
_log(f" -> 💾 Keyframe seguro guardado: {filename}")
|
|
else:
|
|
_log(f" -> 🚀 Ciclo final/único detectado. Omitiendo guardado de keyframe.")
|
|
|
|
_log(f"{'='*50}\n")
|
|
|
|
ui_image = tensor_to_temp_image(last_frame, "sender")
|
|
return {"ui": {"images": [ui_image]}, "result": (valid_images, "\n".join(log_output))}
|