Compare commits
310
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76bc9498a2 | ||
|
|
e0b60696a7 | ||
|
|
f687c34039 | ||
|
|
1afb103fd2 | ||
|
|
ef4dff94c6 | ||
|
|
3c3ebca5b2 | ||
|
|
ee67c2a105 | ||
|
|
4a003e7973 | ||
|
|
c4290fce8e | ||
|
|
2475b79f21 | ||
|
|
45e3c670f4 | ||
|
|
04efd0e47f | ||
|
|
71e9ea2cb7 | ||
|
|
bd28628314 | ||
|
|
41529ff0a0 | ||
|
|
e3b1722393 | ||
|
|
692cc0daa5 | ||
|
|
2bf5719335 | ||
|
|
d70ec7ef48 | ||
|
|
81cf4df040 | ||
|
|
74f28f2d32 | ||
|
|
fc861296e4 | ||
|
|
0c28a75e9b | ||
|
|
3ba6d43097 | ||
|
|
e62615183c | ||
|
|
fccd913fba | ||
|
|
f37c0c77b3 | ||
|
|
fcbaeb00d9 | ||
|
|
322bf716ef | ||
|
|
cbc397694d | ||
|
|
02f3933eda | ||
|
|
4985eba6b0 | ||
|
|
886288406d | ||
|
|
dc8dcd3f53 | ||
|
|
059f9aced9 | ||
|
|
473e774b99 | ||
|
|
ba5a3d5c09 | ||
|
|
2e29322e18 | ||
|
|
d5681c87dd | ||
|
|
698d0101d3 | ||
|
|
7724d4519e | ||
|
|
27d8bc291e | ||
|
|
3b778103b3 | ||
|
|
6e7d61e288 | ||
|
|
c821bf5c6e | ||
|
|
9373fffffe | ||
|
|
43ca97accb | ||
|
|
dda6646920 | ||
|
|
f10514ac03 | ||
|
|
a9b217cf4b | ||
|
|
1bdaa80875 | ||
|
|
b8ec5f3fe8 | ||
|
|
e1db8a2120 | ||
|
|
b417f855c2 | ||
|
|
81504655d6 | ||
|
|
af8ef37b79 | ||
|
|
34b98c922b | ||
|
|
4a34af5027 | ||
|
|
5066eda106 | ||
|
|
4e49b99e2d | ||
|
|
dc3f4c2c07 | ||
|
|
35075fa357 | ||
|
|
59f0be86ba | ||
|
|
f48f69ef43 | ||
|
|
e449cdf0d1 | ||
|
|
5e2d368379 | ||
|
|
c1bc9b375a | ||
|
|
1d9d3356fb | ||
|
|
1bd0792655 | ||
|
|
fc547c5c18 | ||
|
|
19c8d8ea51 | ||
|
|
80a0ea3eb1 | ||
|
|
1468c14e97 | ||
|
|
cf6666885a | ||
|
|
4d9c0a0ad0 | ||
|
|
b04cc4267e | ||
|
|
af36417577 | ||
|
|
03a214b028 | ||
|
|
5089252bca | ||
|
|
6b7432ad4e | ||
|
|
1011e0ce01 | ||
|
|
3d2805019a | ||
|
|
791f394dd5 | ||
|
|
694c2cce2a | ||
|
|
184407b1a2 | ||
|
|
71534b968e | ||
|
|
3afcee2bef | ||
|
|
5a295242e4 | ||
|
|
cc17f2561e | ||
|
|
bff976781c | ||
|
|
7bef683128 | ||
|
|
9df7a16940 | ||
|
|
0c280ec6f2 | ||
|
|
c44796d59b | ||
|
|
6cd32fcfe9 | ||
|
|
db22f2ea3f | ||
|
|
7b900c10cd | ||
|
|
48ce1b3826 | ||
|
|
d1d5f47562 | ||
|
|
960acd7371 | ||
|
|
7f025b9a18 | ||
|
|
eac81a4b14 | ||
|
|
1f64452691 | ||
|
|
7375f9d478 | ||
|
|
d6c2a4b012 | ||
|
|
b7e1fb6f89 | ||
|
|
ba6fc7b8fc | ||
|
|
41c2d7d34c | ||
|
|
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 | ||
|
|
e87cad300c | ||
|
|
64596500b0 | ||
|
|
bfe40b1b65 | ||
|
|
08d7d2747d | ||
|
|
9939314b2f | ||
|
|
3d5ec1646d | ||
|
|
e68d9026d1 | ||
|
|
4d18b4e69d | ||
|
|
209b4c3bea | ||
|
|
29915698e5 | ||
|
|
3c72a7ab31 | ||
|
|
23135bfaa7 | ||
|
|
ff94c32d63 | ||
|
|
1a28034baf | ||
|
|
73581ac582 | ||
|
|
6da4275975 | ||
|
|
cb6541a306 | ||
|
|
9a2b4beebc | ||
|
|
12f46287a9 | ||
|
|
5254d9c241 | ||
|
|
e4e92e4f08 | ||
|
|
13b2cde008 | ||
|
|
0156a1b313 | ||
|
|
55de36b2f0 | ||
|
|
20eeaf00dc | ||
|
|
fc24328c95 | ||
|
|
bcaa17981b | ||
|
|
143784fd48 | ||
|
|
d9bb1dd2c1 | ||
|
|
f9100f0727 | ||
|
|
6bd37b9671 | ||
|
|
c0b1fcbea9 | ||
|
|
5171199a61 | ||
|
|
ab99598940 | ||
|
|
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,101 +1,97 @@
|
||||
# 🔁 ComfyUI Sequential Batcher & Video Loop Master (Beta v0.9.3)
|
||||
# ♾️ ComfyUI Sequential Batcher
|
||||
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This version is currently in **BETA**. We have fully transitioned the terminology from "Job" to **Batch** to align with ComfyUI standards and the project name.
|
||||
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.
|
||||
|
||||
The ultimate tool for creating complex iterative workflows and frame-by-frame video processing in ComfyUI. Designed to handle huge tasks (like high-res video generation with Wan2.2 or LTX Video) without crashing your GPU by using intelligent sequential looping instead of VRAM-heavy batching.
|
||||
|
||||
## 🚀 Why use this?
|
||||
|
||||
Standard ComfyUI batching processes everything at once (4D tensors). For video or large batches, this leads to **Out of Memory (OOM)** errors.
|
||||
**Sequential Batcher** allows you to "split" these tasks and process them **one-by-one** (sequentially) within a single "Queue Prompt" run, then "gather" the results back into a single batch or video file.
|
||||
> 🌍 **Read in English:** [README_EN.md](README_EN.md)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Installation
|
||||
## ✨ Características Clave
|
||||
|
||||
1. Clone this repo into `custom_nodes/comfyui-sequential-batcher`.
|
||||
2. Restart ComfyUI.
|
||||
3. Dependencies (`torch`, `numpy`) will be handled automatically if using ComfyUI Manager.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Key Concepts
|
||||
## ⚠️ Requisitos del Sistema (Crítico)
|
||||
|
||||
- **SEQUENCE**: A simple list of values (numbers, strings, etc.).
|
||||
- **BATCH**: A structured collection of "steps". Each step has named **Attributes**.
|
||||
- **Iteration**: The magic happens in nodes like `Batch To List`, `Image Batch To List`, or `Latent Batch To List`. When ComfyUI sees a "List" output from these nodes, it executes all downstream nodes once for each item in that list.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🎞️ Video Workflow (Wan2.2 / LTX-Video / Future Models)
|
||||
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
|
||||
|
||||
Video models produce many frames that can easily exceed 24GB VRAM.
|
||||
1. **Split**: Use `Latent Batch To List` to turn your video latent into a list of single frames.
|
||||
2. **Process**: Connect to your KSampler/VAE Decoder. ComfyUI will process Frame 1, then Frame 2, then Frame 3...
|
||||
3. **Gather**: Use `Latent List To Batch` (or `Image List To Batch` if you decoded first) to reconstruct the full video batch for saving.
|
||||
4. **Stitch (VHS Combine)**: To stitch heavy video chunks sequentially:
|
||||
- *Option A (Math)*: `Range` -> `MakeBatch` (attribute: "skip_frames") -> `BatchToList` -> `GetAttributeInt` (name: "skip_frames") connected to `VHS_LoadVideo`.
|
||||
- *Option B (CSV)*: `LoadCSV` (with a `skip_frames` column) -> `BatchToList` -> `GetAttributeInt`.
|
||||
- Connect the output of `VHS_VideoCombine` (specifically the `VHS_FILENAMES` output) to the **FFmpeg Video Stitcher** node (`video_paths`). The Stitcher will wait for all chunks to finish and combine them automatically!
|
||||
El procesamiento se divide en roles altamente especializados, organizados en las siguientes categorías:
|
||||
|
||||
> [!WARNING]
|
||||
> **OOM Prevention (Memory Warning):** While `BatchToList` solves the data passing issue, you must still manage PyTorch VRAM. It is highly recommended to use the **easy cleanGpuUsed** node (or similar VRAM clearing nodes) after the VAE Decoder in each cycle of the Batch. Without manual VRAM clearing in each iteration, the sequential loop will accumulate garbage in your graphics card and eventually cause an Out of Memory error.
|
||||
### 🎼 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.
|
||||
|
||||
---
|
||||
|
||||
## 🔢 Detailed Node Reference
|
||||
## 🔌 Guía de Cableado Rápido (Workflow Setup)
|
||||
|
||||
### 🔄 Loop Category (`🔁 Sequential Batcher/Loop`)
|
||||
- **🔁 Sequential Loop Index**: The simplest way to start a loop.
|
||||
- *Input*: `count` (How many times to run).
|
||||
- *Output*: `index` (0, 1, 2...). Useful for seeding or selecting specific items.
|
||||
- **🔁 Repeat**: Takes any input and repeats it N times.
|
||||
- *Input*: `input` (Any), `count` (INT).
|
||||
- *Output*: `output` (List of the same input).
|
||||
|
||||
### 🛠️ Batch Category (`🔁 Sequential Batcher/Batch`)
|
||||
- **📂 Load CSV**: Loads a CSV file as a Batch. Now includes a table preview in the workflow.
|
||||
- *Input*: `path` (File location), `delimiter`, `quotechar`.
|
||||
- *Optional Input*: `index` (To pick a specific row).
|
||||
- *Output*: `batch` (The full list), `current_attributes` (Dict of the selected row), `count` (Total rows).
|
||||
- **📊 Preview Batch**: Displays a table of the batch content in the workflow.
|
||||
- *Input*: `batch`, `index` (Highlight specific row), `max_rows`.
|
||||
- **🛠️ Make Batch**: Turns a sequence into a "Batch" object.
|
||||
- *Input*: `sequence` (The data), `name` (The attribute name, e.g., "cfg_scale").
|
||||
- **🖇️ Combine Batches**: Merges multiple batches.
|
||||
- *Modes*: `zip` (paired) or `product` (every combination).
|
||||
- **🔄 Batch To List**: **CRITICAL**. Converts a Batch into a stream of attributes that triggers the sequential loop.
|
||||
- **📥 Get Attribute**: Extracts a specific value from the current batch step by its name.
|
||||
|
||||
### 🖼️ Image & Latent Category (`🔁 Sequential Batcher/Image` & `/Latent`)
|
||||
- **🖼️ Image Batch To List**: Splits a [N,H,W,C] tensor into N separate images.
|
||||
- **🖼️ Image List To Batch**: Reconstructs a batch from iterated images.
|
||||
- **🎞️ Latent Batch To List**: Splits video latents frame-by-frame for VRAM-safe processing.
|
||||
- **🎞️ Latent List To Batch**: Merges individual frames back into a video latent batch.
|
||||
- **⏳ Progress Bar**: Generates a visual progress indicator.
|
||||
|
||||
### 🎞️ Video Category (`🔁 Sequential Batcher/Video`)
|
||||
- **🎞️ FFmpeg Video Stitcher**: Final node in a video loop. Waits for the entire sequential batch to finish and stitches the video chunks together using FFmpeg without re-encoding.
|
||||
- *Input*: `video_paths` (List of VHS_FILENAMES), `output_filename` (String).
|
||||
- *Output*: `final_video_path` (Path to the stitched video).
|
||||
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!
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro Tips & Use Cases
|
||||
|
||||
### 📝 Using CSV for Prompts and Scenes
|
||||
You can create a CSV with columns like `prompt`, `negative_prompt`, and `seed`.
|
||||
1. Use **📂 Load CSV** to load your file.
|
||||
2. Connect `batch` to **🔄 Batch To List**.
|
||||
3. Use **📥 Get Attribute** to pull the `prompt` into your CLIP Text Encode.
|
||||
4. Each row in your CSV will be processed as one "frame" or "job" in the sequence.
|
||||
|
||||
### 🎬 Scene Timings for Video
|
||||
If you have a CSV with `frame_start` and `prompt`, you can use it to change prompts at specific points in a video generation loop.
|
||||
|
||||
### 🧪 XY Plots
|
||||
Use **🖇️ Combine Batches** in `product` mode to create XY Plots (e.g., test every Prompt against every CFG scale).
|
||||
|
||||
### 🔍 Automatic Model Iteration
|
||||
Use **🔍 Model Finder** to automatically iterate through a folder of LoRAs or Checkpoints.
|
||||
## 🚀 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).*
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# ♾️ ComfyUI Sequential Batcher
|
||||
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
|
||||
|
||||
A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through **Recursive Auto-Queuing**, autonomous batch-by-batch processing, and forensic memory management.
|
||||
|
||||
> 🇪🇸 **Leer en Español:** [README.md](README.md)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 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).*
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
# 🔁 ComfyUI Sequential Batcher & Video Loop Master (Beta v0.9.3)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Esta versión se encuentra actualmente en fase **BETA**. Hemos completado la transición de la terminología de "Job" a **Batch** (Lote) para alinearnos con los estándares de ComfyUI y el nombre del proyecto.
|
||||
|
||||
La herramienta definitiva para crear flujos de trabajo iterativos complejos y procesamiento de vídeo fotograma a fotograma en ComfyUI. Diseñada para manejar tareas pesadas (como generación de vídeo de alta resolución con Wan2.2 o LTX Video) sin colapsar tu GPU, utilizando bucles secuenciales inteligentes en lugar de procesamiento por lotes masivo que agota la VRAM.
|
||||
|
||||
## 🚀 ¿Por qué usar esto?
|
||||
|
||||
El procesamiento por lotes (batch) estándar de ComfyUI procesa todo a la vez (tensores 4D). Para vídeo o lotes grandes, esto provoca errores de **Falta de Memoria (OOM)**.
|
||||
**Sequential Batcher** te permite "dividir" estas tareas y procesarlas **una a una** (secuencialmente) dentro de una sola ejecución de "Queue Prompt", para luego "reunir" los resultados en un solo lote o archivo de vídeo.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Instalación
|
||||
|
||||
1. Clona este repositorio en `custom_nodes/comfyui-sequential-batcher`.
|
||||
2. Reinicia ComfyUI.
|
||||
3. Las dependencias (`torch`, `numpy`) se gestionarán automáticamente si usas ComfyUI Manager gracias al archivo `requirements.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Conceptos Clave
|
||||
|
||||
- **SEQUENCE (Secuencia)**: Una lista simple de valores (números, textos, etc.).
|
||||
- **BATCH (Lote)**: Una colección estructurada de "pasos". Cada paso tiene **Atributos** con nombre.
|
||||
- **Iteración**: La magia ocurre en nodos como `Batch To List`, `Image Batch To List` o `Latent Batch To List`. Cuando ComfyUI detecta una salida tipo "Lista" de estos nodos, ejecuta todos los nodos conectados a continuación una vez por cada elemento de la lista.
|
||||
|
||||
---
|
||||
|
||||
## 🎞️ Flujo de Vídeo (Wan2.2 / LTX-Video / Modelos Futuros)
|
||||
|
||||
Los modelos de vídeo generan muchos fotogramas que pueden superar fácilmente los 24GB de VRAM.
|
||||
1. **Split (Dividir)**: Usa `Latent Batch To List` para convertir tu latente de vídeo en una lista de fotogramas individuales.
|
||||
2. **Process (Procesar)**: Conecta a tu KSampler o VAE Decoder. ComfyUI procesará el Fotograma 1, luego el 2, luego el 3... ahorrando memoria.
|
||||
3. **Gather (Reunir)**: Usa `Latent List To Batch` (o `Image List To Batch` si decodificaste primero) para reconstruir el lote completo de vídeo para guardarlo.
|
||||
4. **Stitch (Unir con VHS)**: Para unir chunks pesados de vídeo secuencialmente:
|
||||
- *Opción A (Matemática)*: `Range` -> `MakeBatch` (atributo: "skip_frames") -> `BatchToList` -> `GetAttributeInt` (nombre: "skip_frames") conectado a `VHS_LoadVideo`.
|
||||
- *Opción B (Por CSV)*: `LoadCSV` (con una columna `skip_frames`) -> `BatchToList` -> `GetAttributeInt`.
|
||||
- Conecta la salida de `VHS_VideoCombine` (específicamente la salida `VHS_FILENAMES`) al nodo **FFmpeg Video Stitcher** (`video_paths`). ¡El Stitcher esperará a que terminen todos los chunks y los unirá automáticamente!
|
||||
|
||||
> [!WARNING]
|
||||
> **Prevención OOM (Aviso de Memoria):** Aunque `BatchToList` soluciona el paso de datos, a nivel de VRAM de PyTorch, se debe seguir gestionando la memoria. Es altamente recomendable usar el nodo **easy cleanGpuUsed** (o similar) después del decodificador (VAE) en cada ciclo del Batch. Sin la liberación manual de VRAM en cada iteración, el bucle secuencial acumulará basura en la tarjeta gráfica y eventualmente provocará un error de "Out of Memory".
|
||||
|
||||
---
|
||||
|
||||
## 🔢 Referencia Detallada de Nodos
|
||||
|
||||
### 🔄 Categoría Bucles (`🔁 Sequential Batcher/Loop`)
|
||||
- **🔁 Sequential Loop Index**: La forma más sencilla de iniciar un bucle.
|
||||
- *Entrada*: `count` (Cuántas veces ejecutar).
|
||||
- *Salida*: `index` (0, 1, 2...). Útil para semillas (seeds) o selección de elementos.
|
||||
- **🔁 Repeat**: Toma cualquier entrada y la repite N veces.
|
||||
- *Entrada*: `input` (Cualquiera), `count` (INT).
|
||||
- *Salida*: `output` (Lista de la misma entrada repetida).
|
||||
|
||||
### 🛠️ Categoría Lote (`🔁 Sequential Batcher/Batch`)
|
||||
- **📂 Load CSV**: Carga un archivo CSV como un Lote (Batch). Ahora incluye una vista previa de la tabla en el flujo de trabajo.
|
||||
- *Entrada*: `path` (Ubicación del archivo), `delimiter`, `quotechar`.
|
||||
- *Entrada Opcional*: `index` (Para elegir una fila específica).
|
||||
- *Salida*: `batch` (La lista completa), `current_attributes` (Diccionario de la fila seleccionada), `count` (Total de filas).
|
||||
- **📊 Preview Batch**: Muestra una tabla del contenido del lote en el flujo de trabajo.
|
||||
- *Entrada*: `batch`, `index` (Resaltar fila específica), `max_rows`.
|
||||
- **🛠️ Make Batch**: Convierte una secuencia en un objeto "Batch".
|
||||
- *Entrada*: `sequence` (Los datos), `name` (El nombre del atributo, ej: "cfg_scale").
|
||||
- **🖇️ Combine Batches**: Fusiona varios lotes.
|
||||
- *Modos*: `zip` (por parejas) o `product` (todas las combinaciones posibles).
|
||||
- **🔄 Batch To List**: **CRÍTICO**. Convierte un Lote en un flujo de atributos que activa el bucle secuencial.
|
||||
- **📥 Get Attribute**: Extrae un valor específico del paso actual del lote por su nombre.
|
||||
|
||||
### 🖼️ Categoría Imagen y Latente (`🔁 Sequential Batcher/Image` & `/Latent`)
|
||||
- **🖼️ Image Batch To List**: Divide un tensor [N,H,W,C] en N imágenes separadas.
|
||||
- **🖼️ Image List To Batch**: Reconstruye un lote a partir de imágenes iteradas.
|
||||
- **🎞️ Latent Batch To List**: Divide latentes de vídeo fotograma a fotograma para un procesamiento seguro en VRAM.
|
||||
- **🎞️ Latent List To Batch**: Une fotogramas individuales de nuevo en un lote latente de vídeo.
|
||||
- **⏳ Progress Bar**: Genera un indicador visual de progreso.
|
||||
|
||||
### 🎞️ Categoría Vídeo (`🔁 Sequential Batcher/Video`)
|
||||
- **🎞️ FFmpeg Video Stitcher**: Nodo final en un bucle de vídeo. Espera a que termine todo el lote secuencial y une los fragmentos de vídeo utilizando FFmpeg sin recodificar.
|
||||
- *Entrada*: `video_paths` (Lista de VHS_FILENAMES), `output_filename` (Cadena de texto).
|
||||
- *Salida*: `final_video_path` (Ruta del vídeo ensamblado).
|
||||
|
||||
---
|
||||
|
||||
## 💡 Consejos Pro y Casos de Uso
|
||||
|
||||
### 📝 Uso de CSV para Prompts y Escenas
|
||||
Puedes crear un CSV con columnas como `prompt`, `negative_prompt` y `seed`.
|
||||
1. Usa **📂 Load CSV** para cargar tu archivo.
|
||||
2. Conecta `batch` a **🔄 Batch To List**.
|
||||
3. Usa **📥 Get Attribute** para pasar el `prompt` a tu CLIP Text Encode.
|
||||
4. Cada fila de tu CSV se procesará como un "fotograma" o "trabajo" en la secuencia.
|
||||
|
||||
### 🎬 Tiempos de Escena para Vídeo
|
||||
Si tienes un CSV con `frame_start` y `prompt`, puedes usarlo para cambiar los prompts en puntos específicos de un bucle de generación de vídeo.
|
||||
|
||||
### 🧪 XY Plots
|
||||
Usa **🖇️ Combine Batches** en modo `product` para crear "XY Plots" (ej: probar cada Prompt contra cada valor de CFG).
|
||||
|
||||
### 🔍 Iteración Automática de Modelos
|
||||
Usa **🔍 Model Finder** para iterar automáticamente a través de una carpeta de LoRAs o Checkpoints.
|
||||
+43
-63
@@ -1,75 +1,55 @@
|
||||
__version__ = "0.9.3-beta"
|
||||
__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
|
||||
|
||||
# Importar tools
|
||||
from . import image, loop, video, tools, vram_node
|
||||
from .switch_node import MasterSwitch, LazySessionCache
|
||||
|
||||
from . import sequence, paths, batch, image, debug, loop, video
|
||||
|
||||
# Display name mappings with emojis
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
# Sequence
|
||||
"Range": "🔢 Range",
|
||||
"Literal": "📝 Literal",
|
||||
"Reorder": "🔄 Reorder",
|
||||
"Combinations": "🧬 Combinations",
|
||||
"Permutations": "🔀 Permutations",
|
||||
"Slice": "✂️ Slice",
|
||||
"Join": "🔗 Join",
|
||||
"ModelFinder": "🔍 Model Finder",
|
||||
|
||||
# Batch
|
||||
"MakeBatch": "🛠️ Make Batch",
|
||||
"CombineBatches": "🖇️ Combine Batches",
|
||||
"EnumerateBatch": "🔢 Enumerate Batch",
|
||||
"GetBatchStep": "📍 Get Batch Step",
|
||||
"FormatAttributes": "📝 Format Attributes",
|
||||
"GetAttribute": "📥 Get Attribute",
|
||||
"GetAttributeInt": "📥 Get Attribute (Int)",
|
||||
"GetAttributeFloat": "📥 Get Attribute (Float)",
|
||||
"GetAttributeString": "📥 Get Attribute (String)",
|
||||
"BatchToList": "🔄 Batch To List",
|
||||
"LoadCSV": "📂 Load CSV",
|
||||
"PreviewBatch": "📊 Preview Batch",
|
||||
|
||||
# Image
|
||||
"JoinImageBatch": "🖼️ Join Image Batch",
|
||||
"JoinImages": "🖼️ Join Images",
|
||||
"SelectImageBatch": "🎯 Select Image Batch",
|
||||
"SelectImageList": "🎯 Select Image List",
|
||||
"GetImageSize": "📏 Get Image Size",
|
||||
"StringToImage": "🔤 String To Image",
|
||||
"ProgressBar": "⏳ Progress Bar",
|
||||
"ImageBatchToList": "🖼️ Image Batch To List",
|
||||
"ImageListToBatch": "🖼️ Image List To Batch",
|
||||
"LatentBatchToList": "🎞️ Latent Batch To List",
|
||||
"LatentListToBatch": "🎞️ Latent List To Batch",
|
||||
|
||||
# Video
|
||||
"FFmpegVideoStitcher": "🎞️ FFmpeg Video Stitcher",
|
||||
|
||||
# Debug
|
||||
"Stringify": "🧵 Stringify",
|
||||
"Interact": "⌨️ Interact",
|
||||
|
||||
# Loop
|
||||
"LoopIndex": "🔁 Sequential Loop Index",
|
||||
"Repeat": "🔁 Repeat",
|
||||
})
|
||||
|
||||
# Backward compatibility for "Job" naming
|
||||
NODE_CLASS_MAPPINGS.update({
|
||||
"MakeJob": batch.MakeBatch,
|
||||
"CombineJobs": batch.CombineBatches,
|
||||
"EnumerateJob": batch.EnumerateBatch,
|
||||
"GetJobStep": batch.GetBatchStep,
|
||||
"JobToList": batch.BatchToList,
|
||||
"MasterSwitch": MasterSwitch,
|
||||
"LazySessionCache": LazySessionCache,
|
||||
})
|
||||
|
||||
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']
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import collections
|
||||
import itertools
|
||||
import csv
|
||||
import os
|
||||
import folder_paths
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
def format_batch_as_table(batch, current_index=-1, max_rows=20):
|
||||
if not batch:
|
||||
return "(Empty Batch)"
|
||||
|
||||
# Get all unique keys across all rows
|
||||
keys = list(dict.fromkeys(itertools.chain.from_iterable(row.keys() for row in batch)))
|
||||
|
||||
if not keys:
|
||||
return "(Empty rows)"
|
||||
|
||||
# Truncate values for display
|
||||
def trunc(v, l=20):
|
||||
s = str(v).replace('\n', ' ')
|
||||
return (s[:l-3] + "...") if len(s) > l else s
|
||||
|
||||
# Calculate widths
|
||||
widths = {k: len(str(k)) for k in keys}
|
||||
rows_to_show = batch[:max_rows]
|
||||
for row in rows_to_show:
|
||||
for k in keys:
|
||||
widths[k] = max(widths[k], len(trunc(row.get(k, ""))))
|
||||
|
||||
# Build header
|
||||
header = "| " + " | ".join(str(k).ljust(widths[k]) for k in keys) + " |"
|
||||
sep = "|-" + "-|-".join("-" * widths[k] for k in keys) + "-|"
|
||||
|
||||
idx_to_highlight = (current_index % len(batch)) if (len(batch) > 0 and current_index != -1) else (0 if len(batch) > 0 else -1)
|
||||
|
||||
lines = [header, sep]
|
||||
for i, row in enumerate(rows_to_show):
|
||||
prefix = ">" if i == idx_to_highlight else " "
|
||||
line = f"{prefix}| " + " | ".join(trunc(row.get(k, "")).ljust(widths[k]) for k in keys) + " |"
|
||||
lines.append(line)
|
||||
|
||||
if len(batch) > max_rows:
|
||||
lines.append(f"... and {len(batch) - max_rows} more rows")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@register_node
|
||||
class MakeBatch:
|
||||
"""Turns a sequence into a batch with one attribute."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"name": ("STRING", {"default": ''}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BATCH", "INT")
|
||||
RETURN_NAMES = ("batch", "count")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def merge_dicts(self, *dicts):
|
||||
#return collections.ChainMap(*reversed(dicts))
|
||||
return dict(itertools.chain.from_iterable(d.items() for d in dicts))
|
||||
|
||||
def go(self, sequence, name):
|
||||
result = [{name: value} for value in sequence]
|
||||
return (result, len(result))
|
||||
|
||||
|
||||
@register_node
|
||||
class CombineBatches(MakeBatch):
|
||||
"""Combines multiple batches."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"a": ("BATCH", ),
|
||||
"method": (("zip", "product"), {"default": "zip"}),
|
||||
},
|
||||
"optional": {
|
||||
x: ("BATCH", ) for x in ('b', 'c', 'd', 'e')
|
||||
}
|
||||
}
|
||||
|
||||
def go(self, method, **kwargs):
|
||||
method = {'product': itertools.product, 'zip': zip}[method]
|
||||
result = [self.merge_dicts(*steps) for steps in method(*kwargs.values())]
|
||||
return (result, len(result))
|
||||
|
||||
|
||||
@register_node
|
||||
class EnumerateBatch(MakeBatch):
|
||||
"""Combines multiple batches."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH", ),
|
||||
"name": ("STRING", {"default": ''}),
|
||||
},
|
||||
}
|
||||
|
||||
def go(self, batch, name):
|
||||
result = [self.merge_dicts(step, {name: n}) for n, step in enumerate(batch)]
|
||||
return (result, len(result))
|
||||
|
||||
|
||||
@register_node
|
||||
class GetBatchStep:
|
||||
"""Gets the batch step by number."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH", ),
|
||||
"step": ("INT", {"default": 0}),
|
||||
"wrap": (("repeat", "clamp"), {"default": "repeat"}),
|
||||
},
|
||||
}
|
||||
RETURN_TYPES = ("ATTRIBUTES", )
|
||||
RETURN_NAMES = ("attributes", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, batch, step, wrap):
|
||||
if wrap == 'repeat':
|
||||
while step < 0:
|
||||
step += len(batch)
|
||||
step = step % len(batch)
|
||||
elif wrap == 'clamp':
|
||||
step = max(min(step, len(batch)-1), 0)
|
||||
return (batch[step], )
|
||||
|
||||
|
||||
@register_node
|
||||
class FormatAttributes:
|
||||
"""Applies attributes to a format string."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"attributes": ("ATTRIBUTES",),
|
||||
"format": ("STRING", {'default': '', 'multiline': True, "dynamicPrompts": False})
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", )
|
||||
RETURN_NAMES = ("string", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, attributes, format):
|
||||
return (format.format(**attributes), )
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@register_node
|
||||
class GetAttribute:
|
||||
"""Gets a named attribute from a step."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"attributes": ("ATTRIBUTES", ),
|
||||
"name": ("STRING", {"default": ''}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (AnyType("*"), )
|
||||
RETURN_NAMES = ("value", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, attributes, name):
|
||||
return (attributes[name], )
|
||||
|
||||
|
||||
# Dynamically register typed attribute getters to avoid wildcard bug
|
||||
# https://github.com/comfyanonymous/ComfyUI/pull/770
|
||||
for t in ('INT', 'FLOAT', 'STRING'):
|
||||
register_node(type(
|
||||
'GetAttribute'+t.title(),
|
||||
(GetAttribute, ),
|
||||
{
|
||||
'RETURN_TYPES': (t, ),
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
@register_node
|
||||
class LoadCSV:
|
||||
"""Loads a CSV file into a batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"path": ("STRING", {"default": "prompt.csv"}),
|
||||
"delimiter": (["comma", "tab", "semicolon"], {"default": "comma"}),
|
||||
"quotechar": (["\"", "'"], {"default": "\""}),
|
||||
},
|
||||
"optional": {
|
||||
"index": ("INT", {"default": -1, "min": -1, "max": 999999}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BATCH", "ATTRIBUTES", "INT")
|
||||
RETURN_NAMES = ("batch", "current_attributes", "count")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, path, delimiter, quotechar, index=-1):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
if input_dir is None:
|
||||
raise ValueError("Input directory not found in ComfyUI configuration.")
|
||||
|
||||
if os.path.isabs(path):
|
||||
full_path = os.path.normpath(path)
|
||||
else:
|
||||
full_path = os.path.normpath(os.path.join(input_dir, path))
|
||||
|
||||
if os.path.commonpath([input_dir, full_path]) != os.path.normpath(input_dir):
|
||||
raise ValueError(f"Access denied: Path '{path}' is outside the input directory.")
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise FileNotFoundError(f"CSV file not found: {path}")
|
||||
|
||||
delim = {
|
||||
"comma": ",",
|
||||
"tab": "\t",
|
||||
"semicolon": ";",
|
||||
}[delimiter]
|
||||
|
||||
batch = []
|
||||
with open(full_path, mode='r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f, delimiter=delim, quotechar=quotechar)
|
||||
for row in reader:
|
||||
# Clean up keys and values
|
||||
clean_row = {k.strip() if k else k: v.strip() if v else v for k, v in row.items()}
|
||||
batch.append(clean_row)
|
||||
|
||||
count = len(batch)
|
||||
current_attributes = batch[index % count] if count > 0 and index != -1 else (batch[0] if count > 0 else {})
|
||||
|
||||
table = format_batch_as_table(batch, index)
|
||||
|
||||
return {"ui": {"text": [table]}, "result": (batch, current_attributes, count)}
|
||||
|
||||
|
||||
@register_node
|
||||
class PreviewBatch:
|
||||
"""Shows a table of the batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH",),
|
||||
},
|
||||
"optional": {
|
||||
"index": ("INT", {"default": -1, "min": -1, "max": 999999}),
|
||||
"max_rows": ("INT", {"default": 20, "min": 1, "max": 1000}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BATCH",)
|
||||
RETURN_NAMES = ("batch",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, batch, index=-1, max_rows=20):
|
||||
table = format_batch_as_table(batch, index, max_rows)
|
||||
return {"ui": {"text": [table]}, "result": (batch,)}
|
||||
|
||||
|
||||
@register_node
|
||||
class BatchToList:
|
||||
"""Converts a batch into a list."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"batch": ("BATCH",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("ATTRIBUTES",)
|
||||
RETURN_NAMES = ("attributes",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Batch"
|
||||
|
||||
def go(self, batch):
|
||||
return (batch,)
|
||||
@@ -1,106 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import textwrap
|
||||
import code
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
@register_node
|
||||
class Stringify:
|
||||
"""Convert any input to str/repr."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"x": ("*", ),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("str", "repr")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Debug"
|
||||
|
||||
def go(self, x):
|
||||
return (str(x), repr(x))
|
||||
|
||||
|
||||
class RestoreStdStreams(object):
|
||||
# ComfyUI-Manager patches sys.stdout and sys.stder
|
||||
# which breaks GNU Readline support and makes the
|
||||
# REPL annoying to use. This context manager temporarily
|
||||
# puts back the originals.
|
||||
|
||||
def __enter__(self):
|
||||
self._stdout, self._stderr = sys.stdout, sys.stderr
|
||||
sys.stdout, sys.stderr = sys.__stdout__, sys.__stderr__
|
||||
self._stdout.flush(); self._stderr.flush()
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
_stdout, _stderr = sys.stdout, sys.stderr
|
||||
sys.stdout, sys.stderr = self._stdout, self._stderr
|
||||
_stdout.flush(); _stderr.flush()
|
||||
|
||||
|
||||
EOF = 'Ctrl-Z plus Return' if os.sep == '\\' else 'Ctrl-D (i.e. EOF)'
|
||||
MESSAGE = f"Press {EOF} to continue processing."
|
||||
|
||||
|
||||
class Quitter:
|
||||
# Replace exit() and quit() to make sure the user exits with EOF
|
||||
# otherwise the whole server gets terminated.
|
||||
|
||||
def __repr__(self):
|
||||
return MESSAGE
|
||||
|
||||
def __call__(self):
|
||||
print(MESSAGE)
|
||||
|
||||
|
||||
@register_node
|
||||
class Interact:
|
||||
"""
|
||||
Opens an interactive REPL whenever the node is evaluated.
|
||||
|
||||
SECURITY WARNING: This node allows arbitrary code execution.
|
||||
To enable it, you must set the environment variable:
|
||||
COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG=1
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {x: "*" for x in ('a', 'b', 'c', 'd')},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "interact"
|
||||
CATEGORY = "🔁 Sequential Batcher/Debug"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def interact(self, **kwargs):
|
||||
if os.environ.get("COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG", "").lower() not in ("true", "1", "yes"):
|
||||
print(f"[{self.__class__.__name__}] Interactive debugging is disabled. Set COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG=1 to enable.")
|
||||
return ()
|
||||
|
||||
if sys.__stdout__.isatty():
|
||||
with RestoreStdStreams():
|
||||
code.interact(
|
||||
banner=textwrap.dedent(f"""
|
||||
Interactive debugging started.
|
||||
Try `print(a)`.
|
||||
{MESSAGE}
|
||||
"""),
|
||||
exitmsg="Resuming workflow...",
|
||||
local={
|
||||
**kwargs,
|
||||
'quit': Quitter(),
|
||||
'exit': Quitter()
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Don't block the server if there is no tty.
|
||||
print("Skipping interactive prompt because there is no tty.")
|
||||
return ()
|
||||
@@ -1,257 +1,221 @@
|
||||
import torch
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
import folder_paths
|
||||
from . import register_node
|
||||
|
||||
global_session_image = None
|
||||
|
||||
def tensor_to_temp_image(tensor_image, prefix="session_img"):
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
filename = f"{prefix}_{random.randint(10000, 99999)}.png"
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
img_array = 255. * tensor_image[0].cpu().numpy()
|
||||
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
||||
img.save(filepath)
|
||||
return {"filename": filename, "subfolder": "", "type": "temp"}
|
||||
|
||||
@register_node
|
||||
class JoinImageBatch:
|
||||
"""Turns an image batch into one big image."""
|
||||
class SessionImageReceiver:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"mode": (("horizontal", "vertical"), {"default": "horizontal"}),
|
||||
"initial_image": ("IMAGE",),
|
||||
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "join"
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("current_image", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "get_image"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def join(self, images, mode):
|
||||
# mode will be a list of strings, e.g. ["horizontal", "horizontal", ...]
|
||||
mode = mode[0]
|
||||
if isinstance(images, list):
|
||||
# images is a list of [1, H, W, C] tensors or similar
|
||||
images = torch.cat(images, dim=0)
|
||||
n, h, w, c = images.shape
|
||||
image = None
|
||||
if mode == "vertical":
|
||||
# for vertical we can just reshape
|
||||
image = images.reshape(1, n * h, w, c)
|
||||
elif mode == "horizontal":
|
||||
# for horizontal we have to swap axes
|
||||
image = torch.transpose(torch.transpose(images, 1, 2).reshape(1, n * w, h, c), 1, 2)
|
||||
return (image,)
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
def get_image(self, initial_image, current_loop_index):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
from . import loop
|
||||
global global_session_image
|
||||
|
||||
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
is_first = (loop_idx == 0)
|
||||
|
||||
# Usamos getattr para evitar errores si el bucle aún no ha inicializado las variables
|
||||
accumulated = getattr(loop, 'global_accumulated_frames', 0)
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📥 [Secuencial Batcher] NODO: Image Receiver")
|
||||
_log(f" -> Ciclo actual: {loop_idx} | Progreso global: {accumulated} / {source_total} frames")
|
||||
|
||||
if is_first or global_session_image is None:
|
||||
global_session_image = initial_image.clone().cpu()
|
||||
_log(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
|
||||
selected = initial_image
|
||||
else:
|
||||
_log(f" -> ♻️ Usando el Keyframe validado y rescatado de la RAM.")
|
||||
selected = global_session_image
|
||||
|
||||
_log(f" -> 🖼️ Tensor shape: {selected.shape}")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
ui_image = tensor_to_temp_image(selected, "receiver")
|
||||
return {"ui": {"images": [ui_image]}, "result": (selected, "\n".join(log_output))}
|
||||
|
||||
@register_node
|
||||
class JoinImages:
|
||||
"""Turns joins two images into one big image."""
|
||||
class SessionImageSender:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image_a": ("IMAGE",),
|
||||
"image_b": ("IMAGE",),
|
||||
"mode": (("horizontal", "vertical"), {"default": "horizontal"}),
|
||||
"generated_images": ("IMAGE",),
|
||||
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"detect_faces": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "join"
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("VALIDATED_IMAGES", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "set_image"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
|
||||
def join(self, image_a, image_b, mode):
|
||||
dim = {'horizontal': 2, 'vertical': 1}[mode]
|
||||
return (torch.concat((image_a, image_b), dim), )
|
||||
|
||||
|
||||
@register_node
|
||||
class SelectImageBatch:
|
||||
"""Selects one image from an image batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"select": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1}),
|
||||
},
|
||||
}
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
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
|
||||
|
||||
def select(self, images, select):
|
||||
n, h, w, c = images.shape
|
||||
if select >= n:
|
||||
select = n - 1
|
||||
return (images[select].reshape(1, h, w, c),)
|
||||
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
|
||||
|
||||
@register_node
|
||||
class SelectImageList:
|
||||
"""Selects one image from an image list."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"select": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1}),
|
||||
},
|
||||
}
|
||||
accumulated = getattr(loop, 'global_accumulated_frames', 0)
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
stride = getattr(loop, 'global_select_every_nth', 1)
|
||||
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"📤 [Secuencial Batcher] NODO: Image Sender (Filtro Dinámico)")
|
||||
_log(f" -> Frames recibidos de la IA: {batch_size}")
|
||||
|
||||
def select(self, images, select):
|
||||
select = select[0]
|
||||
n = len(images)
|
||||
if select >= n:
|
||||
select = n - 1
|
||||
return (images[select],)
|
||||
# 🧠 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)
|
||||
|
||||
@register_node
|
||||
class GetImageSize:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
_log(f" -> 🕵️ Buscando el último rostro frontal en reversa (Desde frame {batch_size - 1} hasta 0)...")
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "INT")
|
||||
RETURN_NAMES = ("width", "height", "batch_size")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
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))
|
||||
|
||||
def go(self, images):
|
||||
return (images.shape[2], images.shape[1], images.shape[0])
|
||||
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
|
||||
|
||||
@register_node
|
||||
class StringToImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "Hello world!"}),
|
||||
"width": ("INT", {"default": 384}),
|
||||
"height": ("INT", {"default": 16}),
|
||||
"colour": ("COLOR", {"default": "white"}),
|
||||
"background": ("COLOR", {"default": "black"}),
|
||||
}
|
||||
}
|
||||
# 1. TRUNCAR EL TENSOR
|
||||
valid_images = generated_images[:best_idx + 1]
|
||||
frames_accepted = best_idx + 1
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "render"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
# 2. AVANZAR LA MÁQUINA
|
||||
stride = getattr(loop, 'global_select_every_nth', 1)
|
||||
advanced_original_frames = frames_accepted * stride
|
||||
|
||||
def render(self, text, width, height, colour, background):
|
||||
font = ImageFont.load_default()
|
||||
img = Image.new("RGB", (width, height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
_, _, w, h = draw.textbbox((0, 0), text, font=font)
|
||||
draw.text(((width - w) / 2, ((height - h) / 2) - 1), text, font=font, fill=colour)
|
||||
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (tensor,)
|
||||
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)
|
||||
|
||||
@register_node
|
||||
class ProgressBar:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"progress": ("FLOAT", {'default': 0, 'forceInput': True}),
|
||||
"padding": ("INT", {'default': 3}),
|
||||
"width": ("INT", {"default": 384}),
|
||||
"height": ("INT", {"default": 16}),
|
||||
"colour": ("COLOR", {"default": "white"}),
|
||||
"background": ("COLOR", {"default": "black"}),
|
||||
}
|
||||
}
|
||||
source_total = getattr(loop, 'global_source_frame_count', 1)
|
||||
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "render"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
|
||||
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
|
||||
|
||||
def render(self, progress, padding, width, height, colour, background):
|
||||
img = Image.new("RGB", (width, height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle((padding, padding, width - padding - 1, height - padding - 1), outline=colour)
|
||||
if progress > 0:
|
||||
ip = padding + 2
|
||||
draw.rectangle((ip, ip, max(ip+1, (width - ip - 1) * progress), height - ip - 1), outline=colour, fill=colour)
|
||||
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (tensor,)
|
||||
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")
|
||||
|
||||
@register_node
|
||||
class ImageBatchToList:
|
||||
"""Splits an image batch into a list for iteration."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"images": ("IMAGE",)}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
_log(f" -> ✂️ Tensor truncado a {frames_accepted} frames válidos.")
|
||||
|
||||
def go(self, images):
|
||||
return (list(torch.split(images, 1, dim=0)),)
|
||||
last_frame = valid_images[-1:].clone().cpu()
|
||||
global_session_image = last_frame
|
||||
|
||||
# 🛡️ Solo guardamos el keyframe si habrá un próximo ciclo
|
||||
if not is_final_cycle:
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
filename = f"keyframe_{loop_idx:03d}.png"
|
||||
filepath = os.path.join(out_dir, filename)
|
||||
|
||||
@register_node
|
||||
class ImageListToBatch:
|
||||
"""Gathers an iterated list of images into a single batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"images": ("IMAGE",)}}
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Image"
|
||||
img_array = 255. * last_frame[0].numpy()
|
||||
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
||||
img.save(filepath)
|
||||
|
||||
def go(self, images):
|
||||
return (torch.cat(images, dim=0),)
|
||||
_log(f" -> 💾 Keyframe seguro guardado: {filename}")
|
||||
else:
|
||||
_log(f" -> 🚀 Ciclo final/único detectado. Omitiendo guardado de keyframe.")
|
||||
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
@register_node
|
||||
class LatentBatchToList:
|
||||
"""Splits a latent batch into a list for iteration."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"latents": ("LATENT",)}}
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("latent",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Latent"
|
||||
|
||||
def go(self, latents):
|
||||
samples = latents["samples"]
|
||||
return ([{"samples": samples[i:i+1]} for i in range(samples.shape[0])],)
|
||||
|
||||
|
||||
@register_node
|
||||
class LatentListToBatch:
|
||||
"""Gathers an iterated list of latents into a single batch."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"latents": ("LATENT",)}}
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("latents",)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Latent"
|
||||
|
||||
def go(self, latents):
|
||||
return ({"samples": torch.cat([l["samples"] for l in latents], dim=0)},)
|
||||
ui_image = tensor_to_temp_image(last_frame, "sender")
|
||||
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.")
|
||||
@@ -1,38 +1,813 @@
|
||||
import random
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
import gc
|
||||
import torch
|
||||
import comfy.model_management as mm
|
||||
from . import register_node
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
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 LoopIndex:
|
||||
"""Iterates a number of times."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"count": ("INT", {"default": 2, "min": 1, "max": 1000000})}}
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("index",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Loop"
|
||||
def go(self, count):
|
||||
return (list(range(count)),)
|
||||
|
||||
@register_node
|
||||
class Repeat:
|
||||
"""Repeats an input N times."""
|
||||
class SequentialLoopStart:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"input": (AnyType("*"),),
|
||||
"count": ("INT", {"default": 2, "min": 1, "max": 1000000}),
|
||||
"reset_loop": ("BOOLEAN", {"default": False}),
|
||||
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"port": ("INT", {"default": 8188, "min": 1, "max": 65535}), # 💡 FAIL-FAST: Pedimos el puerto aquí
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = (AnyType("*"),)
|
||||
RETURN_NAMES = ("output",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "go"
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("current_loop_index",)
|
||||
FUNCTION = "get_index"
|
||||
CATEGORY = "🔁 Sequential Batcher/Loop"
|
||||
def go(self, input, count):
|
||||
return ([input] * count,)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
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 (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 or loop_idx == 0:
|
||||
global_loop_index = 0
|
||||
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" -> 📍 Índice actual de bucle: {global_loop_index}")
|
||||
print(f"{'='*50}\n")
|
||||
return (global_loop_index,)
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculatorTTSBatch:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text_list": ("STRING", {"forceInput": True}),
|
||||
"file_names": ("STRING", {"forceInput": True}),
|
||||
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
|
||||
"current_loop_index": ("INT", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
# 💡 FIX: Mantenemos INPUT_IS_LIST para absorber el batch, pero ELIMINAMOS OUTPUT_IS_LIST
|
||||
# para que ComfyUI desempaquete las salidas automáticamente.
|
||||
INPUT_IS_LIST = True
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "current_file_name", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Text"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
def calculate(self, text_list, file_names, split_mode, current_loop_index):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg); log_output.append(str(msg))
|
||||
|
||||
from . import loop
|
||||
import re
|
||||
|
||||
loop.global_step_by_chunk = True
|
||||
|
||||
texts = text_list[0] if (isinstance(text_list, list) and len(text_list) > 0 and isinstance(text_list[0], list)) else (text_list if isinstance(text_list, list) else [text_list])
|
||||
names = file_names[0] if (isinstance(file_names, list) and len(file_names) > 0 and isinstance(file_names[0], list)) else (file_names if isinstance(file_names, list) else [file_names])
|
||||
|
||||
mode = split_mode[0] if isinstance(split_mode, list) else split_mode
|
||||
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
|
||||
|
||||
# El batch index es extraído directamente de idx para evitar bloqueos
|
||||
current_batch_idx = idx
|
||||
|
||||
if current_batch_idx >= len(texts):
|
||||
current_batch_idx = len(texts) - 1
|
||||
|
||||
current_file_text = texts[current_batch_idx]
|
||||
current_name = names[current_batch_idx] if current_batch_idx < len(names) else "unknown"
|
||||
|
||||
if mode == "Frases (Puntos)":
|
||||
matches = re.findall(r'[^.!?\n]+[.!?\n]*', current_file_text)
|
||||
temp_chunks = [m.strip() for m in matches if m.strip()]
|
||||
MIN_WORDS = 5
|
||||
raw_chunks = []
|
||||
buffer_text = ""
|
||||
for chunk in temp_chunks:
|
||||
buffer_text = (buffer_text + " " + chunk).strip()
|
||||
if len(buffer_text.split()) >= MIN_WORDS:
|
||||
raw_chunks.append(buffer_text)
|
||||
buffer_text = ""
|
||||
if buffer_text:
|
||||
if raw_chunks: raw_chunks[-1] += " " + buffer_text
|
||||
else: raw_chunks.append(buffer_text)
|
||||
chunk_type_name = "Frases Optimizadas"
|
||||
|
||||
raw_chunks = [p.strip() for p in current_file_text.split('\n') if p.strip()]
|
||||
chunk_type_name = "Párrafos"
|
||||
|
||||
total_chunks = len(raw_chunks)
|
||||
if total_chunks == 0:
|
||||
raw_chunks = [""]
|
||||
total_chunks = 1
|
||||
|
||||
safe_index = min(idx, total_chunks - 1)
|
||||
current_chunk_text = raw_chunks[safe_index]
|
||||
|
||||
loop.global_source_frame_count = total_chunks
|
||||
loop.global_accumulated_frames = safe_index + 1
|
||||
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
|
||||
loop.global_has_more_batches = (current_batch_idx < len(texts) - 1)
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS Batch)")
|
||||
_log(f" -> Archivo {current_batch_idx + 1} de {len(texts)}: {current_name}")
|
||||
_log(f" -> Modo de división: {mode}")
|
||||
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
|
||||
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
|
||||
_log(f" -> 📜 Texto a procesar: {current_chunk_text[:75]}...")
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return (current_chunk_text, safe_index, total_chunks, current_name, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculatorLTX:
|
||||
@classmethod
|
||||
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 (effective_chunk_frames, skip_frames, select_every_nth)
|
||||
|
||||
@register_node
|
||||
class SequentialLoopTrigger:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"trigger_dependency": ("*", ),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "trigger_next"
|
||||
CATEGORY = "🔁 Sequential Batcher/Loop"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return time.time()
|
||||
|
||||
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" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
|
||||
|
||||
# 💡 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", {})
|
||||
|
||||
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, 0x7fffffff)
|
||||
m_seeds += 1
|
||||
|
||||
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}
|
||||
data = json.dumps(p).encode('utf-8')
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{global_server_port}/prompt", data=data, headers={'Content-Type': 'application/json'})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
print(f" -> ✅ Siguiente ciclo inyectado en la cola.")
|
||||
except Exception as e:
|
||||
print(f" -> ❌ Error HTTP: {e}")
|
||||
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import pathlib
|
||||
import glob
|
||||
from folder_paths import folder_names_and_paths
|
||||
from . import register_node
|
||||
|
||||
|
||||
folder_types = tuple(sorted(folder_names_and_paths.keys()))
|
||||
folder_default = "checkpoint" if "checkpoint" in folder_types else folder_types[0]
|
||||
|
||||
@register_node
|
||||
class ModelFinder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"filenames": ("SEQUENCE", ),
|
||||
"model_type": (folder_types, {"default": folder_default}),
|
||||
"recursive": ("BOOLEAN", {"default": True}),
|
||||
"skip_missing": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", "SEQUENCE")
|
||||
RETURN_NAMES = ("paths", "stems")
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def find_models(self, fn, paths, recursive):
|
||||
for p in paths:
|
||||
for f in (p.rglob(fn) if recursive else p.glob(fn)):
|
||||
yield f.relative_to(p)
|
||||
|
||||
def go(self, filenames, model_type, recursive, skip_missing):
|
||||
paths = [pathlib.Path(folder) for folder in folder_names_and_paths[model_type][0]]
|
||||
|
||||
available_files = []
|
||||
file_index = {}
|
||||
for p in paths:
|
||||
for f in (p.rglob('*') if recursive else p.glob('*')):
|
||||
try:
|
||||
rel = f.relative_to(p)
|
||||
available_files.append(rel)
|
||||
if rel.name not in file_index:
|
||||
file_index[rel.name] = []
|
||||
file_index[rel.name].append(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
result = []
|
||||
for fn in filenames:
|
||||
if '..' in fn:
|
||||
raise Exception(f'".." is not allowed: {fn}.')
|
||||
|
||||
candidates = []
|
||||
if glob.has_magic(fn):
|
||||
for f in available_files:
|
||||
if f.match(fn):
|
||||
candidates.append(f)
|
||||
else:
|
||||
potential = file_index.get(pathlib.Path(fn).name, [])
|
||||
for f in potential:
|
||||
if f.match(fn):
|
||||
candidates.append(f)
|
||||
|
||||
if candidates:
|
||||
result.extend(candidates)
|
||||
elif not skip_missing:
|
||||
raise Exception(f'Could not find file: {fn}')
|
||||
|
||||
return ([str(f) for f in result], [f.stem for f in result])
|
||||
@@ -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
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
import ast
|
||||
import functools
|
||||
import itertools
|
||||
|
||||
from . import register_node
|
||||
|
||||
|
||||
# Sequence creation.
|
||||
|
||||
|
||||
@register_node
|
||||
class Range:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"start": ("INT", {"default": 0, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"stop": ("INT", {"default": 10, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"step": ("INT", {"default": 1, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, start, stop, step):
|
||||
return (range(start, stop, step), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Literal:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"literal": ("STRING", {'default': '', 'multiline': True})
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, literal):
|
||||
return (ast.literal_eval(literal), )
|
||||
|
||||
|
||||
# Processing of existing sequences.
|
||||
|
||||
|
||||
@register_node
|
||||
class Reorder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"method": (("reverse", "sort", "reverse sort"), {"default": "sort"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, method):
|
||||
f = {"reverse": reversed, "sort": sorted, "reverse_sort": lambda x: sorted(x, reverse=True)}[method]
|
||||
return (f(sequence), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Combinations:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"count": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
|
||||
"replacement": ("BOOLEAN", {"default": False, "label_on": "Yes", "label_off": "No"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, count, replacement):
|
||||
if replacement:
|
||||
return (itertools.combinations_with_replacement(sequence, count), )
|
||||
else:
|
||||
return (itertools.combinations(sequence, count), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Permutations:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"count": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, count):
|
||||
return (itertools.permutations(sequence, count), )
|
||||
|
||||
|
||||
@register_node
|
||||
class Slice:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE",),
|
||||
"start": ("INT", {"default": 0, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"stop": ("INT", {"default": 9999999, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
"step": ("INT", {"default": 1, "min": -9999999, "max": 9999999, "step": 1}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, start, stop, step):
|
||||
return (sequence[start:stop:step], )
|
||||
|
||||
|
||||
|
||||
@register_node
|
||||
class Join:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"sequence": ("SEQUENCE", ),
|
||||
"join_str": ("STRING", {'default': ', '}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SEQUENCE", )
|
||||
RETURN_NAMES = ("sequence", )
|
||||
FUNCTION = "go"
|
||||
CATEGORY = "🔁 Sequential Batcher/Sequence"
|
||||
|
||||
def go(self, sequence, join_str):
|
||||
return ([join_str.join(x) for x in sequence], )
|
||||
+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,92 +1,827 @@
|
||||
import os
|
||||
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
|
||||
|
||||
@register_node
|
||||
class FFmpegVideoStitcher:
|
||||
"""Nodo final: Espera a que termine todo el lote secuencial y une los vídeos con FFmpeg."""
|
||||
# Intento de carga de OpenCV para el Director de Fotografía
|
||||
|
||||
# Caché persistente para evitar re-escaneos pesados de vídeo
|
||||
VIDEO_ANALYSIS_CACHE = {}
|
||||
|
||||
# 🎵 Helper para estandarizar audio a 44100Hz y 2 Canales (Estéreo)
|
||||
def extract_and_standardize_audio(video_path, target_sr=44100):
|
||||
import torchaudio.transforms as T
|
||||
try:
|
||||
waveform, sample_rate = torchaudio.load(video_path)
|
||||
|
||||
# 1. Resampling si la frecuencia es distinta
|
||||
if sample_rate != target_sr:
|
||||
resampler = T.Resample(orig_freq=sample_rate, new_freq=target_sr)
|
||||
waveform = resampler(waveform)
|
||||
sample_rate = target_sr
|
||||
|
||||
# 2. Downmix / Upmix a Estéreo (2 canales) estrictamente
|
||||
channels = waveform.shape[0]
|
||||
if channels > 2:
|
||||
# Si tiene más de 2 canales (ej. 5.1), mezclamos a mono y duplicamos a estéreo
|
||||
mono = torch.mean(waveform, dim=0, keepdim=True)
|
||||
waveform = mono.repeat(2, 1)
|
||||
elif channels == 1:
|
||||
# Si es mono, duplicamos la pista para que sea estéreo
|
||||
waveform = waveform.repeat(2, 1)
|
||||
|
||||
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# 🎬 Helper para crear un Clon de Vídeo Físico Estandarizado (Soluciona el desvío VHS)
|
||||
def standardize_video_file(video_path, _log):
|
||||
import folder_paths
|
||||
|
||||
if "_std_audio.mp4" in video_path:
|
||||
return video_path
|
||||
|
||||
filename = os.path.basename(video_path)
|
||||
name, _ = os.path.splitext(filename)
|
||||
out_filename = f"{name}_std_audio.mp4"
|
||||
out_path = os.path.join(folder_paths.get_temp_directory(), out_filename)
|
||||
|
||||
if os.path.exists(out_path):
|
||||
_log(f" -> ♻️ Usando clon de vídeo estandarizado existente en disco: {out_filename}")
|
||||
return out_path
|
||||
|
||||
# Averiguar info original del audio
|
||||
target_sr = None
|
||||
target_ac = None
|
||||
try:
|
||||
info = torchaudio.info(video_path)
|
||||
# Solo aplicamos limitador si se superan los umbrales máximos de ComfyUI
|
||||
if info.sample_rate > 44100:
|
||||
target_sr = "44100"
|
||||
if info.num_channels > 2:
|
||||
target_ac = "2"
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ No se pudo leer metadata de audio con torchaudio. Forzando límites de seguridad. (Error: {e})")
|
||||
target_sr = "44100"
|
||||
target_ac = "2"
|
||||
|
||||
# Si el audio ya es seguro, no gastamos recursos en clonar
|
||||
if target_sr is None and target_ac is None:
|
||||
_log(f" -> ✅ El audio original ya está en parámetros seguros (<=44100Hz, <=2 Canales). Omitiendo FFmpeg.")
|
||||
return video_path
|
||||
|
||||
_log(f" -> ⚙️ Reestructurando contenedor físico con FFmpeg (Vídeo: Copy | Audio: Limitando a parámetros seguros)...")
|
||||
try:
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-i", video_path,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac"
|
||||
]
|
||||
|
||||
# Añadimos los filtros solo si son necesarios
|
||||
if target_sr:
|
||||
cmd.extend(["-ar", target_sr])
|
||||
if target_ac:
|
||||
cmd.extend(["-ac", target_ac])
|
||||
|
||||
cmd.extend(["-map", "0:v?", "-map", "0:a?", out_path])
|
||||
|
||||
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
_log(f" -> ✅ Contenedor clonado y blindado con éxito para los nodos de VHS.")
|
||||
return out_path
|
||||
except Exception as e:
|
||||
raise Exception(f"❌ [Secuencial Batcher] Error crítico: Falló la reestructuración del vídeo. Asegúrate de tener FFmpeg instalado y accesible en el PATH del sistema. Detalles: {e}")
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
HAS_OPENCV = True
|
||||
except ImportError:
|
||||
HAS_OPENCV = False
|
||||
|
||||
@register_node
|
||||
class VideoAnalyzerFaceDetector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# 1. Obtenemos los archivos locales igual que VHS
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov'] # Mismos que VHS
|
||||
if os.path.exists(input_dir):
|
||||
for f in os.listdir(input_dir):
|
||||
if os.path.isfile(os.path.join(input_dir, f)):
|
||||
file_parts = f.split('.')
|
||||
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
|
||||
files.append(f)
|
||||
|
||||
# 2. El truco VHS: Una tupla con (lista_de_archivos,) pero configurado como STRING y con forceInput=False
|
||||
return {
|
||||
"required": {
|
||||
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
|
||||
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
|
||||
"use_face_detector": ("BOOLEAN", {"default": True}),
|
||||
"blur_threshold": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 1000.0, "step": 1.0}),
|
||||
"unload_detector_after_analysis": ("BOOLEAN", {"default": True}),
|
||||
"unload_detector": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"bbox_detector": ("BBOX_DETECTOR", ), # 💡 Puerto para YOLO/ONNX
|
||||
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "FACE_CUTS", "IMAGE", "STRING")
|
||||
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "safe_faces_list", "reference_frame", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "analyze"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, video, reference_frame_idx, use_face_detector, blur_threshold, **kwargs):
|
||||
if isinstance(video, list):
|
||||
video_str = "".join(video)
|
||||
else:
|
||||
video_str = str(video)
|
||||
return f"{video_str}_{reference_frame_idx}_{use_face_detector}_{blur_threshold}"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
# Bypass para permitir conexiones dinámicas (como descargas en curso)
|
||||
return True
|
||||
|
||||
def analyze(self, video, reference_frame_idx, use_face_detector, blur_threshold, unload_detector_after_analysis=True, bbox_detector=None, current_loop_index=0, **kwargs):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
# Resolución unificada de la ruta, tal como hace VHS
|
||||
if os.path.exists(video):
|
||||
video_path = video
|
||||
else:
|
||||
video_path = folder_paths.get_annotated_filepath(video)
|
||||
|
||||
# 🚀 CLONACIÓN FÍSICA PARA VHS
|
||||
video_path = standardize_video_file(video_path, _log)
|
||||
|
||||
_log(f"\n{'='*50}")
|
||||
_log(f"🕵️ [Secuencial Batcher] NODO: Video Analyzer (Explorador)")
|
||||
_log(f" -> Archivo resuelto: {video_path}")
|
||||
|
||||
cache_key = f"{video_path}_face"
|
||||
|
||||
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
|
||||
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
|
||||
_log(f"♻️ [Face Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
|
||||
cached = VIDEO_ANALYSIS_CACHE[cache_key]
|
||||
frame_count, source_fps, source_audio, safe_faces = cached
|
||||
else:
|
||||
# Análisis completo (Solo Ciclo 0 o primer arranque)
|
||||
_log(f"🎬 [Face Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
|
||||
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
|
||||
source_audio = None
|
||||
try:
|
||||
source_audio = extract_and_standardize_audio(video_path)
|
||||
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
|
||||
|
||||
# 2. Escaneo de OpenCV (Frames, Rostros y Referencia)
|
||||
frame_count = 0
|
||||
source_fps = 0.0
|
||||
safe_faces = []
|
||||
|
||||
if not HAS_OPENCV:
|
||||
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
|
||||
else:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
_log(f" -> 🎞️ Total Frames detectados: {frame_count}")
|
||||
_log(f" -> ⏱️ FPS detectados: {source_fps}")
|
||||
|
||||
# Escaneo de Rostros
|
||||
if bbox_detector is not None or use_face_detector:
|
||||
_log(f" -> 🤖 Iniciando escaneo de rostros (Umbral: {blur_threshold})...")
|
||||
if bbox_detector is not None:
|
||||
_log(f" -> ⚡ Usando detector de rostros por GPU (YOLO/ONNX).")
|
||||
else:
|
||||
_log(f" -> 🐢 Usando detector de rostros por CPU (OpenCV).")
|
||||
|
||||
if HAS_OPENCV:
|
||||
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||
face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Volver al inicio
|
||||
|
||||
_log(f" -> 🤖 Iniciando escaneo de rostros por GPU...")
|
||||
|
||||
# Silenciamos el logger de ultralytics antes de empezar
|
||||
logging.getLogger("ultralytics").setLevel(logging.ERROR)
|
||||
|
||||
# Barra de progreso profesional (desc=Descripción, unit=unidad, leave=True para que no desaparezca)
|
||||
pbar = tqdm(total=frame_count, desc="🔍 Analizando Rostros", unit="frame", dynamic_ncols=True)
|
||||
comfy_pbar = comfy.utils.ProgressBar(frame_count)
|
||||
|
||||
for idx in range(frame_count):
|
||||
ret, frame = cap.read()
|
||||
if not ret: break
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||
|
||||
faces_found = False
|
||||
if variance > blur_threshold:
|
||||
|
||||
if bbox_detector is not None:
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
img_tensor = torch.from_numpy(frame_rgb.astype(np.float32) / 255.0).unsqueeze(0).cpu()
|
||||
|
||||
try:
|
||||
# 🤐 CÁPSULA DE VACÍO: Silenciamos TODO el ruido de la consola
|
||||
with open(os.devnull, 'w') as fnull:
|
||||
with redirect_stdout(fnull), redirect_stderr(fnull):
|
||||
# Inferencia
|
||||
res = bbox_detector.detect(img_tensor, 0.5, 10, 1.0, 10)
|
||||
if isinstance(res, tuple):
|
||||
segs = res[0]
|
||||
else:
|
||||
segs = res
|
||||
if isinstance(segs, tuple) and len(segs) > 1 and isinstance(segs[1], list):
|
||||
faces_found = len(segs[1]) > 0
|
||||
elif isinstance(segs, list):
|
||||
faces_found = len(segs) > 0
|
||||
elif hasattr(segs, '__len__'):
|
||||
faces_found = len(segs) > 0
|
||||
except Exception as e:
|
||||
_log(f" ❌ ERROR BBOX en frame {idx}: {e}")
|
||||
|
||||
else:
|
||||
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
|
||||
faces_found = len(faces) > 0
|
||||
|
||||
if faces_found:
|
||||
safe_faces.append(idx)
|
||||
# _log(f" ✨ Rostro nítido detectado en frame [{idx}] (Varianza: {variance:.2f})")
|
||||
|
||||
pbar.update(1)
|
||||
comfy_pbar.update(1)
|
||||
|
||||
pbar.close()
|
||||
_log(f" -> ✅ Encontrados {len(safe_faces)} frames válidos con rostros.")
|
||||
|
||||
cap.release()
|
||||
|
||||
# Guardar en caché
|
||||
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, safe_faces)
|
||||
|
||||
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
|
||||
ui_result = {}
|
||||
if HAS_OPENCV:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1))
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
|
||||
ref_tensor = ref_tensor.unsqueeze(0)
|
||||
_log(f" -> 🖼️ Frame de referencia extraído (Índice: {safe_ref_idx})")
|
||||
|
||||
from PIL import Image
|
||||
preview_dir = folder_paths.get_temp_directory()
|
||||
preview_name = f"preview_ref_{uuid.uuid4().hex[:5]}.png"
|
||||
Image.fromarray(ref_frame_rgb).save(os.path.join(preview_dir, preview_name))
|
||||
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
cap.release()
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
|
||||
# Liberar el detector de VRAM si se solicita y si se usó
|
||||
if unload_detector_after_analysis and bbox_detector is not None and getattr(kwargs, 'unload_detector', True):
|
||||
_log(f" -> 🧹 Limpiando BBOX_DETECTOR de la VRAM para ahorrar memoria...")
|
||||
import gc
|
||||
import comfy.model_management as mm
|
||||
# Romper referencias
|
||||
del bbox_detector
|
||||
bbox_detector = None
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
mm.soft_empty_cache()
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, safe_faces, ref_tensor, "\n".join(log_output))}
|
||||
|
||||
|
||||
@register_node
|
||||
class VideoAnalyzerSceneDetector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
if os.path.exists(input_dir):
|
||||
files = [f for f in os.listdir(input_dir) if f.split('.')[-1].lower() in ['webm', 'mp4', 'mkv', 'gif', 'mov']]
|
||||
return {
|
||||
"required": {
|
||||
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
|
||||
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000}),
|
||||
"scene_threshold": ("FLOAT", {"default": 25.0, "min": 5.0, "max": 150.0}),
|
||||
},
|
||||
"optional": {
|
||||
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "SCENE_CUTS", "IMAGE", "STRING")
|
||||
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "scene_cuts_list", "reference_frame", "log")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "analyze"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, video, reference_frame_idx, scene_threshold, **kwargs):
|
||||
if isinstance(video, list):
|
||||
video_str = "".join(video)
|
||||
else:
|
||||
video_str = str(video)
|
||||
return f"{video_str}_{reference_frame_idx}_{scene_threshold}"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
return True
|
||||
|
||||
def analyze(self, video, reference_frame_idx, scene_threshold, current_loop_index=0, **kwargs):
|
||||
log_output = []
|
||||
def _log(msg):
|
||||
print(msg)
|
||||
log_output.append(str(msg))
|
||||
|
||||
video_path = video if os.path.exists(video) else folder_paths.get_annotated_filepath(video)
|
||||
|
||||
# 🚀 CLONACIÓN FÍSICA PARA VHS
|
||||
video_path = standardize_video_file(video_path, _log)
|
||||
|
||||
cache_key = f"{video_path}_scene"
|
||||
|
||||
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
|
||||
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
|
||||
_log(f"♻️ [Scene Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
|
||||
cached = VIDEO_ANALYSIS_CACHE[cache_key]
|
||||
frame_count, source_fps, source_audio, scene_cuts = cached
|
||||
else:
|
||||
# Análisis completo (Solo Ciclo 0 o primer arranque)
|
||||
_log(f"🎬 [Scene Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
|
||||
|
||||
frame_count = 0
|
||||
source_fps = 0.0
|
||||
source_audio = None
|
||||
scene_cuts = []
|
||||
|
||||
if HAS_OPENCV:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
|
||||
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
|
||||
source_audio = None
|
||||
try:
|
||||
source_audio = extract_and_standardize_audio(video_path)
|
||||
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
|
||||
except Exception as e:
|
||||
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
|
||||
|
||||
# Detección de cortes
|
||||
_log(f" -> 🎬 Iniciando escaneo de escenas (Umbral Diferencia: {scene_threshold})...")
|
||||
prev_gray = None
|
||||
pbar = tqdm(total=frame_count, desc="🎬 Analizando Escenas")
|
||||
comfy_pbar = comfy.utils.ProgressBar(frame_count)
|
||||
|
||||
for idx in range(frame_count):
|
||||
ret, frame = cap.read()
|
||||
if not ret: break
|
||||
small = cv2.resize(frame, (128, 128))
|
||||
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||
if prev_gray is not None:
|
||||
score = np.mean(cv2.absdiff(gray, prev_gray))
|
||||
if score > scene_threshold:
|
||||
scene_cuts.append(idx)
|
||||
# _log(f" 🎞️ Corte de escena detectado en frame [{idx}] (Puntuación: {score:.2f})")
|
||||
prev_gray = gray
|
||||
pbar.update(1)
|
||||
comfy_pbar.update(1)
|
||||
pbar.close()
|
||||
cap.release()
|
||||
_log(f" -> ✅ Encontrados {len(scene_cuts)} cambios de escena fuertes.")
|
||||
else:
|
||||
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
|
||||
|
||||
# Guardar en caché
|
||||
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, scene_cuts)
|
||||
|
||||
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
|
||||
ui_result = {}
|
||||
if HAS_OPENCV:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1)) if frame_count > 0 else 0
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
|
||||
ref_tensor = ref_tensor.unsqueeze(0)
|
||||
|
||||
from PIL import Image
|
||||
preview_name = f"preview_scene_{uuid.uuid4().hex[:5]}.png"
|
||||
Image.fromarray(ref_frame_rgb).save(os.path.join(folder_paths.get_temp_directory(), preview_name))
|
||||
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3))
|
||||
cap.release()
|
||||
else:
|
||||
ref_tensor = torch.zeros((1, 64, 64, 3))
|
||||
|
||||
_log(f"{'='*50}\n")
|
||||
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, scene_cuts, ref_tensor, "\n".join(log_output))}
|
||||
|
||||
|
||||
|
||||
@register_node
|
||||
class AutoLoopCalculator:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"video_paths": ("VHS_FILENAMES", ),
|
||||
"output_filename": ("STRING", {"default": "SCAIL_Final_Completo.mp4"}),
|
||||
"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 = ("STRING", )
|
||||
RETURN_NAMES = ("final_video_path", )
|
||||
|
||||
# CRÍTICO: Obliga a ComfyUI a recolectar todas las ejecuciones del loop antes de actuar.
|
||||
INPUT_IS_LIST = True
|
||||
FUNCTION = "stitch_videos"
|
||||
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "🔁 Sequential Batcher/Video"
|
||||
|
||||
def stitch_videos(self, video_paths, output_filename):
|
||||
# 1. Comprobación estricta de dependencias
|
||||
if not shutil.which("ffmpeg"):
|
||||
raise Exception("FFmpeg no está instalado o no se encuentra en el PATH del sistema. Por favor, instálalo para unir los vídeos.")
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
# Ensure video_paths is a valid list before iteration
|
||||
if not video_paths or not isinstance(video_paths, list):
|
||||
raise Exception("Error: 'video_paths' inválido. Se esperaba una lista.")
|
||||
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))
|
||||
|
||||
# 2. Aplanar la lista de rutas generadas (VHS_FILENAMES genera listas anidadas)
|
||||
flat_paths = []
|
||||
for path_group in video_paths:
|
||||
if not isinstance(path_group, list):
|
||||
# Sometimes path_group might be a string directly depending on how it's passed
|
||||
if isinstance(path_group, str) and path_group.endswith(".mp4"):
|
||||
flat_paths.append(path_group)
|
||||
continue
|
||||
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))
|
||||
|
||||
for p in path_group:
|
||||
if isinstance(p, str) and p.endswith(".mp4"):
|
||||
flat_paths.append(p)
|
||||
source_frame_count = min(source_frame_count, 10000)
|
||||
select_every_nth = max(1, select_every_nth)
|
||||
|
||||
# Ensure output_filename is safe to read
|
||||
if isinstance(output_filename, list):
|
||||
if not output_filename:
|
||||
out_name = "SCAIL_Final_Completo.mp4"
|
||||
else:
|
||||
out_name = output_filename[0]
|
||||
# --- 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:
|
||||
out_name = output_filename
|
||||
_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
|
||||
|
||||
if not isinstance(out_name, str) or not out_name.strip():
|
||||
out_name = "SCAIL_Final_Completo.mp4"
|
||||
_log(f" -> 📊 Timeline final: 0 a {safe_source_frame_count} (de {source_frame_count} totales)")
|
||||
# -------------------------------------------------------------
|
||||
|
||||
# Add .mp4 extension if not present
|
||||
if not out_name.lower().endswith('.mp4'):
|
||||
out_name += '.mp4'
|
||||
loop.global_select_every_nth = select_every_nth
|
||||
|
||||
if not flat_paths:
|
||||
return ("Error: No se encontraron vídeos .mp4 para unir.", )
|
||||
current_pos = loop.global_accumulated_frames
|
||||
|
||||
# 3. Preparar directorio de salida estándar
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
list_file_path = os.path.join(out_dir, "batch_concat_list.txt")
|
||||
_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}")
|
||||
|
||||
# 4. Crear archivo de concatenación seguro para FFmpeg
|
||||
with open(list_file_path, 'w', encoding='utf-8') as f:
|
||||
for video in sorted(flat_paths):
|
||||
# Usar rutas absolutas aseguradas
|
||||
f.write(f"file '{os.path.abspath(video)}'\n")
|
||||
if current_pos >= safe_source_frame_count:
|
||||
return (1, current_pos, select_every_nth, "\n".join(log_output))
|
||||
|
||||
# 5. Ejecutar unión sin recodificación (Copy codec)
|
||||
final_output = os.path.join(out_dir, out_name)
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", list_file_path, "-c", "copy", final_output
|
||||
]
|
||||
frames_left = safe_source_frame_count - current_pos
|
||||
|
||||
try:
|
||||
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
print(f"[Sequential Batcher] Vídeo ensamblado exitosamente en: {final_output}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(f"Error crítico de FFmpeg al unir los fragmentos: {e.stderr.decode()}")
|
||||
equitable_target = target_frames_per_loop * select_every_nth
|
||||
|
||||
return (final_output, )
|
||||
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