Compare commits

...
Author SHA1 Message Date
Meisoft 76bc9498a2 Merge pull request #157 from Meisoftcoltd/feat/project-plan-loader-12461161277134022224
Add ProjectPlanLoader bridge node
2026-07-01 21:14:31 +02:00
Meisoftcoltd e0b60696a7 feat: add ProjectPlanLoader node for split-workflow bridging
- Appended `ProjectPlanLoader` class to `loop.py`.
- Followed codebase conventions by placing imports (`os`, `json`, `folder_paths`) locally inside the `load_plan` method.
- Registered the node's display name ("📂 Project Plan Loader") in `__init__.py`.
2026-07-01 18:22:45 +00:00
Meisoft f687c34039 Merge pull request #156 from Meisoftcoltd/fix-batch-scene-sync-12454531999106626496
fix: synchronize batch loops with scene chunks
2026-07-01 18:18:33 +02:00
Meisoftcoltd 1afb103fd2 fix: synchronize batch loops with scene chunks
- Inject global state rules into DynamicSceneDirector to act as a chunk Calculator.
- Protect IncrementalVideoStitcher from accumulating frames in chunk mode.
- Anchor AudioBatchSelector file selection to global_batch_index instead of loop index.
- Remove erroneous global_batch_index reset from SequentialLoopStart on scene resets.
2026-06-30 23:02:55 +00:00
Meisoft ef4dff94c6 Merge pull request #155 from Meisoftcoltd/jules-177535240431834190-8f54f3bd
Refactor loop flow control to center on global_loop_index
2026-06-30 23:16:36 +02:00
Meisoftcoltd 3c3ebca5b2 Refactor loop flow control to center on global_loop_index
Centralizes flow control using global_loop_index as the single source of truth.
Removes legacy batch reset logic from loaders and passive nodes.
Strips VRAM memory clearing hacks from Trigger node, delegating entirely to dedicated defragmenter.
Updates DynamicSceneDirector and AudioBatchSelector to passive observer logic.
2026-06-30 21:14:53 +00:00
Meisoft ee67c2a105 Merge pull request #154 from Meisoftcoltd/jules-16613116407217706356-cfe1cdee
fix(loop): restore missing else statement in SequentialLoopStart to fix infinite cycle 0
2026-06-30 20:36:30 +02:00
Meisoftcoltd 4a003e7973 fix(loop): restore missing else statement in SequentialLoopStart to fix infinite cycle 0 2026-06-30 18:34:41 +00:00
Meisoft c4290fce8e Merge pull request #153 from Meisoftcoltd/jules-6283429635430324294-7379d2c7
docs: restructure node documentation and update version to v0.8.3
2026-06-30 17:58:18 +02:00
Meisoftcoltd 2475b79f21 docs: refactor READMEs, group nodes by category, and update version to v0.8.3
- Bumped version in `__init__.py` to 0.8.3.
- Removed "What's New" and "Changelog" sections from `README.md` and `README_EN.md` as per user request to avoid confusion with the version change.
- Refactored the Node Breakdown section to group all new and existing nodes into distinct logical categories (Orchestration, Director, Batches, Analysis, Video, Audio, Resolution, Memory).
2026-06-30 15:50:25 +00:00
Meisoft 45e3c670f4 Update pyproject.toml 2026-06-30 17:27:20 +02:00
Meisoft 04efd0e47f Merge pull request #152 from Meisoftcoltd/fix-director-state-machine-17416656692113806519
fix: refactor DynamicSceneDirector state machine for direct_scene
2026-06-30 17:23:37 +02:00
Meisoftcoltd 71e9ea2cb7 fix(loop): rewrite direct_scene state machine for JSON caching
- Replace `direct_scene` in `DynamicSceneDirector` to implement a strict 1 cycle = 1 scene state machine.
- Read plan from cached JSON file after cycle 0 to avoid redundant LLM calls.
- Remove obsolete `global_audio_offset_frames` logic, simplifying the preproduction flow where audio sync is unnecessary.
2026-06-30 15:23:14 +00:00
Meisoft bd28628314 Merge pull request #120 from Meisoftcoltd/fix/remove-unused-os-import-9501258116321239895
🧹 Code Health: Remove unused os import in vram_node.py
2026-06-30 17:02:31 +02:00
Meisoft 41529ff0a0 Merge pull request #4 from ComfyNodePRs/publish
Add Github Action for Publishing to Comfy Registry
2026-06-30 17:00:23 +02:00
Meisoft e3b1722393 Merge pull request #3 from ComfyNodePRs/pyproject
Add pyproject.toml for Custom Node Registry
2026-06-30 16:59:42 +02:00
Meisoft 692cc0daa5 Merge pull request #151 from Meisoftcoltd/fix-stitcher-intermediate-loops-1353872024854991906
fix(stitcher): prevent NoneType return in intermediate loops.
2026-06-30 16:15:34 +02:00
Meisoftcoltd 2bf5719335 fix(stitcher): prevent NoneType return in intermediate loops. 2026-06-30 14:06:39 +00:00
Meisoft d70ec7ef48 Merge pull request #150 from Meisoftcoltd/fix-sequential-loop-trigger-vram-cleanup-9208431539197096861
fix(core): add missing else blocks in trigger and implement art-flow clock in SaveSceneKeyframe
2026-06-30 15:12:26 +02:00
Meisoftcoltd 81cf4df040 fix(core): add missing else blocks in trigger and implement art-flow clock in SaveSceneKeyframe 2026-06-30 12:47:21 +00:00
Meisoft 74f28f2d32 Merge pull request #149 from Meisoftcoltd/refactor-director-persistent-json-15513519010271136475
refactor(director): Persistent JSON state and output unification
2026-06-30 13:10:13 +02:00
Meisoftcoltd fc861296e4 refactor(director): unify output paths to /output, implement persistent JSON state, and patch WAS node folder bug. 2026-06-29 22:29:05 +00:00
Meisoft 0c28a75e9b Merge pull request #148 from Meisoftcoltd/jules-10869506430448907558-d1b09fca
fix(loop): fix UnboundLocalError for safe_port when ports match.
2026-06-29 23:01:12 +02:00
Meisoftcoltd 3ba6d43097 fix(loop): fix UnboundLocalError for safe_port when ports match.
This commit fixes a bug in `loop.py` where `safe_port` was not defined if the input port matched the system port. By declaring `safe_port = system_port` before the comparison block, `safe_port` is always assigned properly, preventing an `UnboundLocalError` when the variables are logged in the print statements.

In addition, as per the user's direct request, the file `test_import.py` has been removed.
2026-06-29 20:55:47 +00:00
Meisoft e62615183c Merge pull request #147 from Meisoftcoltd/refactor-split-workflow-8326866944814137367
refactor(core): remove is_flux_phase routing logic and experimental gates for split-workflow architecture
2026-06-29 21:44:43 +02:00
Meisoftcoltd fccd913fba refactor(core): remove is_flux_phase routing logic and experimental gates for split-workflow architecture
Removed phase routing from DynamicSceneDirector, IncrementalVideoStitcher, and SaveSceneKeyframe.
Deleted Meisoft_LazyGate and removed references from __init__.py.
2026-06-29 19:37:20 +00:00
async () = process.env.GIT_USERNAME || (await ghUser()).email && (await ghUser()).name || DIE("Missing env.GIT_USERNAME") f37c0c77b3 chore(pyproject): Add pyproject.toml for Custom Node Registry 2026-06-29 18:14:20 +00:00
Meisoft fcbaeb00d9 Merge pull request #146 from Meisoftcoltd/fix-lazygate-registration-13583939289456416668
fix(core): remove broken decorator and use standard ComfyUI registration for LazyGate
2026-06-28 22:26:29 +02:00
Meisoftcoltd 322bf716ef fix(core): remove broken decorator and use standard ComfyUI registration for LazyGate 2026-06-28 20:14:59 +00:00
Meisoft cbc397694d Merge pull request #145 from Meisoftcoltd/fix-boolean-lazy-gate-1926924902711675006
fix(logic): fix boolean string evaluation in stitcher and implement native lazy execution gate
2026-06-27 17:24:27 +02:00
Meisoftcoltd 02f3933eda fix(logic): fix boolean string evaluation in stitcher and implement native lazy execution gate
- Strict boolean evaluation for `is_flux_phase` applied in `IncrementalVideoStitcher` (loop.py).
- Strict boolean evaluation for `is_flux_phase` applied in `SaveSceneKeyframe` (tools.py).
- Added `Meisoft_LazyGate` node to `tools.py` for conditional branch evaluation using ComfyUI's lazy loading API (`check_lazy_status`).
- Added `AnyType` implementation to support `ANY` wire connections on the lazy gate.
- Registered the new node correctly in `__init__.py`.
2026-06-27 15:15:33 +00:00
Meisoft 4985eba6b0 Merge pull request #144 from Meisoftcoltd/jules-6981064425631187705-3728b224
Fix: Apply contiguous tensor formatting for audio saving
2026-06-27 01:27:35 +02:00
Meisoftcoltd 886288406d fix(audio): apply contiguous tensor formatting to prevent safetensors ValueError during slice and save 2026-06-26 23:14:40 +00:00
Meisoft dc8dcd3f53 Merge pull request #143 from Meisoftcoltd/fix/trigger-defensive-unwrap-6727400649902167438
fix(trigger): safely unwrap list inputs for integer addition
2026-06-26 22:48:42 +02:00
Meisoftcoltd 059f9aced9 fix(trigger): safely unwrap estimated_chunk and global variables to prevent TypeError across all dynamic flows 2026-06-26 20:47:16 +00:00
Meisoft 473e774b99 Merge pull request #142 from Meisoftcoltd/jules-16938810536843565318-3d4301e6
refactor(core): implement robust multi-scene state machine and flawless pipeline routing
2026-06-26 18:50:44 +02:00
Meisoftcoltd ba5a3d5c09 refactor(core): implement robust multi-scene state machine and flawless pipeline routing 2026-06-26 16:49:45 +00:00
Meisoft 2e29322e18 Merge pull request #141 from Meisoftcoltd/jules-6404593657163714911-723f8952
feat(tools): add robust Disk I/O nodes and native LTXV single frame injector
2026-06-26 17:32:24 +02:00
Meisoftcoltd d5681c87dd feat(tools): add robust Disk I/O nodes and native LTXV single frame injector 2026-06-26 15:31:50 +00:00
Meisoft 698d0101d3 Merge pull request #140 from Meisoftcoltd/fix-audio-batch-index-7895960705557740318
fix(audio): expose batch_index and total_batches in AudioBatchSelector
2026-06-26 15:10:10 +02:00
Meisoftcoltd 7724d4519e fix(audio): expose batch_index and total_batches outputs in AudioBatchSelector 2026-06-26 13:09:25 +00:00
Meisoft 27d8bc291e Merge pull request #139 from Meisoftcoltd/feat/precise-audio-slicer-7258536341568130636
feat(audio): implement PreciseAudioSlicer with fallback safety for exact chunk synchronization
2026-06-26 14:33:10 +02:00
Meisoftcoltd 3b778103b3 feat(audio): implement PreciseAudioSlicer with fallback safety for exact chunk synchronization 2026-06-26 12:30:11 +00:00
Meisoft 6e7d61e288 Merge pull request #138 from Meisoftcoltd/refactor-audio-batch-telegram-10907512330350585138
refactor(audio): replace SequentialAudioBatchLoader with modular Loader+Selector for Telegram batch support
2026-06-26 00:20:34 +02:00
Meisoftcoltd c821bf5c6e refactor(audio): replace SequentialAudioBatchLoader with modular Loader+Selector for Telegram batch support 2026-06-25 22:16:01 +00:00
Meisoft 9373fffffe Merge pull request #136 from Meisoftcoltd/feat/dynamic-scene-director-3722064079850420133
feat: add DynamicSceneDirector node
2026-06-25 13:08:11 +02:00
Meisoftcoltd 43ca97accb feat: add DynamicSceneDirector to loop.py and cleanup tests
- Injected DynamicSceneDirector node logic at the end of loop.py
- Registered node name in __init__.py
- Removed tests directory and test files to clean up the repository for production
2026-06-24 23:03:31 +00:00
Meisoft dda6646920 Merge pull request #135 from Meisoftcoltd/fix-tts-state-leak-4831858886997017064
Fix state leak in AutoLoopCalculatorTTSBatch
2026-06-02 15:44:46 +02:00
Meisoftcoltd f10514ac03 Fix state leak in AutoLoopCalculatorTTSBatch
This resolves an issue where the sequential batcher would remember
indexes or completion states from previous disconnected flows due to
leftover global cache when processing dynamic text lists.

A hash-based flow detection was implemented in `loop.py` to identify
fresh runs and decisively reset `global_batch_index` and
`global_is_batch_advancing` to zero, preventing array out-of-bounds
errors or missing the initial phrases of a new prompt.
2026-06-02 13:36:27 +00:00
Meisoft a9b217cf4b Merge pull request #134 from Meisoftcoltd/fix-audio-batch-loader-is-absolute-final-138948371854371088
Fix audio batch loader absolute final evaluation
2026-05-26 09:21:03 +02:00
Meisoftcoltd 1bdaa80875 fix: Dynamic has_more_batches detection and absolute final fix
- Updated SequentialAudioBatchLoader to dynamically evaluate global_has_more_batches rather than statically assigning False.
- Refactored IncrementalVideoStitcher to calculate is_absolute_final only when both the current chunk is complete and there are no more batches in queue.
- Updated return blocks in stitch method to propagate new is_absolute_final logic.
2026-05-26 06:50:35 +00:00
Meisoft b8ec5f3fe8 Merge pull request #133 from Meisoftcoltd/feature/video-stitcher-absolute-final-15056983915167339548
Add IS_ABSOLUTE_FINAL to IncrementalVideoStitcher
2026-05-25 18:30:04 +02:00
Meisoftcoltd e1db8a2120 feat: add SequentialAudioBatchLoader node for sequential audio processing
Introduced `SequentialAudioBatchLoader` to `loop.py`, which loads audio files from a directory one-by-one per cycle. It utilizes `extract_and_standardize_audio` from `video.py` to ensure proper formatting (44100Hz, stereo). If the directory is empty or invalid, it falls back to a 0.1-second silent audio tensor while maintaining 0-indexed loop synchronization. This node correctly overrides `loop` module global variables (`global_step_by_chunk`, `global_source_frame_count`, etc.) to control the iteration logic seamlessly. Also added it to `NODE_CLASS_MAPPINGS` in `__init__.py`.
2026-05-25 15:50:50 +00:00
Meisoftcoltd b417f855c2 feat(video): add IS_ABSOLUTE_FINAL output to IncrementalVideoStitcher
Adds a new boolean output `IS_ABSOLUTE_FINAL` to the `IncrementalVideoStitcher` node.
This determines if the current generation cycle is the very last one across all processed batches, by checking `global_has_more_batches` from `loop.py`. Updates the tuple structures and node signatures to reflect this new return type.
2026-05-25 14:47:09 +00:00
Meisoft 81504655d6 Merge pull request #132 from Meisoftcoltd/fix-comfyui-cache-issue-11196945992555818476
Fix false positive cache in ComfyUI cyclic nodes
2026-05-25 07:21:54 +02:00
Meisoftcoltd af8ef37b79 Fix ComfyUI aggressive caching in cyclic nodes
Added `IS_CHANGED` class methods to calculation nodes (`AutoLoopCalculator`, `AutoLoopCalculatorTTS`, `AutoLoopCalculatorWan`, `AutoLoopCalculatorLTX`, `AutoLoopCalculatorTTSBatch`) and `LazySessionCache` to force ComfyUI to re-evaluate them continuously, resolving a false positive cache issue that prevented global Python state variables from updating correctly across batch boundaries.
2026-05-25 04:58:11 +00:00
Meisoft 34b98c922b Merge pull request #131 from Meisoftcoltd/fix-batch-advancing-cycle-zero-15693350492628066411
Fix: Preserve global_is_batch_advancing on cycle 0 and add SSRF check in loop.py
2026-05-23 19:11:09 +02:00
Meisoftcoltd 4a34af5027 fix: prevent global_is_batch_advancing from resetting on cycle 0
Replaces `get_index` in `SequentialLoopStart` to keep `global_is_batch_advancing = True`
during the 0th cycle so that downstream nodes (like Telegram Suite) can read
its state correctly. Includes a fail-fast pre-flight check to prevent SSRF vulnerabilities.
2026-05-23 14:54:36 +00:00
Meisoft 5066eda106 Merge pull request #130 from Meisoftcoltd/fix-tts-batch-output-primitives-14788325393162817772
fix: return primitive values in TTS Batch calculator
2026-05-23 15:46:33 +02:00
Meisoftcoltd 4e49b99e2d fix: return primitives instead of single-item lists in AutoLoopCalculatorTTSBatch
The AutoLoopCalculatorTTSBatch node was returning its output variables
wrapped in single-item lists (e.g., `[current_chunk_text]`) instead of as
raw primitive values. This caused downstream nodes expecting raw types
(like standard string replacement or string concatenation nodes) to crash,
since Python lists do not have `.replace()` or `.join()` methods.

The brackets were removed from the `return` statement so the node now properly
outputs the exact primitive values.
2026-05-23 13:45:37 +00:00
Meisoft dc3f4c2c07 Merge pull request #129 from Meisoftcoltd/fix-tts-batch-output-unwrapping-5285393574070216027
Remove OUTPUT_IS_LIST from AutoLoopCalculatorTTSBatch to allow ComfyUI unwrap
2026-05-23 15:13:40 +02:00
Meisoftcoltd 35075fa357 fix(loop.py): Remove OUTPUT_IS_LIST to enable automatic unwrap in ComfyUI
The `OUTPUT_IS_LIST = (True, True, True, True, True)` assignment in `AutoLoopCalculatorTTSBatch` was preventing ComfyUI from automatically unwrapping 1-item lists, causing downstream nodes to crash with TypeErrors (expecting raw strings/integers but getting lists). By removing this attribute while keeping `INPUT_IS_LIST = True`, ComfyUI can now safely process inputs as batches while unwrapping and forwarding primitive values to the next nodes.
2026-05-23 13:13:27 +00:00
Meisoft 59f0be86ba Merge pull request #128 from Meisoftcoltd/fix-tts-batch-parallel-execution-5012071892428602725
Fix AutoLoopCalculatorTTSBatch parallel execution with list inputs
2026-05-23 14:28:54 +02:00
Meisoftcoltd f48f69ef43 Fix AutoLoopCalculatorTTSBatch parallel execution with list inputs
Restored INPUT_IS_LIST = True to prevent ComfyUI from mapping the node
in parallel when given multiple files, and added OUTPUT_IS_LIST with
five values to properly unpack outputs to the downstream nodes. Also
ensured that the return statement yields a tuple of lists.
2026-05-23 12:04:12 +00:00
Meisoft e449cdf0d1 Merge pull request #127 from Meisoftcoltd/feature/loop-calculator-tts-batch-update-15309557865844476534
Update AutoLoopCalculatorTTSBatch inputs and cleanup tests
2026-05-23 01:23:01 +02:00
Meisoftcoltd 5e2d368379 feat: update AutoLoopCalculatorTTSBatch to support multiple files
- Added `file_names` to INPUT_TYPES and `current_file_name` to RETURN_TYPES.
- Removed `INPUT_IS_LIST = True` to avoid double nesting in ComfyUI list processing.
- Cleaned up temp files (`test_loop_syntax.py`, test `.sh` scripts).
- Verified `BatchTextFileReader` does not exist in the repository.
2026-05-22 23:19:34 +00:00
Meisoft c1bc9b375a Merge pull request #126 from Meisoftcoltd/fix-import-loop-8733227099235388804
Fix absolute import in `loop.py`
2026-05-22 23:36:03 +02:00
Meisoftcoltd 5089252bca 🧹 Removed unused os import from vram_node.py 2026-05-14 21:52:55 +00:00
async () = process.env.GIT_USERNAME || (await ghUser()).email && (await ghUser()).name || DIE("Missing env.GIT_USERNAME") 1d2faec867 chore(publish): Add Github Action for Publishing to Comfy Registry 2026-01-28 10:16:32 +00:00
22 changed files with 808 additions and 985 deletions
+28
View File
@@ -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 }}
+39 -25
View File
@@ -1,5 +1,5 @@
# ♾️ ComfyUI Sequential Batcher
**v1.6.0** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada para sortear los límites extremos de VRAM en la generación de vídeo pesado (WanVideo, Hunyuan, LTX) mediante **Auto-Encolado Recursivo**, procesamiento autónomo lote por lote y gestión de memoria forense.
@@ -7,12 +7,6 @@ Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada p
---
## 🌟 Lo Nuevo en v1.6.0 (Perfect Sync Update)
* **🛡️ VAE Safe Frame Padder (Hold Last Frame):** El motor ahora expande dinámicamente los tensores incompletos al final del vídeo. Si faltan frames para cumplir los estrictos requisitos de WanVideo (regla de `4n + 1`) o LTX (8n+1), clona el último fotograma de forma imperceptible. **Resultado: Cero cuelgues del VAE y 100% de sincronización de audio sin micro-cortes.**
* **📈 Inversión Matemática a "Expansión":** Los calculadores `AutoLoopCalculatorWan` y `AutoLoopCalculatorLTX` ahora redondean el timeline de forma segura hacia arriba, garantizando fluidez total en ciclos intermedios.
---
## ✨ Características Clave
* **Orquestación Autónoma:** Convierte tu ComfyUI en un motor de renderizado continuo. Pulsa "Queue Prompt" una sola vez y el flujo se retroalimentará modificando semillas e índices hasta terminar todo el vídeo.
@@ -25,7 +19,7 @@ Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada p
## ⚠️ Requisitos del Sistema (Crítico)
Para que el nodo `VideoAnalyzerWithAudio` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
Para que el nodo `VideoAnalyzerFaceDetector` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
* **🐧 Ubuntu / Linux / WSL2:**
El script intentará instalarlo por ti. Si falla, ejecuta manualmente:
@@ -43,16 +37,43 @@ El argumento `--highvram` le da la orden estricta a ComfyUI de mantener los mode
---
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
El procesamiento se divide en roles altamente especializados:
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV o mediante un modelo YOLO acelerado por GPU. Extrae rostros, el audio y el Frame de Referencia.
* **📊 Auto Loop Calculator (Base / WanVideo / LTX):** Recibe la biometría y calcula las coordenadas de corte (chunk_frames, skip_frames).
* **🛡️ VAE Safe Frame Padder:** Intercepta el tensor final de VHS y lo rellena ("acolcha") clonando el último frame si falta material, salvando al VAE de un colapso.
* **🎞️ Incremental Auto-Stitcher:** Recoge los lotes renderizados y la pista de audio, cosiendo el vídeo final de forma progresiva en la carpeta temporal.
* **📥 Receiver & 📤 Sender:** Retienen y transfieren el último keyframe válido a través de los ciclos para mantener la coherencia temporal.
* **⏱️ Auto FPS Limiter:** Reduce los FPS inteligentemente garantizando que el audio y el movimiento mantengan una sincronización perfecta sin romper la VRAM.
* **🔀 Master Switch (Evaluación Perezosa):** Amputa físicamente los cables de rutas inactivas en el JSON enviado a ComfyUI. Los nodos pesados que no se necesitan en un ciclo específico ni siquiera se cargan en memoria.
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al Garbage Collector entre ciclos pesados.
El procesamiento se divide en roles altamente especializados, organizados en las siguientes categorías:
### 🎼 Orquestación
* **🏁 Loop Start (Index):** Inicia la secuencia de bucle y mantiene el índice actual.
* **🚀 Loop Trigger (Auto-Queue):** Dispara la siguiente iteración de ComfyUI de manera autónoma hasta que se completa la tarea.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** Máquina de estados para orquestar metadatos (prompts, duraciones) en la preproducción (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Guarda y carga los frames clave de la escena para mantener la continuidad entre flujos separados.
### 📦 Lotes (Batches)
* **📂 Batch Audio Folder Loader:** Carga dinámicamente carpetas enteras de archivos (ej. lotes de audio).
* **🎛️ Audio Batch Selector:** Despacha los archivos del lote uno por uno sincronizándose con el bucle.
### 🔬 Análisis y Cálculos
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV/YOLO, extrayendo rostros, audio y frame de referencia.
* **🎬 Video Analyzer Scene detector:** Detecta cortes de escena en los vídeos.
* **📊 Auto Loop Calculators:** Nodos especializados (`Base`, `WanVideo`, `LTX`, `TTS`) que reciben metadatos y calculan matemáticamente las coordenadas de corte exactas (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Ensambla progresivamente los fragmentos de video renderizados en un único archivo.
* **🛡️ VAE Safe Frame Padder:** Rellena ("acolcha") tensores incompletos clonando el último frame para salvar al VAE de un colapso en modelos restrictivos (ej. reglas 4n+1).
* **💉 LTXV Single Frame Injector:** Inyecta frames individuales específicos requeridos por flujos de LTX.
* **⏱️ Auto FPS Limiter:** Limita de manera inteligente los cuadros por segundo para ahorrar VRAM manteniendo sincronía de audio.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Corta el audio de forma precisa (a nivel de muestra matemática) en base a los frames de vídeo correspondientes.
* **🎛️ Conditional Audio Router (Bypass):** Enruta el audio condicionalmente permitiendo saltar su procesamiento si no es necesario.
### 📐 Herramientas de Resolución
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculan resoluciones óptimas basadas en límites de VRAM y las reglas geométricas (multiplicadores) de diferentes modelos (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Gestión de Memoria / Lógica
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Evaluación perezosa nativa; amputan rutas inactivas para que los nodos pesados ni siquiera se carguen en memoria.
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al recolector de basura (GC) de Python entre ciclos.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retienen y transfieren imágenes en memoria a través de diferentes ciclos.
---
@@ -69,15 +90,8 @@ El procesamiento se divide en roles altamente especializados:
---
## 🚀 Aceleración por GPU (Escaneo de Rostros)
El `VideoAnalyzerWithAudio` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
El `VideoAnalyzerFaceDetector` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
1. Instala el *Impact Pack*.
2. Añade el nodo `UltralyticsDetectorProvider` y selecciona un modelo de rostros (ej. `face_yolov8m.pt`).
3. Conecta su salida al puerto del Analyzer.
*(El nodo cuenta con auto-descarga: destruirá este modelo de la VRAM al terminar para hacer hueco a la generación).*
---
## 📝 Changelog Reciente
* **v1.6.0:** Implementación del `VAESafeFramePadder` (Hold Last Frame) y rediseño matemático a modo Expansión. Adiós a los recortes de audio y a los cuelgues en el ciclo final de WanVideo y LTX.
* **v1.5.4:** Inyección de la variable de entorno de PyTorch `max_split_size_mb:128` para evitar la micro-fragmentación de VRAM con kernels de Triton (SageAttention). Auto-descarga temprana de modelos YOLO.
* **v1.5.3:** "Secuencia Sagrada" en el VRAM Defragmenter (Romper ciclos -> Evacuación Suave -> Limpieza IPC -> Sincronización de hilos).
+39 -25
View File
@@ -1,5 +1,5 @@
# ♾️ ComfyUI Sequential Batcher
**v1.6.0** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through **Recursive Auto-Queuing**, autonomous batch-by-batch processing, and forensic memory management.
@@ -7,12 +7,6 @@ A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extre
---
## 🌟 What's New in v1.6.0 (Perfect Sync Update)
* **🛡️ VAE Safe Frame Padder (Hold Last Frame):** The engine now dynamically expands incomplete tensors at the end of the video. If frames are missing to meet the strict requirements of WanVideo (multiples of 4) or LTX (8n+1), it clones the last frame imperceptibly. **Result: Zero VAE crashes and 100% audio sync with no micro-stutters.**
* **📈 Mathematical Inversion to "Expansion":** The `AutoLoopCalculatorWan` and `AutoLoopCalculatorLTX` calculators now safely round the timeline upwards, guaranteeing total fluidity in intermediate cycles.
---
## ✨ Key Features
* **Autonomous Orchestration:** Turn your ComfyUI into a continuous rendering engine. Press "Queue Prompt" just once and the flow will feed itself by modifying seeds and indices until the entire video is finished.
@@ -25,7 +19,7 @@ A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extre
## ⚠️ System Requirements (Critical)
For the `VideoAnalyzerWithAudio` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
For the `VideoAnalyzerFaceDetector` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
* **🐧 Ubuntu / Linux / WSL2:**
The script will attempt to install it for you. If it fails, run manually:
@@ -43,16 +37,43 @@ The `--highvram` argument gives ComfyUI the strict command to keep models locked
---
## 🧠 The Hybrid Architecture (Node Breakdown)
Processing is divided into highly specialized roles:
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV or using a GPU-accelerated YOLO model. Extracts faces, audio, and the Reference Frame.
* **📊 Auto Loop Calculator (Base / WanVideo / LTX):** Receives the biometrics and calculates cut coordinates (chunk_frames, skip_frames).
* **🛡️ VAE Safe Frame Padder:** Intercepts the final VHS tensor and pads it by cloning the last frame if material is missing, saving the VAE from a collapse.
* **🎞️ Incremental Auto-Stitcher:** Collects rendered batches and the audio track, progressively stitching the final video in the temporary folder.
* **📥 Receiver & 📤 Sender:** Retain and transfer the last valid keyframe across cycles to maintain temporal coherence.
* **⏱️ Auto FPS Limiter:** Smartly reduces FPS guaranteeing audio and motion maintain perfect synchronization without breaking VRAM.
* **🔀 Master Switch (Lazy Evaluation):** Physically amputates wires from inactive routes in the JSON sent to ComfyUI. Heavy nodes not needed in a specific cycle aren't even loaded into memory.
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Garbage Collector between heavy cycles.
Processing is divided into highly specialized roles, organized in the following categories:
### 🎼 Orchestration
* **🏁 Loop Start (Index):** Starts the loop sequence and tracks the current index.
* **🚀 Loop Trigger (Auto-Queue):** Autonomously triggers the next ComfyUI iteration until the task is complete.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** State machine to orchestrate metadata (prompts, durations) in preproduction (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Saves and loads the scene's keyframes to maintain continuity across decoupled flows.
### 📦 Batches
* **📂 Batch Audio Folder Loader:** Dynamically loads entire folders of files (e.g. audio batches).
* **🎛️ Audio Batch Selector:** Dispatches files from the batch one by one in sync with the loop.
### 🔬 Analysis and Calculations
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV/YOLO, extracting faces, audio, and the reference frame.
* **🎬 Video Analyzer Scene detector:** Detects scene cuts in videos.
* **📊 Auto Loop Calculators:** Specialized nodes (`Base`, `WanVideo`, `LTX`, `TTS`) that receive metadata and mathematically calculate the exact cut coordinates (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Progressively stitches rendered video chunks into a single final file.
* **🛡️ VAE Safe Frame Padder:** Pads incomplete tensors by cloning the last frame to save the VAE from a collapse in restrictive models (e.g. 4n+1 rules).
* **💉 LTXV Single Frame Injector:** Injects specific individual frames required by LTX flows.
* **⏱️ Auto FPS Limiter:** Smartly limits frames per second to save VRAM while maintaining audio sync.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Precisely slices audio (at mathematical sample level) based on the corresponding video frames.
* **🎛️ Conditional Audio Router (Bypass):** Conditionally routes audio allowing its processing to be bypassed if not needed.
### 📐 Resolution Tools
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculate optimal resolutions based on VRAM limits and geometric rules (multipliers) of different models (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Memory Management / Logic
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Native lazy evaluation; they cut inactive routes so heavy nodes aren't even loaded into memory.
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Python Garbage Collector (GC) between cycles.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retain and transfer in-memory images across different cycles.
---
@@ -69,15 +90,8 @@ Processing is divided into highly specialized roles:
---
## 🚀 GPU Acceleration (Face Scanning)
The `VideoAnalyzerWithAudio` has a `bbox_detector` port to delegate facial scanning to the graphics card:
The `VideoAnalyzerFaceDetector` has a `bbox_detector` port to delegate facial scanning to the graphics card:
1. Install the *Impact Pack*.
2. Add the `UltralyticsDetectorProvider` node and select a face model (e.g. `face_yolov8m.pt`).
3. Connect its output to the Analyzer's port.
*(The node features auto-unload: it will destroy this model from VRAM upon completion to make room for generation).*
---
## 📝 Recent Changelog
* **v1.6.0:** Implementation of `VAESafeFramePadder` (Hold Last Frame) and mathematical redesign to Expansion mode. Goodbye to audio cuts and crashes in the final cycle of WanVideo and LTX.
* **v1.5.4:** Injection of the PyTorch environment variable `max_split_size_mb:128` to avoid VRAM micro-fragmentation with Triton kernels (SageAttention). Early YOLO model auto-unload.
* **v1.5.3:** "Sacred Sequence" in the VRAM Defragmenter (Break cycles -> Soft Evacuation -> IPC Cleanup -> Thread Synchronization).
+10 -1
View File
@@ -1,4 +1,4 @@
__version__ = "1.6.0"
__version__ = "0.8.3"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -32,6 +32,9 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
"AutoLoopCalculatorTTS": "📊 Auto Loop Calculator (TTS/Texto)",
"SequentialLoopStart": "🏁 Loop Start (Index)",
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
"BatchAudioFolderLoader": "📂 Batch Audio Folder Loader",
"AudioBatchSelector": "🎛️ Audio Batch Selector",
"DynamicSceneDirector": "🎬 Dynamic Scene Director",
# Tools
"AutoFPSLimiter": "⏱️ Auto FPS Limiter",
"VAESafeFramePadder": "🛡️ VAE Safe Frame Padder",
@@ -41,6 +44,12 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
"ResTool64x": "🎬 ResTool 64x (Hunyuan)",
"ResTool64xLTX": "🌌 ResTool 64x (LTX 2.3)",
"ConditionalAudioRouter": "🎛️ Conditional Audio Router (Bypass)",
"PreciseAudioSlicer": "✂️ Precise Audio Slicer",
"SaveSceneKeyframe": "💾 Save Scene Keyframe",
"LoadSceneKeyframe": "🖼️ Load Scene Keyframe",
"LTXVSingleFrameInjector": "💉 LTXV Single Frame Injector",
"ProjectPlanLoader": "📂 Project Plan Loader"
})
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
cd tests
# Because we mocked torch.Tensor as a MagicMock in other tests, and they pollute test_tools, we will run the tests individually like memory suggested.
PYTHONPATH=.. pytest test_loop_calculator.py
PYTHONPATH=.. pytest test_ssrf_fix.py
PYTHONPATH=.. pytest test_switch_node.py
PYTHONPATH=.. pytest test_tools.py
PYTHONPATH=.. pytest test_video_audio.py
PYTHONPATH=.. pytest test_vram_node.py
+430 -64
View File
@@ -44,6 +44,8 @@ class SequentialLoopStart:
global global_accumulated_frames
global global_server_port
global global_ltx_mode
global global_batch_index
global global_is_batch_advancing
import sys
import argparse
@@ -59,12 +61,10 @@ class SequentialLoopStart:
except:
system_port = 8188
if port != system_port:
print(f" -> ⚠️ ATENCIÓN: El puerto de entrada ({port}) no coincide con el puerto del sistema ({system_port}).")
print(f" -> 🔒 Por motivos de seguridad (Prevención SSRF), se forzará el puerto real del sistema: {system_port}.")
safe_port = system_port
else:
safe_port = system_port
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
@@ -79,27 +79,22 @@ class SequentialLoopStart:
if attempt < max_retries - 1:
print(f" -> ⏳ Reintentando conexión ({attempt + 1}/{max_retries}) por alta carga del servidor...")
time.sleep(1)
else:
error_msg = f"🚨 FATAL ERROR: EL PUERTO {safe_port} NO RESPONDE TRAS VARIOS INTENTOS. El servidor ComfyUI podría estar bloqueado o apagado. (Error original: {e})"
print(f" -> ❌ {error_msg}")
raise RuntimeError(error_msg)
# ------------------------------------
is_reset = str(reset_loop).lower() in ['true', '1', 't', 'y']
if is_reset or loop_idx == 0:
global_loop_index = 0
global_accumulated_frames = 0
global_ltx_mode = False # 💡 REINICIO DE SEGURIDAD PARA LTX
global_ltx_mode = False
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
# 💡 NUEVO: Control de lotes (Batch)
global global_batch_index, global_is_batch_advancing
if not global_is_batch_advancing:
global_batch_index = 0
print(" -> 🆕 Reinicio completo detectado. Lote (Batch) reiniciado a 0.")
global_is_batch_advancing = False
else:
global_loop_index = loop_idx
global_is_batch_advancing = False
print(f" -> 📍 Índice actual de bucle: {global_loop_index}")
print(f"{'='*50}\n")
@@ -112,18 +107,27 @@ class AutoLoopCalculatorTTSBatch:
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: Eliminado INPUT_IS_LIST = True para evitar que ComfyUI modifique los tipos de salida
RETURN_TYPES = ("STRING", "INT", "INT", "STRING")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
# 💡 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"
def calculate(self, text_list, split_mode, current_loop_index):
@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))
@@ -133,17 +137,20 @@ class AutoLoopCalculatorTTSBatch:
loop.global_step_by_chunk = True
# Como eliminamos INPUT_IS_LIST, text_list llega como la lista pura desde el Reader
texts = text_list if isinstance(text_list, list) else [text_list]
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
idx = current_loop_index
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
current_batch_idx = getattr(loop, 'global_batch_index', 0)
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)
@@ -160,7 +167,7 @@ class AutoLoopCalculatorTTSBatch:
if raw_chunks: raw_chunks[-1] += " " + buffer_text
else: raw_chunks.append(buffer_text)
chunk_type_name = "Frases Optimizadas"
else:
raw_chunks = [p.strip() for p in current_file_text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
@@ -179,15 +186,14 @@ class AutoLoopCalculatorTTSBatch:
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS Batch)")
_log(f" -> Archivo {current_batch_idx + 1} de {len(texts)} en el lote.")
_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")
# 💡 FIX: Devolvemos valores nativos, no listas
return (current_chunk_text, safe_index, total_chunks, "\n".join(log_output))
return (current_chunk_text, safe_index, total_chunks, current_name, "\n".join(log_output))
@register_node
class AutoLoopCalculatorLTX:
@@ -211,6 +217,11 @@ class AutoLoopCalculatorLTX:
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Loop"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
if source_frame_count <= 0 or target_frames_per_loop <= 0:
return (1, 0, select_every_nth)
@@ -228,7 +239,7 @@ class AutoLoopCalculatorLTX:
if potential_effective_frames < 9:
safe_effective_frames = 9
else:
safe_effective_frames = ((potential_effective_frames + 6) // 8) * 8 + 1
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
@@ -247,7 +258,7 @@ class AutoLoopCalculatorLTX:
print(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
if effective_padding > 0:
print(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
else:
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
print(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
@@ -273,7 +284,7 @@ class AutoLoopCalculatorLTX:
if frames_left <= equitable_target:
best_cut = physical_source_frame_count
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
else:
if scene_cuts_list and len(scene_cuts_list) > 0:
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
@@ -284,10 +295,10 @@ class AutoLoopCalculatorLTX:
if safe_chunk_from_scene < 9: safe_chunk_from_scene = 9
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
print(f" -> 🎬 Bucle aislado por escena (Ajustado x8). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
else:
best_cut = ideal_cut
print(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
else:
best_cut = ideal_cut
print(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
@@ -297,7 +308,7 @@ class AutoLoopCalculatorLTX:
if safe_chunk_from_face < 9: safe_chunk_from_face = 9
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
print(f" -> ✂️ Corte Facial Inteligente (Ajustado x8): {best_cut} (Meta equitativa: {ideal_cut})")
else:
best_cut = ideal_cut
print(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x8: {ideal_cut}")
@@ -310,7 +321,7 @@ class AutoLoopCalculatorLTX:
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
print(f" -> 🏁 Chunk final LTX detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 8n+1.")
loop.global_is_final_chunk = True
else:
loop.global_is_final_chunk = False
skip_frames = current_pos
@@ -372,7 +383,7 @@ class SequentialLoopTrigger:
if not found_calculator and "AutoLoopCalculator" in class_type:
if "TTS" in class_type:
estimated_chunk = 1
else:
estimated_chunk = inputs.get("target_frames_per_loop", 50)
found_calculator = True
@@ -392,7 +403,17 @@ class SequentialLoopTrigger:
if class_type == "MasterSwitch":
master_switches.append(inputs)
is_next_final = (global_accumulated_frames + estimated_chunk) >= global_source_frame_count
# 🛡️ Desempaquetado defensivo universal (Unwrapping de listas de ComfyUI)
_acc_frames = global_accumulated_frames[0] if isinstance(global_accumulated_frames, list) else global_accumulated_frames
_src_frames = global_source_frame_count[0] if isinstance(global_source_frame_count, list) else global_source_frame_count
_est_chunk = estimated_chunk[0] if isinstance(estimated_chunk, list) else estimated_chunk
# Asegurar casteo a enteros puros para evitar colisiones matemáticas en cualquier flujo
_acc_frames = int(_acc_frames) if _acc_frames is not None else 0
_src_frames = int(_src_frames) if _src_frames is not None else 0
_est_chunk = int(_est_chunk) if _est_chunk is not None else 0
is_next_final = (_acc_frames + _est_chunk) >= _src_frames
if is_final_chunk and has_more_batches:
is_next_final = False
@@ -400,9 +421,7 @@ class SequentialLoopTrigger:
inputs["is_final_cycle"] = is_next_final
if is_final_chunk and has_more_batches:
loop.global_batch_index = getattr(loop, 'global_batch_index', 0) + 1
loop.global_is_batch_advancing = True
print(f" -> 📦 Archivo finalizado. Iniciando archivo {loop.global_batch_index + 1} del lote...")
print(f" -> 📦 Archivo finalizado. Iniciando siguiente archivo del lote...")
else:
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
@@ -416,32 +435,379 @@ class SequentialLoopTrigger:
except Exception as e:
print(f" -> ❌ Error HTTP: {e}")
import gc, torch
gc.collect()
if torch.cuda.is_available(): torch.cuda.empty_cache()
else:
print(f" -> 🏁 ¡Generación Finalizada! Todos los archivos del lote completados.")
# ... Mantener aquí el código existente de limpieza extrema de VRAM (mm.unload_all_models, malloc_trim, etc.) ...
print(f" -> 🧹 Iniciando vaciado automático de VRAM...")
import gc
import torch
import comfy.model_management as mm
try:
mm.unload_all_models()
mm.soft_empty_cache()
except Exception as e:
pass
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
import ctypes
try: ctypes.CDLL('libc.so.6').malloc_trim(0)
except Exception: pass
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
print(f" -> ✨ VRAM liberada con éxito. Gráfica lista para nuevos flujos.")
print(f"{'='*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)
+37
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
cd tests
sed -i 's/mock_torch.__version__ = "2.0.0"/mock_torch.__version__ = "2.0.0"\nmock_torch.dtype = type("dtype", (), {})\nmock_torch.empty = MagicMock()\nmock_torch.randn = MagicMock()/g' test_loop_calculator.py
PYTHONPATH=.. pytest test_loop_calculator.py
+5
View File
@@ -65,6 +65,11 @@ class LazySessionCache:
FUNCTION = "execute"
CATEGORY = "🔁 Sequential Batcher/Logic"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
# 🔥 LA CLAVE: Aquí es donde ComfyUI decide si "tira del cable" o no
def check_lazy_status(self, current_loop_index, **kwargs):
# El índice llega como una lista o un int dependiendo del nodo anterior
-26
View File
@@ -1,26 +0,0 @@
import sys
from unittest.mock import MagicMock
sys.modules['torch'] = MagicMock()
sys.modules['comfy'] = MagicMock()
sys.modules['comfy.model_management'] = MagicMock()
# Mocking relative import
class MockRegisterNode:
def __call__(self, cls):
return cls
import builtins
real_import = builtins.__import__
def mocked_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == '' and level > 0 and 'register_node' in fromlist:
mock_module = MagicMock()
mock_module.register_node = MockRegisterNode()
return mock_module
return real_import(name, globals, locals, fromlist, level)
builtins.__import__ = mocked_import
import loop
print("Sintaxis de loop.py verificada correctamente.")
-34
View File
@@ -1,34 +0,0 @@
import unittest
from unittest.mock import patch, MagicMock
import sys
import os
# Mock sys.platform to linux for testing check_and_install_ffmpeg
@patch("sys.platform", "linux")
@patch("shutil.which", return_value=None)
class TestInstall(unittest.TestCase):
@patch("subprocess.check_call")
def test_check_and_install_ffmpeg_linux(self, mock_check_call, mock_which):
from install import check_and_install_ffmpeg
check_and_install_ffmpeg()
# Verify that subprocess.check_call was called twice with the correct lists
self.assertEqual(mock_check_call.call_count, 2)
expected_calls = [
unittest.mock.call(["sudo", "apt-get", "update"]),
unittest.mock.call(["sudo", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "ffmpeg"])
]
mock_check_call.assert_has_calls(expected_calls)
# Verify shell=True was NOT used
for call in mock_check_call.call_args_list:
args, kwargs = call
if "shell" in kwargs:
self.assertFalse(kwargs["shell"], "shell should be False if explicitly provided")
# If not in kwargs, it defaults to False for subprocess.check_call
if __name__ == "__main__":
unittest.main()
-170
View File
@@ -1,170 +0,0 @@
import sys
import types
import unittest
from unittest.mock import MagicMock
import os
# Mock required modules (as per memory)
mock_torch = types.ModuleType("torch")
mock_torch.Tensor = type("Tensor", (), {})
mock_torch.nn = type("nn", (), {"functional": type("functional", (), {}), "Module": type("Module", (), {})})
mock_torch.int64 = MagicMock()
mock_torch.float32 = MagicMock()
mock_torch.from_numpy = MagicMock()
mock_torch.int32 = MagicMock()
mock_torch.bfloat16 = MagicMock()
mock_torch.float16 = MagicMock()
mock_torch.cat = MagicMock()
mock_torch.zeros = MagicMock()
mock_torch.ones = MagicMock()
mock_torch.tensor = MagicMock()
mock_torch.int16 = MagicMock()
mock_torch.int8 = MagicMock()
mock_torch.float64 = MagicMock()
mock_torch.save = MagicMock()
mock_torch.load = MagicMock()
mock_torch.device = MagicMock()
mock_torch.uint8 = MagicMock()
mock_torch.Size = MagicMock()
mock_torch.autocast = MagicMock()
mock_torch.bool = MagicMock()
mock_torch.no_grad = MagicMock()
mock_torch.complex64 = MagicMock()
mock_torch.complex128 = MagicMock()
mock_torch.qint8 = MagicMock()
mock_torch.quint8 = MagicMock()
mock_torch.__version__ = "2.0.0"
mock_torch.dtype = type("dtype", (), {})
mock_torch.empty = MagicMock()
mock_torch.randn = MagicMock()
mock_torch.dtype = type("dtype", (), {})
mock_torch.empty = MagicMock()
mock_torch.randn = MagicMock()
mock_torch.cuda = MagicMock()
mock_torch.cuda.is_available.return_value = False
sys.modules["torch"] = mock_torch
sys.modules["comfy"] = types.ModuleType("comfy")
sys.modules["comfy.model_management"] = types.ModuleType("comfy.model_management")
sys.modules["comfy.utils"] = types.ModuleType("comfy.utils")
sys.modules["torchaudio"] = types.ModuleType("torchaudio")
sys.modules["folder_paths"] = types.ModuleType("folder_paths")
# Mock the loop module since it will be imported in video.py
mock_loop = types.ModuleType("loop")
mock_loop.global_step_by_chunk = False
mock_loop.global_source_frame_count = 0
mock_loop.global_select_every_nth = 1
mock_loop.global_is_final_chunk = False
mock_loop.global_accumulated_frames = 0
sys.modules["loop"] = mock_loop
import sys
sys.modules["tqdm"] = types.ModuleType("tqdm")
sys.modules["tqdm"].tqdm = lambda x, **kwargs: x
sys.modules["cv2"] = types.ModuleType("cv2")
def get_node_class(file_name, class_name):
# Read the file
path = os.path.join(os.path.dirname(__file__), "..", file_name)
with open(path, 'r') as f:
code = f.read()
# Mock 'from . import register_node'
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
# In video.py, there are more relative imports
code = code.replace("from . import loop", "import loop")
# We create a new module
module = types.ModuleType(file_name.replace(".py", ""))
# We must patch sys.modules inside so it can import loop
sys.modules[module.__name__] = module
try:
exec(code, module.__dict__)
except Exception as e:
print(f"Failed to execute {file_name}: {e}")
pass
return getattr(module, class_name, None)
class TestAutoLoopCalculators(unittest.TestCase):
def setUp(self):
LTXClass = get_node_class("loop.py", "AutoLoopCalculatorLTX")
self.calc_ltx = LTXClass() if LTXClass else None
BaseClass = get_node_class("video.py", "AutoLoopCalculator")
self.calc_base = BaseClass() if BaseClass else None
WanClass = get_node_class("video.py", "AutoLoopCalculatorWan")
self.calc_wan = WanClass() if WanClass else None
# LTX Calculator
def test_ltx_guard_zero_source(self):
res = self.calc_ltx.calculate(0, 100, 1, 0)
self.assertEqual(res, (1, 0, 1))
def test_ltx_guard_negative_source(self):
res = self.calc_ltx.calculate(-5, 100, 1, 0)
self.assertEqual(res, (1, 0, 1))
def test_ltx_guard_zero_target(self):
res = self.calc_ltx.calculate(100, 0, 1, 0)
self.assertEqual(res, (1, 0, 1))
def test_ltx_valid_input(self):
res = self.calc_ltx.calculate(80, 81, 1, 0)
self.assertEqual(res[0], 81)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
# Base Calculator
def test_base_guard_zero_source(self):
res = self.calc_base.calculate(0, 100, 1, 0)
self.assertEqual(res[0], 1)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
self.assertIn("Invalid frame count", res[3])
def test_base_guard_zero_target(self):
res = self.calc_base.calculate(100, 0, 1, 0)
self.assertEqual(res[0], 1)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
self.assertIn("Invalid frame count", res[3])
def test_base_valid_input(self):
res = self.calc_base.calculate(100, 50, 1, 0)
self.assertEqual(res[0], 50)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
# Wan Calculator
def test_wan_guard_zero_source(self):
res = self.calc_wan.calculate(0, 100, 1, 0)
self.assertEqual(res[0], 1)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
self.assertIn("Invalid frame count", res[3])
def test_wan_guard_zero_target(self):
res = self.calc_wan.calculate(100, 0, 1, 0)
self.assertEqual(res[0], 1)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
self.assertIn("Invalid frame count", res[3])
def test_wan_valid_input(self):
res = self.calc_wan.calculate(80, 81, 1, 0)
self.assertEqual(res[0], 81)
self.assertEqual(res[1], 0)
self.assertEqual(res[2], 1)
if __name__ == "__main__":
unittest.main()
-26
View File
@@ -1,26 +0,0 @@
import torch
import os
import tempfile
import unittest
from safetensors.torch import save_file, load_file
class TestTorchLoadSecurity(unittest.TestCase):
def test_safetensors_serialization(self):
# Create a dummy tensor and save it
data = {
"waveform": torch.randn(1, 44100),
"sample_rate": torch.tensor(44100, dtype=torch.int32)
}
with tempfile.TemporaryDirectory() as tmpdir:
file_path = os.path.join(tmpdir, "test.safetensors")
save_file(data, file_path)
# Safetensors completely eliminates pickle
loaded_data = load_file(file_path)
self.assertEqual(loaded_data["sample_rate"].item(), 44100)
self.assertTrue(torch.equal(loaded_data["waveform"], data["waveform"]))
if __name__ == "__main__":
unittest.main()
-46
View File
@@ -1,46 +0,0 @@
import sys
import unittest
import types
class TestSSRF(unittest.TestCase):
def test_ssrf_fix(self):
# We want to load loop.py and simulate the port logic.
import os
import urllib.request
from unittest.mock import patch, MagicMock
# Load loop.py dynamically
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
loop_path = os.path.join(REPO_ROOT, "loop.py")
with open(loop_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")
# Mock comfy modules
sys.modules["comfy"] = MagicMock()
sys.modules["comfy.model_management"] = MagicMock()
sys.modules["torch"] = MagicMock()
module = types.ModuleType("loop")
exec(code, module.__dict__)
SequentialLoopStart = module.SequentialLoopStart()
# Scenario 1: Malicious port input, should fallback to sys.argv or default
sys.argv = ["main.py", "--port", "8188"]
with patch('urllib.request.urlopen') as mock_urlopen:
mock_urlopen.return_value = MagicMock()
idx = SequentialLoopStart.get_index(reset_loop=True, loop_idx=0, port=9999)
# The safe port should be 8188 since sys.argv overrides the malicious port
self.assertEqual(module.global_server_port, 8188)
# Scenario 2: sys.argv contains a different valid port
sys.argv = ["main.py", "--port", "8189"]
idx = SequentialLoopStart.get_index(reset_loop=True, loop_idx=0, port=9999)
self.assertEqual(module.global_server_port, 8189)
if __name__ == "__main__":
unittest.main()
-44
View File
@@ -1,44 +0,0 @@
import sys
import os
import pytest
# Add the parent directory to sys.path so we can import switch_node directly
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from switch_node import MasterSwitch
@pytest.fixture
def master_switch():
return MasterSwitch()
def test_master_switch_check_lazy_status(master_switch):
# Test True condition
assert master_switch.check_lazy_status(is_final_cycle=True) == ["on_true"]
# Test False condition
assert master_switch.check_lazy_status(is_final_cycle=False) == ["on_false"]
def test_master_switch_route_true(master_switch):
# Test routing to true branch
result = master_switch.route(is_final_cycle=True, on_true="true_value", on_false="false_value")
# Verify result - check first element to avoid tuple length mismatch assumptions
assert result[0] == "true_value"
def test_master_switch_route_false(master_switch):
# Test routing to false branch
result = master_switch.route(is_final_cycle=False, on_true="true_value", on_false="false_value")
# Verify result
assert result[0] == "false_value"
def test_master_switch_route_none_values(master_switch):
# Test with None values
result_true = master_switch.route(is_final_cycle=True, on_true=None, on_false="false_value")
assert result_true[0] is None
result_false = master_switch.route(is_final_cycle=False, on_true="true_value", on_false=None)
assert result_false[0] is None
if __name__ == "__main__":
pytest.main([__file__])
-50
View File
@@ -1,50 +0,0 @@
import sys
import types
import pytest
from unittest.mock import MagicMock
import os
# Mock dependencies
mock_torch = types.ModuleType("torch")
class MockTensor:
pass
mock_torch.Tensor = MockTensor
mock_torch.Tensor.__module__ = "torch"
sys.modules["torch"] = mock_torch
def get_VAESafeFramePadder():
tools_path = os.path.join(os.path.dirname(__file__), "..", "tools.py")
with open(tools_path, 'r') as f:
code = f.read()
# Mock 'from . import register_node' and other potential relative imports
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
code = code.replace("from .switch_node import any_type", "any_type = '*'")
tools_module = types.ModuleType("tools")
# Provide torch to the module's namespace
tools_module.__dict__["torch"] = mock_torch
try:
exec(code, tools_module.__dict__)
except Exception as e:
print(f"Error executing tools.py code: {e}")
raise e
return tools_module.VAESafeFramePadder
@pytest.fixture
def VAESafeFramePadder():
return get_VAESafeFramePadder()
def test_pad_non_tensor_input_raises_value_error(VAESafeFramePadder):
padder = VAESafeFramePadder()
with pytest.raises(ValueError, match=r"\[VAESafeFramePadder\] Input is not a valid tensor\."):
padder.pad(None, "WanVideo")
with pytest.raises(ValueError, match=r"\[VAESafeFramePadder\] Input is not a valid tensor\."):
padder.pad("not a tensor", "WanVideo")
if __name__ == "__main__":
pytest.main([__file__])
-162
View File
@@ -1,162 +0,0 @@
import sys
import types
import pytest
from unittest.mock import MagicMock, PropertyMock, patch
import os
# Create persistent mocks that we will reset in each test
m_torch = MagicMock()
m_torch.__version__ = "2.1.0"
m_torchaudio = MagicMock()
m_transforms = MagicMock()
# Mock dependencies before importing anything that might depend on them
sys.modules["torch"] = m_torch
sys.modules["torchaudio"] = m_torchaudio
sys.modules["torchaudio.transforms"] = m_transforms
sys.modules["folder_paths"] = MagicMock()
sys.modules["nodes"] = MagicMock()
sys.modules["comfy"] = MagicMock()
sys.modules["comfy.utils"] = MagicMock()
sys.modules["comfy.model_management"] = MagicMock()
sys.modules["tqdm"] = MagicMock()
sys.modules["PIL"] = MagicMock()
sys.modules["cv2"] = MagicMock()
sys.modules["numpy"] = MagicMock()
def get_extract_and_standardize_audio():
# Read the file
video_path = os.path.join(os.path.dirname(__file__), "..", "video.py")
with open(video_path, 'r', encoding='utf-8') as f:
code = f.read()
# Mock 'from . import register_node' by replacing it
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
# ENSURE import torchaudio.transforms as T uses our mock
# We can do this by replacing the string in the code
code = code.replace("import torchaudio.transforms as T", "T = m_transforms")
video_module = types.ModuleType("video")
# We need to set some things in the module dict that might be needed
video_module.__dict__.update({
"__file__": video_path,
"torch": m_torch,
"torchaudio": m_torchaudio,
"m_transforms": m_transforms,
"os": os,
"sys": sys,
"logging": MagicMock(),
"ThreadPoolExecutor": MagicMock(),
"tqdm": MagicMock(),
"comfy": sys.modules["comfy"],
"folder_paths": sys.modules["folder_paths"],
"nodes": sys.modules["nodes"],
"subprocess": MagicMock(),
"math": MagicMock(),
"time": MagicMock(),
"uuid": MagicMock(),
"redirect_stdout": MagicMock(),
"redirect_stderr": MagicMock(),
})
exec(code, video_module.__dict__)
return video_module.extract_and_standardize_audio
# We will load the function ONCE at the module level to avoid re-execing video.py
# which might have side effects on sys.modules or other things.
_extract_and_standardize_audio = get_extract_and_standardize_audio()
@pytest.fixture
def func():
# Reset mocks before each test
m_torch.reset_mock()
m_torchaudio.reset_mock()
m_transforms.reset_mock()
# Re-ensure torch.mean exists after reset as it's used in the function
m_torch.mean = MagicMock()
# Return the already loaded function
return _extract_and_standardize_audio
def test_extract_and_standardize_audio_exception(func):
# Mock torchaudio.load to raise an Exception
m_torchaudio.load.side_effect = Exception("Test exception")
with pytest.raises(Exception) as excinfo:
func("dummy_path")
assert str(excinfo.value) == "Test exception"
m_torchaudio.load.assert_called_once_with("dummy_path")
def test_extract_and_standardize_audio_happy_path_no_resample_stereo(func):
m_torchaudio.load.side_effect = None
# Mock waveform: 2 channels, some length
mock_waveform = MagicMock()
# Use PropertyMock for shape to avoid it being a MagicMock that causes issues in comparison
type(mock_waveform).shape = PropertyMock(return_value=(2, 1000))
m_torchaudio.load.return_value = (mock_waveform, int(44100))
result = func("dummy_path", target_sr=44100)
assert result["sample_rate"] == 44100
mock_waveform.unsqueeze.assert_called_once_with(0)
assert result["waveform"] == mock_waveform.unsqueeze.return_value
def test_extract_and_standardize_audio_resample(func):
m_torchaudio.load.side_effect = None
mock_waveform = MagicMock()
type(mock_waveform).shape = PropertyMock(return_value=(2, 1000))
m_torchaudio.load.return_value = (mock_waveform, int(48000))
mock_resampled_waveform = MagicMock()
type(mock_resampled_waveform).shape = PropertyMock(return_value=(2, 918))
mock_resampler = MagicMock()
mock_resampler.return_value = mock_resampled_waveform
m_transforms.Resample.return_value = mock_resampler
result = func("dummy_path", target_sr=44100)
m_transforms.Resample.assert_called_once_with(orig_freq=48000, new_freq=44100)
mock_resampler.assert_called_once_with(mock_waveform)
assert result["sample_rate"] == 44100
assert result["waveform"] == mock_resampled_waveform.unsqueeze.return_value
def test_extract_and_standardize_audio_mono_to_stereo(func):
m_torchaudio.load.side_effect = None
mock_waveform = MagicMock()
type(mock_waveform).shape = PropertyMock(return_value=(1, 1000))
m_torchaudio.load.return_value = (mock_waveform, int(44100))
mock_stereo_waveform = MagicMock()
type(mock_stereo_waveform).shape = PropertyMock(return_value=(2, 1000))
mock_waveform.repeat.return_value = mock_stereo_waveform
result = func("dummy_path", target_sr=44100)
mock_waveform.repeat.assert_called_once_with(2, 1)
assert result["waveform"] == mock_stereo_waveform.unsqueeze.return_value
def test_extract_and_standardize_audio_multichannel_to_stereo(func):
m_torchaudio.load.side_effect = None
mock_waveform = MagicMock()
type(mock_waveform).shape = PropertyMock(return_value=(6, 1000))
m_torchaudio.load.return_value = (mock_waveform, int(44100))
mock_mono = MagicMock()
type(mock_mono).shape = PropertyMock(return_value=(1, 1000))
m_torch.mean.return_value = mock_mono
mock_stereo = MagicMock()
type(mock_stereo).shape = PropertyMock(return_value=(2, 1000))
mock_mono.repeat.return_value = mock_stereo
result = func("dummy_path", target_sr=44100)
m_torch.mean.assert_called_once_with(mock_waveform, dim=0, keepdim=True)
mock_mono.repeat.assert_called_once_with(2, 1)
assert result["waveform"] == mock_stereo.unsqueeze.return_value
-55
View File
@@ -1,55 +0,0 @@
import sys
import types
import pytest
from unittest.mock import MagicMock
import os
# Mock dependencies before importing anything that might depend on them
mock_torch = types.ModuleType("torch")
mock_torch.cuda = MagicMock()
mock_torch.cuda.is_available.return_value = False
sys.modules["torch"] = mock_torch
sys.modules["comfy"] = types.ModuleType("comfy")
sys.modules["comfy.model_management"] = types.ModuleType("comfy.model_management")
def get_format_bytes():
# Read the file
vram_node_path = os.path.join(os.path.dirname(__file__), "..", "vram_node.py")
with open(vram_node_path, 'r') as f:
code = f.read()
# Mock 'from . import register_node' by replacing it
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
vram_module = types.ModuleType("vram_node")
exec(code, vram_module.__dict__)
return vram_module.format_bytes
@pytest.fixture
def format_bytes():
return get_format_bytes()
def test_format_bytes_zero(format_bytes):
assert format_bytes(0) == "0.00 GB"
def test_format_bytes_gigabyte(format_bytes):
assert format_bytes(1024**3) == "1.00 GB"
def test_format_bytes_fractional(format_bytes):
assert format_bytes(1024**3 * 0.5) == "0.50 GB"
assert format_bytes(1024**3 * 2.5) == "2.50 GB"
def test_format_bytes_small_values(format_bytes):
# 1 MB is 1/1024 GB ≈ 0.000976 GB -> 0.00 GB
assert format_bytes(1024**2) == "0.00 GB"
# 10 MB is 10/1024 GB ≈ 0.009765 GB -> 0.01 GB (due to rounding .2f)
assert format_bytes(1024**2 * 10) == "0.01 GB"
def test_format_bytes_large_values(format_bytes):
assert format_bytes(1024**3 * 1024) == "1024.00 GB"
assert format_bytes(1024**3 * 123.456) == "123.46 GB"
if __name__ == "__main__":
pytest.main([__file__])
-118
View File
@@ -1,118 +0,0 @@
import os
import sys
import csv
# Setup paths
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(TEST_DIR)
SANDBOX_DIR = os.path.join(TEST_DIR, "sandbox")
INPUT_DIR = os.path.join(SANDBOX_DIR, "input")
SECRET_FILE = os.path.join(SANDBOX_DIR, "secret.txt")
# Ensure sandbox exists
if not os.path.exists(INPUT_DIR):
os.makedirs(INPUT_DIR)
# Create secret file
with open(SECRET_FILE, 'w') as f:
f.write("secret_key,secret_value\nsuper,secret")
# Create valid csv
with open(os.path.join(INPUT_DIR, "valid.csv"), 'w') as f:
f.write("col1,col2\nval1,val2")
# Mock folder_paths
import types
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_input_directory = lambda: INPUT_DIR
folder_paths.get_annotated_filepath = lambda x: x # Mock implementation
sys.modules["folder_paths"] = folder_paths
# Load batch.py dynamically
def load_batch_module():
batch_path = os.path.join(REPO_ROOT, "batch.py")
with open(batch_path, 'r') as f:
code = f.read()
# Mock register_node by replacing the import
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
module = types.ModuleType("batch")
exec(code, module.__dict__)
return module
batch_module = load_batch_module()
LoadCSV = batch_module.LoadCSV
def verify_fix():
print("--- Verifying Fix ---")
node = LoadCSV()
# Test 1: Exploit attempt (should fail)
print("Test 1: Exploit attempt (should raise ValueError)")
target_path = SECRET_FILE
try:
# Pass path relative to input, attempting traversal if we were naive,
# but here we can pass absolute path to check absolute path blocking.
# Or relative path "../secret.txt"
# Scenario A: Path traversal relative string
# node.go("../secret.txt", ...)
# Scenario B: Absolute path
# Testing Scenario B (absolute path)
node.go(target_path, "comma", '"', -1)
print("FAIL: Exploit succeeded (No exception raised)")
return False
except ValueError as e:
if "Access denied" in str(e):
print(f"SUCCESS: Blocked absolute path access: {e}")
else:
print(f"FAIL: Raised wrong ValueError: {e}")
return False
except Exception as e:
print(f"FAIL: Raised wrong exception: {type(e)} {e}")
return False
# Test 2: Valid access (should succeed)
print("\nTest 2: Valid access (should succeed)")
try:
# Can pass just filename or relative path
result = node.go("valid.csv", "comma", '"', -1)
batch_data = result["result"][0]
if batch_data[0]['col1'] == 'val1':
print("SUCCESS: Valid CSV read correctly")
else:
print(f"FAIL: content mismatch: {batch_data}")
return False
except Exception as e:
print(f"FAIL: Valid access raised exception: {e}")
return False
# Test 3: Relative path traversal attempt (should fail)
print("\nTest 3: Relative path traversal attempt (should raise ValueError)")
try:
node.go("../secret.txt", "comma", '"', -1)
print("FAIL: Exploit succeeded (No exception raised)")
return False
except ValueError as e:
if "Access denied" in str(e):
print(f"SUCCESS: Blocked relative path traversal: {e}")
else:
print(f"FAIL: Raised wrong ValueError: {e}")
return False
except Exception as e:
# Depending on how os.path.join works, "input/../secret.txt" -> "sandbox/secret.txt"
# commonpath should catch it.
print(f"FAIL: Raised wrong exception: {type(e)} {e}")
return False
return True
if __name__ == "__main__":
if verify_fix():
print("\nVerification Result: FIX VERIFIED")
sys.exit(0)
else:
print("\nVerification Result: FIX FAILED")
sys.exit(1)
+205 -1
View File
@@ -2,7 +2,12 @@ import math
import time
import torch
from . import register_node
from .switch_node import any_type
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
@register_node
class PrimitiveDelay:
@@ -377,3 +382,202 @@ class AudioDurationCalculator:
_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},)
+15 -124
View File
@@ -480,6 +480,11 @@ class AutoLoopCalculator:
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
@@ -599,6 +604,11 @@ class AutoLoopCalculatorTTS:
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):
@@ -668,131 +678,7 @@ class AutoLoopCalculatorTTS:
return (current_text, safe_index, total_chunks, "\n".join(log_output))
@register_node
class IncrementalVideoStitcher:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
"current_loop_index": ("INT", {"default": 0}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "BOOLEAN", "STRING")
RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT", "IS_FINAL_CYCLE", "log")
FUNCTION = "stitch"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def stitch(self, images, audio, current_loop_index):
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
cache_dir = os.path.join(folder_paths.get_temp_directory(), "meisoft_video_cache")
if current_loop_index == 0:
if os.path.exists(cache_dir):
try: shutil.rmtree(cache_dir)
except Exception as e: pass
os.makedirs(cache_dir, exist_ok=True)
_log(f"\n🧹 [Stitcher] Ciclo 0 detectado. Subcarpeta temporal limpiada.")
else:
os.makedirs(cache_dir, exist_ok=True)
timestamp = int(time.time() * 1000)
# 🖼️ GUARDAR IMÁGENES (PNG)
_log(f"📦 [Stitcher] Guardando {images.shape[0]} frames como PNGs de alta calidad...")
for i in range(images.shape[0]):
filename = f"frame_{current_loop_index:04d}_{timestamp}_{i:04d}.png"
path = os.path.join(cache_dir, filename)
img_array = 255. * images[i].cpu().numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
img.save(path, format="PNG")
# 🎵 GUARDAR AUDIO EN CACHÉ (.safetensors)
if audio is not None:
audio_path = os.path.join(cache_dir, f"audio_{current_loop_index:04d}_{timestamp}.safetensors")
# Convert sample_rate to tensor for safetensors compatibility
audio_data = {
"waveform": audio["waveform"],
"sample_rate": torch.tensor(audio["sample_rate"], dtype=torch.int32)
}
save_file(audio_data, audio_path)
source_total = getattr(loop, 'global_source_frame_count', 1)
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
if is_final_chunk or loop.global_accumulated_frames >= source_total:
_log(f" -> 🏁 ¡Último ciclo detectado! Ensamblando recursos desde la caché temporal...")
# 1. ENSAMBLAR VÍDEO
png_files = sorted([f for f in os.listdir(cache_dir) if f.endswith('.png')])
if not png_files:
_log(" -> ❌ ERROR: No se encontraron frames en la subcarpeta.")
return (images, audio, True, "\n".join(log_output))
total_frames = len(png_files)
_log(f" -> 🧩 Extrayendo {total_frames} frames en paralelo...")
# Función pura que devuelve el tensor directamente (Sin in-place updates)
def load_and_process(filename):
img = Image.open(os.path.join(cache_dir, filename)).convert("RGB")
img_np = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(img_np).unsqueeze(0) # Añadimos batch dimension
# Usamos map para garantizar el orden secuencial de los resultados
with ThreadPoolExecutor() as executor:
tensors_list = list(executor.map(load_and_process, png_files))
# Concatenamos de forma segura y nativa
final_tensor = torch.cat(tensors_list, dim=0)
# 2. ENSAMBLAR AUDIO
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:
_log(f" -> 🎵 Ensamblando {len(audio_files)} fragmentos de audio...")
waveforms = []
sample_rate = 44100
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()
# Concatenamos los audios en el eje del tiempo (dimensión -1)
final_waveform = torch.cat(waveforms, dim=-1)
final_audio = {"waveform": final_waveform, "sample_rate": sample_rate}
_log(f" -> ✅ Audio unificado. Duración total: {final_waveform.shape[-1] / sample_rate:.2f} segundos.")
else:
final_audio = audio
_log(f"✅ [Stitcher] VÍDEO COMPLETADO: {final_tensor.shape[0]} frames ensamblados con éxito.")
try:
shutil.rmtree(cache_dir)
_log(f"🧹 [Stitcher] Subcarpeta temporal destruida.")
except:
pass
return (final_tensor, final_audio, True, "\n".join(log_output))
else:
_log(f" -> ⏳ Ciclo intermedio. Recursos almacenados de forma segura. Pasando frames dummy...")
dummy_frame = images[-1:].clone()
return (dummy_frame, None, False, "\n".join(log_output))
@register_node
class AutoLoopCalculatorWan:
@@ -816,6 +702,11 @@ class AutoLoopCalculatorWan:
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
-1
View File
@@ -1,7 +1,6 @@
import torch
import gc
import comfy.model_management as mm
import os
from . import register_node
class AnyType(str):