Compare commits
277
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e25a8fe2 | ||
|
|
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 | ||
|
|
ecdda64169 | ||
|
|
26428789ae | ||
|
|
4ce91323a1 | ||
|
|
cb7b0374ae | ||
|
|
08edcf71cb | ||
|
|
f49014866a | ||
|
|
60fc92fec0 | ||
|
|
8fc697a8c3 | ||
|
|
75ab5d9676 | ||
|
|
cba02bf08f | ||
|
|
8d8e3deb1e | ||
|
|
a875f536da | ||
|
|
12a55c7fd6 | ||
|
|
119626a20f | ||
|
|
b854a90bf9 | ||
|
|
e1181c468c | ||
|
|
e4f53ca75a | ||
|
|
16604f101c | ||
|
|
de0797ddb9 | ||
|
|
771f4bf709 | ||
|
|
d660a6b41b | ||
|
|
0805acb114 | ||
|
|
5983ff65b4 | ||
|
|
d4e507af7c | ||
|
|
863154901b | ||
|
|
6479883f6d | ||
|
|
89a5f767d7 | ||
|
|
fb35df1526 | ||
|
|
2551cd5832 | ||
|
|
3eb5dcb7ae | ||
|
|
ce6041da4a | ||
|
|
7d266f818c | ||
|
|
798d791593 | ||
|
|
41d752577c | ||
|
|
5be95e32ab | ||
|
|
3cf6c13d17 | ||
|
|
7187f783b4 | ||
|
|
10e0affe28 | ||
|
|
444b6c6513 | ||
|
|
490e3322d7 | ||
|
|
8dc273ff16 | ||
|
|
4c5d15567b | ||
|
|
242ff772a5 | ||
|
|
e49056c835 | ||
|
|
0d794fc10e | ||
|
|
55cab6c11b | ||
|
|
74238937dc | ||
|
|
f26a7ef983 | ||
|
|
1b75c40e3e | ||
|
|
5b70ccaf18 | ||
|
|
9e893334a1 | ||
|
|
dea765dbcd | ||
|
|
d06e88a9b2 | ||
|
|
cdc10e72c9 | ||
|
|
7ec112a2cd | ||
|
|
c214b71b76 | ||
|
|
bfa0125a21 | ||
|
|
2731ab0779 | ||
|
|
35975007e7 | ||
|
|
7cc86e6b94 | ||
|
|
63e84c2aa1 | ||
|
|
df18b573d0 | ||
|
|
ecd973b8e5 | ||
|
|
1c90c848e2 | ||
|
|
444dc2c350 | ||
|
|
1e36f9e915 | ||
|
|
1701eeb218 | ||
|
|
3f9c7cbee1 | ||
|
|
ff5643b098 | ||
|
|
e647ac0728 | ||
|
|
6080f61c27 | ||
|
|
9cc66a4c80 | ||
|
|
bd6016f559 | ||
|
|
8a0ceca136 | ||
|
|
608be49841 | ||
|
|
c7c8c93c9c | ||
|
|
a6d8ea88dc | ||
|
|
93d479fb6c | ||
|
|
30670f0602 | ||
|
|
a5db690cdf | ||
|
|
80d739782a | ||
|
|
8ae2905bb3 | ||
|
|
939a057ea0 | ||
|
|
dc0d3db2f8 | ||
|
|
2b9e960836 | ||
|
|
e443ec7112 | ||
|
|
1ffccc5599 | ||
|
|
691130916f | ||
|
|
634edbadc8 | ||
|
|
c4d3341637 | ||
|
|
e77fca6dcf | ||
|
|
16bf944e5f | ||
|
|
95e2f0b8bd | ||
|
|
f6abc37a7c | ||
|
|
762ed08324 | ||
|
|
1d6b4fc284 | ||
|
|
788ee2c084 | ||
|
|
9fa6057024 | ||
|
|
128c2cf890 | ||
|
|
8ab37acb47 | ||
|
|
268ba8f0a1 | ||
|
|
8afdfa703f | ||
|
|
7450480b58 | ||
|
|
b85e6b9181 | ||
|
|
1203c2a305 | ||
|
|
44af2b0db5 | ||
|
|
fd18a73145 | ||
|
|
0cdc0394fe | ||
|
|
5b046f3c4d | ||
|
|
091cffe3d4 | ||
|
|
ae8187515b | ||
|
|
5a9bafe7d6 | ||
|
|
4b2c34017b | ||
|
|
bc49d06000 | ||
|
|
3cdbc20144 | ||
|
|
480ae9d815 | ||
|
|
b34b8db30d | ||
|
|
bc7b27a962 | ||
|
|
de78658336 | ||
|
|
b67c0d12cc | ||
|
|
15702503e4 | ||
|
|
bf7ed3305c | ||
|
|
1620a55a57 | ||
|
|
55dbf0b0b1 | ||
|
|
4fc4fec43d | ||
|
|
3c55c21ba4 | ||
|
|
1c13d37fbc | ||
|
|
f780a63a07 | ||
|
|
64172b5c64 | ||
|
|
d0339ee6c7 | ||
|
|
b91831ab11 | ||
|
|
f35a06d867 | ||
|
|
af3cddf758 | ||
|
|
2e497b4894 | ||
|
|
51a3f58bf1 | ||
|
|
2cd9b13f4c | ||
|
|
e4d091df59 | ||
|
|
e9dda4f3e2 | ||
|
|
bc9fb05e2d | ||
|
|
78ab6ea866 | ||
|
|
c9187b69ef | ||
|
|
bb703951d5 | ||
|
|
18f5041640 | ||
|
|
d6d29f998e | ||
|
|
e4cc1be979 | ||
|
|
8a37c8272c | ||
|
|
a1398edab5 | ||
|
|
7c858643e6 | ||
|
|
e15f0ad26b | ||
|
|
22ef1bd2c9 | ||
|
|
afee0ffc6e | ||
|
|
1bed6fbcbb | ||
|
|
6825022800 | ||
|
|
30a51aebae | ||
|
|
265ac2615b | ||
|
|
0c00b7c1c0 | ||
|
|
bdd650ff69 | ||
|
|
9711831bb5 | ||
|
|
5e412b9a37 | ||
|
|
d7c671aff8 | ||
|
|
8b60e818fa | ||
|
|
2f4a329478 | ||
|
|
91a80b1169 | ||
|
|
d6e1b997b8 | ||
|
|
def18e6390 | ||
|
|
10bf229175 | ||
|
|
18eb230109 | ||
|
|
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 }}
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -1,54 +1,97 @@
|
||||
# ComfyUI Sequential Batcher (v1.1.0)
|
||||
# ♾️ ComfyUI Sequential Batcher
|
||||
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
|
||||
Una suite altamente especializada de nodos personalizados para ComfyUI diseñada para el **Auto-Encolado Recursivo (Recursive Self-Queuing)** y el procesamiento secuencial autónomo. Esta arquitectura minimiza el uso de VRAM procesando tareas pesadas (como la generación de vídeo) de forma secuencial, lote por lote, orquestadas completamente desde dentro del propio grafo.
|
||||
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.
|
||||
|
||||
> **Read in English:** [README_EN.md](README_EN.md)
|
||||
|
||||
## La Arquitectura "Motor de Fórmula 1"
|
||||
|
||||
A partir de la versión 1.0.0, este repositorio ha pivotado exclusivamente hacia la arquitectura de bucles secuenciales autónomos y memoria global. Toda la deuda técnica de los antiguos nodos (lotes, secuencias, depuración) ha sido eliminada, dejando un código base limpio y fácil de mantener enfocado en los 6 fantásticos.
|
||||
|
||||
En la **v1.1.0**, hemos introducido capacidades de guardado a disco progresivo, soporte para multiplexado de audio y un hackeo profundo a nivel de JSON para vencer la agresiva caché de ComfyUI.
|
||||
|
||||
## Los 6 Nodos Principales
|
||||
|
||||
El sistema se construye alrededor de tres categorías principales:
|
||||
|
||||
### 🔁 Loop (Orquestación Autónoma)
|
||||
1. **🏁 Loop Start (Index) (`SequentialLoopStart`)**: Inicia el bucle, gestiona el índice global y provee el índice actual a los nodos de imagen y vídeo del flujo.
|
||||
2. **🚀 Loop Trigger (Auto-Queue) (`SequentialLoopTrigger`)**: Se coloca al final del flujo de trabajo. Incrementa el contador y auto-encola el siguiente ciclo mediante un POST a la propia API de ComfyUI (`/prompt`).
|
||||
- **Mutador de Semillas:** Escanea el lienzo, localiza nodos con una semilla (`seed` o `noise_seed`) y les inyecta una nueva (32 bits), rompiendo la caché hacia adelante en los samplers.
|
||||
- **💉 Inyección Anti-Caché (¡Nuevo en v1.1.0!):** Busca específicamente al nodo `Loop Start` dentro de la carga útil (payload) JSON y le **inyecta forzosamente el nuevo índice**. Esto rompe el infame "caché inverso" (bottom-up) de ComfyUI que congelaba los nodos iniciales durante el Auto-Queue, garantizando un avance ininterrumpido.
|
||||
|
||||
### 🖼️ Image (Memoria de Sesión)
|
||||
3. **📥 Session Image Receiver (`SessionImageReceiver`)**: Proporciona la imagen inicial o la última generada del ciclo anterior, detectando inteligentemente el inicio de una sesión en la memoria RAM.
|
||||
4. **📤 Session Image Sender (`SessionImageSender`)**: Extrae la última imagen del lote y la asegura en la memoria del sistema para el siguiente ciclo.
|
||||
- **💾 Guardado de Keyframes (¡Nuevo en v1.1.0!):** Ahora recibe el índice actual y realiza un volcado de seguridad en el disco duro, guardando progresivamente `keyframe_XXX.png` en cada ciclo para prevenir pérdidas de datos.
|
||||
|
||||
### 🎞️ Video (Ensamblaje y Validación)
|
||||
5. **🛡️ Wan Frame Validator (`WanFrameValidator`)**: Valida y corrige el número objetivo de fotogramas para asegurar que encajen en la fórmula `4k+1` requerida por modelos específicos (ej. Wan).
|
||||
6. **🎞️ Incremental Auto-Stitcher (`IncrementalVideoStitcher`)**: Ensambla secuencialmente los fragmentos de vídeo generados usando FFmpeg.
|
||||
- **📦 Historial Progresivo (¡Nuevo en v1.1.0!):** Ya no sobrescribe el vídeo anterior. Ahora guarda MP4s acumulativos (`SCAIL_Final_0001.mp4`, `SCAIL_Final_0002.mp4`), protegiendo tu trabajo de cuelgues del servidor.
|
||||
- **🎵 Multiplexado de Audio (¡Nuevo en v1.1.0!):** Acepta una entrada estándar `AUDIO`. Captura el sonido con `torchaudio` y emplea el comando `-shortest` de FFmpeg. Esto hace que el audio se sincronice a la perfección y se corte exactamente a la duración del ensamblaje del ciclo actual.
|
||||
|
||||
## Configuración y Uso
|
||||
|
||||
### Prerrequisitos
|
||||
- **FFmpeg**: Debe estar instalado y disponible en el PATH del sistema para que el `Incremental Auto-Stitcher` funcione correctamente.
|
||||
- **Torchaudio**: (Generalmente incluido en los entornos ComfyUI) es necesario para extraer la pista de audio antes del multiplexado.
|
||||
|
||||
### Instalación
|
||||
1. Ve a la carpeta `custom_nodes` de ComfyUI.
|
||||
2. Clona este repositorio: `git clone https://github.com/your-repo/ComfyUI-Sequential-Batcher.git`
|
||||
3. Reinicia ComfyUI.
|
||||
|
||||
### Cómo Conectar tu Nueva Máquina Autónoma
|
||||
1. **El Inicio:** Añade el nodo `🏁 Loop Start (Index)`.
|
||||
- Conecta su salida `current_loop_index` a las entradas de índice de tu `SessionImageReceiver`, `SessionImageSender`, y tu `Incremental Auto-Stitcher`. *¡No olvides conectar el Sender para el guardado de los keyframes!*
|
||||
- Asegúrate de que su interruptor `reset_loop` está en `False`.
|
||||
2. **Conectando el Audio (Opcional):** Si tu flujo tiene sonido, saca un cable de la salida de audio de tu nodo inicial (ej. `VHS_LoadVideo`) y conéctalo al puerto `audio` azul de tu `Incremental Auto-Stitcher`.
|
||||
3. **El Final:** Añade el nodo `🚀 Loop Trigger (Auto-Queue)`. Crucial: Conecta la salida de texto (`final_video_path`) de tu `Incremental Auto-Stitcher` en la entrada `trigger_dependency`. Esto obliga al trigger a esperar a que el vídeo se haya guardado físicamente antes de disparar. Configura la cantidad de lotes que quieres en `target_loops`.
|
||||
4. **Ejecución:** **Ya no tienes que marcar la casilla "Auto Queue" nunca más.** Pulsa "Queue Prompt" **1 sola vez**. El lote 0 arranca y, al llegar al final, el nodo `Trigger` envía una señal invisible al servidor. Gracias a la Inyección Anti-Caché, la caché se destruye en cada iteración y el progreso fluye hasta completar tu vídeo.
|
||||
> 🌍 **Read in English:** [README_EN.md](README_EN.md)
|
||||
|
||||
---
|
||||
*Creado para llevar los límites de la automatización en ComfyUI un paso más allá.*
|
||||
|
||||
## ✨ 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.
|
||||
* **Cortes Inteligentes (Smart Chunking):** Analiza el vídeo base y realiza cortes matemáticos protegiendo los frames donde los rostros son más nítidos, manteniendo la coherencia de identidad (Face Cuts).
|
||||
* **Extracción de Audio Nativa:** Extrae la pista original directamente desde el nodo inicial y la inyecta de vuelta en el ensamblaje final.
|
||||
* **Soporte Arquitecturas DiT (WanVideo & LTX 2.3):** Calculadores matemáticos específicos aseguran que los lotes de vídeo cumplan con las estrictas reglas de descompresión (regla de `4n + 1` para WanVideo, o regla `8n + 1` para LTX).
|
||||
* **Resoluciones Protegidas (Megapixel Shield):** Herramientas matemáticas (`ResTool`) que escalan las dimensiones automáticamente protegiendo el *training floor* de cada modelo base.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Requisitos del Sistema (Crítico)
|
||||
|
||||
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:
|
||||
```bash
|
||||
sudo apt update && sudo apt install ffmpeg -y
|
||||
```
|
||||
* **🪟 Windows:**
|
||||
Descarga los binarios precompilados de FFmpeg y añade la carpeta `bin` a tus variables de entorno (PATH).
|
||||
|
||||
### 🚨 Advertencia Crítica sobre el Uso de la VRAM
|
||||
Se **desrecomienda encarecidamente** el uso del argumento `--highvram` al arrancar ComfyUI.
|
||||
|
||||
El argumento `--highvram` le da la orden estricta a ComfyUI de mantener los modelos bloqueados en la memoria de video. Esto sabotea directamente el funcionamiento de nuestro `VRAM Defragmenter`, ya que bloquea el comando `mm.unload_all_models()` impidiendo que la gráfica se vacíe entre ciclos. Para que esta suite funcione correctamente con modelos masivos como WanVideo y evite errores de OOM (Out Of Memory), los usuarios deben usar el arranque estándar (o `--normalvram`), permitiendo que el sistema libere dinámicamente la memoria hasta llegar a 0 GB entre lotes.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Guía de Cableado Rápido (Workflow Setup)
|
||||
|
||||
1. **El Explorador:** Coloca el `Video Analyzer` al principio. Sube un vídeo (o usa una ruta de YTDLP).
|
||||
2. **Matemáticas:** Conecta `total_frames` y `safe_faces_list` hacia tu `Auto Loop Calculator` elegido.
|
||||
3. **Extracción Segura:** Añade el nodo nativo `VHS_LoadVideo`. Conviértele la entrada a *video_name* y conéctala al Analyzer. Conecta su salida `IMAGE` al nuevo nodo `VAE Safe Frame Padder`.
|
||||
4. **Passthrough de Audio:** Lleva el cable `source_audio` desde el Explorador directamente hasta el puerto `audio` de tu `Incremental Auto-Stitcher` (al final del flujo).
|
||||
5. **El Gatillo:** Conecta la salida de tu Stitcher al nodo `Loop Trigger (Auto-Queue)`.
|
||||
6. **Ejecución:** Conecta el `Loop Start (Index)` al Cerebro y al Stitcher.
|
||||
7. **Dispara:** Haz clic en "Queue Prompt" **UNA SOLA VEZ**. ¡Disfruta de la magia autónoma!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Aceleración por GPU (Escaneo de Rostros)
|
||||
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).*
|
||||
|
||||
+93
-50
@@ -1,54 +1,97 @@
|
||||
# ComfyUI Sequential Batcher (v1.1.0)
|
||||
# ♾️ ComfyUI Sequential Batcher
|
||||
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
|
||||
A highly specialized suite of custom nodes for ComfyUI designed for **Recursive Self-Queuing** and autonomous sequential processing. This architecture minimizes VRAM usage by processing heavy tasks (like video generation) sequentially, batch-by-batch, orchestrated entirely from within the graph.
|
||||
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.
|
||||
|
||||
> **Leer en Español:** [README.md](README.md)
|
||||
|
||||
## The "Formula 1" Engine Architecture
|
||||
|
||||
Starting with v1.0.0, this repository has pivoted exclusively to the autonomous sequential loops and global memory architecture. All legacy technical debt (deprecated batch/sequence/debug nodes) has been pruned, leaving a clean, highly maintainable codebase focused on 6 core nodes.
|
||||
|
||||
In **v1.1.0**, we have introduced progressive disk saving capabilities, audio multiplexing support, and a deep JSON hack to defeat ComfyUI's aggressive cache.
|
||||
|
||||
## The 6 Core Nodes
|
||||
|
||||
The system is built around three major categories:
|
||||
|
||||
### 🔁 Loop (Autonomous Orchestration)
|
||||
1. **🏁 Loop Start (Index) (`SequentialLoopStart`)**: Initiates the loop, manages the global loop index, and provides the current iteration index to downstream nodes.
|
||||
2. **🚀 Loop Trigger (Auto-Queue) (`SequentialLoopTrigger`)**: Placed at the very end of your workflow. It increments the loop counter and autonomously triggers an HTTP POST request to the ComfyUI API (`/prompt`) to queue the next batch cycle.
|
||||
- **Seed Mutator:** Scans the canvas, locates nodes with a seed (`seed` or `noise_seed`), and injects a new 32-bit random seed, breaking forward cache in samplers.
|
||||
- **💉 Anti-Cache Injection (New in v1.1.0!):** Specifically searches for the `Loop Start` node within the JSON payload and **forcefully injects the new index**. This shatters ComfyUI's infamous "reverse cache" (bottom-up) that froze initial nodes during Auto-Queue, guaranteeing uninterrupted progression.
|
||||
|
||||
### 🖼️ Image (Session Memory)
|
||||
3. **📥 Session Image Receiver (`SessionImageReceiver`)**: Retrieves the initial image or the last generated frame from the previous cycle, intelligently detecting the start of a RAM session.
|
||||
4. **📤 Session Image Sender (`SessionImageSender`)**: Extracts the final image of a batch and secures it in system memory for the next cycle.
|
||||
- **💾 Keyframe Dumping (New in v1.1.0!):** Now receives the current index and performs a safety dump to the hard drive, progressively saving `keyframe_XXX.png` every cycle to prevent data loss.
|
||||
|
||||
### 🎞️ Video (Assembly and Validation)
|
||||
5. **🛡️ Wan Frame Validator (`WanFrameValidator`)**: Validates and corrects the target number of frames to ensure they fit the `4k+1` formula required by specific models (e.g., Wan).
|
||||
6. **🎞️ Incremental Auto-Stitcher (`IncrementalVideoStitcher`)**: Sequentially assembles the generated video chunks using FFmpeg.
|
||||
- **📦 Progressive History (New in v1.1.0!):** No longer overwrites the previous video. It now saves cumulative MP4s (`SCAIL_Final_0001.mp4`, `SCAIL_Final_0002.mp4`), protecting your work from server crashes.
|
||||
- **🎵 Audio Multiplexing (New in v1.1.0!):** Accepts a standard `AUDIO` input. Captures the sound with `torchaudio` and employs FFmpeg's `-shortest` command. This ensures the audio synchronizes perfectly and cuts exactly to the duration of the current cycle's assembly.
|
||||
|
||||
## Setup & Usage
|
||||
|
||||
### Prerequisites
|
||||
- **FFmpeg**: Must be installed and available in your system's PATH for the `Incremental Auto-Stitcher` to function correctly.
|
||||
- **Torchaudio**: (Usually included in ComfyUI environments) is required to extract the audio track before multiplexing.
|
||||
|
||||
### Installation
|
||||
1. Navigate to your ComfyUI `custom_nodes` folder.
|
||||
2. Clone this repository: `git clone https://github.com/your-repo/ComfyUI-Sequential-Batcher.git`
|
||||
3. Restart ComfyUI.
|
||||
|
||||
### How to use the Autonomous Machine
|
||||
1. **The Start:** Add the `🏁 Loop Start (Index)` node.
|
||||
- Connect its `current_loop_index` to the index inputs of your `SessionImageReceiver`, `SessionImageSender`, and `Incremental Auto-Stitcher`. *Don't forget the Sender for keyframe saving!*
|
||||
- Ensure `reset_loop` is set to `False`.
|
||||
2. **Connecting Audio (Optional):** If your workflow has sound, pull a cable from your initial node's audio output (e.g., `VHS_LoadVideo`) and connect it to the blue `audio` port on your `Incremental Auto-Stitcher`.
|
||||
3. **The End:** Add the `🚀 Loop Trigger (Auto-Queue)` node. Crucially, connect the text output (`final_video_path`) from your `Incremental Auto-Stitcher` to the `trigger_dependency` input. This forces the trigger to wait until the video is physically saved. Set your desired `target_loops`.
|
||||
4. **Execution:** **You no longer need to check "Auto Queue".** Just press "Queue Prompt" **once**. Batch 0 starts, and upon finishing, the trigger invisibly signals the server. Thanks to Anti-Cache Injection, cache is destroyed on every iteration, and progress flows until your video is complete.
|
||||
> 🇪🇸 **Leer en Español:** [README.md](README.md)
|
||||
|
||||
---
|
||||
*Created to push the boundaries of ComfyUI automation.*
|
||||
|
||||
## ✨ 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.
|
||||
* **Smart Chunking:** Analyzes the base video and makes mathematical cuts protecting the frames where faces are sharpest, maintaining identity coherence (Face Cuts).
|
||||
* **Native Audio Extraction:** Extracts the original track directly from the initial node and injects it back into the final assembly.
|
||||
* **DiT Architectures Support (WanVideo & LTX 2.3):** Specific mathematical calculators ensure video batches comply with strict decompression rules (multiples of 4 for WanVideo, or the `8n + 1` rule for LTX).
|
||||
* **Protected Resolutions (Megapixel Shield):** Mathematical tools (`ResTool`) that automatically scale dimensions protecting the *training floor* of each base model.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ System Requirements (Critical)
|
||||
|
||||
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:
|
||||
```bash
|
||||
sudo apt update && sudo apt install ffmpeg -y
|
||||
```
|
||||
* **🪟 Windows:**
|
||||
Download the precompiled FFmpeg binaries and add the `bin` folder to your environment variables (PATH).
|
||||
|
||||
### 🚨 Critical Warning Regarding VRAM Usage
|
||||
The use of the `--highvram` argument when starting ComfyUI is **strongly discouraged**.
|
||||
|
||||
The `--highvram` argument gives ComfyUI the strict command to keep models locked in video memory. This directly sabotages the operation of our `VRAM Defragmenter`, as it blocks the `mm.unload_all_models()` command, preventing the graphics card from emptying between cycles. For this suite to work correctly with massive models like WanVideo and avoid OOM (Out Of Memory) errors, users must use the standard startup (or `--normalvram`), allowing the system to dynamically release memory down to 0 GB between batches.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 The Hybrid Architecture (Node Breakdown)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Quick Wiring Guide (Workflow Setup)
|
||||
|
||||
1. **The Explorer:** Place the `Video Analyzer` at the beginning. Upload a video (or use a YTDLP path).
|
||||
2. **Math:** Connect `total_frames` and `safe_faces_list` to your chosen `Auto Loop Calculator`.
|
||||
3. **Safe Extraction:** Add the native `VHS_LoadVideo` node. Convert its input to *video_name* and connect it to the Analyzer. Connect its `IMAGE` output to the new `VAE Safe Frame Padder` node.
|
||||
4. **Audio Passthrough:** Route the `source_audio` wire from the Explorer directly to the `audio` port of your `Incremental Auto-Stitcher` (at the end of the flow).
|
||||
5. **The Trigger:** Connect the output of your Stitcher to the `Loop Trigger (Auto-Queue)` node.
|
||||
6. **Execution:** Connect the `Loop Start (Index)` to the Brain and the Stitcher.
|
||||
7. **Fire:** Click "Queue Prompt" **JUST ONCE**. Enjoy the autonomous magic!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 GPU Acceleration (Face Scanning)
|
||||
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).*
|
||||
|
||||
+43
-18
@@ -1,30 +1,55 @@
|
||||
__version__ = "1.0.0" # Subimos a versión 1.0 ya que es una arquitectura nueva
|
||||
__version__ = "0.8.3"
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
def register_node(c, display_name=None):
|
||||
assert not isinstance(c.RETURN_TYPES, str), "Error: string found instead of tuple."
|
||||
assert not isinstance(c.RETURN_NAMES, str), "Error: string found instead of tuple."
|
||||
NODE_CLASS_MAPPINGS[c.__name__] = c
|
||||
NODE_DISPLAY_NAME_MAPPINGS[c.__name__] = display_name or c.__name__
|
||||
return c
|
||||
|
||||
from . import image, loop, video
|
||||
# Importar tools
|
||||
from . import image, loop, video, tools, vram_node
|
||||
from .switch_node import MasterSwitch, LazySessionCache
|
||||
|
||||
# Display name mappings with emojis
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
# Image (Memoria de Sesión)
|
||||
"SessionImageReceiver": "📥 Session Image Receiver",
|
||||
"SessionImageSender": "📤 Session Image Sender",
|
||||
|
||||
# Video (Ensamblaje y Validación)
|
||||
"IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher",
|
||||
"WanFrameValidator": "🛡️ Wan Frame Validator",
|
||||
|
||||
# Loop (Orquestación Autónoma)
|
||||
"SequentialLoopStart": "🏁 Loop Start (Index)",
|
||||
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
|
||||
NODE_CLASS_MAPPINGS.update({
|
||||
"MasterSwitch": MasterSwitch,
|
||||
"LazySessionCache": LazySessionCache,
|
||||
})
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
"MasterSwitch": "🔀 Master Switch",
|
||||
"LazySessionCache": "🗄️ Lazy Session Cache",
|
||||
"MeisoftVRAMDefragmenter": "🧹 VRAM Defragmenter",
|
||||
"SessionImageReceiver": "📥 Session Image Receiver",
|
||||
"SessionImageSender": "📤 Session Image Sender",
|
||||
"IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher",
|
||||
"VideoAnalyzerFaceDetector": "🕵️ Video Analyzer Face detector + Audio",
|
||||
"VideoAnalyzerSceneDetector": "🎬 Video Analyzer Scene detector",
|
||||
"AutoLoopCalculator": "📊 Auto Loop Calculator",
|
||||
"AutoLoopCalculatorWan": "📊 Auto Loop Calculator (WanVideo 3dVAE)",
|
||||
"AutoLoopCalculatorLTX": "📊 Auto Loop Calculator (LTX 2.3)",
|
||||
"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",
|
||||
"ResTool8x": "📐 ResTool 8x (SD1.5)",
|
||||
"ResTool16x": "📏 ResTool 16x (SDXL)",
|
||||
"ResTool32x": "🎞️ ResTool 32x (WanVideo)",
|
||||
"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']
|
||||
|
||||
@@ -29,8 +29,8 @@ class SessionImageReceiver:
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("current_image",)
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("current_image", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "get_image"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
@@ -40,27 +40,37 @@ class SessionImageReceiver:
|
||||
return time.time()
|
||||
|
||||
def get_image(self, initial_image, current_loop_index):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
from . import loop
|
||||
global global_session_image
|
||||
|
||||
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
is_first = (loop_idx == 0)
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"📥 [DEBUG] NODO: Image Receiver")
|
||||
print(f" -> Ciclo actual detectado: {loop_idx}")
|
||||
# Usamos getattr para evitar errores si el bucle aún no ha inicializado las variables
|
||||
accumulated = getattr(loop, 'global_accumulated_frames', 0)
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📥 [Secuencial Batcher] NODO: Image Receiver")
|
||||
_log(f" -> Ciclo actual: {loop_idx} | Progreso global: {accumulated} / {source_total} frames")
|
||||
|
||||
if is_first or global_session_image is None:
|
||||
global_session_image = initial_image.clone().cpu()
|
||||
print(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
|
||||
_log(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
|
||||
selected = initial_image
|
||||
else:
|
||||
print(f" -> ♻️ Usando el fotograma rescatado de la RAM.")
|
||||
_log(f" -> ♻️ Usando el Keyframe validado y rescatado de la RAM.")
|
||||
selected = global_session_image
|
||||
|
||||
print(f" -> 🖼️ Tensor shape: {selected.shape}")
|
||||
print(f"{'='*50}\n")
|
||||
_log(f" -> 🖼️ Tensor shape: {selected.shape}")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
ui_image = tensor_to_temp_image(selected, "receiver")
|
||||
return {"ui": {"images": [ui_image]}, "result": (selected, )}
|
||||
return {"ui": {"images": [ui_image]}, "result": (selected, "\n".join(log_output))}
|
||||
|
||||
@register_node
|
||||
class SessionImageSender:
|
||||
@@ -70,11 +80,12 @@ class SessionImageSender:
|
||||
"required": {
|
||||
"generated_images": ("IMAGE",),
|
||||
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"detect_faces": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("VALIDATED_IMAGES", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "set_image"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
@@ -83,35 +94,128 @@ class SessionImageSender:
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
def set_image(self, generated_images, current_loop_index):
|
||||
if generated_images is None:
|
||||
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Session Image Sender' no está recibiendo imágenes. Conecta la salida de tu VAE Decode o Sampler.")
|
||||
def set_image(self, generated_images, current_loop_index, detect_faces):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
from . import loop
|
||||
import cv2
|
||||
import numpy as np
|
||||
import os, folder_paths, torch
|
||||
from PIL import Image
|
||||
|
||||
if current_loop_index is None:
|
||||
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Session Image Sender' necesita el cable de 'current_loop_index' desde el Loop Start para nombrar los archivos.")
|
||||
if generated_images is None:
|
||||
raise ValueError("❌ ERROR CRÍTICO: No se recibieron imágenes en el Sender.")
|
||||
|
||||
global global_session_image
|
||||
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
batch_size = generated_images.shape[0]
|
||||
best_idx = batch_size - 1
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"📤 [DEBUG] NODO: Image Sender")
|
||||
print(f" -> Ciclo actual: {loop_idx} | Frames recibidos: {generated_images.shape[0]}")
|
||||
accumulated = getattr(loop, 'global_accumulated_frames', 0)
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
stride = getattr(loop, 'global_select_every_nth', 1)
|
||||
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
|
||||
|
||||
last_frame = generated_images[-1:].clone().cpu()
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📤 [Secuencial Batcher] NODO: Image Sender (Filtro Dinámico)")
|
||||
_log(f" -> Frames recibidos de la IA: {batch_size}")
|
||||
|
||||
# 🧠 BYPASS IMPLACABLE: Si al sumar este lote llegamos al final del video, NO RECORTAMOS
|
||||
advance_check = (batch_size - 1) * stride if ltx_mode_active else batch_size * stride
|
||||
if (accumulated + advance_check) >= source_total:
|
||||
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Desactivando recorte de rostros para forzar la salida de TODOS los frames.")
|
||||
detect_faces = False
|
||||
|
||||
if detect_faces:
|
||||
try:
|
||||
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||
face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||
|
||||
_log(f" -> 🕵️ Buscando el último rostro frontal en reversa (Desde frame {batch_size - 1} hasta 0)...")
|
||||
|
||||
found = False
|
||||
for i in range(batch_size - 1, -1, -1):
|
||||
img_np = (generated_images[i].cpu().numpy() * 255.0).astype(np.uint8)
|
||||
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
|
||||
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(30, 30))
|
||||
|
||||
if len(faces) > 0:
|
||||
best_idx = i
|
||||
found = True
|
||||
_log(f" -> ✅ Rostro detectado en el frame [{i}].")
|
||||
break
|
||||
|
||||
if not found:
|
||||
_log(f" -> ⚠️ ALERTA: No se detectó rostro frontal en NINGÚN frame. Aceptando todo el lote para evitar bucles infinitos.")
|
||||
best_idx = batch_size - 1
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ Error en OpenCV al validar: {e}")
|
||||
else:
|
||||
_log(f" -> ⏩ Detección de rostros desactivada. Usando el último frame absoluto del lote ({batch_size - 1}).")
|
||||
best_idx = batch_size - 1
|
||||
|
||||
# 1. TRUNCAR EL TENSOR
|
||||
valid_images = generated_images[:best_idx + 1]
|
||||
frames_accepted = best_idx + 1
|
||||
|
||||
# 2. AVANZAR LA MÁQUINA
|
||||
stride = getattr(loop, 'global_select_every_nth', 1)
|
||||
advanced_original_frames = frames_accepted * stride
|
||||
|
||||
if not hasattr(loop, 'global_accumulated_frames'):
|
||||
loop.global_accumulated_frames = 0
|
||||
|
||||
# LTX Mode Fix: Avoid counting the overlap frame in the progression
|
||||
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
|
||||
if ltx_mode_active:
|
||||
# We subtract 1 from frames_accepted to account for the anchor frame
|
||||
# The overlap frame shouldn't advance the global accumulated frames count
|
||||
# since it will be reused as the start of the next chunk.
|
||||
advanced_original_frames = max(1, (frames_accepted - 1) * stride)
|
||||
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
|
||||
|
||||
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
|
||||
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
|
||||
|
||||
if is_chunk_mode:
|
||||
# En modo TTS (Texto), el progreso se mide estrictamente en frases/chunks, no en frames de video.
|
||||
is_final_cycle = is_final_chunk
|
||||
_log(f" -> 🧩 Modo Texto activo: Progreso gestionado por frases ({loop.global_accumulated_frames} / {source_total}).")
|
||||
else:
|
||||
# Lógica original por frames de video
|
||||
if is_final_chunk:
|
||||
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Compensando mermas del VAE (Timeline forzado al 100%).")
|
||||
loop.global_accumulated_frames = source_total
|
||||
else:
|
||||
loop.global_accumulated_frames += advanced_original_frames
|
||||
|
||||
is_final_cycle = loop.global_accumulated_frames >= source_total
|
||||
_log(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total} frames")
|
||||
|
||||
_log(f" -> ✂️ Tensor truncado a {frames_accepted} frames válidos.")
|
||||
|
||||
last_frame = valid_images[-1:].clone().cpu()
|
||||
global_session_image = last_frame
|
||||
|
||||
# Guardar disco progresivo
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
filename = f"keyframe_{loop_idx:03d}.png"
|
||||
filepath = os.path.join(out_dir, filename)
|
||||
# 🛡️ Solo guardamos el keyframe si habrá un próximo ciclo
|
||||
if not is_final_cycle:
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
filename = f"keyframe_{loop_idx:03d}.png"
|
||||
filepath = os.path.join(out_dir, filename)
|
||||
|
||||
img_array = 255. * last_frame[0].numpy()
|
||||
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
||||
img.save(filepath)
|
||||
img_array = 255. * last_frame[0].numpy()
|
||||
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
||||
img.save(filepath)
|
||||
|
||||
print(f" -> 💾 Keyframe guardado físicamente: {filename}")
|
||||
print(f" -> 🧠 RAM asegurada para el próximo ciclo.")
|
||||
print(f"{'='*50}\n")
|
||||
_log(f" -> 💾 Keyframe seguro guardado: {filename}")
|
||||
else:
|
||||
_log(f" -> 🚀 Ciclo final/único detectado. Omitiendo guardado de keyframe.")
|
||||
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
ui_image = tensor_to_temp_image(last_frame, "sender")
|
||||
return {"ui": {"images": [ui_image]}}
|
||||
return {"ui": {"images": [ui_image]}, "result": (valid_images, "\n".join(log_output))}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import sys
|
||||
import subprocess
|
||||
import os
|
||||
import shutil
|
||||
|
||||
def install_requirements():
|
||||
req_file = os.path.join(os.path.dirname(__file__), "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
print("📦 [Sequential Batcher] Instalando dependencias de Python...")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file])
|
||||
print("✅ Dependencias de Python instaladas.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error instalando dependencias: {e}")
|
||||
|
||||
def check_and_install_ffmpeg():
|
||||
if shutil.which("ffmpeg") is None:
|
||||
print("⚠️ [Sequential Batcher] ADVERTENCIA CRÍTICA: FFmpeg NO está instalado.")
|
||||
print("⚠️ La extracción de audio en Video Analyzer fallará.")
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
try:
|
||||
print("⚙️ Intentando instalar FFmpeg automáticamente (Linux/WSL)...")
|
||||
subprocess.check_call(["sudo", "apt-get", "update"])
|
||||
subprocess.check_call(["sudo", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "ffmpeg"])
|
||||
print("✅ FFmpeg instalado correctamente.")
|
||||
except Exception as e:
|
||||
print(f"❌ Falló la instalación automática de FFmpeg: {e}")
|
||||
print("👉 EJECUTA MANUALMENTE: sudo apt update && sudo apt install ffmpeg -y")
|
||||
elif sys.platform == "win32":
|
||||
print("👉 ACCIÓN REQUERIDA: Descarga FFmpeg para Windows y añádelo al PATH.")
|
||||
else:
|
||||
print("✅ [Sequential Batcher] Motor FFmpeg detectado correctamente.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 Iniciando configuración de ComfyUI Sequential Batcher...")
|
||||
install_requirements()
|
||||
check_and_install_ffmpeg()
|
||||
print("🎉 Configuración finalizada.")
|
||||
@@ -2,9 +2,21 @@ import random
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
import gc
|
||||
import torch
|
||||
import comfy.model_management as mm
|
||||
from . import register_node
|
||||
|
||||
global_loop_index = 0
|
||||
global_accumulated_frames = 0
|
||||
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:
|
||||
@@ -13,7 +25,8 @@ class SequentialLoopStart:
|
||||
return {
|
||||
"required": {
|
||||
"reset_loop": ("BOOLEAN", {"default": False}),
|
||||
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}), # Inyectado por el Trigger
|
||||
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"port": ("INT", {"default": 8188, "min": 1, "max": 65535}), # 💡 FAIL-FAST: Pedimos el puerto aquí
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,25 +39,297 @@ class SequentialLoopStart:
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
def get_index(self, reset_loop, loop_idx):
|
||||
def get_index(self, reset_loop, loop_idx, port):
|
||||
global global_loop_index
|
||||
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")
|
||||
print(f" -> Input loop_idx (UI/Trigger): {loop_idx}")
|
||||
print(f" -> Input reset_loop: {reset_loop}")
|
||||
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
|
||||
|
||||
# --- PRE-FLIGHT CHECK (SECURITY SSRF FIX) ---
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8188)
|
||||
try:
|
||||
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:
|
||||
|
||||
if is_reset or loop_idx == 0:
|
||||
global_loop_index = 0
|
||||
print(" -> 🔄 Bucle reiniciado a 0 manualmente.")
|
||||
global_accumulated_frames = 0
|
||||
global_ltx_mode = False
|
||||
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
|
||||
else:
|
||||
global_loop_index = loop_idx
|
||||
global_is_batch_advancing = False
|
||||
|
||||
print(f" -> 📤 OUTPUT current_loop_index: {global_loop_index}")
|
||||
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"
|
||||
else:
|
||||
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
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"source_frame_count": ("INT", {"forceInput": True}),
|
||||
"target_frames_per_loop": ("INT", {"default": 81, "min": 9, "max": 257, "step": 8}),
|
||||
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
|
||||
"current_loop_index": ("INT", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
|
||||
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "INT")
|
||||
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth")
|
||||
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
|
||||
|
||||
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
|
||||
physical_source_frame_count = potential_effective_frames * select_every_nth
|
||||
loop.global_source_frame_count = physical_source_frame_count
|
||||
effective_padding = safe_effective_frames - potential_effective_frames
|
||||
|
||||
# Ajuste proporcional
|
||||
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
|
||||
if estimated_loops > 0:
|
||||
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
|
||||
adjusted_target = ((optimal_target - 1 + 7) // 8) * 8 + 1
|
||||
target_frames_per_loop = adjusted_target
|
||||
print(f" -> ⚖️ Ajuste Proporcional LTX: Target recalculado a {adjusted_target} frames por ciclo.")
|
||||
|
||||
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)")
|
||||
|
||||
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
|
||||
|
||||
print(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
|
||||
|
||||
loop.global_select_every_nth = select_every_nth
|
||||
global global_ltx_mode
|
||||
global_ltx_mode = True
|
||||
|
||||
current_pos = loop.global_accumulated_frames
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"📊 [DEBUG] NODO: Auto Loop Calculator (LTX 2.3)")
|
||||
print(f" -> Timeline físico ajustado a LTX: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
|
||||
|
||||
if current_pos >= physical_source_frame_count:
|
||||
return (1, current_pos, select_every_nth)
|
||||
|
||||
frames_left = physical_source_frame_count - current_pos
|
||||
|
||||
equitable_target = target_frames_per_loop * select_every_nth
|
||||
ideal_cut = current_pos + equitable_target
|
||||
|
||||
if frames_left <= equitable_target:
|
||||
best_cut = physical_source_frame_count
|
||||
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
|
||||
|
||||
if scene_cuts_list and len(scene_cuts_list) > 0:
|
||||
future_cuts = [c for c in scene_cuts_list if c > current_pos]
|
||||
if future_cuts:
|
||||
next_cut = min(future_cuts)
|
||||
if next_cut <= ideal_cut:
|
||||
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
|
||||
safe_chunk_from_scene = ((chunk_from_scene + 6) // 8) * 8 + 1
|
||||
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})")
|
||||
|
||||
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})")
|
||||
|
||||
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:
|
||||
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
|
||||
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
|
||||
safe_chunk_from_face = ((chunk_from_face + 6) // 8) * 8 + 1
|
||||
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})")
|
||||
|
||||
best_cut = ideal_cut
|
||||
print(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x8: {ideal_cut}")
|
||||
|
||||
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
||||
effective_chunk_frames = ((effective_chunk_frames + 6) // 8) * 8 + 1
|
||||
|
||||
if effective_chunk_frames < 9:
|
||||
effective_chunk_frames = 9
|
||||
|
||||
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
|
||||
|
||||
loop.global_is_final_chunk = False
|
||||
|
||||
skip_frames = current_pos
|
||||
|
||||
print(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
return (global_loop_index, )
|
||||
return (effective_chunk_frames, skip_frames, select_every_nth)
|
||||
|
||||
@register_node
|
||||
class SequentialLoopTrigger:
|
||||
@@ -53,8 +338,6 @@ class SequentialLoopTrigger:
|
||||
return {
|
||||
"required": {
|
||||
"trigger_dependency": ("*", ),
|
||||
"target_loops": ("INT", {"default": 4, "min": 1, "max": 1000}),
|
||||
"port": ("INT", {"default": 8188, "min": 1000, "max": 9999}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}
|
||||
}
|
||||
@@ -69,48 +352,462 @@ class SequentialLoopTrigger:
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
def trigger_next(self, trigger_dependency, target_loops, port, prompt=None, extra_pnginfo=None):
|
||||
if trigger_dependency is None:
|
||||
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Loop Trigger' no tiene nada conectado en 'trigger_dependency'. Debes conectar la salida del Stitcher para que el bucle pueda continuar.")
|
||||
|
||||
def trigger_next(self, trigger_dependency, prompt=None, extra_pnginfo=None):
|
||||
global global_loop_index
|
||||
global global_accumulated_frames
|
||||
global global_source_frame_count
|
||||
global global_server_port
|
||||
|
||||
next_loop = global_loop_index + 1
|
||||
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" -> Ciclo terminado: {global_loop_index} | Target: {target_loops}")
|
||||
print(f" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
|
||||
|
||||
if next_loop < target_loops:
|
||||
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:
|
||||
estimated_chunk = 50
|
||||
found_calculator = False
|
||||
m_seeds = 0
|
||||
master_switches = []
|
||||
|
||||
for node_id, node_data in prompt.items():
|
||||
class_type = node_data.get("class_type", "")
|
||||
inputs = node_data.get("inputs", {})
|
||||
# Mutar Semillas
|
||||
|
||||
if not found_calculator and "AutoLoopCalculator" in class_type:
|
||||
if "TTS" in class_type:
|
||||
estimated_chunk = 1
|
||||
|
||||
estimated_chunk = inputs.get("target_frames_per_loop", 50)
|
||||
found_calculator = True
|
||||
|
||||
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
|
||||
# 💥 INYECCIÓN ANTI-CACHÉ: Forzar a Loop Start a leer el nuevo índice
|
||||
if node_data.get("class_type") == "SequentialLoopStart":
|
||||
inputs["loop_idx"] = next_loop
|
||||
inputs["reset_loop"] = False
|
||||
print(f" -> 💉 Inyectado loop_idx={next_loop} en LoopStart (Nodo {node_id})")
|
||||
print(f" -> 🎲 Semillas mutadas: {m_seeds}")
|
||||
|
||||
if class_type == "SequentialLoopStart":
|
||||
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
|
||||
|
||||
if class_type == "MasterSwitch":
|
||||
master_switches.append(inputs)
|
||||
|
||||
# 🛡️ 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:
|
||||
inputs["is_final_cycle"] = is_next_final
|
||||
|
||||
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:{port}/prompt", data=data, headers={'Content-Type': 'application/json'})
|
||||
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}")
|
||||
|
||||
else:
|
||||
print(f" -> 🏁 ¡Generación finalizada! ({target_loops} ciclos)")
|
||||
global_loop_index = 0
|
||||
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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
torch
|
||||
torchaudio
|
||||
numpy
|
||||
Pillow
|
||||
opencv-python
|
||||
safetensors
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
class MasterSwitch:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"is_final_cycle": ("BOOLEAN", {"default": False, "forceInput": False}),
|
||||
},
|
||||
"optional": {
|
||||
"on_true": (any_type,),
|
||||
"on_false": (any_type,),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (any_type, "STRING")
|
||||
RETURN_NAMES = ("output", "log")
|
||||
FUNCTION = "route"
|
||||
CATEGORY = "🔁 Sequential Batcher/Logic"
|
||||
|
||||
# 🔥 Evaluación Perezosa Nativa (Protege el Ciclo 0)
|
||||
def check_lazy_status(self, is_final_cycle, on_true=None, on_false=None):
|
||||
if is_final_cycle:
|
||||
return ["on_true"]
|
||||
else:
|
||||
return ["on_false"]
|
||||
|
||||
def route(self, is_final_cycle, on_true=None, on_false=None):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
if is_final_cycle:
|
||||
return (on_true, "\n".join(log_output))
|
||||
else:
|
||||
return (on_false, "\n".join(log_output))
|
||||
|
||||
|
||||
# Diccionario global para persistencia entre ciclos de ejecución
|
||||
GLOBAL_SESSION_CACHE = {}
|
||||
|
||||
class LazySessionCache:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
# Mantenemos el índice como obligatorio para saber en qué ciclo estamos
|
||||
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
# 'value_in' DEBE ser opcional para que la evaluación perezosa funcione
|
||||
"value_in": (any_type, {"forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID"
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (any_type, "STRING")
|
||||
RETURN_NAMES = ("value_out", "log")
|
||||
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
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
|
||||
if idx == 0:
|
||||
# Ciclo 0: Obligamos a ComfyUI a ejecutar todo lo que haya atrás
|
||||
return ["value_in"]
|
||||
else:
|
||||
# Ciclos > 0: NO pedimos 'value_in'. ComfyUI NO ejecutará los nodos anteriores.
|
||||
return []
|
||||
|
||||
def execute(self, current_loop_index, value_in=None, unique_id="0"):
|
||||
global GLOBAL_SESSION_CACHE
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg); log_output.append(str(msg))
|
||||
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
|
||||
_log(f"🧠 [Cache] Ciclo {idx} | Nodo ID: {unique_id}")
|
||||
|
||||
# --- LÓGICA DE CAPTURA (Ciclo 0) ---
|
||||
if idx == 0:
|
||||
if value_in is not None:
|
||||
GLOBAL_SESSION_CACHE[unique_id] = value_in
|
||||
_log(" -> 💾 Valor capturado y guardado en RAM.")
|
||||
return (value_in, "\n".join(log_output))
|
||||
else:
|
||||
# Si es el ciclo 0 y no hay nada, algo va mal en el flujo
|
||||
raise ValueError(f"❌ Error en LazySessionCache [{unique_id}]: No se recibió valor en el Ciclo 0.")
|
||||
|
||||
# --- LÓGICA DE RECUPERACIÓN (Ciclos > 0) ---
|
||||
else:
|
||||
if unique_id in GLOBAL_SESSION_CACHE:
|
||||
_log(" -> ♻️ Recuperando valor de RAM (Upstream bloqueado con éxito).")
|
||||
return (GLOBAL_SESSION_CACHE[unique_id], "\n".join(log_output))
|
||||
else:
|
||||
# Fallback de seguridad por si el caché se limpió o se saltó el ciclo 0
|
||||
_log(" -> ⚠️ No hay caché. Intentando usar entrada directa.")
|
||||
return (value_in, "\n".join(log_output))
|
||||
@@ -1,73 +0,0 @@
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
import importlib.util
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# --- Import setup (same as benchmark) ---
|
||||
mock_folder_paths = MagicMock()
|
||||
mock_folder_paths.folder_names_and_paths = {"checkpoint": ([], {})}
|
||||
sys.modules['folder_paths'] = mock_folder_paths
|
||||
sys.modules['torch'] = MagicMock()
|
||||
sys.modules['numpy'] = MagicMock()
|
||||
sys.modules['PIL'] = MagicMock()
|
||||
sys.modules['PIL.Image'] = MagicMock()
|
||||
sys.modules['PIL.ImageOps'] = MagicMock()
|
||||
sys.modules['PIL.ImageSequence'] = MagicMock()
|
||||
sys.modules['PIL.ImageFile'] = MagicMock()
|
||||
|
||||
repo_root = os.getcwd()
|
||||
spec = importlib.util.spec_from_file_location("comfy_nodes", os.path.join(repo_root, "__init__.py"))
|
||||
comfy_nodes = importlib.util.module_from_spec(spec)
|
||||
sys.modules["comfy_nodes"] = comfy_nodes
|
||||
spec.loader.exec_module(comfy_nodes)
|
||||
|
||||
format_batch_as_table = comfy_nodes.batch.format_batch_as_table
|
||||
|
||||
class TestBatchFormat(unittest.TestCase):
|
||||
def test_basic_batch(self):
|
||||
batch = [{'a': 1, 'b': 2}, {'a': 3, 'b': 4}]
|
||||
result = format_batch_as_table(batch)
|
||||
self.assertIn("| a | b |", result)
|
||||
self.assertIn("| 1 | 2 |", result)
|
||||
self.assertIn("| 3 | 4 |", result)
|
||||
|
||||
def test_order_preservation(self):
|
||||
# Keys should appear in order of first appearance
|
||||
# Row 1 introduces 'b', 'a'
|
||||
# Row 2 introduces 'c'
|
||||
# Expected order: b, a, c
|
||||
batch = [{'b': 1, 'a': 2}, {'c': 3}]
|
||||
result = format_batch_as_table(batch)
|
||||
# Check header line
|
||||
lines = result.split('\n')
|
||||
header = lines[0]
|
||||
# We expect something like "| b | a | c |"
|
||||
# Spacing might vary depending on value lengths, so we check order in string
|
||||
self.assertTrue(header.index('b') < header.index('a'))
|
||||
self.assertTrue(header.index('a') < header.index('c'))
|
||||
|
||||
def test_disjoint_keys(self):
|
||||
batch = [{'a': 1}, {'b': 2}]
|
||||
result = format_batch_as_table(batch)
|
||||
self.assertIn("| a | b |", result)
|
||||
# Row 1 should have empty for b
|
||||
# Row 2 should have empty for a
|
||||
# Note: Current implementation puts empty string for missing keys
|
||||
|
||||
# Verify row 1 has '1' and empty for 'b'
|
||||
# Since we don't know exact spacing easily, we trust the table formatter logic
|
||||
# but ensure no crash and content is there.
|
||||
self.assertIn("1", result)
|
||||
self.assertIn("2", result)
|
||||
|
||||
def test_empty_batch(self):
|
||||
result = format_batch_as_table([])
|
||||
self.assertEqual(result, "(Empty Batch)")
|
||||
|
||||
def test_empty_rows(self):
|
||||
result = format_batch_as_table([{}, {}])
|
||||
self.assertEqual(result, "(Empty rows)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,583 @@
|
||||
import math
|
||||
import time
|
||||
import torch
|
||||
from . import register_node
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
@register_node
|
||||
class PrimitiveDelay:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"delay_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3600.0, "step": 0.1, "tooltip": "Tiempo de pausa en segundos"}),
|
||||
},
|
||||
"optional": {
|
||||
"any_in": (any_type, {"tooltip": "Conecta aquí el cable que quieres retrasar"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (any_type,)
|
||||
RETURN_NAMES = ("* (passthrough)",)
|
||||
FUNCTION = "execute_delay"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def execute_delay(self, delay_seconds, any_in=None):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"⏱️ [Secuencial Batcher] NODO: Primitive Delay")
|
||||
_log(f" -> Congelando la ejecución durante {delay_seconds} segundos...")
|
||||
|
||||
time.sleep(delay_seconds)
|
||||
|
||||
_log(f" -> ✅ Pausa terminada. Reanudando flujo.")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (any_in,)
|
||||
|
||||
# 🧠 Clase Base Invisible (El molde matemático por Megapíxeles)
|
||||
class BaseResolutionTool:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"aspect_ratio": (["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "2:3"],),
|
||||
# 💡 Cambio clave: Dejamos de usar combo y pasamos a un campo INT libre
|
||||
"base_resolution": ("INT", {"default": 1024, "min": 128, "max": 8192, "step": 8}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = ("width", "height", "debug_info", "log")
|
||||
FUNCTION = "get_resolution"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def get_resolution(self, aspect_ratio, base_resolution):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
div = self.DIVISOR
|
||||
min_pixels = getattr(self, "MIN_PIXELS", 0)
|
||||
longest_side = int(base_resolution) # Mantenemos el casting por seguridad
|
||||
|
||||
w_str, h_str = aspect_ratio.split(':')
|
||||
w_ratio = float(w_str)
|
||||
h_ratio = float(h_str)
|
||||
ratio = w_ratio / h_ratio
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🛠️ [Secuencial Batcher] NODO: Resolution Tool {div}x")
|
||||
_log(f" -> Petición Original: Lado mayor {longest_side}px | Ratio {aspect_ratio}")
|
||||
|
||||
# 1. Calcular resolución inicial teórica
|
||||
if w_ratio >= h_ratio:
|
||||
ideal_w = longest_side
|
||||
ideal_h = ideal_w / ratio
|
||||
else:
|
||||
ideal_h = longest_side
|
||||
ideal_w = ideal_h * ratio
|
||||
|
||||
current_pixels = ideal_w * ideal_h
|
||||
|
||||
# 2. 🛡️ Protección de Suelo por ÁREA TOTAL (Training Floor)
|
||||
if current_pixels < min_pixels:
|
||||
_log(f" -> ⚠️ ALERTA: La resolución pedida ({int(current_pixels)} píxeles) es inferior al mínimo vital del modelo ({min_pixels} píxeles).")
|
||||
_log(f" -> 🛡️ Escalando proporcionalmente para evitar artefactos...")
|
||||
# Factor de escala basado en área
|
||||
scale_factor = math.sqrt(min_pixels / current_pixels)
|
||||
ideal_w *= scale_factor
|
||||
ideal_h *= scale_factor
|
||||
|
||||
# 3. Ajuste final de divisibilidad estricta
|
||||
# Usamos round para asegurar que nos quedamos lo más cerca posible del área ideal
|
||||
width = max(div, round(ideal_w / div) * div)
|
||||
height = max(div, round(ideal_h / div) * div)
|
||||
|
||||
debug_msg = f"{width}x{height} (Div {div})"
|
||||
|
||||
_log(f" -> 🎯 Resultado Final Seguro: Ancho {width} | Alto {height} | Píxeles totales: {width*height}")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (width, height, debug_msg, "\n".join(log_output))
|
||||
|
||||
# 📦 Nodos Específicos Blindados por Píxeles
|
||||
@register_node
|
||||
class ResTool8x(BaseResolutionTool):
|
||||
DIVISOR = 8
|
||||
MIN_PIXELS = 262144 # SD1.5 (Equivale a 512x512)
|
||||
|
||||
@register_node
|
||||
class ResTool16x(BaseResolutionTool):
|
||||
DIVISOR = 16
|
||||
MIN_PIXELS = 1048576 # SDXL (Equivale a 1024x1024)
|
||||
|
||||
@register_node
|
||||
class ResTool32x(BaseResolutionTool):
|
||||
DIVISOR = 32
|
||||
MIN_PIXELS = 399360 # WanVideo (Equivale a 832x480)
|
||||
|
||||
@register_node
|
||||
class ResTool64x(BaseResolutionTool):
|
||||
DIVISOR = 64
|
||||
MIN_PIXELS = 921600 # Hunyuan (Equivale a 1280x720)
|
||||
|
||||
@register_node
|
||||
class ResTool64xLTX(BaseResolutionTool):
|
||||
DIVISOR = 64
|
||||
MIN_PIXELS = 393216 # LTX 2.3 (Equivale a 768x512)
|
||||
|
||||
|
||||
@register_node
|
||||
class AutoFPSLimiter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"source_fps": ("FLOAT", {"forceInput": True}),
|
||||
"target_max_fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("select_every_nth", "new_fps", "log")
|
||||
FUNCTION = "calculate_fps"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def calculate_fps(self, source_fps, target_max_fps):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
import math
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"⏱️ [Secuencial Batcher] NODO: Auto FPS Limiter")
|
||||
_log(f" -> FPS Originales: {source_fps:.2f} | Meta Máxima: {target_max_fps:.2f}")
|
||||
|
||||
if source_fps <= target_max_fps:
|
||||
nth = 1
|
||||
new_fps = source_fps
|
||||
_log(f" -> ✅ Los FPS originales ya están dentro del límite.")
|
||||
else:
|
||||
# Calculamos el salto matemático (hacia arriba) para garantizar no superar la meta
|
||||
nth = math.ceil(source_fps / target_max_fps)
|
||||
new_fps = source_fps / nth
|
||||
_log(f" -> ✂️ Reducción necesaria. Procesando 1 de cada {nth} frames.")
|
||||
|
||||
_log(f" -> 🎯 Resultado: {new_fps:.2f} FPS finales (Nth: {nth})")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (nth, new_fps, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class ConditionalAudioRouter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"is_final_cycle": ("BOOLEAN", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO", "STRING")
|
||||
RETURN_NAMES = ("routed_audio", "log")
|
||||
FUNCTION = "route"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def route(self, audio, is_final_cycle):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
if is_final_cycle:
|
||||
_log(" -> 🔊 [Audio Router] Ciclo Final: Enviando pista de audio completa al Lip Sync.")
|
||||
return (audio, "\n".join(log_output))
|
||||
else:
|
||||
_log(" -> 🔇 [Audio Router] Ciclo Intermedio: Generando micro-silencio para bypasear el Lip Sync...")
|
||||
|
||||
# Detectar el sample rate del audio original (estándar de ComfyUI es un dict)
|
||||
sample_rate = 44100
|
||||
if isinstance(audio, dict) and "sample_rate" in audio:
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
# Creamos 0.1 segundos de silencio absoluto (1 canal, mínimos samples)
|
||||
samples = int(sample_rate * 0.1)
|
||||
dummy_waveform = torch.zeros((1, 1, samples), dtype=torch.float32)
|
||||
dummy_audio = {"waveform": dummy_waveform, "sample_rate": sample_rate}
|
||||
|
||||
return (dummy_audio, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class VAESafeFramePadder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"rule": (["WanVideo (Regla 4n+1)", "LTX (Regla 8n+1)"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT", "STRING")
|
||||
RETURN_NAMES = ("PADDED_IMAGES", "frames_added", "log")
|
||||
FUNCTION = "pad"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def pad(self, images, rule):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
if not isinstance(images, torch.Tensor):
|
||||
raise ValueError("[VAESafeFramePadder] Input is not a valid tensor.")
|
||||
|
||||
n_frames = images.shape[0]
|
||||
target_frames = n_frames
|
||||
|
||||
if "WanVideo" in rule:
|
||||
# 🚀 FIX: Regla 4n+1 para WanVideo (Evita la pérdida de 3 frames en el VAE)
|
||||
if n_frames < 5:
|
||||
target_frames = 5
|
||||
else:
|
||||
target_frames = ((n_frames + 2) // 4) * 4 + 1
|
||||
elif "LTX" in rule:
|
||||
if n_frames < 9:
|
||||
target_frames = 9
|
||||
else:
|
||||
target_frames = ((n_frames + 6) // 8) * 8 + 1
|
||||
|
||||
diff = target_frames - n_frames
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🛡️ [Secuencial Batcher] NODO: VAE Safe Frame Padder")
|
||||
_log(f" -> Frames recibidos: {n_frames}")
|
||||
_log(f" -> Regla aplicada: {rule}")
|
||||
|
||||
if diff > 0:
|
||||
_log(f" -> 🧱 Tensor incompleto. Clonando el último frame {diff} vez/veces...")
|
||||
last_frame = images[-1:] # Extraemos el último frame
|
||||
padding = last_frame.repeat(diff, 1, 1, 1) # Lo multiplicamos
|
||||
images = torch.cat([images, padding], dim=0) # Lo fusionamos al final
|
||||
_log(f" -> ✅ Tensor final acolchado a: {target_frames} frames.")
|
||||
else:
|
||||
_log(f" -> ✅ Tensor perfecto. No requiere acolchado.")
|
||||
|
||||
_log(f"{'='*50}\n")
|
||||
return (images, diff, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class VHS_Path_Selector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"vhs_filenames": ("VHS_FILENAMES",),
|
||||
"select_file": (["video_with_audio", "video_muted", "png_thumbnail"],),
|
||||
}
|
||||
}
|
||||
|
||||
# 🔄 Ahora devuelve la cadena y el objeto VHS reordenado
|
||||
RETURN_TYPES = ("STRING", "VHS_FILENAMES", "STRING")
|
||||
RETURN_NAMES = ("file_path", "vhs_filenames", "log")
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools" # 📍 Ubicación corregida
|
||||
|
||||
def select(self, vhs_filenames, select_file):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
try:
|
||||
if vhs_filenames is None or not isinstance(vhs_filenames, (list, tuple)) or len(vhs_filenames) < 2:
|
||||
_log("⚠️ [Path Selector] Entrada vhs_filenames vacía o inválida.")
|
||||
return ("", vhs_filenames, "\n".join(log_output))
|
||||
|
||||
paths = vhs_filenames[1]
|
||||
if not isinstance(paths, list) or not paths:
|
||||
return ("", vhs_filenames, "\n".join(log_output))
|
||||
|
||||
# Identificación de archivos
|
||||
audio_path = next((s for s in paths if isinstance(s, str) and s.endswith("-audio.mp4")), None)
|
||||
muted_path = next((s for s in paths if isinstance(s, str) and s.endswith(".mp4") and not s.endswith("-audio.mp4")), None)
|
||||
png_path = next((s for s in paths if isinstance(s, str) and s.endswith(".png")), None)
|
||||
|
||||
# Selección del archivo principal (result)
|
||||
if select_file == "video_with_audio":
|
||||
result = audio_path if audio_path else (muted_path if muted_path else paths[0])
|
||||
elif select_file == "video_muted":
|
||||
result = muted_path if muted_path else (audio_path if audio_path else paths[0])
|
||||
else: # png_thumbnail
|
||||
result = png_path if png_path else paths[0]
|
||||
|
||||
# 🪄 REORDENADO: Ponemos el elegido en la posición 0
|
||||
new_paths = list(paths)
|
||||
if result in new_paths:
|
||||
new_paths.remove(result)
|
||||
new_paths.insert(0, result)
|
||||
|
||||
# Creamos el nuevo paquete VHS [bool, [lista_reordenada]]
|
||||
reordered_vhs = [vhs_filenames[0], new_paths]
|
||||
|
||||
_log(f"📂 [Path Selector] Selección: {select_file} -> {result}")
|
||||
return (str(result), reordered_vhs, "\n".join(log_output))
|
||||
|
||||
except Exception as e:
|
||||
_log(f"❌ [Path Selector] Error crítico: {e}")
|
||||
return ("", vhs_filenames, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class AudioDurationCalculator:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLOAT", "STRING")
|
||||
RETURN_NAMES = ("duration_seconds", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def calculate(self, audio):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"⏱️ [Secuencial Batcher] NODO: Audio Duration Calculator")
|
||||
|
||||
# 1. Validación de seguridad para evitar crashes
|
||||
if not isinstance(audio, dict):
|
||||
_log(" -> ⚠️ No se detectó un diccionario de audio válido. Devolviendo 0.0")
|
||||
_log(f"{'='*50}\n")
|
||||
return (0.0, "\n".join(log_output))
|
||||
|
||||
waveform = audio.get("waveform")
|
||||
sample_rate = audio.get("sample_rate", 44100)
|
||||
|
||||
if waveform is None:
|
||||
_log(" -> ⚠️ No se detectó 'waveform' en el audio. Devolviendo 0.0")
|
||||
_log(f"{'='*50}\n")
|
||||
return (0.0, "\n".join(log_output))
|
||||
|
||||
# 2. Cálculo matemático
|
||||
samples = waveform.shape[-1]
|
||||
duration = float(samples) / float(sample_rate)
|
||||
|
||||
_log(f" -> ✅ Duración calculada: {duration:.3f} segundos ({samples} samples @ {sample_rate}Hz)")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (duration, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class PreciseAudioSlicer:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"skip_frames": ("INT", {"forceInput": True}),
|
||||
"chunk_frames": ("INT", {"forceInput": True}),
|
||||
"fps": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 120.0, "step": 1.0}),
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ("AUDIO", "STRING")
|
||||
RETURN_NAMES = ("sliced_audio", "log")
|
||||
FUNCTION = "slice_audio"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def slice_audio(self, audio, skip_frames, chunk_frames, fps):
|
||||
log_output = []
|
||||
def _log(msg): print(msg); log_output.append(str(msg))
|
||||
import torch
|
||||
from . import loop
|
||||
|
||||
# 🧠 COMPENSACIÓN MULTI-ESCENA
|
||||
global_offset = getattr(loop, 'global_audio_offset_frames', 0)
|
||||
absolute_skip_frames = skip_frames + global_offset
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"✂️ [Secuencial Batcher] NODO: Precise Audio Slicer")
|
||||
|
||||
if not isinstance(audio, dict) or "waveform" not in audio:
|
||||
_log(" -> ⚠️ Audio no válido. Generando silencio...")
|
||||
return ({"waveform": torch.zeros((1, 2, int((chunk_frames/fps)*44100)), dtype=torch.float32), "sample_rate": 44100}, "\n".join(log_output))
|
||||
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio.get("sample_rate", 44100)
|
||||
|
||||
start_sec = absolute_skip_frames / fps
|
||||
duration_sec = chunk_frames / fps
|
||||
|
||||
start_sample = int(start_sec * sample_rate)
|
||||
chunk_samples = int(duration_sec * sample_rate)
|
||||
end_sample = start_sample + chunk_samples
|
||||
total_samples = waveform.shape[-1]
|
||||
|
||||
_log(f" -> Sincronizando audio ({chunk_frames} frames a {fps} FPS)")
|
||||
_log(f" -> Tramo de tiempo absoluto: {start_sec:.3f}s hasta {start_sec + duration_sec:.3f}s")
|
||||
|
||||
if start_sample >= total_samples:
|
||||
sliced_waveform = torch.zeros((*waveform.shape[:-1], chunk_samples), dtype=waveform.dtype, device=waveform.device)
|
||||
elif end_sample > total_samples:
|
||||
valid_audio = waveform[..., start_sample:total_samples]
|
||||
pad_tensor = torch.zeros((*waveform.shape[:-1], end_sample - total_samples), dtype=waveform.dtype, device=waveform.device)
|
||||
sliced_waveform = torch.cat([valid_audio, pad_tensor], dim=-1)
|
||||
else:
|
||||
sliced_waveform = waveform[..., start_sample:end_sample]
|
||||
|
||||
sliced_waveform = sliced_waveform.contiguous()
|
||||
|
||||
_log(f"{'='*50}\n")
|
||||
return ({"waveform": sliced_waveform, "sample_rate": sample_rate}, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class SaveSceneKeyframe:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"file_path": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("image", "file_path")
|
||||
# 💡 Eliminado OUTPUT_NODE = True para permitir evaluación perezosa
|
||||
FUNCTION = "save"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def save(self, image, file_path):
|
||||
|
||||
import os, numpy as np
|
||||
from PIL import Image
|
||||
from . import loop
|
||||
|
||||
img_array = 255. * image[0].cpu().numpy()
|
||||
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
||||
|
||||
if os.path.isdir(file_path):
|
||||
os.rmdir(file_path) # Borra la carpeta fantasma creada por WAS
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
img.save(file_path) # Ahora sí, guarda la imagen (sobrescribiendo si existe)
|
||||
|
||||
# 🧠 HACK DE RELOJ (Art Flow): Marca la escena como terminada para que el Director avance.
|
||||
loop.global_is_final_chunk = True
|
||||
|
||||
print(f"💾 [Keyframe Saver] Guardado con éxito en: {file_path}")
|
||||
return (image, file_path)
|
||||
|
||||
@register_node
|
||||
class LoadSceneKeyframe:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"file_path": ("STRING", {"forceInput": True})
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("IMAGE",)
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def load(self, file_path):
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"⚠️ [Keyframe Loader] Archivo no encontrado: {file_path}. Generando tensor negro de seguridad.")
|
||||
return (torch.zeros((1, 512, 512, 3), dtype=torch.float32),)
|
||||
|
||||
img = Image.open(file_path)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
print(f"📥 [Keyframe Loader] Imagen cargada desde: {file_path}")
|
||||
return (img_tensor,)
|
||||
|
||||
@register_node
|
||||
class LTXVSingleFrameInjector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"vae": ("VAE",),
|
||||
"latent": ("LATENT",),
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("latent",)
|
||||
FUNCTION = "inject"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def inject(self, vae, latent, image):
|
||||
import torch
|
||||
import comfy.utils
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"💉 [Secuencial Batcher] NODO: LTXV Single Frame Injector")
|
||||
|
||||
samples = latent["samples"].clone()
|
||||
|
||||
# Extraer factores de escala del VAE (Típicamente 8x32x32 para LTXV)
|
||||
scale_factors = getattr(vae, "downscale_index_formula", (8, 32, 32))
|
||||
height_scale_factor = scale_factors[1]
|
||||
width_scale_factor = scale_factors[2]
|
||||
|
||||
batch, channels, latent_frames, latent_height, latent_width = samples.shape
|
||||
width = latent_width * width_scale_factor
|
||||
height = latent_height * height_scale_factor
|
||||
|
||||
if "noise_mask" in latent:
|
||||
mask = latent["noise_mask"].clone()
|
||||
else:
|
||||
mask = torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=samples.device)
|
||||
|
||||
print(f" -> 📐 Adaptando resolución de la imagen ({image.shape[2]}x{image.shape[1]}) a la del Latent ({width}x{height})")
|
||||
|
||||
# Ajustar resolución de la imagen si no coincide con el latent
|
||||
if image.shape[1] != height or image.shape[2] != width:
|
||||
pixels = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1)
|
||||
else:
|
||||
pixels = image
|
||||
|
||||
encode_pixels = pixels[:, :, :, :3]
|
||||
|
||||
# Codificar imagen usando el VAE de LTX
|
||||
t = vae.encode(encode_pixels)
|
||||
|
||||
# Compatibilidad 4D a 5D (Si el VAE devuelve [B, C, H, W] lo pasamos a [B, C, T, H, W])
|
||||
if len(t.shape) == 4:
|
||||
t = t.unsqueeze(2)
|
||||
|
||||
latent_idx = 0
|
||||
end_index = min(latent_idx + t.shape[2], latent_frames)
|
||||
|
||||
# Inyección directa
|
||||
samples[:, :, latent_idx:end_index] = t[:, :, :end_index - latent_idx]
|
||||
mask[:, :, latent_idx:end_index] = 0.0 # 0.0 significa "Proteger este frame del ruido"
|
||||
|
||||
print(f" -> ✅ Imagen inyectada y sellada en el Frame 0 (Índice Latente {latent_idx}).")
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
return ({"samples": samples, "noise_mask": mask},)
|
||||
|
||||
@@ -1,169 +1,827 @@
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from tqdm import tqdm
|
||||
import comfy.utils
|
||||
import math
|
||||
import torch
|
||||
import torchaudio
|
||||
import folder_paths
|
||||
import time
|
||||
from safetensors.torch import save_file, load_file
|
||||
import uuid
|
||||
import subprocess
|
||||
import re
|
||||
from . import register_node
|
||||
|
||||
# Intento de carga de OpenCV para el Director de Fotografía
|
||||
|
||||
# Caché persistente para evitar re-escaneos pesados de vídeo
|
||||
VIDEO_ANALYSIS_CACHE = {}
|
||||
|
||||
# 🎵 Helper para estandarizar audio a 44100Hz y 2 Canales (Estéreo)
|
||||
def extract_and_standardize_audio(video_path, target_sr=44100):
|
||||
import torchaudio.transforms as T
|
||||
try:
|
||||
waveform, sample_rate = torchaudio.load(video_path)
|
||||
|
||||
# 1. Resampling si la frecuencia es distinta
|
||||
if sample_rate != target_sr:
|
||||
resampler = T.Resample(orig_freq=sample_rate, new_freq=target_sr)
|
||||
waveform = resampler(waveform)
|
||||
sample_rate = target_sr
|
||||
|
||||
# 2. Downmix / Upmix a Estéreo (2 canales) estrictamente
|
||||
channels = waveform.shape[0]
|
||||
if channels > 2:
|
||||
# Si tiene más de 2 canales (ej. 5.1), mezclamos a mono y duplicamos a estéreo
|
||||
mono = torch.mean(waveform, dim=0, keepdim=True)
|
||||
waveform = mono.repeat(2, 1)
|
||||
elif channels == 1:
|
||||
# Si es mono, duplicamos la pista para que sea estéreo
|
||||
waveform = waveform.repeat(2, 1)
|
||||
|
||||
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# 🎬 Helper para crear un Clon de Vídeo Físico Estandarizado (Soluciona el desvío VHS)
|
||||
def standardize_video_file(video_path, _log):
|
||||
import folder_paths
|
||||
|
||||
if "_std_audio.mp4" in video_path:
|
||||
return video_path
|
||||
|
||||
filename = os.path.basename(video_path)
|
||||
name, _ = os.path.splitext(filename)
|
||||
out_filename = f"{name}_std_audio.mp4"
|
||||
out_path = os.path.join(folder_paths.get_temp_directory(), out_filename)
|
||||
|
||||
if os.path.exists(out_path):
|
||||
_log(f" -> ♻️ Usando clon de vídeo estandarizado existente en disco: {out_filename}")
|
||||
return out_path
|
||||
|
||||
# Averiguar info original del audio
|
||||
target_sr = None
|
||||
target_ac = None
|
||||
try:
|
||||
info = torchaudio.info(video_path)
|
||||
# Solo aplicamos limitador si se superan los umbrales máximos de ComfyUI
|
||||
if info.sample_rate > 44100:
|
||||
target_sr = "44100"
|
||||
if info.num_channels > 2:
|
||||
target_ac = "2"
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ No se pudo leer metadata de audio con torchaudio. Forzando límites de seguridad. (Error: {e})")
|
||||
target_sr = "44100"
|
||||
target_ac = "2"
|
||||
|
||||
# Si el audio ya es seguro, no gastamos recursos en clonar
|
||||
if target_sr is None and target_ac is None:
|
||||
_log(f" -> ✅ El audio original ya está en parámetros seguros (<=44100Hz, <=2 Canales). Omitiendo FFmpeg.")
|
||||
return video_path
|
||||
|
||||
_log(f" -> ⚙️ Reestructurando contenedor físico con FFmpeg (Vídeo: Copy | Audio: Limitando a parámetros seguros)...")
|
||||
try:
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-i", video_path,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac"
|
||||
]
|
||||
|
||||
# Añadimos los filtros solo si son necesarios
|
||||
if target_sr:
|
||||
cmd.extend(["-ar", target_sr])
|
||||
if target_ac:
|
||||
cmd.extend(["-ac", target_ac])
|
||||
|
||||
cmd.extend(["-map", "0:v?", "-map", "0:a?", out_path])
|
||||
|
||||
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
_log(f" -> ✅ Contenedor clonado y blindado con éxito para los nodos de VHS.")
|
||||
return out_path
|
||||
except Exception as e:
|
||||
raise Exception(f"❌ [Secuencial Batcher] Error crítico: Falló la reestructuración del vídeo. Asegúrate de tener FFmpeg instalado y accesible en el PATH del sistema. Detalles: {e}")
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
HAS_OPENCV = True
|
||||
except ImportError:
|
||||
HAS_OPENCV = False
|
||||
|
||||
@register_node
|
||||
class WanFrameValidator:
|
||||
"""Valida y corrige el número de fotogramas para que encaje en la fórmula 4k+1 de Wan."""
|
||||
|
||||
class VideoAnalyzerFaceDetector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
def INPUT_TYPES(cls):
|
||||
# 1. Obtenemos los archivos locales igual que VHS
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov'] # Mismos que VHS
|
||||
if os.path.exists(input_dir):
|
||||
for f in os.listdir(input_dir):
|
||||
if os.path.isfile(os.path.join(input_dir, f)):
|
||||
file_parts = f.split('.')
|
||||
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
|
||||
files.append(f)
|
||||
|
||||
# 2. El truco VHS: Una tupla con (lista_de_archivos,) pero configurado como STRING y con forceInput=False
|
||||
return {
|
||||
"required": {
|
||||
"target_frames": ("INT", {"default": 49, "min": 1, "max": 10000, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("valid_frames",)
|
||||
FUNCTION = "validate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
def validate(self, target_frames):
|
||||
# 1. Aplicamos la fórmula para redondear a la baja al 4k+1 más cercano
|
||||
k = (target_frames - 1) // 4
|
||||
corrected_frames = (4 * k) + 1
|
||||
|
||||
# 2. Límite de seguridad por si ponen 0 o negativo
|
||||
if corrected_frames < 1:
|
||||
corrected_frames = 1
|
||||
|
||||
# 3. Chivato por consola
|
||||
if corrected_frames != target_frames:
|
||||
print(f"🛡️ [Wan Validator] Aviso: {target_frames} no es válido para Wan. Redondeado a la baja a -> {corrected_frames}")
|
||||
else:
|
||||
print(f"🛡️ [Wan Validator] Fotogramas perfectos: {corrected_frames}")
|
||||
|
||||
return (corrected_frames, )
|
||||
|
||||
session_video_list = []
|
||||
|
||||
@register_node
|
||||
class IncrementalVideoStitcher:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"trigger": ("VHS_FILENAMES", ),
|
||||
"output_prefix": ("STRING", {"default": "SCAIL_Final"}),
|
||||
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
|
||||
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
|
||||
"use_face_detector": ("BOOLEAN", {"default": True}),
|
||||
"blur_threshold": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 1000.0, "step": 1.0}),
|
||||
"unload_detector_after_analysis": ("BOOLEAN", {"default": True}),
|
||||
"unload_detector": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"audio": ("AUDIO", ), # Nueva entrada de audio opcional
|
||||
"bbox_detector": ("BBOX_DETECTOR", ), # 💡 Puerto para YOLO/ONNX
|
||||
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", )
|
||||
RETURN_NAMES = ("final_video_path", )
|
||||
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "FACE_CUTS", "IMAGE", "STRING")
|
||||
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "safe_faces_list", "reference_frame", "log")
|
||||
OUTPUT_NODE = True
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "stitch_incremental"
|
||||
FUNCTION = "analyze"
|
||||
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.")
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, video, reference_frame_idx, use_face_detector, blur_threshold, **kwargs):
|
||||
if isinstance(video, list):
|
||||
video_str = "".join(video)
|
||||
else:
|
||||
video_str = str(video)
|
||||
return f"{video_str}_{reference_frame_idx}_{use_face_detector}_{blur_threshold}"
|
||||
|
||||
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.")
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
# Bypass para permitir conexiones dinámicas (como descargas en curso)
|
||||
return True
|
||||
|
||||
global session_video_list
|
||||
def analyze(self, video, reference_frame_idx, use_face_detector, blur_threshold, unload_detector_after_analysis=True, bbox_detector=None, current_loop_index=0, **kwargs):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
# Resolución unificada de la ruta, tal como hace VHS
|
||||
if os.path.exists(video):
|
||||
video_path = video
|
||||
else:
|
||||
video_path = folder_paths.get_annotated_filepath(video)
|
||||
|
||||
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
|
||||
# 🚀 CLONACIÓN FÍSICA PARA VHS
|
||||
video_path = standardize_video_file(video_path, _log)
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"🎞️ [DEBUG] NODO: Incremental Auto-Stitcher")
|
||||
print(f" -> Ciclo actual: {loop_idx} | Prefix: {prefix}")
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🕵️ [Secuencial Batcher] NODO: Video Analyzer (Explorador)")
|
||||
_log(f" -> Archivo resuelto: {video_path}")
|
||||
|
||||
if loop_idx == 0:
|
||||
print(f" -> 🧹 Limpiando lista de vídeos de sesiones anteriores.")
|
||||
session_video_list.clear()
|
||||
cache_key = f"{video_path}_face"
|
||||
|
||||
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)
|
||||
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
|
||||
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
|
||||
_log(f"♻️ [Face Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
|
||||
cached = VIDEO_ANALYSIS_CACHE[cache_key]
|
||||
frame_count, source_fps, source_audio, safe_faces = cached
|
||||
else:
|
||||
# Análisis completo (Solo Ciclo 0 o primer arranque)
|
||||
_log(f"🎬 [Face Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
|
||||
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
|
||||
source_audio = None
|
||||
try:
|
||||
source_audio = extract_and_standardize_audio(video_path)
|
||||
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
|
||||
|
||||
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)}")
|
||||
# 2. Escaneo de OpenCV (Frames, Rostros y Referencia)
|
||||
frame_count = 0
|
||||
source_fps = 0.0
|
||||
safe_faces = []
|
||||
|
||||
print(f" -> 📦 Total de vídeos a ensamblar: {len(session_video_list)}")
|
||||
|
||||
if not session_video_list:
|
||||
print(f"{'='*50}\n")
|
||||
return ("", )
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
with open(list_file_path, 'w', encoding='utf-8') as f:
|
||||
for video in session_video_list: f.write(f"file '{video}'\n")
|
||||
|
||||
# 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)
|
||||
|
||||
# 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...")
|
||||
|
||||
audio_data = audio[0]
|
||||
waveform = audio_data.get("waveform")
|
||||
sample_rate = audio_data.get("sample_rate", 44100)
|
||||
|
||||
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}")
|
||||
if not HAS_OPENCV:
|
||||
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
|
||||
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}")
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
_log(f" -> 🎞️ Total Frames detectados: {frame_count}")
|
||||
_log(f" -> ⏱️ FPS detectados: {source_fps}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" -> ❌ Error FFmpeg: {e}")
|
||||
finally:
|
||||
if os.path.exists(list_file_path): os.remove(list_file_path)
|
||||
# Escaneo de Rostros
|
||||
if bbox_detector is not None or use_face_detector:
|
||||
_log(f" -> 🤖 Iniciando escaneo de rostros (Umbral: {blur_threshold})...")
|
||||
if bbox_detector is not None:
|
||||
_log(f" -> ⚡ Usando detector de rostros por GPU (YOLO/ONNX).")
|
||||
else:
|
||||
_log(f" -> 🐢 Usando detector de rostros por CPU (OpenCV).")
|
||||
|
||||
print(f"{'='*50}\n")
|
||||
return (final_output, )
|
||||
if HAS_OPENCV:
|
||||
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||
face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Volver al inicio
|
||||
|
||||
_log(f" -> 🤖 Iniciando escaneo de rostros por GPU...")
|
||||
|
||||
# Silenciamos el logger de ultralytics antes de empezar
|
||||
logging.getLogger("ultralytics").setLevel(logging.ERROR)
|
||||
|
||||
# Barra de progreso profesional (desc=Descripción, unit=unidad, leave=True para que no desaparezca)
|
||||
pbar = tqdm(total=frame_count, desc="🔍 Analizando Rostros", unit="frame", dynamic_ncols=True)
|
||||
comfy_pbar = comfy.utils.ProgressBar(frame_count)
|
||||
|
||||
for idx in range(frame_count):
|
||||
ret, frame = cap.read()
|
||||
if not ret: break
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||
|
||||
faces_found = False
|
||||
if variance > blur_threshold:
|
||||
|
||||
if bbox_detector is not None:
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
img_tensor = torch.from_numpy(frame_rgb.astype(np.float32) / 255.0).unsqueeze(0).cpu()
|
||||
|
||||
try:
|
||||
# 🤐 CÁPSULA DE VACÍO: Silenciamos TODO el ruido de la consola
|
||||
with open(os.devnull, 'w') as fnull:
|
||||
with redirect_stdout(fnull), redirect_stderr(fnull):
|
||||
# Inferencia
|
||||
res = bbox_detector.detect(img_tensor, 0.5, 10, 1.0, 10)
|
||||
if isinstance(res, tuple):
|
||||
segs = res[0]
|
||||
else:
|
||||
segs = res
|
||||
if isinstance(segs, tuple) and len(segs) > 1 and isinstance(segs[1], list):
|
||||
faces_found = len(segs[1]) > 0
|
||||
elif isinstance(segs, list):
|
||||
faces_found = len(segs) > 0
|
||||
elif hasattr(segs, '__len__'):
|
||||
faces_found = len(segs) > 0
|
||||
except Exception as e:
|
||||
_log(f" ❌ ERROR BBOX en frame {idx}: {e}")
|
||||
|
||||
else:
|
||||
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
|
||||
faces_found = len(faces) > 0
|
||||
|
||||
if faces_found:
|
||||
safe_faces.append(idx)
|
||||
# _log(f" ✨ Rostro nítido detectado en frame [{idx}] (Varianza: {variance:.2f})")
|
||||
|
||||
pbar.update(1)
|
||||
comfy_pbar.update(1)
|
||||
|
||||
pbar.close()
|
||||
_log(f" -> ✅ Encontrados {len(safe_faces)} frames válidos con rostros.")
|
||||
|
||||
cap.release()
|
||||
|
||||
# Guardar en caché
|
||||
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, safe_faces)
|
||||
|
||||
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
|
||||
ui_result = {}
|
||||
if HAS_OPENCV:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1))
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
|
||||
ref_tensor = ref_tensor.unsqueeze(0)
|
||||
_log(f" -> 🖼️ Frame de referencia extraído (Índice: {safe_ref_idx})")
|
||||
|
||||
from PIL import Image
|
||||
preview_dir = folder_paths.get_temp_directory()
|
||||
preview_name = f"preview_ref_{uuid.uuid4().hex[:5]}.png"
|
||||
Image.fromarray(ref_frame_rgb).save(os.path.join(preview_dir, preview_name))
|
||||
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
cap.release()
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
|
||||
# Liberar el detector de VRAM si se solicita y si se usó
|
||||
if unload_detector_after_analysis and bbox_detector is not None and getattr(kwargs, 'unload_detector', True):
|
||||
_log(f" -> 🧹 Limpiando BBOX_DETECTOR de la VRAM para ahorrar memoria...")
|
||||
import gc
|
||||
import comfy.model_management as mm
|
||||
# Romper referencias
|
||||
del bbox_detector
|
||||
bbox_detector = None
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
mm.soft_empty_cache()
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, safe_faces, ref_tensor, "\n".join(log_output))}
|
||||
|
||||
|
||||
@register_node
|
||||
class VideoAnalyzerSceneDetector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
if os.path.exists(input_dir):
|
||||
files = [f for f in os.listdir(input_dir) if f.split('.')[-1].lower() in ['webm', 'mp4', 'mkv', 'gif', 'mov']]
|
||||
return {
|
||||
"required": {
|
||||
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
|
||||
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000}),
|
||||
"scene_threshold": ("FLOAT", {"default": 25.0, "min": 5.0, "max": 150.0}),
|
||||
},
|
||||
"optional": {
|
||||
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "SCENE_CUTS", "IMAGE", "STRING")
|
||||
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "scene_cuts_list", "reference_frame", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "analyze"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, video, reference_frame_idx, scene_threshold, **kwargs):
|
||||
if isinstance(video, list):
|
||||
video_str = "".join(video)
|
||||
else:
|
||||
video_str = str(video)
|
||||
return f"{video_str}_{reference_frame_idx}_{scene_threshold}"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
return True
|
||||
|
||||
def analyze(self, video, reference_frame_idx, scene_threshold, current_loop_index=0, **kwargs):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
video_path = video if os.path.exists(video) else folder_paths.get_annotated_filepath(video)
|
||||
|
||||
# 🚀 CLONACIÓN FÍSICA PARA VHS
|
||||
video_path = standardize_video_file(video_path, _log)
|
||||
|
||||
cache_key = f"{video_path}_scene"
|
||||
|
||||
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
|
||||
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
|
||||
_log(f"♻️ [Scene Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
|
||||
cached = VIDEO_ANALYSIS_CACHE[cache_key]
|
||||
frame_count, source_fps, source_audio, scene_cuts = cached
|
||||
else:
|
||||
# Análisis completo (Solo Ciclo 0 o primer arranque)
|
||||
_log(f"🎬 [Scene Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
|
||||
|
||||
frame_count = 0
|
||||
source_fps = 0.0
|
||||
source_audio = None
|
||||
scene_cuts = []
|
||||
|
||||
if HAS_OPENCV:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
|
||||
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
|
||||
source_audio = None
|
||||
try:
|
||||
source_audio = extract_and_standardize_audio(video_path)
|
||||
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
|
||||
|
||||
# Detección de cortes
|
||||
_log(f" -> 🎬 Iniciando escaneo de escenas (Umbral Diferencia: {scene_threshold})...")
|
||||
prev_gray = None
|
||||
pbar = tqdm(total=frame_count, desc="🎬 Analizando Escenas")
|
||||
comfy_pbar = comfy.utils.ProgressBar(frame_count)
|
||||
|
||||
for idx in range(frame_count):
|
||||
ret, frame = cap.read()
|
||||
if not ret: break
|
||||
small = cv2.resize(frame, (128, 128))
|
||||
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||
if prev_gray is not None:
|
||||
score = np.mean(cv2.absdiff(gray, prev_gray))
|
||||
if score > scene_threshold:
|
||||
scene_cuts.append(idx)
|
||||
# _log(f" 🎞️ Corte de escena detectado en frame [{idx}] (Puntuación: {score:.2f})")
|
||||
prev_gray = gray
|
||||
pbar.update(1)
|
||||
comfy_pbar.update(1)
|
||||
pbar.close()
|
||||
cap.release()
|
||||
_log(f" -> ✅ Encontrados {len(scene_cuts)} cambios de escena fuertes.")
|
||||
else:
|
||||
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
|
||||
|
||||
# Guardar en caché
|
||||
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, scene_cuts)
|
||||
|
||||
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
|
||||
ui_result = {}
|
||||
if HAS_OPENCV:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1)) if frame_count > 0 else 0
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
|
||||
ref_tensor = ref_tensor.unsqueeze(0)
|
||||
|
||||
from PIL import Image
|
||||
preview_name = f"preview_scene_{uuid.uuid4().hex[:5]}.png"
|
||||
Image.fromarray(ref_frame_rgb).save(os.path.join(folder_paths.get_temp_directory(), preview_name))
|
||||
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3))
|
||||
cap.release()
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3))
|
||||
|
||||
_log(f"{'='*50}\n")
|
||||
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, scene_cuts, ref_tensor, "\n".join(log_output))}
|
||||
|
||||
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculator:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"source_frame_count": ("INT", {"forceInput": True}),
|
||||
"target_frames_per_loop": ("INT", {"default": 50, "min": 1, "max": 10000}),
|
||||
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
|
||||
"current_loop_index": ("INT", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
|
||||
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@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
|
||||
|
||||
safe_source_frame_count = safe_effective_frames * select_every_nth
|
||||
loop.global_source_frame_count = safe_source_frame_count
|
||||
effective_loss = potential_effective_frames - safe_effective_frames
|
||||
|
||||
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
|
||||
if estimated_loops > 0:
|
||||
target_frames_per_loop = math.ceil(safe_effective_frames / estimated_loops)
|
||||
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado a {target_frames_per_loop} frames por ciclo.")
|
||||
|
||||
# --- LOGS MEJORADOS Y TRANSPARENTES ---
|
||||
_log(f" -> 🎞️ Capacidad del video: {potential_effective_frames} frames procesables (Nth: {select_every_nth})")
|
||||
if effective_loss > 0:
|
||||
_log(f" -> 🛡️ Ajuste VAE: Se usarán {safe_effective_frames} frames (Descarte técnico: {effective_loss} frame/s de proceso)")
|
||||
else:
|
||||
_log(f" -> ✅ Ajuste VAE: Perfecto. Múltiplo de 4 detectado.") # Manteniendo el mismo string por ahora o podemos omitirlo, lo adapto a que es genérico
|
||||
|
||||
_log(f" -> 📊 Timeline final: 0 a {safe_source_frame_count} (de {source_frame_count} totales)")
|
||||
# -------------------------------------------------------------
|
||||
|
||||
loop.global_select_every_nth = select_every_nth
|
||||
|
||||
current_pos = loop.global_accumulated_frames
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (Motor Dinámico)")
|
||||
_log(f" -> Posición en el timeline original: {current_pos} / {source_frame_count}")
|
||||
|
||||
if current_pos >= safe_source_frame_count:
|
||||
return (1, current_pos, select_every_nth, "\n".join(log_output))
|
||||
|
||||
frames_left = safe_source_frame_count - current_pos
|
||||
|
||||
equitable_target = target_frames_per_loop * select_every_nth
|
||||
|
||||
ideal_cut = current_pos + equitable_target
|
||||
|
||||
if frames_left <= equitable_target:
|
||||
best_cut = safe_source_frame_count
|
||||
_log(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
|
||||
else:
|
||||
if scene_cuts_list and len(scene_cuts_list) > 0:
|
||||
# 🎬 LÓGICA DE ESCENAS ESTRICTA (1 Escena = 1 Bucle)
|
||||
# Buscar el PRIMER corte que venga inmediatamente después de la posición actual
|
||||
future_cuts = [c for c in scene_cuts_list if c > current_pos]
|
||||
|
||||
if future_cuts:
|
||||
next_cut = min(future_cuts)
|
||||
|
||||
# Verificamos si esta escena entera cabe en la VRAM
|
||||
if next_cut <= ideal_cut:
|
||||
best_cut = next_cut
|
||||
_log(f" -> 🎬 Bucle aislado por escena. Cortando exacto en el frame: {best_cut}")
|
||||
else:
|
||||
best_cut = ideal_cut
|
||||
_log(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
|
||||
else:
|
||||
best_cut = ideal_cut
|
||||
_log(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
|
||||
|
||||
elif safe_faces_list and len(safe_faces_list) > 0:
|
||||
# 👤 LÓGICA DE ROSTROS (Fallback si no hay escenas conectadas)
|
||||
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
|
||||
best_cut = closest_face
|
||||
_log(f" -> ✂️ Corte Facial Inteligente: {best_cut} (Meta: {ideal_cut})")
|
||||
|
||||
else:
|
||||
best_cut = ideal_cut
|
||||
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo: {ideal_cut}")
|
||||
|
||||
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
||||
if current_pos + (effective_chunk_frames * select_every_nth) > safe_source_frame_count:
|
||||
effective_chunk_frames = (safe_source_frame_count - current_pos) // select_every_nth
|
||||
|
||||
skip_frames = current_pos
|
||||
|
||||
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculatorTTS:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"multiline": True, "dynamicPrompts": False}),
|
||||
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
|
||||
"current_loop_index": ("INT", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Text"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
def calculate(self, text, split_mode, current_loop_index):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
from . import loop
|
||||
|
||||
loop.global_step_by_chunk = True
|
||||
|
||||
# 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:
|
||||
# Comportamiento original: Dividir por párrafos
|
||||
raw_chunks = [p.strip() for p in text.split('\n') if p.strip()]
|
||||
chunk_type_name = "Párrafos"
|
||||
|
||||
total_chunks = len(raw_chunks)
|
||||
|
||||
if total_chunks == 0:
|
||||
raw_chunks = [""]
|
||||
total_chunks = 1
|
||||
|
||||
# 2. Seguridad de índice
|
||||
safe_index = min(current_loop_index, total_chunks - 1)
|
||||
current_text = raw_chunks[safe_index]
|
||||
|
||||
# 3. 🧠 HACK CORE: Inyectamos los bloques como si fueran frames
|
||||
loop.global_source_frame_count = total_chunks
|
||||
loop.global_accumulated_frames = safe_index + 1
|
||||
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS)")
|
||||
_log(f" -> Modo de división: {split_mode}")
|
||||
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
|
||||
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
|
||||
_log(f" -> 📜 Texto a procesar: {current_text[:75]}...")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (current_text, safe_index, total_chunks, "\n".join(log_output))
|
||||
|
||||
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculatorWan:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"source_frame_count": ("INT", {"forceInput": True}),
|
||||
"target_frames_per_loop": ("INT", {"default": 48, "min": 4, "max": 10000, "step": 4}),
|
||||
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
|
||||
"current_loop_index": ("INT", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
|
||||
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@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
|
||||
|
||||
if potential_effective_frames < 5:
|
||||
safe_effective_frames = 5
|
||||
else:
|
||||
safe_effective_frames = ((potential_effective_frames + 2) // 4) * 4 + 1
|
||||
|
||||
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
|
||||
physical_source_frame_count = potential_effective_frames * select_every_nth
|
||||
loop.global_source_frame_count = physical_source_frame_count
|
||||
effective_padding = safe_effective_frames - potential_effective_frames
|
||||
|
||||
loop.global_select_every_nth = select_every_nth
|
||||
|
||||
# 2. Ajuste Proporcional Real (Evitar micro-ciclos al final)
|
||||
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
|
||||
if estimated_loops > 0:
|
||||
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
|
||||
adjusted_target = ((optimal_target + 2) // 4) * 4 + 1
|
||||
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado de {target_frames_per_loop} a {adjusted_target} frames por ciclo (para {estimated_loops} ciclos)")
|
||||
target_frames_per_loop = adjusted_target
|
||||
|
||||
_log(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
|
||||
if effective_padding > 0:
|
||||
_log(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
|
||||
else:
|
||||
_log(f" -> ✅ Ajuste VAE: Perfecto. Regla 4n+1 detectada.")
|
||||
|
||||
_log(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
|
||||
|
||||
current_pos = getattr(loop, 'global_accumulated_frames', 0)
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (WanVideo 3D VAE)")
|
||||
_log(f" -> Timeline físico ajustado: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
|
||||
|
||||
# Prevención de desbordamiento de bucle (Usando límite físico)
|
||||
if current_pos >= physical_source_frame_count:
|
||||
return (4, current_pos, select_every_nth, "\n".join(log_output))
|
||||
|
||||
frames_left = physical_source_frame_count - current_pos
|
||||
|
||||
equitable_target = target_frames_per_loop * select_every_nth
|
||||
ideal_cut = current_pos + equitable_target
|
||||
|
||||
if frames_left <= equitable_target:
|
||||
best_cut = physical_source_frame_count
|
||||
_log(f" -> 🧮 Absorbiendo resto final seguro: meta fijada en frame {best_cut}")
|
||||
else:
|
||||
if scene_cuts_list and len(scene_cuts_list) > 0:
|
||||
future_cuts = [c for c in scene_cuts_list if c > current_pos]
|
||||
if future_cuts:
|
||||
next_cut = min(future_cuts)
|
||||
if next_cut <= ideal_cut:
|
||||
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
|
||||
safe_chunk_from_scene = (chunk_from_scene // 4) * 4
|
||||
if safe_chunk_from_scene < 4: safe_chunk_from_scene = 4
|
||||
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
|
||||
_log(f" -> 🎬 Bucle aislado por escena (Ajustado x4). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
|
||||
else:
|
||||
best_cut = ideal_cut
|
||||
_log(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
|
||||
else:
|
||||
best_cut = ideal_cut
|
||||
_log(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
|
||||
elif safe_faces_list and len(safe_faces_list) > 0:
|
||||
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
|
||||
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
|
||||
safe_chunk_from_face = (chunk_from_face // 4) * 4
|
||||
if safe_chunk_from_face < 4: safe_chunk_from_face = 4
|
||||
|
||||
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
|
||||
_log(f" -> ✂️ Corte Facial Inteligente (Ajustado x4): {best_cut} (Cara original detectada: {closest_face})")
|
||||
else:
|
||||
best_cut = ideal_cut
|
||||
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x4: {ideal_cut}")
|
||||
|
||||
# 4. Cálculo final del chunk consolidado a regla 4n+1
|
||||
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
|
||||
|
||||
effective_chunk_frames = ((effective_chunk_frames + 2) // 4) * 4 + 1
|
||||
|
||||
if effective_chunk_frames < 5:
|
||||
effective_chunk_frames = 5
|
||||
|
||||
# Comprobación contra el límite físico para evitar pedir más allá del final
|
||||
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
|
||||
_log(f" -> 🏁 Chunk final detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 4n+1.")
|
||||
loop.global_is_final_chunk = True
|
||||
else:
|
||||
loop.global_is_final_chunk = False
|
||||
|
||||
skip_frames = current_pos
|
||||
|
||||
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos a VHS (Saltando {skip_frames})")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import torch
|
||||
import gc
|
||||
import comfy.model_management as mm
|
||||
from . import register_node
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
def format_bytes(bytes_val):
|
||||
return f"{bytes_val / (1024 ** 3):.2f} GB"
|
||||
|
||||
@register_node
|
||||
class MeisoftVRAMDefragmenter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"anything": (any_type,),
|
||||
"force_model_unload": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (any_type, "STRING")
|
||||
RETURN_NAMES = ("anything", "log")
|
||||
FUNCTION = "defragment"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def defragment(self, anything, force_model_unload):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
_log(f"\n{'='*65}")
|
||||
_log(f"🧹 [Secuencial Batcher] NODO: VRAM Defragmenter (Modo Arquitectura Profunda)")
|
||||
|
||||
# --- ESTADÍSTICAS ANTES ---
|
||||
alloc_before = 0
|
||||
res_before = 0
|
||||
if torch.cuda.is_available():
|
||||
alloc_before = torch.cuda.memory_allocated()
|
||||
res_before = torch.cuda.memory_reserved()
|
||||
_log(f" -> 📊 Estado Inicial | Asignada: {format_bytes(alloc_before)} | Reservada (Caché): {format_bytes(res_before)}")
|
||||
|
||||
# --- FASE 1: CONFIGURACIÓN DINÁMICA DEL ALLOCATOR ---
|
||||
try:
|
||||
# Permite que la reserva de VRAM crezca en bloque elástico
|
||||
torch.cuda.memory._set_allocator_settings("expandable_segments:True")
|
||||
_log(" -> ⚙️ PyTorch Allocator ajustado a expandable_segments:True")
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ Aviso: No se pudo ajustar expandable_segments: {e}")
|
||||
|
||||
# --- FASE 2: EVACUACIÓN (Opcional) ---
|
||||
if force_model_unload:
|
||||
_log(" -> 📦 Trasladando modelos inactivos a la RAM (unload_all_models)...")
|
||||
mm.unload_all_models()
|
||||
|
||||
# --- FASE 3: LIMPIEZA INTERNA DE COMFYUI (LA CLAVE) ---
|
||||
_log(" -> 🧹 Destruyendo referencias ocultas en el gestor de ComfyUI...")
|
||||
if hasattr(mm, "cleanup_models"):
|
||||
mm.cleanup_models()
|
||||
if hasattr(mm, "cleanup_models_gc"):
|
||||
mm.cleanup_models_gc()
|
||||
|
||||
# --- FASE 4: ROMPER REFERENCIAS PYTHON ---
|
||||
_log(" -> 🗑️ Forzando Garbage Collector de Python (Doble pasada)...")
|
||||
gc.collect()
|
||||
gc.collect() # Doble pasada para romper referencias circulares rebeldes
|
||||
|
||||
# --- FASE 5: EVACUACIÓN SUAVE ---
|
||||
mm.soft_empty_cache()
|
||||
|
||||
# --- FASE 6: PURGADO PROFUNDO DE PYTORCH ---
|
||||
if torch.cuda.is_available():
|
||||
_log(" -> 🧱 Desfragmentando Caching Allocator de CUDA...")
|
||||
torch.cuda.ipc_collect()
|
||||
torch.cuda.synchronize() # 🚀 OPTIMIZACIÓN: Esperar a que terminen los kernels pendientes PRIMERO
|
||||
torch.cuda.empty_cache() # 🚀 Luego vaciamos la caché de forma segura
|
||||
|
||||
# --- FASE 7: LIBERACIÓN DE MEMORIA DEL SISTEMA (Multi-Plataforma) ---
|
||||
import platform
|
||||
try:
|
||||
import ctypes
|
||||
if platform.system() == "Windows":
|
||||
# API de Windows: Reduce el 'Working Set' del proceso actual al mínimo
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
current_process = kernel32.GetCurrentProcess()
|
||||
kernel32.SetProcessWorkingSetSize(current_process, -1, -1)
|
||||
_log(" -> 💻 Working Set reducido (RAM del sistema purgada en Windows).")
|
||||
else:
|
||||
# API de Linux/WSL: Libera la memoria de la librería C
|
||||
ctypes.CDLL('libc.so.6').malloc_trim(0)
|
||||
_log(" -> 💻 Malloc Trim ejecutado (RAM del sistema purgada en Linux/WSL).")
|
||||
except Exception as e:
|
||||
# Si algo falla a nivel de SO, fallamos silenciosamente sin romper el nodo
|
||||
pass
|
||||
|
||||
# --- ESTADÍSTICAS DESPUÉS ---
|
||||
if torch.cuda.is_available():
|
||||
alloc_after = torch.cuda.memory_allocated()
|
||||
res_after = torch.cuda.memory_reserved()
|
||||
|
||||
# Cálculos de liberación
|
||||
freed_alloc = alloc_before - alloc_after
|
||||
freed_res = res_before - res_after
|
||||
|
||||
sign_alloc = "+" if freed_alloc < 0 else "-"
|
||||
sign_res = "+" if freed_res < 0 else "-"
|
||||
|
||||
_log(f" -> 📈 Estado Final | Asignada: {format_bytes(alloc_after)} | Reservada (Caché): {format_bytes(res_after)}")
|
||||
_log(f" -> ✨ TOTAL LIBERADO | {sign_alloc}{abs(freed_alloc)/(1024**3):.2f} GB (Asignada) | {sign_res}{abs(freed_res)/(1024**3):.2f} GB (Reservada purgada)")
|
||||
else:
|
||||
_log(" -> ✨ Limpieza completada (Modo CPU/Otra arquitectura).")
|
||||
|
||||
_log(f"{'='*65}\n")
|
||||
return (anything, "\n".join(log_output))
|
||||
Reference in New Issue
Block a user