Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76bc9498a2 | ||
|
|
e0b60696a7 | ||
|
|
f687c34039 | ||
|
|
1afb103fd2 | ||
|
|
ef4dff94c6 | ||
|
|
3c3ebca5b2 | ||
|
|
ee67c2a105 | ||
|
|
4a003e7973 | ||
|
|
c4290fce8e | ||
|
|
2475b79f21 | ||
|
|
45e3c670f4 | ||
|
|
04efd0e47f | ||
|
|
71e9ea2cb7 | ||
|
|
bd28628314 | ||
|
|
41529ff0a0 | ||
|
|
e3b1722393 | ||
|
|
692cc0daa5 | ||
|
|
2bf5719335 | ||
|
|
d70ec7ef48 | ||
|
|
81cf4df040 | ||
|
|
74f28f2d32 | ||
|
|
fc861296e4 | ||
|
|
0c28a75e9b | ||
|
|
3ba6d43097 | ||
|
|
e62615183c | ||
|
|
fccd913fba | ||
|
|
f37c0c77b3 | ||
|
|
fcbaeb00d9 | ||
|
|
322bf716ef | ||
|
|
cbc397694d | ||
|
|
02f3933eda | ||
|
|
4985eba6b0 | ||
|
|
886288406d | ||
|
|
dc8dcd3f53 | ||
|
|
059f9aced9 | ||
|
|
473e774b99 | ||
|
|
ba5a3d5c09 | ||
|
|
2e29322e18 | ||
|
|
d5681c87dd | ||
|
|
698d0101d3 | ||
|
|
7724d4519e | ||
|
|
27d8bc291e | ||
|
|
3b778103b3 | ||
|
|
6e7d61e288 | ||
|
|
c821bf5c6e | ||
|
|
9373fffffe | ||
|
|
43ca97accb | ||
|
|
dda6646920 | ||
|
|
f10514ac03 | ||
|
|
a9b217cf4b | ||
|
|
1bdaa80875 | ||
|
|
b8ec5f3fe8 | ||
|
|
e1db8a2120 | ||
|
|
b417f855c2 | ||
|
|
81504655d6 | ||
|
|
af8ef37b79 | ||
|
|
34b98c922b | ||
|
|
4a34af5027 | ||
|
|
5066eda106 | ||
|
|
4e49b99e2d | ||
|
|
dc3f4c2c07 | ||
|
|
35075fa357 | ||
|
|
59f0be86ba | ||
|
|
f48f69ef43 | ||
|
|
e449cdf0d1 | ||
|
|
5e2d368379 | ||
|
|
c1bc9b375a | ||
|
|
1d9d3356fb | ||
|
|
1bd0792655 | ||
|
|
fc547c5c18 | ||
|
|
19c8d8ea51 | ||
|
|
80a0ea3eb1 | ||
|
|
1468c14e97 | ||
|
|
cf6666885a | ||
|
|
4d9c0a0ad0 | ||
|
|
b04cc4267e | ||
|
|
af36417577 | ||
|
|
03a214b028 | ||
|
|
5089252bca | ||
|
|
6b7432ad4e | ||
|
|
1011e0ce01 | ||
|
|
3d2805019a | ||
|
|
791f394dd5 | ||
|
|
694c2cce2a | ||
|
|
184407b1a2 | ||
|
|
71534b968e | ||
|
|
3afcee2bef | ||
|
|
5a295242e4 | ||
|
|
cc17f2561e | ||
|
|
bff976781c | ||
|
|
7bef683128 | ||
|
|
9df7a16940 | ||
|
|
0c280ec6f2 | ||
|
|
c44796d59b | ||
|
|
6cd32fcfe9 | ||
|
|
db22f2ea3f | ||
|
|
7b900c10cd | ||
|
|
48ce1b3826 | ||
|
|
d1d5f47562 | ||
|
|
960acd7371 | ||
|
|
7f025b9a18 | ||
|
|
eac81a4b14 | ||
|
|
1f64452691 | ||
|
|
7375f9d478 | ||
|
|
d6c2a4b012 | ||
|
|
b7e1fb6f89 | ||
|
|
ba6fc7b8fc | ||
|
|
41c2d7d34c | ||
|
|
26428789ae | ||
|
|
4ce91323a1 | ||
|
|
cb7b0374ae | ||
|
|
08edcf71cb | ||
|
|
f49014866a | ||
|
|
60fc92fec0 | ||
|
|
1d2faec867 |
@@ -0,0 +1,28 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'Meisoftcoltd' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -1,5 +1,5 @@
|
||||
# ♾️ ComfyUI Sequential Batcher
|
||||
**v1.6.0** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
|
||||
Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada para sortear los límites extremos de VRAM en la generación de vídeo pesado (WanVideo, Hunyuan, LTX) mediante **Auto-Encolado Recursivo**, procesamiento autónomo lote por lote y gestión de memoria forense.
|
||||
|
||||
@@ -7,12 +7,6 @@ Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada p
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Lo Nuevo en v1.6.0 (Perfect Sync Update)
|
||||
* **🛡️ VAE Safe Frame Padder (Hold Last Frame):** El motor ahora expande dinámicamente los tensores incompletos al final del vídeo. Si faltan frames para cumplir los estrictos requisitos de WanVideo (regla de `4n + 1`) o LTX (8n+1), clona el último fotograma de forma imperceptible. **Resultado: Cero cuelgues del VAE y 100% de sincronización de audio sin micro-cortes.**
|
||||
* **📈 Inversión Matemática a "Expansión":** Los calculadores `AutoLoopCalculatorWan` y `AutoLoopCalculatorLTX` ahora redondean el timeline de forma segura hacia arriba, garantizando fluidez total en ciclos intermedios.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Características Clave
|
||||
|
||||
* **Orquestación Autónoma:** Convierte tu ComfyUI en un motor de renderizado continuo. Pulsa "Queue Prompt" una sola vez y el flujo se retroalimentará modificando semillas e índices hasta terminar todo el vídeo.
|
||||
@@ -25,7 +19,7 @@ Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada p
|
||||
|
||||
## ⚠️ Requisitos del Sistema (Crítico)
|
||||
|
||||
Para que el nodo `VideoAnalyzerWithAudio` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
|
||||
Para que el nodo `VideoAnalyzerFaceDetector` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
|
||||
|
||||
* **🐧 Ubuntu / Linux / WSL2:**
|
||||
El script intentará instalarlo por ti. Si falla, ejecuta manualmente:
|
||||
@@ -43,16 +37,43 @@ El argumento `--highvram` le da la orden estricta a ComfyUI de mantener los mode
|
||||
---
|
||||
|
||||
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
|
||||
El procesamiento se divide en roles altamente especializados:
|
||||
|
||||
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV o mediante un modelo YOLO acelerado por GPU. Extrae rostros, el audio y el Frame de Referencia.
|
||||
* **📊 Auto Loop Calculator (Base / WanVideo / LTX):** Recibe la biometría y calcula las coordenadas de corte (chunk_frames, skip_frames).
|
||||
* **🛡️ VAE Safe Frame Padder:** Intercepta el tensor final de VHS y lo rellena ("acolcha") clonando el último frame si falta material, salvando al VAE de un colapso.
|
||||
* **🎞️ Incremental Auto-Stitcher:** Recoge los lotes renderizados y la pista de audio, cosiendo el vídeo final de forma progresiva en la carpeta temporal.
|
||||
* **📥 Receiver & 📤 Sender:** Retienen y transfieren el último keyframe válido a través de los ciclos para mantener la coherencia temporal.
|
||||
* **⏱️ Auto FPS Limiter:** Reduce los FPS inteligentemente garantizando que el audio y el movimiento mantengan una sincronización perfecta sin romper la VRAM.
|
||||
* **🔀 Master Switch (Evaluación Perezosa):** Amputa físicamente los cables de rutas inactivas en el JSON enviado a ComfyUI. Los nodos pesados que no se necesitan en un ciclo específico ni siquiera se cargan en memoria.
|
||||
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al Garbage Collector entre ciclos pesados.
|
||||
El procesamiento se divide en roles altamente especializados, organizados en las siguientes categorías:
|
||||
|
||||
### 🎼 Orquestación
|
||||
* **🏁 Loop Start (Index):** Inicia la secuencia de bucle y mantiene el índice actual.
|
||||
* **🚀 Loop Trigger (Auto-Queue):** Dispara la siguiente iteración de ComfyUI de manera autónoma hasta que se completa la tarea.
|
||||
|
||||
### 🎬 Director / Storyboard
|
||||
* **🎬 Dynamic Scene Director:** Máquina de estados para orquestar metadatos (prompts, duraciones) en la preproducción (Split-Workflow).
|
||||
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Guarda y carga los frames clave de la escena para mantener la continuidad entre flujos separados.
|
||||
|
||||
### 📦 Lotes (Batches)
|
||||
* **📂 Batch Audio Folder Loader:** Carga dinámicamente carpetas enteras de archivos (ej. lotes de audio).
|
||||
* **🎛️ Audio Batch Selector:** Despacha los archivos del lote uno por uno sincronizándose con el bucle.
|
||||
|
||||
### 🔬 Análisis y Cálculos
|
||||
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV/YOLO, extrayendo rostros, audio y frame de referencia.
|
||||
* **🎬 Video Analyzer Scene detector:** Detecta cortes de escena en los vídeos.
|
||||
* **📊 Auto Loop Calculators:** Nodos especializados (`Base`, `WanVideo`, `LTX`, `TTS`) que reciben metadatos y calculan matemáticamente las coordenadas de corte exactas (`chunk_frames`, `skip_frames`).
|
||||
|
||||
### 🎞️ Video
|
||||
* **🎞️ Incremental Auto-Stitcher:** Ensambla progresivamente los fragmentos de video renderizados en un único archivo.
|
||||
* **🛡️ VAE Safe Frame Padder:** Rellena ("acolcha") tensores incompletos clonando el último frame para salvar al VAE de un colapso en modelos restrictivos (ej. reglas 4n+1).
|
||||
* **💉 LTXV Single Frame Injector:** Inyecta frames individuales específicos requeridos por flujos de LTX.
|
||||
* **⏱️ Auto FPS Limiter:** Limita de manera inteligente los cuadros por segundo para ahorrar VRAM manteniendo sincronía de audio.
|
||||
|
||||
### 🎵 Audio
|
||||
* **✂️ Precise Audio Slicer:** Corta el audio de forma precisa (a nivel de muestra matemática) en base a los frames de vídeo correspondientes.
|
||||
* **🎛️ Conditional Audio Router (Bypass):** Enruta el audio condicionalmente permitiendo saltar su procesamiento si no es necesario.
|
||||
|
||||
### 📐 Herramientas de Resolución
|
||||
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculan resoluciones óptimas basadas en límites de VRAM y las reglas geométricas (multiplicadores) de diferentes modelos (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
|
||||
|
||||
### 🧠 Gestión de Memoria / Lógica
|
||||
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Evaluación perezosa nativa; amputan rutas inactivas para que los nodos pesados ni siquiera se carguen en memoria.
|
||||
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al recolector de basura (GC) de Python entre ciclos.
|
||||
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retienen y transfieren imágenes en memoria a través de diferentes ciclos.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,15 +90,8 @@ El procesamiento se divide en roles altamente especializados:
|
||||
---
|
||||
|
||||
## 🚀 Aceleración por GPU (Escaneo de Rostros)
|
||||
El `VideoAnalyzerWithAudio` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
|
||||
El `VideoAnalyzerFaceDetector` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
|
||||
1. Instala el *Impact Pack*.
|
||||
2. Añade el nodo `UltralyticsDetectorProvider` y selecciona un modelo de rostros (ej. `face_yolov8m.pt`).
|
||||
3. Conecta su salida al puerto del Analyzer.
|
||||
*(El nodo cuenta con auto-descarga: destruirá este modelo de la VRAM al terminar para hacer hueco a la generación).*
|
||||
|
||||
---
|
||||
|
||||
## 📝 Changelog Reciente
|
||||
* **v1.6.0:** Implementación del `VAESafeFramePadder` (Hold Last Frame) y rediseño matemático a modo Expansión. Adiós a los recortes de audio y a los cuelgues en el ciclo final de WanVideo y LTX.
|
||||
* **v1.5.4:** Inyección de la variable de entorno de PyTorch `max_split_size_mb:128` para evitar la micro-fragmentación de VRAM con kernels de Triton (SageAttention). Auto-descarga temprana de modelos YOLO.
|
||||
* **v1.5.3:** "Secuencia Sagrada" en el VRAM Defragmenter (Romper ciclos -> Evacuación Suave -> Limpieza IPC -> Sincronización de hilos).
|
||||
|
||||
+39
-25
@@ -1,5 +1,5 @@
|
||||
# ♾️ ComfyUI Sequential Batcher
|
||||
**v1.6.0** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
|
||||
A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through **Recursive Auto-Queuing**, autonomous batch-by-batch processing, and forensic memory management.
|
||||
|
||||
@@ -7,12 +7,6 @@ A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extre
|
||||
|
||||
---
|
||||
|
||||
## 🌟 What's New in v1.6.0 (Perfect Sync Update)
|
||||
* **🛡️ VAE Safe Frame Padder (Hold Last Frame):** The engine now dynamically expands incomplete tensors at the end of the video. If frames are missing to meet the strict requirements of WanVideo (multiples of 4) or LTX (8n+1), it clones the last frame imperceptibly. **Result: Zero VAE crashes and 100% audio sync with no micro-stutters.**
|
||||
* **📈 Mathematical Inversion to "Expansion":** The `AutoLoopCalculatorWan` and `AutoLoopCalculatorLTX` calculators now safely round the timeline upwards, guaranteeing total fluidity in intermediate cycles.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
* **Autonomous Orchestration:** Turn your ComfyUI into a continuous rendering engine. Press "Queue Prompt" just once and the flow will feed itself by modifying seeds and indices until the entire video is finished.
|
||||
@@ -25,7 +19,7 @@ A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extre
|
||||
|
||||
## ⚠️ System Requirements (Critical)
|
||||
|
||||
For the `VideoAnalyzerWithAudio` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
|
||||
For the `VideoAnalyzerFaceDetector` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
|
||||
|
||||
* **🐧 Ubuntu / Linux / WSL2:**
|
||||
The script will attempt to install it for you. If it fails, run manually:
|
||||
@@ -43,16 +37,43 @@ The `--highvram` argument gives ComfyUI the strict command to keep models locked
|
||||
---
|
||||
|
||||
## 🧠 The Hybrid Architecture (Node Breakdown)
|
||||
Processing is divided into highly specialized roles:
|
||||
|
||||
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV or using a GPU-accelerated YOLO model. Extracts faces, audio, and the Reference Frame.
|
||||
* **📊 Auto Loop Calculator (Base / WanVideo / LTX):** Receives the biometrics and calculates cut coordinates (chunk_frames, skip_frames).
|
||||
* **🛡️ VAE Safe Frame Padder:** Intercepts the final VHS tensor and pads it by cloning the last frame if material is missing, saving the VAE from a collapse.
|
||||
* **🎞️ Incremental Auto-Stitcher:** Collects rendered batches and the audio track, progressively stitching the final video in the temporary folder.
|
||||
* **📥 Receiver & 📤 Sender:** Retain and transfer the last valid keyframe across cycles to maintain temporal coherence.
|
||||
* **⏱️ Auto FPS Limiter:** Smartly reduces FPS guaranteeing audio and motion maintain perfect synchronization without breaking VRAM.
|
||||
* **🔀 Master Switch (Lazy Evaluation):** Physically amputates wires from inactive routes in the JSON sent to ComfyUI. Heavy nodes not needed in a specific cycle aren't even loaded into memory.
|
||||
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Garbage Collector between heavy cycles.
|
||||
Processing is divided into highly specialized roles, organized in the following categories:
|
||||
|
||||
### 🎼 Orchestration
|
||||
* **🏁 Loop Start (Index):** Starts the loop sequence and tracks the current index.
|
||||
* **🚀 Loop Trigger (Auto-Queue):** Autonomously triggers the next ComfyUI iteration until the task is complete.
|
||||
|
||||
### 🎬 Director / Storyboard
|
||||
* **🎬 Dynamic Scene Director:** State machine to orchestrate metadata (prompts, durations) in preproduction (Split-Workflow).
|
||||
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Saves and loads the scene's keyframes to maintain continuity across decoupled flows.
|
||||
|
||||
### 📦 Batches
|
||||
* **📂 Batch Audio Folder Loader:** Dynamically loads entire folders of files (e.g. audio batches).
|
||||
* **🎛️ Audio Batch Selector:** Dispatches files from the batch one by one in sync with the loop.
|
||||
|
||||
### 🔬 Analysis and Calculations
|
||||
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV/YOLO, extracting faces, audio, and the reference frame.
|
||||
* **🎬 Video Analyzer Scene detector:** Detects scene cuts in videos.
|
||||
* **📊 Auto Loop Calculators:** Specialized nodes (`Base`, `WanVideo`, `LTX`, `TTS`) that receive metadata and mathematically calculate the exact cut coordinates (`chunk_frames`, `skip_frames`).
|
||||
|
||||
### 🎞️ Video
|
||||
* **🎞️ Incremental Auto-Stitcher:** Progressively stitches rendered video chunks into a single final file.
|
||||
* **🛡️ VAE Safe Frame Padder:** Pads incomplete tensors by cloning the last frame to save the VAE from a collapse in restrictive models (e.g. 4n+1 rules).
|
||||
* **💉 LTXV Single Frame Injector:** Injects specific individual frames required by LTX flows.
|
||||
* **⏱️ Auto FPS Limiter:** Smartly limits frames per second to save VRAM while maintaining audio sync.
|
||||
|
||||
### 🎵 Audio
|
||||
* **✂️ Precise Audio Slicer:** Precisely slices audio (at mathematical sample level) based on the corresponding video frames.
|
||||
* **🎛️ Conditional Audio Router (Bypass):** Conditionally routes audio allowing its processing to be bypassed if not needed.
|
||||
|
||||
### 📐 Resolution Tools
|
||||
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculate optimal resolutions based on VRAM limits and geometric rules (multipliers) of different models (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
|
||||
|
||||
### 🧠 Memory Management / Logic
|
||||
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Native lazy evaluation; they cut inactive routes so heavy nodes aren't even loaded into memory.
|
||||
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Python Garbage Collector (GC) between cycles.
|
||||
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retain and transfer in-memory images across different cycles.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,15 +90,8 @@ Processing is divided into highly specialized roles:
|
||||
---
|
||||
|
||||
## 🚀 GPU Acceleration (Face Scanning)
|
||||
The `VideoAnalyzerWithAudio` has a `bbox_detector` port to delegate facial scanning to the graphics card:
|
||||
The `VideoAnalyzerFaceDetector` has a `bbox_detector` port to delegate facial scanning to the graphics card:
|
||||
1. Install the *Impact Pack*.
|
||||
2. Add the `UltralyticsDetectorProvider` node and select a face model (e.g. `face_yolov8m.pt`).
|
||||
3. Connect its output to the Analyzer's port.
|
||||
*(The node features auto-unload: it will destroy this model from VRAM upon completion to make room for generation).*
|
||||
|
||||
---
|
||||
|
||||
## 📝 Recent Changelog
|
||||
* **v1.6.0:** Implementation of `VAESafeFramePadder` (Hold Last Frame) and mathematical redesign to Expansion mode. Goodbye to audio cuts and crashes in the final cycle of WanVideo and LTX.
|
||||
* **v1.5.4:** Injection of the PyTorch environment variable `max_split_size_mb:128` to avoid VRAM micro-fragmentation with Triton kernels (SageAttention). Early YOLO model auto-unload.
|
||||
* **v1.5.3:** "Sacred Sequence" in the VRAM Defragmenter (Break cycles -> Soft Evacuation -> IPC Cleanup -> Thread Synchronization).
|
||||
|
||||
+10
-3
@@ -1,6 +1,4 @@
|
||||
import os
|
||||
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "0.8.3"
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
@@ -34,6 +32,9 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
"AutoLoopCalculatorTTS": "📊 Auto Loop Calculator (TTS/Texto)",
|
||||
"SequentialLoopStart": "🏁 Loop Start (Index)",
|
||||
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
|
||||
"BatchAudioFolderLoader": "📂 Batch Audio Folder Loader",
|
||||
"AudioBatchSelector": "🎛️ Audio Batch Selector",
|
||||
"DynamicSceneDirector": "🎬 Dynamic Scene Director",
|
||||
# Tools
|
||||
"AutoFPSLimiter": "⏱️ Auto FPS Limiter",
|
||||
"VAESafeFramePadder": "🛡️ VAE Safe Frame Padder",
|
||||
@@ -43,6 +44,12 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
"ResTool64x": "🎬 ResTool 64x (Hunyuan)",
|
||||
"ResTool64xLTX": "🌌 ResTool 64x (LTX 2.3)",
|
||||
"ConditionalAudioRouter": "🎛️ Conditional Audio Router (Bypass)",
|
||||
|
||||
"PreciseAudioSlicer": "✂️ Precise Audio Slicer",
|
||||
"SaveSceneKeyframe": "💾 Save Scene Keyframe",
|
||||
"LoadSceneKeyframe": "🖼️ Load Scene Keyframe",
|
||||
"LTXVSingleFrameInjector": "💉 LTXV Single Frame Injector",
|
||||
"ProjectPlanLoader": "📂 Project Plan Loader"
|
||||
})
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -179,17 +179,24 @@ class SessionImageSender:
|
||||
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
|
||||
|
||||
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
|
||||
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_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
|
||||
|
||||
# 🚀 NUEVO: Detectar si es el ciclo final o un ciclo único
|
||||
is_final_cycle = loop.global_accumulated_frames >= source_total
|
||||
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.")
|
||||
_log(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total}")
|
||||
|
||||
last_frame = valid_images[-1:].clone().cpu()
|
||||
global_session_image = last_frame
|
||||
|
||||
@@ -13,6 +13,10 @@ 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:
|
||||
@@ -40,37 +44,157 @@ class SequentialLoopStart:
|
||||
global global_accumulated_frames
|
||||
global global_server_port
|
||||
global global_ltx_mode
|
||||
global global_batch_index
|
||||
global global_is_batch_advancing
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
|
||||
|
||||
# --- PRE-FLIGHT CHECK (FAIL-FAST) ---
|
||||
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto {port}...")
|
||||
# --- PRE-FLIGHT CHECK (SECURITY SSRF FIX) ---
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8188)
|
||||
try:
|
||||
# Ping ultrarrápido a la API. Si falla, colapsa en 0.1 segundos.
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{port}/system_stats")
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
global_server_port = port # Guardamos el puerto correcto para el Trigger
|
||||
print(f" -> ✅ Conexión establecida. Puerto blindado.")
|
||||
except Exception as e:
|
||||
print(f" -> ❌ ERROR FATAL: No se pudo conectar al puerto {port}.")
|
||||
raise ValueError(f"🚨 EL PUERTO {port} ES INCORRECTO O COMFYUI NO RESPONDE. "
|
||||
f"Cambia el puerto en el nodo 'Loop Start' antes de procesar. (Error: {e})")
|
||||
args, _ = parser.parse_known_args(sys.argv[1:])
|
||||
system_port = args.port
|
||||
except:
|
||||
system_port = 8188
|
||||
|
||||
safe_port = system_port
|
||||
if port != safe_port:
|
||||
print(f" -> ⚠️ ATENCIÓN: El puerto de entrada ({port}) no coincide con el puerto del sistema ({safe_port}).")
|
||||
print(f" -> 🔒 Por motivos de seguridad (Prevención SSRF), se forzará el puerto real del sistema: {safe_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)
|
||||
|
||||
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
|
||||
global_ltx_mode = False
|
||||
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
|
||||
else:
|
||||
global_loop_index = loop_idx
|
||||
global_is_batch_advancing = False
|
||||
|
||||
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}),
|
||||
"file_names": ("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: Mantenemos INPUT_IS_LIST para absorber el batch, pero ELIMINAMOS OUTPUT_IS_LIST
|
||||
# para que ComfyUI desempaquete las salidas automáticamente.
|
||||
INPUT_IS_LIST = True
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "current_file_name", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Text"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
def calculate(self, text_list, file_names, 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
|
||||
|
||||
texts = text_list[0] if (isinstance(text_list, list) and len(text_list) > 0 and isinstance(text_list[0], list)) else (text_list if isinstance(text_list, list) else [text_list])
|
||||
names = file_names[0] if (isinstance(file_names, list) and len(file_names) > 0 and isinstance(file_names[0], list)) else (file_names if isinstance(file_names, list) else [file_names])
|
||||
|
||||
mode = split_mode[0] if isinstance(split_mode, list) else split_mode
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
|
||||
# El batch index es extraído directamente de idx para evitar bloqueos
|
||||
current_batch_idx = idx
|
||||
|
||||
if current_batch_idx >= len(texts):
|
||||
current_batch_idx = len(texts) - 1
|
||||
|
||||
current_file_text = texts[current_batch_idx]
|
||||
current_name = names[current_batch_idx] if current_batch_idx < len(names) else "unknown"
|
||||
|
||||
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"
|
||||
|
||||
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)}: {current_name}")
|
||||
_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")
|
||||
|
||||
return (current_chunk_text, safe_index, total_chunks, current_name, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculatorLTX:
|
||||
@classmethod
|
||||
@@ -93,16 +217,29 @@ class AutoLoopCalculatorLTX:
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Loop"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
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
|
||||
@@ -121,7 +258,7 @@ class AutoLoopCalculatorLTX:
|
||||
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)")
|
||||
@@ -147,7 +284,7 @@ class AutoLoopCalculatorLTX:
|
||||
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:
|
||||
@@ -158,10 +295,10 @@ class AutoLoopCalculatorLTX:
|
||||
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:
|
||||
@@ -171,7 +308,7 @@ class AutoLoopCalculatorLTX:
|
||||
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}")
|
||||
|
||||
@@ -184,7 +321,7 @@ class AutoLoopCalculatorLTX:
|
||||
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
|
||||
@@ -222,16 +359,18 @@ class SequentialLoopTrigger:
|
||||
global global_server_port
|
||||
|
||||
next_loop = global_loop_index + 1
|
||||
is_final = global_accumulated_frames >= global_source_frame_count
|
||||
is_final_chunk = global_accumulated_frames >= global_source_frame_count
|
||||
|
||||
from . 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 del vídeo: {global_accumulated_frames} / {global_source_frame_count} frames.")
|
||||
print(f" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
|
||||
|
||||
if not is_final:
|
||||
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
|
||||
# 💡 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:
|
||||
# 🧠 HACKER MODE: Estimamos si el ciclo que vamos a encolar es el final
|
||||
estimated_chunk = 50
|
||||
found_calculator = False
|
||||
m_seeds = 0
|
||||
@@ -242,94 +381,433 @@ class SequentialLoopTrigger:
|
||||
inputs = node_data.get("inputs", {})
|
||||
|
||||
if not found_calculator and "AutoLoopCalculator" in class_type:
|
||||
estimated_chunk = inputs.get("target_frames_per_loop", 50)
|
||||
if "TTS" in class_type:
|
||||
estimated_chunk = 1
|
||||
|
||||
estimated_chunk = inputs.get("target_frames_per_loop", 50)
|
||||
found_calculator = True
|
||||
|
||||
# Mutación de semillas
|
||||
for key in ["seed", "noise_seed"]:
|
||||
if key in inputs and isinstance(inputs[key], (int, float)):
|
||||
inputs[key] = random.randint(1, 0xffffffff)
|
||||
inputs[key] = random.randint(1, 0x7fffffff)
|
||||
m_seeds += 1
|
||||
|
||||
# Mutación de índice
|
||||
if class_type == "SequentialLoopStart":
|
||||
inputs["loop_idx"] = next_loop
|
||||
inputs["reset_loop"] = False
|
||||
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
|
||||
|
||||
# 🔪 Recolectamos Master Switch para cirugía diferida
|
||||
if class_type == "MasterSwitch":
|
||||
master_switches.append(inputs)
|
||||
|
||||
is_next_final = (global_accumulated_frames + estimated_chunk) >= global_source_frame_count
|
||||
# 🛡️ Desempaquetado defensivo universal (Unwrapping de listas de ComfyUI)
|
||||
_acc_frames = global_accumulated_frames[0] if isinstance(global_accumulated_frames, list) else global_accumulated_frames
|
||||
_src_frames = global_source_frame_count[0] if isinstance(global_source_frame_count, list) else global_source_frame_count
|
||||
_est_chunk = estimated_chunk[0] if isinstance(estimated_chunk, list) else estimated_chunk
|
||||
|
||||
# Asegurar casteo a enteros puros para evitar colisiones matemáticas en cualquier flujo
|
||||
_acc_frames = int(_acc_frames) if _acc_frames is not None else 0
|
||||
_src_frames = int(_src_frames) if _src_frames is not None else 0
|
||||
_est_chunk = int(_est_chunk) if _est_chunk is not None else 0
|
||||
|
||||
is_next_final = (_acc_frames + _est_chunk) >= _src_frames
|
||||
if is_final_chunk and has_more_batches:
|
||||
is_next_final = False
|
||||
|
||||
for inputs in master_switches:
|
||||
print(f" -> 🔀 [Cirugía de Grafo] Mutando Master Switch para Ciclo {next_loop}...")
|
||||
# Sobrescribimos el cable que viene del Stitcher por un booleano estático
|
||||
inputs["is_final_cycle"] = is_next_final
|
||||
|
||||
if is_next_final:
|
||||
if "on_false" in inputs:
|
||||
del inputs["on_false"]
|
||||
print(f" ✂️ Cable 'on_false' eliminado (Ahorro de VRAM en ruta inactiva).")
|
||||
else:
|
||||
if "on_true" in inputs:
|
||||
del inputs["on_true"]
|
||||
print(f" ✂️ Cable 'on_true' eliminado (Ruta pesada desconectada).")
|
||||
|
||||
print(f" -> 🎲 Semillas mutadas: {m_seeds}")
|
||||
if is_final_chunk and has_more_batches:
|
||||
print(f" -> 📦 Archivo finalizado. Iniciando siguiente archivo del lote...")
|
||||
else:
|
||||
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
|
||||
|
||||
p = {"prompt": prompt}
|
||||
if extra_pnginfo:
|
||||
p["extra_data"] = {"extra_pnginfo": extra_pnginfo}
|
||||
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" -> ✅ Ciclo {next_loop} inyectado en la cola.")
|
||||
print(f" -> ✅ Siguiente ciclo inyectado en la cola.")
|
||||
except Exception as e:
|
||||
print(f" -> ❌ Error HTTP: {e}")
|
||||
|
||||
# Limpieza ligera por ciclo para evitar fragmentación
|
||||
import gc
|
||||
import torch
|
||||
import comfy.model_management as mm
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
else:
|
||||
print(f" -> 🏁 ¡Generación Finalizada! Todos los frames ensamblados.")
|
||||
|
||||
# --- LIMPIEZA EXTREMA DE VRAM AUTOMÁTICA (Multi-Plataforma) ---
|
||||
print(f" -> 🧹 Iniciando vaciado automático de VRAM...")
|
||||
import gc
|
||||
import torch
|
||||
import comfy.model_management as mm
|
||||
try:
|
||||
# 1. Obligamos al motor interno de ComfyUI a soltar los modelos
|
||||
mm.unload_all_models()
|
||||
mm.soft_empty_cache()
|
||||
except Exception as e:
|
||||
print(f" -> ⚠️ Aviso: No se pudo usar model_management: {e}")
|
||||
|
||||
# 2. Forzamos al recolector de basura de Python
|
||||
gc.collect()
|
||||
|
||||
# 3. Le arrancamos a PyTorch la memoria reservada (CUDA, ROCm y Mac MPS)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.ipc_collect()
|
||||
|
||||
# HACK: Forzar la desfragmentación de la memoria caché de PyTorch
|
||||
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" -> 🏁 ¡Generación Finalizada! Todos los archivos del lote completados.")
|
||||
|
||||
print(f"{'='*50}\n")
|
||||
return ()
|
||||
|
||||
@register_node
|
||||
class BatchAudioFolderLoader:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"folder_path": ("STRING", {"forceInput": True, "tooltip": "Conectar al output folder_path de Telegram WaitForMultipleFiles"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO_LIST", "STRING_LIST", "INT", "STRING")
|
||||
RETURN_NAMES = ("audio_list", "file_names", "file_count", "log")
|
||||
FUNCTION = "load_audios"
|
||||
CATEGORY = "🔁 Sequential Batcher/Audio"
|
||||
|
||||
def load_audios(self, folder_path):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg); log_output.append(str(msg))
|
||||
|
||||
import os
|
||||
import torch
|
||||
from .video import extract_and_standardize_audio
|
||||
|
||||
_log(f"\n📂 [Batch Audio Loader] Escaneando lote en: {folder_path}")
|
||||
|
||||
if not folder_path or not os.path.exists(folder_path):
|
||||
_log(" -> ❌ ERROR: Directorio nulo o inexistente. Devolviendo silencio.")
|
||||
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
|
||||
return ([silent], ["error_silence.ogg"], 0, "\n".join(log_output))
|
||||
|
||||
valid_extensions = ('.mp3', '.wav', '.ogg', '.flac', '.m4a')
|
||||
files = sorted([f for f in os.listdir(folder_path) if f.lower().endswith(valid_extensions)])
|
||||
|
||||
if not files:
|
||||
_log(" -> ⚠️ No se encontraron archivos de audio en el lote.")
|
||||
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
|
||||
return ([silent], ["empty_silence.ogg"], 0, "\n".join(log_output))
|
||||
|
||||
audio_list = []
|
||||
file_names = []
|
||||
|
||||
for f in files:
|
||||
path = os.path.join(folder_path, f)
|
||||
try:
|
||||
_log(f" -> 🎵 Cargando y estandarizando: {f}")
|
||||
audio_dict = extract_and_standardize_audio(path)
|
||||
audio_list.append(audio_dict)
|
||||
file_names.append(f)
|
||||
except Exception as e:
|
||||
_log(f" -> ❌ Error procesando {f}: {e}")
|
||||
|
||||
if not audio_list:
|
||||
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
|
||||
return ([silent], ["error_silence.ogg"], 0, "\n".join(log_output))
|
||||
|
||||
_log(f" -> ✅ Lote procesado: {len(audio_list)} audios listos en RAM.")
|
||||
return (audio_list, file_names, len(audio_list), "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class AudioBatchSelector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"audio_list": ("AUDIO_LIST", {"forceInput": True}),
|
||||
"file_names": ("STRING_LIST", {"forceInput": True}),
|
||||
"current_loop_index": ("INT", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
# 💡 AÑADIDOS LOS PINES PARA EL ÍNDICE DEL LOTE
|
||||
RETURN_TYPES = ("AUDIO", "STRING", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("current_audio", "current_file_name", "batch_index", "total_batches", "log")
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "🔁 Sequential Batcher/Audio"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
def select(self, audio_list, file_names, current_loop_index):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg); log_output.append(str(msg))
|
||||
|
||||
from . import loop
|
||||
|
||||
# Normalización de listas
|
||||
audios = audio_list if isinstance(audio_list, list) else [audio_list]
|
||||
names = file_names if isinstance(file_names, list) else [file_names]
|
||||
|
||||
if len(audios) == 1 and isinstance(audios[0], list): audios = audios[0]
|
||||
if len(names) == 1 and isinstance(names[0], list): names = names[0]
|
||||
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
|
||||
# 🧠 FIX: El archivo lo dicta el índice de lote global, NO el índice de ciclo (que es para escenas)
|
||||
current_batch_idx = getattr(loop, 'global_batch_index', 0)
|
||||
|
||||
if current_batch_idx >= len(audios):
|
||||
current_batch_idx = len(audios) - 1
|
||||
|
||||
current_audio = audios[current_batch_idx]
|
||||
current_name = names[current_batch_idx]
|
||||
total_audios = len(audios)
|
||||
|
||||
loop.global_has_more_batches = (current_batch_idx < total_audios - 1)
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🎛️ [Secuencial Batcher] NODO: Audio Batch Selector")
|
||||
_log(f" -> Lote (Batch) Actual: {current_batch_idx + 1} de {total_audios}")
|
||||
_log(f" -> Archivo activo: {current_name}")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (current_audio, current_name, current_batch_idx, total_audios, "\n".join(log_output))
|
||||
|
||||
import folder_paths
|
||||
import math
|
||||
|
||||
@register_node
|
||||
class DynamicSceneDirector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"agent_json": ("STRING", {"multiline": True, "default": '{"scenes": [{"scene_id": 1, "duration_seconds": 5.0, "flux_prompt": "A cinematic wide shot...", "wan_prompt": "Camera pans..."}]}'}),
|
||||
"audio_filename": ("STRING", {"default": "audio_track"}),
|
||||
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"fps": ("INT", {"default": 12, "min": 8, "max": 60}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = ("flux_prompt", "wan_prompt", "chunk_frames", "image_load_path", "project_json_path")
|
||||
FUNCTION = "direct_scene"
|
||||
CATEGORY = "🔁 Sequential Batcher/Director"
|
||||
|
||||
def direct_scene(self, agent_json, audio_filename, current_loop_index, fps):
|
||||
log_output = []
|
||||
def _log(msg): print(msg); log_output.append(str(msg))
|
||||
from . import loop
|
||||
import os, folder_paths, json, math, re
|
||||
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🎬 [Director] Evaluando Ciclo: {idx}")
|
||||
|
||||
base_output = folder_paths.get_output_directory()
|
||||
safe_audio_name = "".join(c for c in audio_filename if c.isalnum() or c in " _-").strip() or "proyecto"
|
||||
plan_path = os.path.join(base_output, f"{safe_audio_name}_plan.json")
|
||||
|
||||
# 1. 🛡️ GESTIÓN DEL JSON (Ignora a Ollama a partir del ciclo 1)
|
||||
if idx == 0:
|
||||
try:
|
||||
# Extracción robusta por si el LLM añade texto extra
|
||||
json_match = re.search(r'\{.*\}', agent_json, re.DOTALL)
|
||||
raw_json = json_match.group() if json_match else agent_json
|
||||
data = json.loads(raw_json)
|
||||
|
||||
with open(plan_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
_log(f" -> 💾 Plan maestro guardado en: {plan_path}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"❌ Error al interpretar el JSON del Agente: {e}")
|
||||
else:
|
||||
try:
|
||||
with open(plan_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_log(f" -> ♻️ Plan recuperado del disco (Ignorando entrada del Agente).")
|
||||
except Exception as e:
|
||||
raise ValueError(f"❌ Error al leer el JSON guardado en {plan_path}: {e}")
|
||||
|
||||
scenes = data.get("scenes", [])
|
||||
total_scenes = len(scenes)
|
||||
if total_scenes == 0: raise ValueError("❌ JSON sin escenas.")
|
||||
|
||||
# 2. ⏱️ MÁQUINA DE ESTADOS (1 Ciclo = 1 Escena)
|
||||
# El director es un observador pasivo, scene_idx se deriva directamente de idx
|
||||
scene_idx = idx
|
||||
|
||||
if scene_idx >= total_scenes:
|
||||
scene_idx = total_scenes - 1
|
||||
|
||||
scene = scenes[scene_idx]
|
||||
|
||||
# 🧠 REGLA APLICADA: Este nodo actúa como "Calculator" del flujo JSON
|
||||
loop.global_step_by_chunk = True
|
||||
loop.global_source_frame_count = total_scenes
|
||||
loop.global_accumulated_frames = scene_idx + 1
|
||||
loop.global_is_final_chunk = (scene_idx + 1) >= total_scenes
|
||||
loop.global_is_absolute_video_final = loop.global_is_final_chunk
|
||||
|
||||
keyframes_dir = os.path.join(base_output, f"{safe_audio_name}_Keyframes")
|
||||
os.makedirs(keyframes_dir, exist_ok=True)
|
||||
|
||||
image_path = os.path.join(keyframes_dir, f"scene_{scene.get('scene_id', scene_idx)}.png")
|
||||
|
||||
flux_prompt = scene.get("flux_prompt", "")
|
||||
wan_prompt = scene.get("wan_prompt", "")
|
||||
duration = scene.get("duration_seconds", 5.0)
|
||||
chunk_frames = math.ceil(duration * fps)
|
||||
|
||||
_log(f" -> 🎬 Procesando Escena {scene_idx + 1}/{total_scenes} ({duration}s)")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (flux_prompt, wan_prompt, chunk_frames, image_path, plan_path)
|
||||
|
||||
@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", "BOOLEAN", "STRING")
|
||||
RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT", "IS_FINAL_CYCLE", "IS_ABSOLUTE_FINAL", "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
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
cache_dir = os.path.join(folder_paths.get_temp_directory(), "meisoft_video_cache")
|
||||
|
||||
if idx == 0:
|
||||
if os.path.exists(cache_dir): shutil.rmtree(cache_dir, ignore_errors=True)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
_log(f"\n🧹 [Stitcher] Ciclo 0 detectado. Caché general limpiada.")
|
||||
else:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
for i in range(images.shape[0]):
|
||||
filename = f"frame_{idx:04d}_{timestamp}_{i:04d}.png"
|
||||
path = os.path.join(cache_dir, filename)
|
||||
img_array = 255. * images[i].cpu().numpy()
|
||||
Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)).save(path, format="PNG")
|
||||
|
||||
if audio is not None:
|
||||
audio_path = os.path.join(cache_dir, f"audio_{idx:04d}_{timestamp}.safetensors")
|
||||
from safetensors.torch import save_file
|
||||
save_file({"waveform": audio["waveform"].contiguous(), "sample_rate": torch.tensor(audio["sample_rate"], dtype=torch.int32)}, audio_path)
|
||||
|
||||
frames_accepted = images.shape[0]
|
||||
stride = getattr(loop, 'global_select_every_nth', 1)
|
||||
ltx_mode = getattr(loop, 'global_ltx_mode', False)
|
||||
advanced_frames = max(1, (frames_accepted - 1) * stride) if ltx_mode else frames_accepted * stride
|
||||
|
||||
# 🧠 PROTECCIÓN: Solo el Stitcher suma frames si NO estamos en modo Chunk (Director/TTS)
|
||||
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
|
||||
if not is_chunk_mode:
|
||||
loop.global_accumulated_frames += advanced_frames
|
||||
loop.global_is_final_chunk = loop.global_accumulated_frames >= getattr(loop, 'global_source_frame_count', 1)
|
||||
|
||||
is_absolute_final = getattr(loop, 'global_is_absolute_video_final', False) and getattr(loop, 'global_is_final_chunk', False)
|
||||
has_more_batches = getattr(loop, 'global_has_more_batches', False)
|
||||
|
||||
if is_absolute_final:
|
||||
_log(f" -> 🏁 ¡Guion Completo! Ensamblando todas las escenas juntas...")
|
||||
png_files = sorted([f for f in os.listdir(cache_dir) if f.endswith('.png')])
|
||||
if not png_files: return (images, audio, True, True, "\n".join(log_output))
|
||||
|
||||
def load_and_process(filename):
|
||||
img_np = np.array(Image.open(os.path.join(cache_dir, filename)).convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(img_np).unsqueeze(0)
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
tensors_list = list(executor.map(load_and_process, png_files))
|
||||
|
||||
final_tensor = torch.cat(tensors_list, dim=0)
|
||||
|
||||
audio_files = sorted([f for f in os.listdir(cache_dir) if f.startswith('audio_') and f.endswith('.safetensors')])
|
||||
final_audio = None
|
||||
if audio_files:
|
||||
waveforms = []
|
||||
sample_rate = 44100
|
||||
from safetensors.torch import load_file
|
||||
for af in audio_files:
|
||||
chunk_audio = load_file(os.path.join(cache_dir, af))
|
||||
waveforms.append(chunk_audio["waveform"])
|
||||
sample_rate = chunk_audio["sample_rate"].item()
|
||||
final_audio = {"waveform": torch.cat(waveforms, dim=-1), "sample_rate": sample_rate}
|
||||
|
||||
final_audio = audio
|
||||
|
||||
try: shutil.rmtree(cache_dir)
|
||||
except: pass
|
||||
|
||||
return (final_tensor, final_audio, True, not has_more_batches, "\n".join(log_output))
|
||||
|
||||
# 💡 FIX: Retorno de seguridad para ciclos intermedios
|
||||
# Devolvemos el último frame procesado para que el flujo no rompa
|
||||
return (images[-1:].clone(), None, False, False, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class ProjectPlanLoader:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"audio_filename": ("STRING", {"forceInput": True, "tooltip": "Conectar al 'current_file_name' del Audio Batch Selector"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "STRING")
|
||||
RETURN_NAMES = ("agent_json", "scene_count", "plan_path")
|
||||
FUNCTION = "load_plan"
|
||||
CATEGORY = "🔁 Sequential Batcher/Director"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# Queremos que siempre lea el archivo más reciente del disco
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
def load_plan(self, audio_filename):
|
||||
log_output = []
|
||||
def _log(msg): print(msg); log_output.append(str(msg))
|
||||
|
||||
import os
|
||||
import json
|
||||
import folder_paths
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📂 [Plan Loader] Buscando proyecto para: {audio_filename}")
|
||||
|
||||
# La misma lógica exacta del DynamicSceneDirector
|
||||
base_output = folder_paths.get_output_directory()
|
||||
safe_audio_name = "".join(c for c in audio_filename if c.isalnum() or c in " _-").strip() or "proyecto"
|
||||
plan_path = os.path.join(base_output, f"{safe_audio_name}_plan.json")
|
||||
|
||||
if not os.path.exists(plan_path):
|
||||
raise ValueError(f"❌ Error: No se encontró el plan de proyecto en {plan_path}. Asegúrate de que el Flujo 1 lo haya generado.")
|
||||
|
||||
with open(plan_path, 'r', encoding='utf-8') as f:
|
||||
raw_json = f.read()
|
||||
|
||||
try:
|
||||
data = json.loads(raw_json)
|
||||
scenes = data.get("scenes", [])
|
||||
scene_count = len(scenes)
|
||||
except Exception as e:
|
||||
raise ValueError(f"❌ Error al interpretar el JSON guardado en {plan_path}: {e}")
|
||||
|
||||
_log(f" -> ✅ Plan cargado con éxito. Escenas detectadas: {scene_count}")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (raw_json, scene_count, plan_path)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
[project]
|
||||
name = "Sequential-Batcher"
|
||||
description = "A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through Recursive Auto-Queuing, autonomous batch-by-batch processing, and forensic memory management."
|
||||
version = "0.8.3"
|
||||
license = "MIT"
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
# "Operating System :: OS Independent",
|
||||
#
|
||||
# # OR for OS-specific nodes, specify the supported systems:
|
||||
# "Operating System :: Microsoft :: Windows", # Windows specific
|
||||
# "Operating System :: POSIX :: Linux", # Linux specific
|
||||
# "Operating System :: MacOS", # macOS specific
|
||||
#
|
||||
# # GPU Accelerator support. Pick the ones that are supported by your extension.
|
||||
# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
|
||||
# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
|
||||
# "Environment :: GPU :: Intel Arc", # Intel Arc support
|
||||
# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
|
||||
# "Environment :: GPU :: Apple Metal", # Apple Metal support
|
||||
# ]
|
||||
|
||||
dependencies = ["torch", "torchaudio", "numpy", "Pillow", "opencv-python", "safetensors"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/Meisoftcoltd/ComfyUI-Sequential-Batcher"
|
||||
# Used by Comfy Registry https://registry.comfy.org
|
||||
Documentation = "https://github.com/Meisoftcoltd/ComfyUI-Sequential-Batcher/wiki"
|
||||
"Bug Tracker" = "https://github.com/Meisoftcoltd/ComfyUI-Sequential-Batcher/issues"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = ""
|
||||
DisplayName = "ComfyUI-Sequential-Batcher"
|
||||
Icon = ""
|
||||
includes = []
|
||||
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
|
||||
|
||||
@@ -3,3 +3,4 @@ torchaudio
|
||||
numpy
|
||||
Pillow
|
||||
opencv-python
|
||||
safetensors
|
||||
|
||||
@@ -65,6 +65,11 @@ class LazySessionCache:
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "🔁 Sequential Batcher/Logic"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
# 🔥 LA CLAVE: Aquí es donde ComfyUI decide si "tira del cable" o no
|
||||
def check_lazy_status(self, current_loop_index, **kwargs):
|
||||
# El índice llega como una lista o un int dependiendo del nodo anterior
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Mock sys.platform to linux for testing check_and_install_ffmpeg
|
||||
@patch("sys.platform", "linux")
|
||||
@patch("shutil.which", return_value=None)
|
||||
class TestInstall(unittest.TestCase):
|
||||
|
||||
@patch("subprocess.check_call")
|
||||
def test_check_and_install_ffmpeg_linux(self, mock_check_call, mock_which):
|
||||
from install import check_and_install_ffmpeg
|
||||
|
||||
check_and_install_ffmpeg()
|
||||
|
||||
# Verify that subprocess.check_call was called twice with the correct lists
|
||||
self.assertEqual(mock_check_call.call_count, 2)
|
||||
|
||||
expected_calls = [
|
||||
unittest.mock.call(["sudo", "apt-get", "update"]),
|
||||
unittest.mock.call(["sudo", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "ffmpeg"])
|
||||
]
|
||||
mock_check_call.assert_has_calls(expected_calls)
|
||||
|
||||
# Verify shell=True was NOT used
|
||||
for call in mock_check_call.call_args_list:
|
||||
args, kwargs = call
|
||||
if "shell" in kwargs:
|
||||
self.assertFalse(kwargs["shell"], "shell should be False if explicitly provided")
|
||||
# If not in kwargs, it defaults to False for subprocess.check_call
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,55 +0,0 @@
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
import os
|
||||
|
||||
# Mock dependencies before importing anything that might depend on them
|
||||
mock_torch = types.ModuleType("torch")
|
||||
mock_torch.cuda = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
sys.modules["torch"] = mock_torch
|
||||
|
||||
sys.modules["comfy"] = types.ModuleType("comfy")
|
||||
sys.modules["comfy.model_management"] = types.ModuleType("comfy.model_management")
|
||||
|
||||
def get_format_bytes():
|
||||
# Read the file
|
||||
vram_node_path = os.path.join(os.path.dirname(__file__), "..", "vram_node.py")
|
||||
with open(vram_node_path, 'r') as f:
|
||||
code = f.read()
|
||||
|
||||
# Mock 'from . import register_node' by replacing it
|
||||
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
|
||||
|
||||
vram_module = types.ModuleType("vram_node")
|
||||
exec(code, vram_module.__dict__)
|
||||
|
||||
return vram_module.format_bytes
|
||||
|
||||
@pytest.fixture
|
||||
def format_bytes():
|
||||
return get_format_bytes()
|
||||
|
||||
def test_format_bytes_zero(format_bytes):
|
||||
assert format_bytes(0) == "0.00 GB"
|
||||
|
||||
def test_format_bytes_gigabyte(format_bytes):
|
||||
assert format_bytes(1024**3) == "1.00 GB"
|
||||
|
||||
def test_format_bytes_fractional(format_bytes):
|
||||
assert format_bytes(1024**3 * 0.5) == "0.50 GB"
|
||||
assert format_bytes(1024**3 * 2.5) == "2.50 GB"
|
||||
|
||||
def test_format_bytes_small_values(format_bytes):
|
||||
# 1 MB is 1/1024 GB ≈ 0.000976 GB -> 0.00 GB
|
||||
assert format_bytes(1024**2) == "0.00 GB"
|
||||
# 10 MB is 10/1024 GB ≈ 0.009765 GB -> 0.01 GB (due to rounding .2f)
|
||||
assert format_bytes(1024**2 * 10) == "0.01 GB"
|
||||
|
||||
def test_format_bytes_large_values(format_bytes):
|
||||
assert format_bytes(1024**3 * 1024) == "1024.00 GB"
|
||||
assert format_bytes(1024**3 * 123.456) == "123.46 GB"
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
@@ -1,118 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
|
||||
# Setup paths
|
||||
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.dirname(TEST_DIR)
|
||||
SANDBOX_DIR = os.path.join(TEST_DIR, "sandbox")
|
||||
INPUT_DIR = os.path.join(SANDBOX_DIR, "input")
|
||||
SECRET_FILE = os.path.join(SANDBOX_DIR, "secret.txt")
|
||||
|
||||
# Ensure sandbox exists
|
||||
if not os.path.exists(INPUT_DIR):
|
||||
os.makedirs(INPUT_DIR)
|
||||
|
||||
# Create secret file
|
||||
with open(SECRET_FILE, 'w') as f:
|
||||
f.write("secret_key,secret_value\nsuper,secret")
|
||||
|
||||
# Create valid csv
|
||||
with open(os.path.join(INPUT_DIR, "valid.csv"), 'w') as f:
|
||||
f.write("col1,col2\nval1,val2")
|
||||
|
||||
# Mock folder_paths
|
||||
import types
|
||||
folder_paths = types.ModuleType("folder_paths")
|
||||
folder_paths.get_input_directory = lambda: INPUT_DIR
|
||||
folder_paths.get_annotated_filepath = lambda x: x # Mock implementation
|
||||
sys.modules["folder_paths"] = folder_paths
|
||||
|
||||
# Load batch.py dynamically
|
||||
def load_batch_module():
|
||||
batch_path = os.path.join(REPO_ROOT, "batch.py")
|
||||
with open(batch_path, 'r') as f:
|
||||
code = f.read()
|
||||
|
||||
# Mock register_node by replacing the import
|
||||
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
|
||||
|
||||
module = types.ModuleType("batch")
|
||||
exec(code, module.__dict__)
|
||||
return module
|
||||
|
||||
batch_module = load_batch_module()
|
||||
LoadCSV = batch_module.LoadCSV
|
||||
|
||||
def verify_fix():
|
||||
print("--- Verifying Fix ---")
|
||||
node = LoadCSV()
|
||||
|
||||
# Test 1: Exploit attempt (should fail)
|
||||
print("Test 1: Exploit attempt (should raise ValueError)")
|
||||
target_path = SECRET_FILE
|
||||
try:
|
||||
# Pass path relative to input, attempting traversal if we were naive,
|
||||
# but here we can pass absolute path to check absolute path blocking.
|
||||
# Or relative path "../secret.txt"
|
||||
|
||||
# Scenario A: Path traversal relative string
|
||||
# node.go("../secret.txt", ...)
|
||||
# Scenario B: Absolute path
|
||||
|
||||
# Testing Scenario B (absolute path)
|
||||
node.go(target_path, "comma", '"', -1)
|
||||
print("FAIL: Exploit succeeded (No exception raised)")
|
||||
return False
|
||||
except ValueError as e:
|
||||
if "Access denied" in str(e):
|
||||
print(f"SUCCESS: Blocked absolute path access: {e}")
|
||||
else:
|
||||
print(f"FAIL: Raised wrong ValueError: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"FAIL: Raised wrong exception: {type(e)} {e}")
|
||||
return False
|
||||
|
||||
# Test 2: Valid access (should succeed)
|
||||
print("\nTest 2: Valid access (should succeed)")
|
||||
try:
|
||||
# Can pass just filename or relative path
|
||||
result = node.go("valid.csv", "comma", '"', -1)
|
||||
batch_data = result["result"][0]
|
||||
if batch_data[0]['col1'] == 'val1':
|
||||
print("SUCCESS: Valid CSV read correctly")
|
||||
else:
|
||||
print(f"FAIL: content mismatch: {batch_data}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"FAIL: Valid access raised exception: {e}")
|
||||
return False
|
||||
|
||||
# Test 3: Relative path traversal attempt (should fail)
|
||||
print("\nTest 3: Relative path traversal attempt (should raise ValueError)")
|
||||
try:
|
||||
node.go("../secret.txt", "comma", '"', -1)
|
||||
print("FAIL: Exploit succeeded (No exception raised)")
|
||||
return False
|
||||
except ValueError as e:
|
||||
if "Access denied" in str(e):
|
||||
print(f"SUCCESS: Blocked relative path traversal: {e}")
|
||||
else:
|
||||
print(f"FAIL: Raised wrong ValueError: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
# Depending on how os.path.join works, "input/../secret.txt" -> "sandbox/secret.txt"
|
||||
# commonpath should catch it.
|
||||
print(f"FAIL: Raised wrong exception: {type(e)} {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
if verify_fix():
|
||||
print("\nVerification Result: FIX VERIFIED")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nVerification Result: FIX FAILED")
|
||||
sys.exit(1)
|
||||
@@ -2,7 +2,12 @@ import math
|
||||
import time
|
||||
import torch
|
||||
from . import register_node
|
||||
from .switch_node import any_type
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
@register_node
|
||||
class PrimitiveDelay:
|
||||
@@ -233,6 +238,10 @@ class VAESafeFramePadder:
|
||||
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
|
||||
|
||||
@@ -326,3 +335,249 @@ class VHS_Path_Selector:
|
||||
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},)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
@@ -9,15 +8,13 @@ import math
|
||||
import torch
|
||||
import torchaudio
|
||||
import folder_paths
|
||||
import nodes
|
||||
import time
|
||||
from safetensors.torch import save_file, load_file
|
||||
import uuid
|
||||
import subprocess
|
||||
import re
|
||||
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
|
||||
@@ -483,15 +480,30 @@ class AutoLoopCalculator:
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
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))
|
||||
|
||||
if source_frame_count <= 0 or target_frames_per_loop <= 0:
|
||||
_log("⚠️ [WARNING] Invalid frame count or target frames per loop.")
|
||||
return (1, 0, select_every_nth, "\n".join(log_output))
|
||||
|
||||
source_frame_count = min(source_frame_count, 10000)
|
||||
select_every_nth = max(1, select_every_nth)
|
||||
|
||||
# --- Ajuste Proporcional Base (Sin restricciones VAE) ---
|
||||
import math
|
||||
from . import loop
|
||||
|
||||
loop.global_step_by_chunk = False
|
||||
|
||||
potential_effective_frames = source_frame_count // select_every_nth
|
||||
safe_effective_frames = potential_effective_frames # Sin restricciones
|
||||
|
||||
@@ -582,168 +594,91 @@ class AutoLoopCalculatorTTS:
|
||||
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_paragraph", "current_index", "total_paragraphs", "log")
|
||||
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Text"
|
||||
|
||||
def calculate(self, text, current_loop_index):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
from . import loop
|
||||
|
||||
# 1. Limpiar y separar párrafos ignorando saltos de línea vacíos
|
||||
raw_paragraphs = [p.strip() for p in text.split('\n') if p.strip()]
|
||||
total_paragraphs = len(raw_paragraphs)
|
||||
|
||||
if total_paragraphs == 0:
|
||||
raw_paragraphs = [""]
|
||||
total_paragraphs = 1
|
||||
|
||||
# 2. Seguridad de índice
|
||||
safe_index = min(current_loop_index, total_paragraphs - 1)
|
||||
current_paragraph = raw_paragraphs[safe_index]
|
||||
|
||||
# 3. 🧠 HACK CORE: Inyectamos los párrafos como si fueran frames
|
||||
# para que el SequentialLoopTrigger entienda el progreso.
|
||||
loop.global_source_frame_count = total_paragraphs
|
||||
loop.global_accumulated_frames = safe_index + 1
|
||||
loop.global_is_final_chunk = (safe_index + 1) >= total_paragraphs
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS)")
|
||||
_log(f" -> Párrafos detectados: {total_paragraphs}")
|
||||
_log(f" -> Timeline: Párrafo {safe_index + 1} de {total_paragraphs}")
|
||||
_log(f" -> 📜 Texto a procesar: {current_paragraph[:75]}...")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (current_paragraph, safe_index, total_paragraphs, "\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):
|
||||
def calculate(self, text, split_mode, 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")
|
||||
loop.global_step_by_chunk = True
|
||||
|
||||
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.")
|
||||
# 1. Separar el texto según el modo elegido
|
||||
if split_mode == "Frases (Puntos)":
|
||||
# PASO A: Separación cruda respetando todos los signos
|
||||
matches = re.findall(r'[^.!?\n]+[.!?\n]*', text)
|
||||
temp_chunks = [m.strip() for m in matches if m.strip()]
|
||||
|
||||
# PASO B: Fusión inteligente (Evitar micro-frases y roturas por "...")
|
||||
MIN_WORDS = 5
|
||||
raw_chunks = []
|
||||
buffer_text = ""
|
||||
|
||||
for chunk in temp_chunks:
|
||||
buffer_text = (buffer_text + " " + chunk).strip()
|
||||
# Contamos las palabras reales acumuladas en el buffer
|
||||
word_count = len(buffer_text.split())
|
||||
|
||||
# Si el bloque ya tiene suficiente "cuerpo", lo cerramos
|
||||
if word_count >= MIN_WORDS:
|
||||
raw_chunks.append(buffer_text)
|
||||
buffer_text = ""
|
||||
|
||||
# PASO C: Si quedó texto corto huérfano al final, lo unimos al bloque anterior
|
||||
if buffer_text:
|
||||
if raw_chunks:
|
||||
raw_chunks[-1] += " " + buffer_text
|
||||
else:
|
||||
raw_chunks.append(buffer_text)
|
||||
|
||||
chunk_type_name = "Frases Optimizadas"
|
||||
else:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Comportamiento original: Dividir por párrafos
|
||||
raw_chunks = [p.strip() for p in text.split('\n') if p.strip()]
|
||||
chunk_type_name = "Párrafos"
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
total_chunks = len(raw_chunks)
|
||||
|
||||
# 🖼️ 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")
|
||||
if total_chunks == 0:
|
||||
raw_chunks = [""]
|
||||
total_chunks = 1
|
||||
|
||||
# 🎵 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)
|
||||
# 2. Seguridad de índice
|
||||
safe_index = min(current_loop_index, total_chunks - 1)
|
||||
current_text = raw_chunks[safe_index]
|
||||
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
|
||||
# 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
|
||||
|
||||
if is_final_chunk or loop.global_accumulated_frames >= source_total:
|
||||
_log(f" -> 🏁 ¡Último ciclo detectado! Ensamblando recursos desde la caché temporal...")
|
||||
_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")
|
||||
|
||||
# 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))
|
||||
return (current_text, safe_index, total_chunks, "\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:
|
||||
@@ -767,14 +702,29 @@ class AutoLoopCalculatorWan:
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
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))
|
||||
|
||||
if source_frame_count <= 0 or target_frames_per_loop <= 0:
|
||||
_log("⚠️ [WARNING] Invalid frame count or target frames per loop.")
|
||||
return (1, 0, select_every_nth, "\n".join(log_output))
|
||||
|
||||
source_frame_count = min(source_frame_count, 10000)
|
||||
select_every_nth = max(1, select_every_nth)
|
||||
|
||||
import math
|
||||
from . import loop
|
||||
|
||||
loop.global_step_by_chunk = False
|
||||
|
||||
# --- 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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import torch
|
||||
import gc
|
||||
import comfy.model_management as mm
|
||||
import os
|
||||
from . import register_node
|
||||
|
||||
class AnyType(str):
|
||||
|
||||
Reference in New Issue
Block a user