From 10bf229175fae7b8df22eba73c5382e552d07b4e Mon Sep 17 00:00:00 2001 From: Meisoftcoltd <38667978+Meisoftcoltd@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:26:11 +0000 Subject: [PATCH] Refactor IncrementalVideoStitcher to handle raw image tensors and add LoadVideoWithSourceAudio node. Changes: - Modified IncrementalVideoStitcher to process IMAGE tensors directly instead of MP4 lists. - Implemented RAM/VRAM saving mechanism by saving tensor state dynamically to `.pt` files and cleaning them manually via `gc`. - Introduced LoadVideoWithSourceAudio mapping to extract complete original tracks dynamically. --- __pycache__/video.cpython-312.pyc | Bin 0 -> 9378 bytes video.py | 213 ++++++++++++++++++------------ 2 files changed, 129 insertions(+), 84 deletions(-) create mode 100644 __pycache__/video.cpython-312.pyc diff --git a/__pycache__/video.cpython-312.pyc b/__pycache__/video.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d136beca1a4aefd40f39f50f940c56edc349fef GIT binary patch literal 9378 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 N%t`xK3>FDr{{z;N15N+{ literal 0 HcmV?d00001 diff --git a/video.py b/video.py index 0d07852..31d94ee 100644 --- a/video.py +++ b/video.py @@ -41,7 +41,8 @@ class WanFrameValidator: return (corrected_frames, ) -session_video_list = [] +import gc +import torch @register_node class IncrementalVideoStitcher: @@ -49,121 +50,165 @@ class IncrementalVideoStitcher: def INPUT_TYPES(s): return { "required": { - "trigger": ("VHS_FILENAMES", ), + "images": ("IMAGE", ), "output_prefix": ("STRING", {"default": "SCAIL_Final"}), "current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}), + "total_loops": ("INT", {"default": 1, "min": 1, "max": 10000}), }, "optional": { - "audio": ("AUDIO", ), # Nueva entrada de audio opcional + "audio": ("AUDIO", ), } } - RETURN_TYPES = ("STRING", ) - RETURN_NAMES = ("final_video_path", ) + RETURN_TYPES = ("IMAGE", "AUDIO") + RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT") OUTPUT_NODE = True - INPUT_IS_LIST = True FUNCTION = "stitch_incremental" CATEGORY = "🔁 Sequential Batcher/Video" - def stitch_incremental(self, trigger, output_prefix, current_loop_index, audio=None): - if not trigger or trigger[0] is None: - raise ValueError("❌ ERROR CRÍTICO: El nodo 'Incremental Auto-Stitcher' no recibe archivos de vídeo. Conecta la salida 'filenames' de tu Video Combine.") + def stitch_incremental(self, images, output_prefix, current_loop_index, total_loops, audio=None): + if images is None: + raise ValueError("❌ ERROR CRÍTICO: El nodo 'Incremental Auto-Stitcher' necesita el cable de 'images' con los tensores.") - if not current_loop_index or current_loop_index[0] is None: - raise ValueError("❌ ERROR CRÍTICO: El nodo 'Incremental Auto-Stitcher' necesita el cable de 'current_loop_index' para gestionar la sesión.") + if current_loop_index is None or total_loops is None: + raise ValueError("❌ ERROR CRÍTICO: El nodo 'Incremental Auto-Stitcher' necesita 'current_loop_index' y 'total_loops'.") - global session_video_list + # Barrera de seguridad para evitar que un string vacío borre el directorio principal + if not output_prefix or not output_prefix.strip(): + output_prefix = "SCAIL_Final" out_dir = folder_paths.get_output_directory() - prefix = output_prefix[0] if isinstance(output_prefix, list) else output_prefix - loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index + # Creamos una subcarpeta usando el output_prefix + stitch_dir = os.path.join(out_dir, output_prefix) + os.makedirs(stitch_dir, exist_ok=True) print(f"\n{'='*50}") print(f"🎞️ [DEBUG] NODO: Incremental Auto-Stitcher") - print(f" -> Ciclo actual: {loop_idx} | Prefix: {prefix}") + print(f" -> Ciclo actual: {current_loop_index} / Total: {total_loops} | Prefix: {output_prefix}") - if loop_idx == 0: - print(f" -> 🧹 Limpiando lista de vídeos de sesiones anteriores.") - session_video_list.clear() + # Ciclo 0: Limpieza + if current_loop_index == 0: + print(f" -> 🧹 Limpiando archivos de tensores de sesiones anteriores en: {stitch_dir}") + pt_files = glob.glob(os.path.join(stitch_dir, "*.pt")) + for pt_file in pt_files: + try: + os.remove(pt_file) + except Exception as e: + print(f" -> ❌ Error borrando {pt_file}: {e}") - current_mp4s = [] - def extract_mp4s(data): - if isinstance(data, str) and data.endswith(".mp4"): - current_mp4s.append(data) - elif isinstance(data, (list, tuple)): - for item in data: extract_mp4s(item) - elif isinstance(data, dict): - for item in data.values(): extract_mp4s(item) - extract_mp4s(trigger) + # Guardar tensor a disco para liberar RAM/VRAM + pt_filename = f"batch_{current_loop_index:04d}.pt" + pt_path = os.path.join(stitch_dir, pt_filename) - for v in current_mp4s: - abs_path = v if os.path.isabs(v) else os.path.join(out_dir, v) - if abs_path not in session_video_list and os.path.exists(abs_path): - session_video_list.append(abs_path) - print(f" -> ➕ Añadido al ensamblaje: {os.path.basename(abs_path)}") + print(f" -> 💾 Descargando tensores a la CPU y guardando en: {pt_filename}") + cpu_images = images.cpu() + torch.save(cpu_images, pt_path) - print(f" -> 📦 Total de vídeos a ensamblar: {len(session_video_list)}") + # Limpieza manual de memoria + del images + del cpu_images + gc.collect() - if not session_video_list: + # Comprobamos si es el último ciclo + if current_loop_index < (total_loops - 1): + print(f" -> ⏳ Bucle en proceso. Retornando tensor vacío para ahorrar RAM.") print(f"{'='*50}\n") - return ("", ) + # Devolvemos un tensor vacío y None para el audio + dummy_tensor = torch.zeros((1, 16, 16, 3)) + return (dummy_tensor, None) - list_file_path = os.path.join(out_dir, "batch_concat_list.txt") - out_name = f"{prefix}_{loop_idx:04d}.mp4" - temp_concat = os.path.join(out_dir, f"temp_concat_video_{loop_idx:04d}.mp4") - final_output = os.path.join(out_dir, out_name) + elif current_loop_index == (total_loops - 1): + print(f" -> 🏁 Último ciclo alcanzado. Ensamblando tensores finales...") + pt_files = sorted(glob.glob(os.path.join(stitch_dir, "*.pt"))) + tensor_list = [] - try: - with open(list_file_path, 'w', encoding='utf-8') as f: - for video in session_video_list: f.write(f"file '{video}'\n") + for pt_file in pt_files: + try: + loaded_tensor = torch.load(pt_file) + tensor_list.append(loaded_tensor) + print(f" -> 📦 Cargado: {os.path.basename(pt_file)}") + except Exception as e: + print(f" -> ❌ Error cargando {pt_file}: {e}") - # Paso 1: Ensamblar los vídeos de forma silenciosa - ffmpeg_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file_path, "-c", "copy", temp_concat] - subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if not tensor_list: + print(f" -> ⚠️ No se encontraron tensores guardados para ensamblar.") + print(f"{'='*50}\n") + dummy_tensor = torch.zeros((1, 16, 16, 3)) + return (dummy_tensor, audio) - # Paso 2: Procesar e inyectar el Audio (si existe) - has_audio = audio is not None and isinstance(audio, list) and len(audio) > 0 and audio[0] is not None - if has_audio: - print(f" -> 🎵 Audio detectado. Sincronizando y multiplexando...") + # Unir todos los tensores + print(f" -> 🧩 Concatenando {len(tensor_list)} tensores...") + final_images = torch.cat(tensor_list, dim=0) - audio_data = audio[0] - waveform = audio_data.get("waveform") - sample_rate = audio_data.get("sample_rate", 44100) + # (Opcional) Borrar la subcarpeta para ahorrar espacio + print(f" -> 🧹 Limpiando subcarpeta temporal: {stitch_dir}") + try: + shutil.rmtree(stitch_dir) + except Exception as e: + print(f" -> ❌ Error borrando subcarpeta: {e}") - if waveform is not None: - # ComfyUI a veces envía el tensor como [Batch, Canales, Muestras]. Lo pasamos a [Canales, Muestras] - if waveform.dim() == 3: - waveform = waveform.squeeze(0) - - temp_audio_path = os.path.join(out_dir, f"temp_audio_{loop_idx:04d}.wav") - torchaudio.save(temp_audio_path, waveform, sample_rate) - - # Multiplexamos usando -shortest para que el audio se corte donde termina el vídeo - ffmpeg_mux_cmd = [ - "ffmpeg", "-y", - "-i", temp_concat, - "-i", temp_audio_path, - "-c:v", "copy", - "-c:a", "aac", "-b:a", "192k", - "-shortest", final_output - ] - subprocess.run(ffmpeg_mux_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - if os.path.exists(temp_audio_path): os.remove(temp_audio_path) - if os.path.exists(temp_concat): os.remove(temp_concat) - print(f" -> ✅ Ensamblaje progresivo CON AUDIO exitoso: {out_name}") - else: - shutil.move(temp_concat, final_output) - print(f" -> ⚠️ Audio detectado pero sin waveform. Ensamblaje sin audio: {out_name}") + print(f" -> ✅ Ensamblaje exitoso. Retornando tensor gigante: {list(final_images.shape)}") + if audio is not None: + print(f" -> 🎵 Retornando audio ('passthrough').") else: - # Si no hay audio conectado, simplemente renombramos el archivo temporal - shutil.move(temp_concat, final_output) - print(f" -> ✅ Ensamblaje progresivo exitoso (Mudo): {out_name}") + print(f" -> 🔇 No se detectó audio ('passthrough' vacío).") + print(f"{'='*50}\n") + return (final_images, audio) + +import nodes + +@register_node +class LoadVideoWithSourceAudio: + @classmethod + def INPUT_TYPES(s): + # Clonar los inputs dinámicamente si VHS está instalado + vhs_class = nodes.NODE_CLASS_MAPPINGS.get("VHS_LoadVideo") + if vhs_class: + return vhs_class.INPUT_TYPES() + else: + raise Exception("❌ VHS_LoadVideo no encontrado. Instala VideoHelperSuite.") + + RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO", "AUDIO") + RETURN_NAMES = ("IMAGE", "frame_count", "audio", "video_info", "source_audio") + FUNCTION = "load_video_with_audio" + CATEGORY = "🔁 Sequential Batcher/Video" + + # Intentamos mantener el Display Name para la interfaz web a través del Custom Node mapping original de ComfyUI (opcional pero util para que se llame diferente a VHS) + # Normalmente esto se hace en el NODE_DISPLAY_NAME_MAPPINGS global de __init__.py pero la clase será LoadVideoWithSourceAudio + + def load_video_with_audio(self, **kwargs): + vhs_class = nodes.NODE_CLASS_MAPPINGS.get("VHS_LoadVideo") + if not vhs_class: + raise Exception("❌ VHS_LoadVideo no encontrado.") + + print(f"\n{'='*50}") + print(f"🎥 [DEBUG] NODO: Load Video + Source Audio") + + # 1. Instanciar el nodo original y ejecutar su lógica + print(f" -> Ejecutando lógica original de VHS...") + vhs_instance = vhs_class() + vhs_result = vhs_instance.load_video(**kwargs) + + # vhs_result contiene: (IMAGE, frame_count, audio, video_info) + + # 2. Lógica nueva: Extraer el audio completo del archivo fuente + video_filename = kwargs.get("video") + # El nombre del archivo puede venir como una ruta completa desde input, o desde un subdirectorio. + # En VHS y ComfyUI, usan get_annotated_filepath (esto lo traemos si hace falta, o simplemente path del input dir) + # VHS lo carga así: + video_path = folder_paths.get_annotated_filepath(video_filename) + + print(f" -> 🎵 Extrayendo pista de audio completa (sin cortes) desde: {video_path}") + try: + waveform, sample_rate = torchaudio.load(video_path) + # Retornamos el diccionario en el formato estándar AUDIO de ComfyUI + source_audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate} + print(f" -> ✅ Audio extraído con éxito. Muestreo: {sample_rate}Hz") except Exception as e: - print(f" -> ❌ Error FFmpeg: {e}") - finally: - if os.path.exists(list_file_path): os.remove(list_file_path) + print(f" -> ⚠️ Error cargando audio fuente: {e}") + source_audio = None print(f"{'='*50}\n") - return (final_output, ) \ No newline at end of file + # 3. Devolver el resultado original + nuestro audio completo en el 5º puerto + return (vhs_result[0], vhs_result[1], vhs_result[2], vhs_result[3], source_audio) \ No newline at end of file