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 1d9d3356fb Fix absolute import in loop.py 2026-05-22 21:34:27 +00:00
Meisoft 1bd0792655 Merge pull request #125 from Meisoftcoltd/fix-tts-batch-type-6614993364835646221
Fix AutoLoopCalculatorTTSBatch type bug
2026-05-22 22:57:09 +02:00
Meisoftcoltd fc547c5c18 Fix AutoLoopCalculatorTTSBatch data type bug for downstream TTS nodes
- Removed `INPUT_IS_LIST = True` to prevent ComfyUI from implicitly converting input values to lists and expecting lists as outputs.
- Refactored `calculate` to handle list inputs natively using `isinstance` while correctly interacting with the standard Text Reader's native Python lists.
- Changed the return signature to emit native primitive values (`str`, `int`) instead of wrapping them in lists, correctly accommodating downstream integration with TTS models (e.g. Fish Speech) that expect pure strings.
2026-05-22 20:45:25 +00:00
Meisoft 19c8d8ea51 Merge pull request #124 from Meisoftcoltd/fix-tts-double-wrapping-8736248982233625434
Fix: Unpack double-wrapped list in AutoLoopCalculatorTTSBatch
2026-05-22 21:31:47 +02:00
Meisoftcoltd 80a0ea3eb1 fix: handle double-wrapped list in AutoLoopCalculatorTTSBatch
ComfyUI's `INPUT_IS_LIST = True` behavior wraps incoming data in a list. When an upstream node (like `BatchTextFileReader`) already returns a list, ComfyUI wraps it again, creating a list of lists (e.g., `[["file1", "file2"]]`).

This patch updates `AutoLoopCalculatorTTSBatch.calculate` to robustly detect and unpack this double-wrapping before extracting the target texts, preventing a downstream `AttributeError` during string manipulation.
2026-05-22 19:29:05 +00:00
Meisoft 1468c14e97 Merge pull request #123 from Meisoftcoltd/feat/tts-batch-processing-9692705988502475457
feat: Implement AutoLoopCalculatorTTSBatch for sequential batch processing
2026-05-21 21:44:30 +02:00
Meisoftcoltd cf6666885a feat: Implement AutoLoopCalculatorTTSBatch for sequential text file processing
- Added global tracking variables (`global_batch_index`, `global_has_more_batches`, `global_is_batch_advancing`) to `loop.py` to maintain batch state
- Updated `SequentialLoopStart` reset logic to prevent loop index reset on automatic batch advances
- Implemented the `AutoLoopCalculatorTTSBatch` node with `INPUT_IS_LIST = True` returning list-wrapped chunks to bypass native ComfyUI parallelization
- Updated `SequentialLoopTrigger` requeue logic to handle batch completion correctly using `reset_loop=True` with `loop_idx=0` when `has_more_batches` is truthy
2026-05-21 19:42:01 +00:00
Meisoft 4d9c0a0ad0 Merge pull request #122 from Meisoftcoltd/fix-seed-overflow-ollama-9437070014955011207
fix: prevent seed overflow crash in OllamaOptionsV2 by lowering max seed to 0x7fffffff
2026-05-20 21:38:16 +02:00
Meisoftcoltd b04cc4267e fix(loop): lower max random seed to 0x7fffffff to prevent OllamaOptionsV2 crash
The `SequentialLoopTrigger` node was mutating seeds with `0xffffffff`, which exceeded the maximum value for a signed 32-bit integer. This caused a validation error in strict nodes like `OllamaOptionsV2` during loop iterations. This commit lowers the maximum generated random seed value to `0x7fffffff` to remain within safe limits.
2026-05-20 19:37:52 +00:00
Meisoft af36417577 Merge pull request #121 from Meisoftcoltd/fix-comfyui-connection-retry-11397371798100148086
feat: add robust ComfyUI server connection retry in SequentialLoopStart
2026-05-19 23:05:22 +02:00
Meisoftcoltd 03a214b028 feat: add connection retry mechanism to SequentialLoopStart
Replaces the single `urlopen` call with a resilient retry loop (up to 3 attempts, 10s timeout, 1s delay) when checking the ComfyUI server status. Now raises a strict `RuntimeError` if all attempts fail, halting the execution queue and surfacing the error on the UI.
2026-05-19 20:46:41 +00:00
Meisoftcoltd 5089252bca 🧹 Removed unused os import from vram_node.py 2026-05-14 21:52:55 +00:00
Meisoft 6b7432ad4e Merge pull request #115 from Meisoftcoltd/test-master-switch-11731836374353204067
🧪 Add tests for MasterSwitch route
2026-05-14 22:32:24 +02:00
Meisoft 1011e0ce01 Merge pull request #116 from Meisoftcoltd/fix/unused-import-os-2345010763044814460
🧹 Code Health Improvement: Remove unused import os
2026-05-14 22:31:27 +02:00
Meisoft 3d2805019a Merge pull request #119 from Meisoftcoltd/jules-security-ssrf-fix-6533768813890255424
🔒 Fix SSRF Vulnerability in Loop Trigger via Port Auto-Detection
2026-05-14 22:30:59 +02:00
Meisoft 791f394dd5 Merge pull request #118 from Meisoftcoltd/fix-insecure-deserialization-12125730200173000788
🔒 Fix insecure deserialization via torch.load
2026-05-14 22:29:15 +02:00
Meisoftcoltd 694c2cce2a 🔒 Fix SSRF vulnerability in SequentialLoopStart by securely auto-detecting the ComfyUI port from sys.argv instead of trusting the node's UI input port. 2026-05-14 20:29:08 +00:00
Meisoft 184407b1a2 Merge pull request #117 from Meisoftcoltd/fix-calculator-guards-9910739610236774791
🧪 Test: add safety guards and tests for video calculators
2026-05-14 22:26:39 +02:00
Meisoftcoltd 71534b968e Use safetensors instead of torch.save/load to fix insecure deserialization 2026-05-14 20:25:20 +00:00
Meisoftcoltd 3afcee2bef test: add safety guards and unit tests for video loop calculators
Added input validations (`source_frame_count <= 0` or `target_frames_per_loop <= 0`) to `AutoLoopCalculatorLTX` (in `loop.py`), and `AutoLoopCalculator`, `AutoLoopCalculatorWan` (in `video.py`). When conditions are met, a safe default is returned to prevent math domain errors. Also included a new comprehensive unit test suite in `tests/test_loop_calculator.py`.
2026-05-14 20:24:55 +00:00
Meisoftcoltd 5a295242e4 🧹 Remove unused import os from __init__.py 2026-05-14 20:22:39 +00:00
Meisoftcoltd cc17f2561e Add tests for MasterSwitch route
Addresses the missing tests for MasterSwitch node's route and check_lazy_status methods in switch_node.py. Tests verify branching functionality based on is_final_cycle parameter and include edge case checking for None values.
2026-05-14 20:15:45 +00:00
Meisoft bff976781c Merge pull request #113 from Meisoftcoltd/add-exception-check-to-padding-4025659044740554957
🧪 [Testing] Add exception check for non-tensor input in pad()
2026-05-14 22:14:58 +02:00
Meisoftcoltd 7bef683128 Add exception check for non-tensor input in VAESafeFramePadder.pad()
Modified `tools.py` to add an `isinstance` check and raise a `ValueError` when non-tensor input is passed. Also added `test_tools.py` to verify this behavior.
2026-05-14 20:03:57 +00:00
Meisoft 9df7a16940 Merge pull request #112 from Meisoftcoltd/feat/audio-duration-calculator-1774713163093024603
feat(tools): add AudioDurationCalculator node
2026-05-14 11:49:21 +02:00
Meisoftcoltd 0c280ec6f2 feat(tools): add AudioDurationCalculator node
Adds a new ComfyUI utility node `AudioDurationCalculator` to `tools.py` for
calculating the exact duration of an audio file in seconds. Includes robust
error handling to ensure the input is a valid dictionary and returns a
logging string output to maintain consistency with other tools.
2026-05-14 08:42:29 +00:00
Meisoft c44796d59b Merge pull request #111 from Meisoftcoltd/fix/unit-clash-tts-timeline-2852292516543970131
Fix: Resolve TTS timeline unit clash in SessionImageSender
2026-05-14 00:36:59 +02:00
Meisoftcoltd 6cd32fcfe9 Fix: Resolve Unit Clash between TTS chunks and video frames in Sequential Batcher
- Introduce `global_step_by_chunk` ghost variable initialized to `False` in `loop.py`
- Set `loop.global_step_by_chunk = True` in `AutoLoopCalculatorTTS` to indicate chunk mode
- Reset `loop.global_step_by_chunk = False` in video calculators (`AutoLoopCalculator`, `AutoLoopCalculatorWan`, `AutoLoopCalculatorLTX`) to ensure state resets
- Update `SessionImageSender.send_image` timeline management to branch logic, using `is_chunk_mode` to calculate progress differently for text chunks versus video frames
2026-05-13 22:34:58 +00:00
Meisoft db22f2ea3f Merge pull request #110 from Meisoftcoltd/improve-tts-auto-loop-calculator-2913379197447008085
feat(tts): add intelligent buffer to TTS sequence chunking
2026-05-11 23:57:48 +02:00
Meisoftcoltd 7b900c10cd feat(tts): implement intelligent text buffering for AutoLoopCalculatorTTS
This commit replaces the raw regex text-splitting logic in `AutoLoopCalculatorTTS` with an "intelligent buffer" approach. It first splits the text by sentence terminators as before, but now aggregates these smaller segments until they contain a minimum of 5 words.

This prevents TTS models from generating robotic, out-of-context micro-audios when encountering trailing ellipses (`...`) or extremely short phrases, leading to more natural intonation and pacing.

All existing loop and timeline injections are preserved.
2026-05-11 21:48:30 +00:00
Meisoft 48ce1b3826 Merge pull request #109 from Meisoftcoltd/feat/tts-regex-chunking-8529750223989142037
feat(tts): improve AutoLoopCalculatorTTS regex chunking
2026-05-09 19:54:48 +02:00
Meisoftcoltd d1d5f47562 feat(tts): implement regex-based robust chunking for AutoLoopCalculatorTTS
Replaced simple paragraph/sentence splitting with advanced regex
to gracefully handle split scenarios involving `.`, `?`, `!`,
quotes, ellipses, and `\n`. Punctuation is correctly retained on
the chunks to assure proper TTS intonation.

Moved `import re` to module level imports.
2026-05-09 17:46:49 +00:00
Meisoft 960acd7371 Merge pull request #108 from Meisoftcoltd/fix-graph-surgery-node-input-error-6346624600916527026
fix: remove destructive graph surgery causing NodeInputError
2026-05-09 13:37:07 +02:00
Meisoftcoltd 7f025b9a18 fix: remove destructive graph surgery in SequentialLoopTrigger
Replaced destructive `del inputs["on_true"]` and `del inputs["on_false"]` with simple state update `inputs["is_final_cycle"] = is_next_final` to rely on MasterSwitch's native lazy evaluation (`check_lazy_status`), fixing a `NodeInputError` bug during looping.
2026-05-09 11:27:29 +00:00
Meisoft eac81a4b14 Merge pull request #107 from Meisoftcoltd/fix-tts-chunk-calculation-6615407643540930151
Fix estimated chunk size for AutoLoopCalculatorTTS
2026-05-03 12:44:59 +02:00
Meisoftcoltd 1f64452691 Fix estimated chunk size calculation for AutoLoopCalculatorTTS
Update `SequentialLoopTrigger` in `loop.py` to correctly identify
`AutoLoopCalculatorTTS` and set `estimated_chunk` to 1 instead of falling
back to a default of 50. This prevents the trigger from incorrectly guessing
when the final loop occurs and mutating the graph prematurely, which caused
errors during sequential TTS tasks.
2026-05-03 10:41:15 +00:00
Meisoft 7375f9d478 Merge pull request #106 from Meisoftcoltd/fix/incremental-video-stitcher-inplace-error-9647683454534116380
Fix RuntimeError: Inplace update to inference tensor outside InferenceMode
2026-05-02 23:55:48 +02:00
Meisoftcoltd d6c2a4b012 Fix RuntimeError due to in-place tensor updates in InferenceMode
Refactors `IncrementalVideoStitcher.stitch` to eliminate in-place assignments
into a pre-allocated empty tensor when loading image frames in parallel, which
caused `RuntimeError: Inplace update to inference tensor outside InferenceMode
is not allowed.` in newer PyTorch versions.

The refactored implementation uses a pure map function to return individual
tensors and concatenates them sequentially using `torch.cat`, remaining thread-safe
and compatible with PyTorch's `InferenceMode`. Security against deserialization
vulnerabilities is preserved by ensuring `weights_only=True` remains when loading
audio caches.
2026-05-02 21:55:05 +00:00
Meisoft b7e1fb6f89 Merge pull request #105 from Meisoftcoltd/test-video-audio-error-handling-369389381641730631
🧪 Add error handling and coverage tests for audio extraction
2026-05-02 22:46:31 +02:00
Meisoftcoltd ba6fc7b8fc 🧪 Add error handling and coverage tests for audio extraction
- Implemented unit tests for `extract_and_standardize_audio` in `video.py`.
- Added test case to verify exception handling when `torchaudio.load` fails.
- Expanded coverage to include resampling and channel standardization (mono/multichannel to stereo).
- Followed the repository's clean testing pattern for mocking dependencies and loading code under test.
2026-05-02 20:46:16 +00:00
Meisoft 41c2d7d34c Merge pull request #104 from Meisoftcoltd/performance-loop-optimization-4925127687414595231
⚡ Performance Optimization: Merge sequential loops in prompt iteration
2026-05-02 22:45:54 +02:00
Meisoftcoltd ecdda64169 ⚡ optimize: merge sequential loops in prompt iteration
Merged two sequential loops over `prompt.items()` in `SequentialLoopTrigger.trigger_next` into a single pass.
The first pass now simultaneously finds the `estimated_chunk` from `AutoLoopCalculator` and performs mutations for seeds and loop indices.
`MasterSwitch` mutations are deferred to a small secondary pass after `is_next_final` is calculated using the gathered chunk size.

This change reduces the overhead of iterating over large prompt dictionaries and yielded a ~26.8% performance improvement in synthetic benchmarks.
2026-05-02 20:45:27 +00:00
Meisoft 26428789ae Merge pull request #103 from Meisoftcoltd/fix-insecure-deserialization-torch-load-video-3886399146367191308
🔒 [security fix] Insecure Deserialization via torch.load() in video.py
2026-05-02 22:44:51 +02:00
Meisoftcoltd 4ce91323a1 🔒 Security fix: Use weights_only=True in torch.load()
Added `weights_only=True` to `torch.load()` in `video.py` to prevent insecure deserialization and potential arbitrary code execution via crafted pickle files. This change restricts the unpickler to only load tensors, primitive types, and basic containers, which is sufficient for the audio dictionary being loaded.
2026-05-02 20:44:38 +00:00
Meisoft cb7b0374ae Merge pull request #102 from Meisoftcoltd/update-tts-calculator-split-mode-4843793747721075440
feat: Update AutoLoopCalculatorTTS to support sentence and paragraph splitting
2026-05-02 22:43:02 +02:00
Meisoftcoltd 08edcf71cb feat: add split mode to AutoLoopCalculatorTTS
This commit introduces a new dropdown to the `AutoLoopCalculatorTTS` node inside `video.py`, allowing text to be split by either "Párrafos (Saltos de línea)" or "Frases (Puntos)". When splitting by sentences, it cleanly preserves the periods to maintain proper intonation in TTS systems. Output pins are renamed (`current_text`, `total_chunks`) for better semantics.
2026-05-02 20:31:44 +00:00
Meisoft f49014866a Merge pull request #101 from Meisoftcoltd/cleanup-video-imports-5692053012707757175
🧹 Cleanup unused and redundant imports in video.py
2026-05-02 22:29:26 +02:00
Meisoftcoltd 60fc92fec0 🧹 Cleanup unused and redundant imports in video.py
- Removed unused import `sys`.
- Removed unused import `nodes`.
- Removed redundant duplicate import `torch`.
- Removed orphaned comment related to the duplicate import.
2026-05-02 20:29:12 +00:00
Meisoft 8fc697a8c3 Merge pull request #100 from Meisoftcoltd/testing-format-bytes-utility-10775869003227782014
🧪 Unit tests for format_bytes utility function
2026-04-30 17:16:46 +02:00
Meisoftcoltd 75ab5d9676 🧪 Add unit tests for format_bytes utility function
- Created tests/test_vram_node.py
- Mocked dependencies (torch, comfy) to allow testing in isolation
- Implemented test cases for zero values, exact GB, fractional values, small values (rounding check), and large values.
- Verified test robustness by confirming failure when logic was intentionally altered.
2026-04-30 15:16:06 +00:00
Meisoft cba02bf08f Merge pull request #99 from Meisoftcoltd/security-fix-command-injection-ffmpeg-3363411280039704601
🔒 [security] Fix command injection in FFmpeg installation
2026-04-30 17:14:42 +02:00
Meisoftcoltd 8d8e3deb1e fix: 🔒 prevent command injection in FFmpeg installation
Replaces the shell-based subprocess call with list-based arguments and
separate invocations for 'apt-get update' and 'apt-get install'.
This eliminates the risk of command injection by avoiding shell
interpretation and properly handles the DEBIAN_FRONTEND environment
variable via sudo.
2026-04-30 14:59:46 +00:00
Meisoft a875f536da Merge pull request #98 from Meisoftcoltd/perf-concurrent-image-loading-13430517007901480050
⚡ [Performance] Concurrent image loading in Video Stitcher
2026-04-30 16:56:44 +02:00
Meisoftcoltd 12a55c7fd6 ⚡ [Performance] Concurrent image loading in Video Stitcher
Introduced concurrent image loading and processing in the Video Stitcher node
using ThreadPoolExecutor. This optimizes the transition from disk-cached PNGs
to the final video tensor by parallelizing I/O and color conversion.

- Replaced sequential loop with ThreadPoolExecutor.map
- Wrapped executor.map in list() to ensure exception propagation
- Verified ~85% speedup in mocked I/O-bound benchmarks
2026-04-30 14:55:12 +00:00
Meisoft 119626a20f Merge pull request #97 from Meisoftcoltd/add-tts-calculator-and-audio-stitcher-15540597175344943849
Support text-driven sequential looping and audio stitching
2026-04-28 19:29:57 +02:00
Meisoftcoltd b854a90bf9 feat: add AutoLoopCalculatorTTS and update IncrementalVideoStitcher for audio caching
- Registered `AutoLoopCalculatorTTS` in `NODE_DISPLAY_NAME_MAPPINGS`.
- Added `AutoLoopCalculatorTTS` class to `video.py` to allow looping through text paragraphs instead of video frames.
- Replaced the implementation of `IncrementalVideoStitcher` to cache audio tensors as `.pt` files and concatenate them along the time axis on the final cycle.
2026-04-28 17:26:50 +00:00
Meisoft e1181c468c Merge pull request #96 from Meisoftcoltd/ffmpeg-audio-standardizer-5748644544721797280
Fix VHS audio bug by physically cloning video with standardized limits
2026-04-25 21:12:26 +02:00
Meisoftcoltd e4f53ca75a feat(video): add conditional FFmpeg audio standardizer for VHS safety
- Introduced `standardize_video_file` helper in `video.py`.
- The helper creates a physical video clone via FFmpeg, copying the video stream exactly to retain quality while limiting the audio stream.
- Audio limits are conditionally applied using `torchaudio.info` (<=44100Hz and <=2 Channels) to avoid unnecessary processing.
- Implemented a "Fail-Fast" approach with `raise Exception` if the FFmpeg cloning process fails, preventing silent downstream crashes.
- Injected the helper directly into `VideoAnalyzerFaceDetector` and `VideoAnalyzerSceneDetector` to secure the path passed down to VHS.
2026-04-25 18:50:46 +00:00
Meisoft 16604f101c Merge pull request #95 from Meisoftcoltd/refactor/standardize-audio-extraction-7105937645850785286
Standardize audio to 44100Hz/Stereo and cleanup residual files
2026-04-25 15:57:42 +02:00
Meisoftcoltd de0797ddb9 Refactor audio extraction and clean up residual files
- Created `extract_and_standardize_audio` helper in `video.py` to standardize audio to 44100Hz and Stereo format.
- Refactored `VideoAnalyzerFaceDetector` and `VideoAnalyzerSceneDetector` to use the new helper function.
- Permanently deleted `modify_facedetector.py`, `modify_facedetector2.py`, and `modify_facedetector3.py` residual scripts.
2026-04-25 13:46:36 +00:00
Meisoft 771f4bf709 Merge pull request #94 from Meisoftcoltd/feat/scene-cuts-priority-1402347662369726826
Refactor scene cuts priority in loop calculators
2026-04-25 14:23:15 +02:00
Meisoftcoltd d660a6b41b Refactor loop calculators to prioritize scene cuts over face cuts\n\n* Added SCENE_CUTS type to VideoAnalyzerSceneDetector.\n* Replaced "safe_faces_list" usage in loop calculators with "scene_cuts_list" as priority.\n* Removed dead code/scripts: update_video.py, fix_newlines.py, fix_tqdm.py, add_scenedetector.py. 2026-04-25 12:20:48 +00:00
Meisoft 0805acb114 Merge pull request #93 from Meisoftcoltd/video-scene-detector-2048273577937433579
feat(video): add scene detector node and caching for sequential batcher
2026-04-25 08:39:27 +02:00
Meisoftcoltd 5983ff65b4 feat: rename video analyzer to face detector, add global cache, and introduce scene detector node
- Renamed `VideoAnalyzerWithAudio` to `VideoAnalyzerFaceDetector`.
- Introduced `VIDEO_ANALYSIS_CACHE` and `current_loop_index` for cycle caching.
- Created `VideoAnalyzerSceneDetector` to detect scene changes via pixel difference.
- Added dynamic reference frame preview functionality outside the caching logic.
- Registered nodes with proper display names in `__init__.py`.
2026-04-24 21:29:55 +00:00
Meisoft d4e507af7c Merge pull request #91 from Meisoftcoltd/fix-lazy-session-cache-8902851697963122458
Fix aggressive Lazy Evaluation in LazySessionCache
2026-04-22 19:34:37 +02:00
Meisoftcoltd 863154901b fix(switch_node): implement aggressive lazy evaluation for LazySessionCache
- Moved `value_in` to the `optional` group to allow ComfyUI to defer its evaluation.
- Updated `check_lazy_status` to return an empty list `[]` for loops > 0, completely cutting off upstream execution.
- Retained the `GLOBAL_SESSION_CACHE` logic scoped by `unique_id`.
- Removed `@register_node` decorator to prevent duplicate registry issues since mapping is handled in `__init__.py`.
2026-04-22 17:34:09 +00:00
Meisoft 6479883f6d Merge pull request #90 from Meisoftcoltd/feat-primitive-delay-12488753207120426735
Add PrimitiveDelay Tool Node
2026-04-21 23:04:10 +02:00
Meisoftcoltd 89a5f767d7 feat: add PrimitiveDelay tool node for timeline control
Adds a PrimitiveDelay custom node to tools.py that blocks execution for a user-configurable number of seconds, acting as a passthrough using the global `AnyType` wildcard connection. This allows users to throttle rapid API requests, wait for IO tasks to complete, or generally space out ComfyUI execution times in sequential batcher workflows. Re-uses the existing AnyType instance from switch_node.py to adhere to DRY principles.
2026-04-21 21:01:56 +00:00
Meisoft fb35df1526 Merge pull request #89 from Meisoftcoltd/feature/lazy-session-cache-8805062770877095561
Add LazySessionCache node with lazy evaluation
2026-04-21 08:59:51 +02:00
Meisoftcoltd 2551cd5832 feat: add LazySessionCache to prevent redundant input node executions
Adds the LazySessionCache node to `switch_node.py` with lazy evaluation.
It intercepts and stores the input value in cycle 0 and bypasses upstream
evaluation in subsequent loops. Updates `__init__.py` to map the class
and register its display name cleanly.
2026-04-21 06:25:30 +00:00
Meisoft 3eb5dcb7ae Merge pull request #88 from Meisoftcoltd/feat/add-logs-and-rename-debug-9893747542349634290
feat: add log string outputs to all nodes and rename debug prefix
2026-04-19 15:35:51 +02:00
Meisoftcoltd ce6041da4a feat: rename [DEBUG] to [Secuencial Batcher] and add STRING log outputs to all nodes
This commit addresses two primary requests:
1. Replaces all console log prefixes `[DEBUG]` (and variations) with `[Secuencial Batcher]`.
2. Extends the `RETURN_TYPES` and `RETURN_NAMES` of all nodes to output a `log` string containing exactly the text emitted via `print()`. This string is constructed by intercepting `print()` calls inside node methods using a local wrapper and appending the texts to a list, which is then returned joined by newlines.
2026-04-19 13:33:52 +00:00
Meisoft 7d266f818c Merge pull request #87 from Meisoftcoltd/feat/vhs-path-selector-dual-output-1840554855603352002
feat: update VHS_Path_Selector outputs and category
2026-04-18 12:40:45 +02:00
Meisoftcoltd 798d791593 feat(tools): enhance VHS_Path_Selector with dual outputs and reordering
- Added `VHS_FILENAMES` to output alongside `STRING` to allow better integration with downstream nodes (like Telegram).
- Implemented list reordering to place the selected file at index 0, tricking default downstream behavior.
- Moved `VHS_Path_Selector` category to "🔁 Sequential Batcher/Tools" for better organization.
- Added `__pycache__/` to `.gitignore` to prevent tracking compiled binaries.
2026-04-18 09:45:16 +00:00
Meisoft 41d752577c Merge pull request #86 from Meisoftcoltd/add-vhs-path-selector-5637212873751470347
Add VHS Path Selector node
2026-04-17 23:08:36 +02:00
Meisoftcoltd 5be95e32ab Add robust VHS_Path_Selector custom node to tools.py
Added a new versatile custom node, VHS_Path_Selector, that intelligently processes the output of the VHS Video Combine node (VHS_FILENAMES). It features a robust "armored" mode to gracefully handle unexpected inputs (None, missing lists, or invalid types) without crashing the workflow. It allows users to select a specific path (video with audio, muted video, or png thumbnail) via a dropdown, with smart fallbacks to ensure downstream nodes like Telegram bots always receive a valid file path. Debug logs and print statements are maintained in Spanish for consistency.
2026-04-17 20:12:03 +00:00
Meisoft 3cf6c13d17 Merge pull request #85 from Meisoftcoltd/feat/wanvideo-4n-plus-1-rule-2198541111553782481
feat: enforce 4n+1 frame rule for WanVideo VAE padding and calculations
2026-04-12 21:12:33 +02:00
Meisoftcoltd 7187f783b4 feat: enforce 4n+1 frame rule for WanVideo VAE padding and calculations
Replaces the previous "multiple of 4" rule with the `4n + 1` logic across
the AutoLoopCalculatorWan and VAESafeFramePadder nodes. This prevents frame
loss / truncation inside the VAE and ensures absolute consistency for
long generation runs and video reconstruction. Mention of "múltiplo de 4"
in the primary README has also been updated to "regla de 4n + 1".
2026-04-12 19:02:40 +00:00
Meisoft 10e0affe28 Merge pull request #84 from Meisoftcoltd/fix-vae-frame-loss-and-png-cache-15856296271751515989
Fix VAE Frame Loss and Optimize RAM via PNG Cache
2026-04-12 18:16:03 +02:00
Meisoftcoltd 444b6c6513 Fix VAE frame loss by forcing timeline completion on final chunk and optimize stitcher RAM via lightweight PNG caching. 2026-04-12 16:10:56 +00:00
Meisoft 490e3322d7 Merge pull request #83 from Meisoftcoltd/fix/incremental-video-stitcher-overwrite-13565695973057979585
fix(video): blindar el IncrementalVideoStitcher contra bugs de caché y sobreescritura
2026-04-12 16:30:00 +02:00
Meisoftcoltd 8dc273ff16 fix: blindar IncrementalVideoStitcher para evitar sobreescritura de frames
- Se introduce la subcarpeta `meisoft_video_cache`.
- Se guardan los lotes con un timestamp en milisegundos para evitar sobreescrituras en caso de que falle el `current_loop_index`.
- Se incorpora `@classmethod def IS_CHANGED` con `time.time()` para obligar a ComfyUI a ejecutar siempre el nodo e ignorar el cache.
- El directorio temporal se limpia correctamente en el ciclo 0 y se destruye tras ensamblar el vídeo completo.
2026-04-12 14:26:43 +00:00
Meisoft 4c5d15567b Merge pull request #82 from Meisoftcoltd/fix-vram-allocation-and-sync-3903827223534401494
Optimize VRAM Defragmenter execution order and eliminate allocation conflicts
2026-04-12 14:46:51 +02:00
Meisoftcoltd 242ff772a5 fix: optimize VRAM purge order and remove conflicting environment variable
- Removed `PYTORCH_CUDA_ALLOC_CONF` from `__init__.py` to allow elastic VRAM expansion via `expandable_segments:True`.
- Swapped `torch.cuda.empty_cache()` and `torch.cuda.synchronize()` order in `vram_node.py` to ensure pending operations finish before purging cache.
- Added critical warnings in `README.md` and `README_EN.md` discouraging the use of `--highvram` flag when starting ComfyUI to prevent VRAM Defragmenter sabotage.
2026-04-12 12:46:33 +00:00
Meisoft e49056c835 Merge pull request #81 from Meisoftcoltd/fix/timeline-overflow-padding-7170942151912358163
fix: decouple timeline from frame padding to prevent EOF crashes
2026-04-12 12:53:57 +02:00
Meisoftcoltd 0d794fc10e fix: decouple timeline from frame padding to prevent EOF crashes
Replaced artificial safe limit in AutoLoopCalculatorWan (video.py) and
AutoLoopCalculatorLTX (loop.py) with actual physical source frame limit.
This prevents trigger nodes from falsely requesting frames past the EOF
which resulted in "RuntimeError: No frames generated".
2026-04-12 10:26:08 +00:00
Meisoft 55cab6c11b Merge pull request #80 from Meisoftcoltd/update-readme-v1-6-0-8587778050238850121
docs: Update READMEs and bump version to 1.6.0
2026-04-12 12:00:40 +02:00
Meisoftcoltd 74238937dc docs: update README to version 1.6.0 with English translation
Updated README.md with the newly provided v1.6.0 presentation text.
Translated the updated text and updated README_EN.md accordingly.
Bumped __version__ in __init__.py to "1.6.0".
2026-04-12 02:38:05 +00:00
Meisoft f26a7ef983 Merge pull request #79 from Meisoftcoltd/vae-safe-frame-padder-6798265915743811604
Add VAESafeFramePadder for Hold Last Frame expansion
2026-04-12 03:48:39 +02:00
Meisoftcoltd 1b75c40e3e feat: add VAESafeFramePadder and update loop calculators math
- Added VAESafeFramePadder to `tools.py` which clones the last frame
  of an image tensor to satisfy mathematical rules (4n for WanVideo,
  8n+1 for LTX) avoiding audio clipping and VAE crashes.
- Registered the new tool in `__init__.py` under NODE_DISPLAY_NAME_MAPPINGS.
- Updated `AutoLoopCalculatorWan` in `video.py` to pad and round up to a
  multiple of 4.
- Updated `AutoLoopCalculatorLTX` in `loop.py` to pad and round up to an
  8n+1 multiple.
2026-04-12 01:33:46 +00:00
Meisoft 5b70ccaf18 Merge pull request #78 from Meisoftcoltd/jules-fix-frame-absorption-bypass-final-loop-race-condition-18010398085203789983
Fix final batch race condition by delegating global counter reset
2026-04-11 19:30:41 +02:00
Meisoftcoltd 9e893334a1 Fix final batch race condition by delegating global counter reset
Removes the zeroing of `global_loop_index` and `global_accumulated_frames` from the `SequentialLoopTrigger` node.
This architectural shift solves a race condition where the stitcher would process the final chunk based on freshly reset counters (0) instead of the actual completed value, resulting in empty frames or structural bypassing. State cleanup is now exclusively handled by the entry start node.
2026-04-11 17:16:12 +00:00
Meisoft dea765dbcd Merge pull request #77 from Meisoftcoltd/jules-fix-frame-absorption-bypass-2060959048677752393
fix: implement final batch bypass and aggressive frame absorption
2026-04-11 15:06:46 +02:00
Meisoftcoltd d06e88a9b2 fix: implement final batch bypass and aggressive frame absorption
- In `image.py`, added a final batch bypass (`advance_check`) to disable face detection and cropping when the current batch completes the remaining video sequence. This prevents the generation of single-frame dummy batches at the end.
- In `video.py` and `loop.py`, updated `AutoLoopCalculator`, `AutoLoopCalculatorWan`, and `AutoLoopCalculatorLTX` to use an `absorption_threshold = equitable_target * 1.5`. This forces the calculator to absorb up to 50% extra remaining frames in a single batch, eliminating micro-cycles.
2026-04-11 12:24:50 +00:00
Meisoft cdc10e72c9 Merge pull request #76 from Meisoftcoltd/vram-defragmenter-upgrade-7373945555480505137
Upgrade MeisoftVRAMDefragmenter with deep cleanup and visual stats
2026-04-11 04:15:27 +02:00
Meisoftcoltd 7ec112a2cd feat: add multi-platform support to system RAM purging
- Detected OS platform dynamically
- Added `ctypes.windll.kernel32.SetProcessWorkingSetSize` for deep RAM release on Windows
- Retained `malloc_trim` via `libc.so.6` for Linux environments
2026-04-11 02:13:43 +00:00
Meisoftcoltd c214b71b76 feat: upgrade VRAM Defragmenter node for deep memory cleaning
- Implemented dynamic allocator configuration (`expandable_segments:True`)
- Added native ComfyUI internal reference cleanup
- Improved python GC and system RAM purging (`malloc_trim` on Linux)
- Added clear visual debug output displaying VRAM recovered
2026-04-11 02:06:38 +00:00
Meisoft bfa0125a21 Merge pull request #75 from Meisoftcoltd/fix-video-analyzer-cache-3265285744156689374
fix: correct VideoAnalyzerWithAudio caching logic
2026-04-10 10:31:22 +02:00
Meisoftcoltd 2731ab0779 feat(video): add native ComfyUI progress bar to VideoAnalyzerWithAudio
Inject `comfy.utils.ProgressBar` into the frame scanning loop in `VideoAnalyzerWithAudio`. This provides a visual native green progress bar directly on the node in the ComfyUI interface, giving real-time feedback during long video analyses without requiring the user to monitor the terminal output.
2026-04-10 08:30:56 +00:00
Meisoftcoltd 35975007e7 fix(video): fix cache invalidation in VideoAnalyzerWithAudio
Update `IS_CHANGED` in `VideoAnalyzerWithAudio` to rely on the video path
and node parameters instead of `os.path.getmtime` and `time.time()`. This fixes
an issue where the node would constantly invalidate the cache and re-run on
every execution, rather than reusing cached results when inputs have not changed.
2026-04-10 08:19:59 +00:00
Meisoft 7cc86e6b94 Merge pull request #74 from Meisoftcoltd/fix/micro-cycle-death-loop-3346897521993443470
Fix micro-cycle death loops in mathematical frame calculation
2026-04-08 23:28:51 +02:00
Meisoftcoltd 63e84c2aa1 Fix Micro-Cycle Death Loops in frame calculation
This commit patches the `SessionImageSender`, `AutoLoopCalculatorWan`,
and `AutoLoopCalculatorLTX` nodes to prevent infinite loops caused by
extreme truncation at the end of video sequences.

- `image.py`: Ensures `SessionImageSender` accepts the entire batch if no
  faces are detected, rather than defaulting to frame 0, which previously
  trapped the workflow in a 1-frame micro-cycle loop.
- `video.py`: Updates `AutoLoopCalculatorWan` to always round the final
  chunk size up to the nearest multiple of 4, removing the strict
  cropping logic that broke the 3D VAE rule.
- `loop.py`: Updates `AutoLoopCalculatorLTX` to always round the final
  chunk size up to the nearest `8n + 1` value, removing the strict
  cropping logic that broke the DiT rule.
2026-04-08 21:22:08 +00:00
Meisoft df18b573d0 Merge pull request #73 from Meisoftcoltd/vram-defragmentation-wanvideo-13266185802220085488
feat: Implement Deep VRAM Defragmentation for WanVideo
2026-04-08 20:01:44 +02:00
Meisoftcoltd ecd973b8e5 feat: implement deep VRAM defragmentation and update docs
- Enforced a strict memory cleanup sequence in `MeisoftVRAMDefragmenter` (including IPC cleanup and CUDA synchronization).
- Injected `PYTORCH_CUDA_ALLOC_CONF="max_split_size_mb:128"` in `__init__.py` to prevent VRAM micro-fragmentation.
- Added `unload_detector` option to `VideoAnalyzerWithAudio` to eagerly destroy the face detector model and free VRAM.
- Updated README.md with detailed explanations of the new features.
2026-04-08 12:04:56 +00:00
Meisoftcoltd 1c90c848e2 feat: implement deep VRAM defragmentation and PyTorch memory allocator fix
- Enforced a strict memory cleanup sequence in `MeisoftVRAMDefragmenter` (including IPC cleanup and CUDA synchronization).
- Injected `PYTORCH_CUDA_ALLOC_CONF="max_split_size_mb:128"` in `__init__.py` to prevent VRAM micro-fragmentation.
- Added `unload_detector` option to `VideoAnalyzerWithAudio` to eagerly destroy the face detector model and free VRAM.
2026-04-08 11:56:02 +00:00
Meisoft 444dc2c350 Merge pull request #72 from Meisoftcoltd/vram-defragmenter-1390025307932295400
Implement VRAM Defragmentation Tactic
2026-04-08 09:56:13 +02:00
Meisoftcoltd 1e36f9e915 feat: implement VRAM defragmentation strategy and optimization
- Created `MeisoftVRAMDefragmenter` in `vram_node.py` as a passthrough to force model unloading, Python garbage collection, and CUDA cache defragmentation.
- Registered the new node in `__init__.py`.
- Added light memory cleanup on intermediate batch loops in `loop.py`, alongside a heavy cleanup (including ctypes `malloc_trim`) at the end.
- Added `unload_detector_after_analysis` boolean flag to `VideoAnalyzerWithAudio` in `video.py` to allow releasing the BBOX detector model immediately after use.
2026-04-08 07:48:48 +00:00
Meisoft 1701eeb218 Merge pull request #71 from Meisoftcoltd/feat/master-switch-lazy-evaluation-17740267901861549413
feat: Implement MasterSwitch for intelligent lazy branch evaluation
2026-04-07 22:12:11 +02:00
Meisoftcoltd 3f9c7cbee1 docs: update READMEs with MasterSwitch documentation
- Add Spanish and English descriptions for the new MasterSwitch node.
- Highlight Native Lazy Evaluation and Hot Graph Surgery features.
2026-04-07 20:11:40 +00:00
Meisoftcoltd ff5643b098 feat: add MasterSwitch node with lazy evaluation and graph surgery
- Implement `switch_node.py` with `MasterSwitch` handling native lazy evaluation.
- Register `MasterSwitch` in `__init__.py` using clean names.
- Intercept and apply Hot Graph Surgery in `loop.py` to strip out unneeded nodes in auto loop cycles for reduced VRAM usage.
2026-04-07 19:23:01 +00:00
Meisoft e647ac0728 Merge pull request #69 from Meisoftcoltd/feat/conditional-audio-router-14058830236118345976
feat: add ConditionalAudioRouter for bypassing audio processes in intermediate cycles
2026-04-06 10:31:34 +02:00
Meisoftcoltd 6080f61c27 feat: Add ConditionalAudioRouter node for Lip Sync bypass
Added `ConditionalAudioRouter` to `tools.py` with `torch` import to generate an empty audio tensor for bypassing a LipSync node during intermediate loops without breaking it. Registered node to `__init__.py`.
2026-04-06 08:08:48 +00:00
Meisoft 9cc66a4c80 Merge pull request #68 from Meisoftcoltd/fix/sequential-batcher-final-cycle-15656548652125589395
Fix Sequential Batcher intermediate behaviors on final cycle
2026-04-05 23:36:11 +02:00
Meisoftcoltd bd6016f559 fix: bypass keyframe save and output full tensor on final cycle
- Updated `SessionImageSender` to conditionally save keyframe only when not in the final cycle.
- Updated `IncrementalVideoStitcher` to return full concatenated tensor when in the final cycle, avoiding dummy frame output and blocking downstream nodes.
- Expose `IS_FINAL_CYCLE` boolean from `IncrementalVideoStitcher` to UI for downstream conditionals.
2026-04-05 21:32:14 +00:00
Meisoft 8a0ceca136 Merge pull request #67 from Meisoftcoltd/fix-silence-and-transparency-11065722630316388106
fix: Silence YOLO outputs and improve transparency in calculators
2026-04-05 19:07:58 +02:00
Meisoftcoltd 608be49841 fix: Silence YOLO outputs and improve transparency in calculators 2026-04-05 17:03:15 +00:00
Meisoft c7c8c93c9c Merge pull request #66 from Meisoftcoltd/fix/video-detector-tqdm-18028740387197632355
Fix bbox detector tuple error, silence YOLO spam, and add tqdm to video scan
2026-04-05 13:53:50 +02:00
Meisoftcoltd a6d8ea88dc docs: add documentation for GPU-accelerated face scanning
- Added Spanish documentation block to `README.md` explaining how to use the new `bbox_detector` port with Impact Pack.
- Added English documentation block to `README_EN.md` with the same instructions.
2026-04-05 11:33:59 +00:00
Meisoftcoltd 93d479fb6c fix: correct ImpactPack tuple unpacking and add tqdm to video frame scan
- Fixed tuple index extraction (`res[0]`) for `SEGS` object in bbox_detector.
- Silenced Ultralytics/YOLO console spam by wrapping detection logic in `redirect_stdout`.
- Upgraded the `while True:` loop to a `for` loop with `tqdm` for a clean progress bar, while preserving the RGB frame conversion logic for accuracy.
2026-04-05 11:26:17 +00:00
Meisoft 30670f0602 Merge pull request #65 from Meisoftcoltd/patch-loop-calculators-6028840695815220919
Implement proportional adjustment in Loop Calculators
2026-04-05 11:52:57 +02:00
Meisoftcoltd a5db690cdf feat: implement proportional adjustment logic for AutoLoopCalculators
- Patches `AutoLoopCalculator` in `video.py` to add base proportional adjustment without VAE constraints.
- Patches `AutoLoopCalculatorLTX` in `loop.py` to enforce the strict 8n + 1 frame rule for LTX models, matching its calculation mathematically correctly for proportional chunking.
- Fixes `__pycache__` to be excluded correctly.
2026-04-05 09:36:44 +00:00
Meisoft 80d739782a Merge pull request #64 from Meisoftcoltd/feature/gpu-bbox-detector-16186696208497101373
Modularize VideoAnalyzerWithAudio with external GPU BBOX_DETECTOR support
2026-04-05 10:25:44 +02:00
Meisoftcoltd 8ae2905bb3 feat: modularize VideoAnalyzerWithAudio to accept external YOLO/ONNX models
- Add optional `bbox_detector` input to `VideoAnalyzerWithAudio`.
- Implement GPU-accelerated face detection using external BBOX_DETECTOR models.
- Support both ImpactPack and standard ONNX bounding box output formats.
- Retain CPU-based OpenCV face detection as a fallback.
- Rename node in UI to "🕵️ Video Analyzer Face detector + Audio".
- Update README.md and README_EN.md to reflect new architecture and wiring.
2026-04-04 23:56:51 +00:00
Meisoft 939a057ea0 Merge pull request #63 from Meisoftcoltd/fix/wan-video-proportional-calculator-12700460131794536740
feat: auto recalculate proportional chunk targets for wanvideo
2026-04-04 21:35:16 +02:00
Meisoftcoltd dc0d3db2f8 feat: proportional frame target recalculation for wanvideo calculator
* Add MEISOFT PATCH to AutoLoopCalculatorWan to dynamically adjust target frames based on safe effective frame multiples.
* Replace static math.ceil calculations and frame absorptions to equitably distribute frames for 3D VAE rendering.
* Simplify chunk limits validation utilizing pre-calculated mathematical bounds, preventing VAE crash micro-cycles.
2026-04-04 19:33:17 +00:00
Meisoft 2b9e960836 Merge pull request #62 from Meisoftcoltd/feat/ltx-2.3-native-support-15170523607356332340
Add native support for LTX 2.3 model (8n+1 chunking and 32x resolution rules)
2026-04-03 15:24:04 +02:00
Meisoftcoltd e443ec7112 feat: rename ResTool32xLTX to ResTool64xLTX for IC-LoRA compatibility
- Renamed `ResTool32xLTX` to `ResTool64xLTX` and updated DIVISOR to 64 to prevent odd-dimension `ValueError`s when using latent downscale factors of 2.0 (IC-LoRA).
- Updated visual node name in `__init__.py` to accurately reflect the 64x divisor behavior to avoid technical debt.
- Updated documentation in `README.md` and `README_EN.md` to reflect the rename and explain the downscale compatibility logic.
2026-04-03 13:06:59 +00:00
Meisoftcoltd 1ffccc5599 feat: add native support for LTX 2.3 model
- Implemented `AutoLoopCalculatorLTX` node to enforce `8n + 1` frame chunks.
- Added `ResTool32xLTX` node for strict 32x divisible resolutions with proper minimum pixel scaling.
- Updated `IncrementalVideoStitcher` (in `image.py`) to correctly subtract the overlapping anchor frame from global frame progression when LTX mode is active.
- Added `global_ltx_mode` reset in `SequentialLoopStart` to prevent state leakage to other non-LTX workflows.
- Registered nodes in `__init__.py` and updated `README.md` & `README_EN.md` with new features.
2026-04-03 13:00:41 +00:00
Meisoft 691130916f Merge pull request #61 from Meisoftcoltd/feat-auto-fps-limiter-8114498070454826927
feat: Add Auto FPS Limiter node
2026-04-03 12:33:52 +02:00
Meisoftcoltd 634edbadc8 feat: Add Auto FPS Limiter node to prevent VRAM OOM on high framerate videos
- Added AutoFPSLimiter class in `tools.py` to calculate `select_every_nth` and proportional `new_fps`.
- Registered `AutoFPSLimiter` in `__init__.py` under NODE_DISPLAY_NAME_MAPPINGS.
- Updated `README.md` and `README_EN.md` Architecture sections with details about the new node.
2026-04-03 10:19:42 +00:00
Meisoft c4d3341637 Merge pull request #60 from Meisoftcoltd/fix-video-analyzer-output-type-10133959255409206744
Update VideoAnalyzerWithAudio path output to wildcard
2026-04-03 10:23:26 +02:00
Meisoftcoltd e77fca6dcf feat: change VideoAnalyzerWithAudio output path type to wildcard
Changed the output type for the video path in the VideoAnalyzerWithAudio
node from STRING to `*` to allow for flexible wildcard connections in
ComfyUI. Also updated the return name to video_path to accurately
reflect the output variable.
2026-04-03 08:21:25 +00:00
Meisoft 16bf944e5f Merge pull request #59 from Meisoftcoltd/docs/update-readme-17998199661185094310
docs: Add missing node descriptions and fix UI titles
2026-04-02 10:28:05 +02:00
Meisoftcoltd 95e2f0b8bd docs: Update README with missing node documentation
- Synced documented node names with actual UI titles in `__init__.py`.
- Added documentation for missing nodes: `Auto Loop Calculator (WanVideo 3dVAE)`, `Session Image Receiver`, `Session Image Sender`.
- Documented the `Megapixel Shield` / `ResTool` modules.
- Applied symmetric updates to both `README.md` and `README_EN.md`.
2026-04-02 08:20:01 +00:00
Meisoft f6abc37a7c Merge pull request #58 from Meisoftcoltd/fix-fail-fast-loop-port-3226992272487219752
feat: implement Fail-Fast validation for API port in Sequential Loops
2026-04-02 09:56:21 +02:00
Meisoftcoltd 762ed08324 feat: implement Fail-Fast mechanism for sequential loop port verification
- Moved port configuration from `SequentialLoopTrigger` to `SequentialLoopStart` to act as the single source of truth.
- Added a pre-flight check in `SequentialLoopStart` that pings the ComfyUI API endpoint `/system_stats` with a 2-second timeout.
- Aborts workflow execution immediately via `ValueError` if the port is incorrect or unreachable, saving hours of processing time by failing fast instead of at the end of the batch.
- Configured `SequentialLoopTrigger` to silently read `global_server_port` rather than requiring it as a node input.
- Ensured console debug logs remain cleanly formatted in Spanish to match user expectations.
2026-04-02 07:45:54 +00:00
Meisoft 1d6b4fc284 Merge pull request #57 from Meisoftcoltd/feat/vram-auto-cleanup-17961398468376006004
Implementar limpieza automática extrema de VRAM en SequentialLoopTrigger
2026-04-02 09:14:59 +02:00
Meisoftcoltd 788ee2c084 docs: Actualizar README para incluir Auto-Limpieza de VRAM
- Añadida sección detallada sobre la nueva característica de Auto-Limpieza de VRAM en SequentialLoopTrigger.
- Añadidos detalles en Changelog (v1.5.3) en los README en español e inglés.
2026-04-02 07:14:22 +00:00
Meisoftcoltd 9fa6057024 feat: Implementar limpieza extrema de VRAM automática en SequentialLoopTrigger
- Añadir las dependencias gc, torch y comfy.model_management en loop.py.
- Implementar vaciado profundo de VRAM al finalizar la generación de frames en el método trigger_next.
- Asegurar reinicio del contador global_accumulated_frames.
- Incluir soporte multiplataforma para el vaciado de cachés de PyTorch (CUDA, ROCm y MPS para Mac).
2026-04-02 07:05:16 +00:00
Meisoft 128c2cf890 Merge pull request #56 from Meisoftcoltd/update-v1.5.2-2137503402934091735
Global Update v1.5.2
2026-04-02 08:54:23 +02:00
Meisoftcoltd 8ab37acb47 feat: Global Update v1.5.2
- Improve error handling in video.py to capture and display Torchaudio exceptions.
- Add explicit list of dependencies in requirements.txt.
- Introduce install.py for automated installation of Python and OS dependencies (FFmpeg).
- Complete redesign of Spanish and English READMEs with v1.5.2 Changelog, new features, and professional workflow setup.
2026-04-02 06:52:39 +00:00
Meisoft 268ba8f0a1 Merge pull request #55 from Meisoftcoltd/fix/vhs-hybrid-input-support-3754654525981139686
Refactor VideoAnalyzerWithAudio for VHS-native hybrid input
2026-04-01 15:49:20 +02:00
Meisoftcoltd 8afdfa703f Refactor VideoAnalyzerWithAudio input to natively support VHS hybrid connections
Updated the `VideoAnalyzerWithAudio` node in `video.py` to match the exact behavior of `VHS_LoadVideo`. By removing the wildcard (`*`) hack and providing a valid tuple with a local file list alongside `{"forceInput": False, "video_upload": True}`, the node now officially renders a standard ComfyUI file uploader widget that can be cleanly converted to a `STRING` input via right-click ("Convert video to input").

Also retained the absolute `VALIDATE_INPUTS` shield returning `True`, guaranteeing dynamic wire inputs (e.g. from YTDLP) won't cause ComfyUI execution queue validation errors when dealing with paths generated at runtime. Cleaned up internal list unpacking hacks in `IS_CHANGED` and `analyze` since standard strings are now properly passed.
2026-04-01 13:49:06 +00:00
Meisoft 7450480b58 Merge pull request #54 from Meisoftcoltd/fix/video-analyzer-input-type-15902259390195045088
Fix VideoAnalyzerWithAudio input type compatibility
2026-04-01 15:25:19 +02:00
Meisoftcoltd b85e6b9181 fix(video): change video input type to wildcard to support dynamic connections
Replaced the strict 'STRING' input type with the '*' wildcard in `VideoAnalyzerWithAudio`. This bypasses ComfyUI's strict type checking for the input port, allowing downloading nodes (like YTDPL) that output custom types (e.g., `VIDEO_PATH` or `PATH`) to connect seamlessly. Updated `VALIDATE_INPUTS` comments to reflect its role as a 'Total Shield' for bypassing dynamic path validation.
2026-04-01 13:08:09 +00:00
Meisoft 1203c2a305 Merge pull request #53 from Meisoftcoltd/fix-video-analyzer-bypass-17137400409083468259
Fix ComfyUI strict pre-flight validation for dynamic video downloads
2026-04-01 14:49:03 +02:00
Meisoftcoltd 44af2b0db5 Fix ComfyUI strict pre-flight validation for dynamic video downloads
- Inject VALIDATE_INPUTS method into VideoAnalyzerWithAudio class.
- Allows downstream dynamic file paths while keeping manual VHS upload widget intact.
2026-04-01 12:48:45 +00:00
Meisoft fd18a73145 Merge pull request #52 from Meisoftcoltd/fix-video-analyzer-input-validation-10810886702170566018
Refactor VideoAnalyzerWithAudio to resolve dynamic path validation blocks
2026-04-01 14:22:04 +02:00
Meisoftcoltd 0cdc0394fe refactor: remove VALIDATE_INPUTS to fix dynamic routing block
Deleted `opt_video_path` parameter and the strict `VALIDATE_INPUTS` classmethod in `VideoAnalyzerWithAudio`. ComfyUI was validating the cached manual widget value before downstream dynamic nodes could finish processing strings. Routing everything directly through a single `video` input and completely removing `VALIDATE_INPUTS` properly delegates execution dependency to ComfyUI's native graph path order.
2026-04-01 12:20:54 +00:00
Meisoft 5b046f3c4d Merge pull request #51 from Meisoftcoltd/fix-video-analyzer-validation-16722397764196416172
Fix: Dynamic bypass in VideoAnalyzerWithAudio validation
2026-04-01 13:37:18 +02:00
Meisoftcoltd 091cffe3d4 Update VALIDATE_INPUTS in VideoAnalyzerWithAudio
Implemented a hybrid validation logic in the `VALIDATE_INPUTS` method of `VideoAnalyzerWithAudio`.
This bypasses the strict `os.path.exists()` check if the optional dynamic input (`opt_video_path`) is connected, allowing ComfyUI to start the workflow and wait for upstream nodes (like downloaders) to create the file at runtime.
Strict physical file validation is maintained for the standard manual video upload widget.
2026-04-01 11:36:58 +00:00
Meisoft ae8187515b Merge pull request #50 from Meisoftcoltd/add-video-path-input-4779784611605875262
feat: allow physical video path input for VideoAnalyzerWithAudio
2026-04-01 13:13:38 +02:00
Meisoftcoltd 5a9bafe7d6 feat: add optional physical wire input for video path to VideoAnalyzerWithAudio
Added an optional `opt_video_path` string input (with `forceInput: True`) to the `VideoAnalyzerWithAudio` node. This allows the node to receive a video path from another upstream node physically via a wire connection, bypassing the default file upload widget when connected. The `IS_CHANGED`, `VALIDATE_INPUTS` and `analyze` methods were uniformly updated to intercept and prioritize this new optional input.
2026-04-01 11:13:06 +00:00
Meisoft 4b2c34017b Merge pull request #49 from Meisoftcoltd/feature/allow-absolute-paths-video-analyzer-17120620811168814967
feat: allow absolute paths in VideoAnalyzerWithAudio
2026-03-31 18:29:55 +02:00
Meisoftcoltd bc49d06000 feat: allow absolute paths in VideoAnalyzerWithAudio
Update `VideoAnalyzerWithAudio` in `video.py` to support absolute paths for the `video_name` parameter. The node's `IS_CHANGED`, `VALIDATE_INPUTS`, and `analyze` methods have been modified to first check if the provided path exists directly using `os.path.exists()`. If it does, it uses the path directly, enabling the use of absolute paths. Otherwise, it falls back to the default `folder_paths.get_annotated_filepath()`.
2026-03-31 16:08:05 +00:00
Meisoft 3cdbc20144 Merge pull request #48 from Meisoftcoltd/add-autoloopcalculatorwan-node-15951337519853474380
feat: add AutoLoopCalculatorWan node for WanVideo model support
2026-03-31 17:37:09 +02:00
Meisoftcoltd 480ae9d815 feat: add AutoLoopCalculatorWan node with mathematical truncation for WanVideo 3D VAE
- Register `AutoLoopCalculatorWan` with its visual display name in `__init__.py`.
- Append `AutoLoopCalculatorWan` class definition to `video.py`.
- The new node enforces strict truncation in multiples of 4 to prevent 3D VAE crashes on the WanVideo model while skipping asymmetric trailing frames.
2026-03-31 15:30:44 +00:00
Meisoft b34b8db30d Merge pull request #47 from Meisoftcoltd/fix/batch-allocation-and-face-detection-13975859446113424338
Refactor Dynamic Batch Allocation and Session Image Sender Logic
2026-03-31 15:57:24 +02:00
Meisoftcoltd bc7b27a962 Refactor AutoLoopCalculator and SessionImageSender
- Changed validate_face to detect_faces parameter in SessionImageSender to skip OpenCV when False.
- Refactored AutoLoopCalculator to use dynamic batch allocation equitably distributing total frames over the number of needed loops.
2026-03-31 10:03:20 +00:00
Meisoft de78658336 Merge pull request #46 from Meisoftcoltd/feature/v1.5.2-resolution-numerical-freedom-10321333975368385029
feat(tools): change base_resolution to free INT input
2026-03-30 23:50:11 +02:00
Meisoftcoltd b67c0d12cc feat(tools): change base_resolution from dropdown to free numerical input
- Replaced the predefined dropdown list for `base_resolution` in `tools.py` with an open `INT` input (`min`: 128, `max`: 8192, `step`: 8) for the four Resolution Tools.
- Updated `README.md` and `README_EN.md` to reflect the new user interface change.
2026-03-30 21:45:50 +00:00
Meisoft 15702503e4 Merge pull request #45 from Meisoftcoltd/update-readme-v1.5.1-14949212851897037070
docs: reestructurar y actualizar README a la versión 1.5.1
2026-03-30 23:15:16 +02:00
Meisoftcoltd bf7ed3305c docs: reestructurar y actualizar README a la versión 1.5.1
- Se actualizaron las versiones de 1.5.0 a 1.5.1.
- Se añadió documentación exhaustiva de las entradas (Inputs) y salidas (Outputs) de todos los nodos según el código (`__init__.py`).
- Se introdujo documentación detallada para el nuevo "Escudo de Megapíxeles" (Megapixel Shield) de la suite de Herramientas.
- Se redactó una guía de conexiones limpia para la Arquitectura Híbrida de Identidad.
- Se mantuvo la paridad entre README.md (Español) y README_EN.md (Inglés).
2026-03-30 20:11:12 +00:00
Meisoft 1620a55a57 Merge pull request #44 from Meisoftcoltd/feature/add-source-fps-output-2961003863844222275
feat: Añadir salida source_fps al VideoAnalyzerWithAudio
2026-03-30 22:03:46 +02:00
Meisoftcoltd 55dbf0b0b1 feat: Añadir Escudo de Megapíxeles a Tools (v1.5.1)
- Se ha refactorizado `tools.py` para incluir un "Escudo de Área" que evita artefactos visuales en resoluciones bajas extremas escalando matemáticamente usando `MIN_PIXELS`.
- Se han actualizado los archivos `README.md` y `README_EN.md` con la nueva característica en la sección Tools.
- La versión de la suite en `__init__.py` se ha incrementado a 1.5.1.
2026-03-30 20:01:00 +00:00
Meisoftcoltd 4fc4fec43d feat(video): add source_fps float output to VideoAnalyzerWithAudio
- Added `source_fps` float extraction using OpenCV (`cv2.CAP_PROP_FPS`).
- Updated `RETURN_TYPES`, `RETURN_NAMES`, and the function's return payload.
- Printed detected FPS smoothly to the console debug stream.
2026-03-30 19:52:58 +00:00
Meisoft 3c55c21ba4 Merge pull request #43 from Meisoftcoltd/feature/v1.5.0-dynamic-engine-and-tools-14187832195967987532
Upgrade to v1.5.0: Dynamic Engine & Tools Suite
2026-03-30 21:11:08 +02:00
Meisoftcoltd 1c13d37fbc feat: implement Dynamic Engine and Tools suite for v1.5.0
This commit introduces the Hybrid Identity Architecture's Dynamic Engine:
- `tools.py`: Adds new Math constraint nodes (`ResTool 8x, 16x, 32x, 64x`) to calculate safe resolutions dynamically, preventing VRAM overflow.
- `loop.py`: Replaces the legacy static mathematical state machine with a dynamic performance-based state using `global_accumulated_frames` to progress accurately based on successful generations.
- `image.py`: Overhauls `SessionImageSender` to dynamically truncate generated image tensors using OpenCV if faces are lost, saving the last valid frame, and correctly adjusting the loop progression. `SessionImageReceiver` updated to log dynamic progression.
- `video.py`: Refactors `AutoLoopCalculator` and `IncrementalVideoStitcher` to work under the new dynamic state machine logic.
- `__init__.py`: Registered new nodes, updated imports to include `tools`, and bumped version to v1.5.0.
- `README.md` & `README_EN.md`: Updated documentation to detail the new Dynamic Engine functionality and Tools suite.
2026-03-30 19:03:16 +00:00
Meisoft f780a63a07 Merge pull request #42 from Meisoftcoltd/feat/tools-resolution-nodes-3416685684358741616
feat: add dedicated ResTool suite for safe resolutions
2026-03-30 20:19:41 +02:00
Meisoftcoltd 64172b5c64 feat: add dedicated ResTool suite for safe resolutions
This commit introduces a new `tools.py` module containing a `BaseResolutionTool` and its specific derived classes (`ResTool8x`, `ResTool16x`, `ResTool32x`, `ResTool64x`). These nodes calculate safe, model-specific dimensions that strictly conform to divisibility rules using provided aspect ratios and base resolutions.

The `__init__.py` file is updated to import and register these tools along with emojis for UI display. Both the English and Spanish READMEs are updated to document the new `Tools` suite.
2026-03-30 18:16:48 +00:00
Meisoft d0339ee6c7 Merge pull request #41 from Meisoftcoltd/feature/dynamic-margin-v1.4.3-3601289385715301537
feat(AutoLoopCalculator): implement dynamic +/- 10% margin and bump to v1.4.3
2026-03-30 19:46:18 +02:00
Meisoftcoltd b91831ab11 feat: apply +/- 10% dynamic margin to AutoLoopCalculator
Update `calculate` method in `video.py` to dynamically adjust loop lengths
by a 10% margin, absorbing remainders safely and preventing inefficient
micro-cycles at the end of videos. Update both README.md and README_EN.md
to reflect this new capability and bump the version to 1.4.3.
2026-03-30 17:24:45 +00:00
Meisoft f35a06d867 Merge pull request #40 from Meisoftcoltd/fix-video-analyzer-absolute-path-9584550515842607860
Fix absolute video path return in VideoAnalyzerWithAudio
2026-03-30 18:01:04 +02:00
Meisoftcoltd af3cddf758 Fix: Return absolute video_path in VideoAnalyzerWithAudio
Updated `VideoAnalyzerWithAudio.analyze` to return `video_path` instead
of `video_name` in the result tuple. This bypasses the issue with
VHS_LoadVideo failing on complex filenames by directly passing the
absolute path to the video.
2026-03-30 15:58:31 +00:00
Meisoft 2e497b4894 Merge pull request #39 from Meisoftcoltd/update-videoanalyzer-ui-17617168194567367529
Update VideoAnalyzerWithAudio Node for Cleaner UI and Previews
2026-03-30 17:20:34 +02:00
Meisoftcoltd 51a3f58bf1 docs: update documentation to reflect VideoAnalyzerWithAudio v1.4.1 changes
- Bumped version number in README.md and README_EN.md from 1.4.0 to 1.4.1.
- Updated the "Explorer" (`VideoAnalyzerWithAudio`) node description to highlight its new clean interface, native VHS widget integration, and visual Reference Frame generation.
2026-03-30 15:18:24 +00:00
Meisoftcoltd 2cd9b13f4c feat: update VideoAnalyzerWithAudio UI and preview logic
- Replaced VideoAnalyzerWithAudio class with improved UI handling logic.
- Implemented reference frame preview generation within the node.
- Added IS_CHANGED and VALIDATE_INPUTS class methods for correct caching and validation.
- Output formatting changed to return both UI payload and specific results tuple.
2026-03-30 14:42:34 +00:00
Meisoft e4d091df59 Merge pull request #38 from Meisoftcoltd/feat/hybrid-identity-architecture-2472698231588164134
feat: Hybrid Identity Architecture (OpenCV + Smart Cuts)
2026-03-30 15:53:31 +02:00
Meisoftcoltd e9dda4f3e2 docs: update README to reflect the new Hybrid Identity Architecture
- Updates README.md and README_EN.md.
- Replaces legacy loader references with the new "Explorer", "Brain", and "Worker" logic.
- Adds OpenCV dependency requirement instructions.
- Provides step-by-step setup guides to connect the updated 3-node structure.
2026-03-30 13:04:29 +00:00
Meisoftcoltd bc9fb05e2d feat: implement Hybrid Identity Architecture with VideoAnalyzerWithAudio
- Replaces LoadVideoWithSourceAudio with VideoAnalyzerWithAudio (the "Explorer").
- Introduces OpenCV-based frame scanning to detect sharp and frontal faces.
- Updates AutoLoopCalculator (the "Brain") to utilize safe_faces_list for asymmetric intelligent cuts.
- Updates __init__.py to register the new node correctly with the corresponding display name mapping ("🕵️ Video Analyzer + Audio").
2026-03-30 12:56:10 +00:00
Meisoft 78ab6ea866 Merge pull request #37 from Meisoftcoltd/feature/lazy-evaluation-cycle-0-3427900761752346966
feat: Lazy evaluation via Explorer Cycle 0
2026-03-29 18:19:21 +02:00
Meisoftcoltd c9187b69ef feat: Implement lazy evaluation Cycle 0 architecture and update READMES
- Introduced global state memory (`global_source_frame_count`, `global_target_frames`, `global_stride`) in `loop.py`.
- Removed visual `source_frame_count` wire from `AutoLoopCalculator` in `video.py`
- Upgraded `AutoLoopCalculator` to execute as a blind "Explorer" in Cycle 0.
- Updated `LoadVideoWithSourceAudio` to intercept real `source_frame_count` directly from VHS payloads.
- Added dynamic calculation of total cycles based on intercepted frames at the end of Cycle 0.
- Updated both README.md and README_EN.md with v1.3.1 documentation reflecting the new Cycle 0 features.
2026-03-29 16:19:02 +00:00
Meisoftcoltd bb703951d5 feat: Implement lazy evaluation Cycle 0 architecture
- Introduced global state memory (`global_source_frame_count`, `global_target_frames`, `global_stride`) in `loop.py`.
- Removed visual `source_frame_count` wire from `AutoLoopCalculator` in `video.py`
- Upgraded `AutoLoopCalculator` to execute as a blind "Explorer" in Cycle 0.
- Updated `LoadVideoWithSourceAudio` to intercept real `source_frame_count` directly from VHS payloads.
- Added dynamic calculation of total cycles based on intercepted frames at the end of Cycle 0.
2026-03-29 16:08:21 +00:00
Meisoft 18f5041640 Merge pull request #36 from Meisoftcoltd/update-loop-calculator-v1.3.0-11365687735615672106
feat: Add AutoLoopCalculator and global ghost loop variables
2026-03-29 17:02:21 +02:00
Meisoftcoltd d6d29f998e feat: Add AutoLoopCalculator and remove circular loop wires\n\n- Replace WanFrameValidator with AutoLoopCalculator in video.py\n- Introduce global_total_loops in loop.py to track loops implicitly\n- Eliminate total_loops parameter from SequentialLoopStart, SequentialLoopTrigger, IncrementalVideoStitcher, SessionImageReceiver, and SessionImageSender to prevent ComfyUI wire clutter and avoid circular dependencies.\n- Add support for striding (select_every_nth) in frame distribution calculations.\n- Update README.md and README_EN.md to match the new architecture. 2026-03-29 14:58:28 +00:00
Meisoft e4cc1be979 Merge pull request #35 from Meisoftcoltd/fix/incremental-video-stitcher-tensor-init-8551476164075642520
fix(video): Prevent tensor dimension mismatch in post-processing nodes
2026-03-29 13:00:12 +02:00
Meisoftcoltd 8a37c8272c fix(video): Prevent tensor dimension mismatch in post-processing nodes
- Replaced 8x8 zero tensor initialization in IncrementalVideoStitcher intermediate steps.
- Sliced the actual tensor input (`images[0:1]`) to send the first frame as preview instead.
- This maintains active cables and properly initializes cache resolutions for downstream RIFE/Upscale nodes.
2026-03-29 10:58:17 +00:00
Meisoft a1398edab5 Merge pull request #34 from Meisoftcoltd/feat/v1.2.0-sequential-batcher-update-7177647917115546157
feat(nodes): update sequential batcher architecture for v1.2.0
2026-03-28 23:14:38 +01:00
Meisoftcoltd 7c858643e6 feat: v1.2.0 global update for Sequential Batcher nodes
- Update WanFrameValidator to dynamically calculate skip_frames based on current_loop_index.
- Update LoadVideoWithSourceAudio to extract and output the first frame safely.
- Add total_loops tracking and detailed debug logs to SequentialLoopStart.
- Add total_loops to SessionImageReceiver and SessionImageSender for progressive tracking.
- Enhance print logs with visible Spanish banners across modified nodes.
2026-03-28 22:12:23 +00:00
Meisoft e15f0ad26b Merge pull request #33 from Meisoftcoltd/jules-2851335323260380883-8873c9d0
Fix VHS inheritance issue via Lazy Evaluation Wrapper
2026-03-28 21:07:05 +01:00
Meisoftcoltd 22ef1bd2c9 fix(video): implement lazy evaluation wrapper for VHS inheritance
- Replaced `vhs_load_video_class` inheritance at import time with a Lazy Evaluation Wrapper that dynamically fetches inputs and instantiates the class at runtime.
- Removed legacy technical debt from `video.py`.
- Cleaned up local Python cache files to prevent `__pycache__` ghosts from interfering.
2026-03-28 20:06:38 +00:00
Meisoft afee0ffc6e Merge pull request #32 from Meisoftcoltd/feature/vhs-load-video-inheritance-2223059013720671906
Refactor video nodes to inherit from VHS and improve audio passthrough
2026-03-28 20:37:15 +01:00
Meisoftcoltd 1bed6fbcbb Refactor LoadVideoWithSourceAudio to inherit from VHS_LoadVideo.
Implement safe audio passthrough in `IncrementalVideoStitcher`.
Update README files to reflect VHS as a mandatory dependency.
2026-03-28 19:36:51 +00:00
Meisoft 6825022800 Merge pull request #31 from Meisoftcoltd/fix-video-unpacking-kwargs-1896346447845060005
Fix video node kwargs and unpacking issues
2026-03-28 18:11:51 +01:00
Meisoftcoltd 30a51aebae fix(video): resolve kwargs and unpacking errors in LoadVideoWithSourceAudio
- Fixes `ValueError: not enough values to unpack` by safely handling the `video` param from VHS_LoadVideo whether it is a string or tuple.
- Adds strict filtering for `kwargs` to prevent `force_rate` unexpected keyword argument crashes in newer versions of VHS.
- Modifies `IS_CHANGED` to return `time.time()` to forcefully bypass ComfyUI caching.
- Optimizes `IncrementalVideoStitcher` to return an 8x8 micro-tensor during intermediate loops to prevent missing input errors.
2026-03-28 17:08:50 +00:00
Meisoft 265ac2615b Merge pull request #30 from Meisoftcoltd/update-video-py-architecture-11899055623373293907
Refactor: Replace video.py Architecture and Fix Cache & Type Handling
2026-03-28 17:47:03 +01:00
Meisoftcoltd 0c00b7c1c0 refactor: replace video.py architecture and fix caching bugs
- Overwrite `video.py` with entirely new architecture.
- Add `WanFrameValidator` node.
- Update `LoadVideoWithSourceAudio` to use `time.time()` for cache invalidation instead of `float("NaN")`.
- Update `IncrementalVideoStitcher` to return a `torch.zeros((1, 8, 8, 3))` micro-tensor during intermediate loops to keep ComfyUI's strictly typed pipelines valid.
2026-03-28 16:46:26 +00:00
Meisoft bdd650ff69 Merge pull request #29 from Meisoftcoltd/fix-vhs-video-loader-kwargs-9842839560735093901
fix(video): handle kwargs strictly in LoadVideoWithSourceAudio to prevent VHS queue crash
2026-03-28 17:26:18 +01:00
Meisoftcoltd 9711831bb5 Fix ComfyUI queue crash in LoadVideoWithSourceAudio node
- Ensure `IS_CHANGED` and `VALIDATE_INPUTS` are fully utilizing `@classmethod` logic.
- Filter `**kwargs` before passing them to the native `VHS_LoadVideo` instance to prevent "unexpected keyword argument" crashes.
- Update internal Spanish comments to align with user requested format.
2026-03-28 16:25:47 +00:00
Meisoft 5e412b9a37 Merge pull request #28 from Meisoftcoltd/replace-vhs-validator-with-native-logic-4322568155129062363
Update LoadVideoWithSourceAudio validation and cache checking
2026-03-28 16:43:35 +01:00
Meisoftcoltd d7c671aff8 Replace VHS validation in LoadVideoWithSourceAudio with native file checking
- Removed dependency on VHS_LoadVideo cache and validation methods
- Replaced IS_CHANGED to check modification time instead of slow SHA256
- Replaced VALIDATE_INPUTS to check file existence directly and absorb kwargs
- These changes improve the node's independence and speed for large files
2026-03-28 15:43:06 +00:00
Meisoft 8b60e818fa Merge pull request #27 from Meisoftcoltd/fix-video-nodes-10941441744767870842
Arreglar nodos LoadVideoWithSourceAudio e IncrementalVideoStitcher
2026-03-28 16:09:20 +01:00
Meisoftcoltd 2f4a329478 Actualizar READMEs y restablecer tests
- Se actualizaron las descripciones en README.md y README_EN.md para reflejar las nuevas capacidades de LoadVideoWithSourceAudio (widget UI intacto, upload directo) y el Stitcher (INPUT_IS_LIST=False, passthrough directo de audio al último frame).
- Se restauraron los scripts de testing en `tests/` que se habían eliminado accidentalmente.
2026-03-28 15:08:50 +00:00
Meisoftcoltd 91a80b1169 Fix y mejoras en nodos de video para el manejo de UI, subida y batching
- Se reimplementó `LoadVideoWithSourceAudio` para envolver adecuadamente `VHS_LoadVideo`, añadiendo la directiva de UI "video_upload", y métodos para la verificación de cambios (IS_CHANGED, VALIDATE_INPUTS) mientras se conserva el widget de UI.
- Se ha expuesto explícitamente `INPUT_IS_LIST = False` en `IncrementalVideoStitcher` para forzar el tratamiento de imágenes como tensores puros.
- El passthrough de audio en `IncrementalVideoStitcher` ya funciona correctamente de forma predeterminada sin cambios adicionales.
- Se agregó el nuevo alias de nodo en `__init__.py` para su registro en ComfyUI.
2026-03-28 15:01:44 +00:00
Meisoft d6e1b997b8 Merge pull request #26 from Meisoftcoltd/refactor-incremental-stitcher-and-add-source-audio-loader-17321561639295743240
Refactor IncrementalVideoStitcher and Add LoadVideoWithSourceAudio
2026-03-28 15:37:49 +01:00
Meisoftcoltd def18e6390 Update README documentation for new video capabilities
Changes:
- Documented zero-OOM tensor handling and audio passthrough for IncrementalVideoStitcher in README.md and README_EN.md.
- Added documentation for the new LoadVideoWithSourceAudio node explaining dynamic VHS integration and uncropped audio extraction.
2026-03-28 14:34:05 +00:00
Meisoftcoltd 10bf229175 Refactor IncrementalVideoStitcher to handle raw image tensors and add LoadVideoWithSourceAudio node.
Changes:
- Modified IncrementalVideoStitcher to process IMAGE tensors directly instead of MP4 lists.
- Implemented RAM/VRAM saving mechanism by saving tensor state dynamically to `.pt` files and cleaning them manually via `gc`.
- Introduced LoadVideoWithSourceAudio mapping to extract complete original tracks dynamically.
2026-03-28 14:26:11 +00:00
Meisoft 18eb230109 Merge pull request #25 from Meisoftcoltd/feat/input-validations-16278556174992080220
feat: Add explicit input validations and Spanish error messages to key nodes
2026-03-23 21:57:43 +01:00
Meisoftcoltd e87cad300c feat: add explicit input validations to key nodes
- `loop.py`: Added check for `trigger_dependency` in `SequentialLoopTrigger.trigger_next`.
- `image.py`: Added checks for `generated_images` and `current_loop_index` in `SessionImageSender.set_image`.
- `video.py`: Added list-aware checks for `trigger` and `current_loop_index` in `IncrementalVideoStitcher.stitch_incremental` due to `INPUT_IS_LIST = True`.

All validations throw a `ValueError` with clear Spanish instructions when inputs are missing to provide immediate user feedback on the ComfyUI canvas.
2026-03-23 20:49:22 +00:00
Meisoft 64596500b0 Merge pull request #24 from Meisoftcoltd/fix/loop-reverse-cache-16882262106205794182
Fix Reverse Cache & Add Progressive Video/Image Storage
2026-03-23 21:13:11 +01:00
Meisoftcoltd bfe40b1b65 Update README documentation to v1.1.0 and add new features
Updated README.md and README_EN.md to version 1.1.0. Documented the new
Anti-Cache Injection mechanism in the Loop Trigger, progressive keyframe
and MP4 saving features, and audio multiplexing using torchaudio and
FFmpeg. Also updated the setup instructions with the new wiring requirements.
2026-03-23 20:09:36 +00:00
Meisoftcoltd 08d7d2747d Add support for optional audio multiplexing
Updated video.py to accept an optional 'AUDIO' input in IncrementalVideoStitcher.
Uses torchaudio to save the waveform temporarily, and then applies ffmpeg with
the '-shortest' flag to seamlessly multiplex it with the concatenated mp4 segments.
2026-03-23 20:03:40 +00:00
Meisoftcoltd 9939314b2f Fix loop reverse cache with payload mutation and add debug logic
Replaced loop.py and image.py, and updated video.py to fix the 'reverse cache'
issue by injecting the new index directly into the ComfyUI API JSON payload.
Added large debug banners in Spanish, PNG keyframe extraction, and progressive MP4 saving.
2026-03-23 19:55:57 +00:00
Meisoft 3d5ec1646d Merge pull request #23 from Meisoftcoltd/feature/cache-breaking-seed-mutator-8604892349368461134
Add Seed Mutator to Loop Trigger
2026-03-23 18:36:51 +01:00
Meisoftcoltd e68d9026d1 Update READMEs to set Spanish as primary language
- Replaced README.md with the Spanish translation.
- Renamed the old English README to README_EN.md.
- Added cross-links between the two versions.
- Updated documentation to include the new Seed Mutator feature for v1.0.1.
2026-03-23 17:35:24 +00:00
Meisoftcoltd 4d18b4e69d Implement Seed Mutator in SequentialLoopTrigger to break ComfyUI cache
- Replaced existing SequentialLoopTrigger logic.
- Added dictionary scanning to intercept the queue POST JSON payload.
- Injects a new 32-bit random integer (`0xffffffff` max) into any node possessing `seed` or `noise_seed` in its inputs.
- Preserves the sequential auto-queue logic but now guarantees the sampler processes a new generation instead of instantly returning cached results in subsequent loops.
2026-03-23 17:24:12 +00:00
Meisoft 209b4c3bea Merge pull request #22 from Meisoftcoltd/fix/cache-invalidation-time-2776254223784323803
Fix IS_CHANGED cache breaking with time.time()
2026-03-23 17:09:07 +01:00
Meisoftcoltd 29915698e5 Fix: Update cache breaking logic in IS_CHANGED from NaN to time.time()
This resolves aggressive caching bugs in newer ComfyUI versions.
Replaced float("NaN") with time.time() in image.py and loop.py
to ensure stateful memory/trigger nodes consistently fire.
2026-03-22 22:17:23 +00:00
Meisoft 3c72a7ab31 Merge pull request #21 from Meisoftcoltd/fix-ram-session-nodes-2451402040826957469
Refactor SessionImageSender and SessionImageReceiver to use persistent System RAM
2026-03-22 22:20:53 +01:00
Meisoftcoltd 23135bfaa7 Refactor SessionImageSender/Receiver to use CPU RAM
Replaced the entirety of image.py with a simplified and corrected implementation. The previous cyclic dependency concept was abandoned due to ComfyUI's DAG constraints. Instead, the global RAM approach is used, heavily augmented with `.clone().cpu()` during storage to protect the tensor from ComfyUI's VRAM garbage collector destroying it between Auto Queue cycles. Extraneous technical debt within the file was completely removed.
2026-03-22 21:18:49 +00:00
Meisoft ff94c32d63 Merge pull request #20 from Meisoftcoltd/feature/recursive-self-queue-1733009829692853919
feat: recursive self-queuing system for sequential execution
2026-03-22 20:40:06 +01:00
Meisoftcoltd 1a28034baf docs: update readme files to reflect v1.0.0 recursive self-queuing architecture
- Rewrite README.md and README_ES.md.
- Document the shift to the "Formula 1" engine architecture with the 6 core nodes.
- Remove references to deprecated batch, sequence, and debug nodes.
- Add setup and usage instructions for the new autonomous machine.
2026-03-22 19:37:11 +00:00
Meisoftcoltd 73581ac582 refactor: clean up repository to v1.0.0 and implement recursive self-queuing architecture
- Delete deprecated modules: `sequence.py`, `paths.py`, `batch.py`, `debug.py`.
- Clean `image.py`, retaining only SessionImageReceiver, SessionImageSender, and auxiliary function.
- Clean `video.py`, retaining only IncrementalVideoStitcher and WanFrameValidator.
- Clean `loop.py`, retaining only SequentialLoopStart and SequentialLoopTrigger.
- Rewrite `__init__.py` mapping explicitly to the 6 core components and bump version to 1.0.0.
2026-03-22 19:25:11 +00:00
Meisoftcoltd 6da4275975 feat: implement recursive self-queuing nodes for autonomous execution
- Add `SequentialLoopStart` and `SequentialLoopTrigger` nodes in `loop.py`.
- Manage global loop index for memory persistence across cycles.
- Implement auto-queue HTTP POST trigger using local ComfyUI API `/prompt`.
- Add non-blocking timeout of 5s on urllib requests.
- Register new nodes in `__init__.py` under NODE_DISPLAY_NAME_MAPPINGS.
2026-03-22 19:12:43 +00:00
Meisoft cb6541a306 Merge pull request #19 from Meisoftcoltd/fix/simplify-loop-index-reset-9730679421414511812
Simplify flow by using current_loop_index for internal state reset
2026-03-22 18:57:26 +01:00
Meisoftcoltd 9a2b4beebc Update documentation for current_loop_index reset logic
Updated `README.md` and `README_ES.md` to document the change from boolean
reset flags to using `current_loop_index` for internal session resets in
the `Incremental Auto-Stitcher` and `Session Image Receiver` nodes.
2026-03-22 17:56:24 +00:00
Meisoftcoltd 12f46287a9 Simplify flow by using current_loop_index for internal state reset
Modified `SessionImageReceiver` in `image.py` and `IncrementalVideoStitcher`
in `video.py` to accept the integer `current_loop_index` instead of external
boolean values. Automatic state clearing is now correctly performed when the
loop index equals 0. List unwrapping logic for inputs was implemented to
defend against ComfyUI `INPUT_IS_LIST = True` iteration behavior while
preserving the robust path resolution checks built in previous iterations.
2026-03-22 17:51:31 +00:00
Meisoft 5254d9c241 Merge pull request #18 from Meisoftcoltd/add-wan-validator-13605443588029445601
Add Wan Frame Validator and Visual UI Debugging
2026-03-22 17:35:29 +01:00
Meisoftcoltd e4e92e4f08 Add Wan Frame Validator and Visual UI Debugging to Image Nodes
- Added WanFrameValidator node in video.py to ensure 4k+1 frame logic.
- Registered WanFrameValidator in __init__.py with "WanFrameValidator": "🛡️ Wan Frame Validator".
- Updated both README.md and README_ES.md to document the Wan Frame Validator.
- Modified SessionImageReceiver and SessionImageSender in image.py to return a UI object mapping for Visual UI Debugging in ComfyUI frontend.
2026-03-22 16:33:49 +00:00
Meisoft 13b2cde008 Merge pull request #17 from Meisoftcoltd/fix-return-names-in-session-image-sender-2688597451786092335
Fix missing RETURN_NAMES attribute on SessionImageSender node
2026-03-22 13:55:25 +01:00
Meisoftcoltd 0156a1b313 fix: add missing RETURN_NAMES to SessionImageSender
The custom `@register_node` decorator requires `RETURN_NAMES` to be explicitly defined as a tuple. `SessionImageSender` had `RETURN_TYPES = ()` but was missing `RETURN_NAMES = ()`, which caused an `AttributeError` upon importing the module, breaking the startup of ComfyUI. This patch adds the missing empty tuple.
2026-03-22 12:54:00 +00:00
Meisoft 55de36b2f0 Merge pull request #16 from Meisoftcoltd/fix-incremental-video-stitcher-12677637160509818319
Update IncrementalVideoStitcher with session memory and strict path extraction
2026-03-22 13:39:04 +01:00
Meisoftcoltd 20eeaf00dc Update READMEs with SessionImageSender/Receiver docs
- Documented the `SessionImageSender` and `SessionImageReceiver` nodes under the "Image & Latent Category" in both `README.md` and `README_ES.md`.
- Described the "Sender/Receiver" pattern functionality: transferring the final frame from one Auto Queue cycle as the first frame to the next without generating ComfyUI cyclic dependency errors.
- Both language versions maintain accurate context regarding infinite/continuous video loop generation.
2026-03-22 12:37:21 +00:00
Meisoftcoltd fc24328c95 Add SessionImageSender and SessionImageReceiver nodes
- Added `SessionImageSender` to save the last frame of an image batch (using `.clone()` to safely decouple the tensor) into a global session variable.
- Added `SessionImageReceiver` to inject the stored image into the start of the next workflow execution cycle.
- Both nodes utilize `IS_CHANGED` returning `NaN` to force execution on every Auto Queue tick.
- Updated `__init__.py` to register the new nodes under the display names "📤 Session Image Sender" and "📥 Session Image Receiver".
- Ensures users can chain frame sequences across independent batch loops without creating cyclic dependencies in ComfyUI.
2026-03-22 12:33:59 +00:00
Meisoftcoltd bcaa17981b Update documentation for Incremental Auto-Stitcher
- Updated README.md and README_ES.md descriptions for the Incremental Auto-Stitcher node.
- Replaced mention of `video_prefix` search (globbing) with strict JSON path extraction (`reset_list` added).
- Explained the session memory behavior that clears on restart and guarantees immunity against old files in the ComfyUI output directory.
2026-03-22 12:14:05 +00:00
Meisoftcoltd 143784fd48 Update IncrementalVideoStitcher to use strict JSON parsing and session memory
- Replaced glob search with strict JSON payload parsing of VHS_FILENAMES
- Added a `session_video_list` global variable to accumulate session-specific videos
- Added a boolean `reset_list` input to optionally clear session memory
- Validated and joined relative paths from VHS with ComfyUI's output directory
- Added `try...finally` block to guarantee automatic cleanup of the temporary FFmpeg concat list file
- Updated related prints/error messages in Spanish to match project conventions
2026-03-22 12:08:48 +00:00
Meisoft d9bb1dd2c1 Merge pull request #15 from Meisoftcoltd/fix-incremental-video-stitcher-11651493821724131751
Fix IncrementalVideoStitcher inputs in video.py
2026-03-22 01:15:19 +01:00
Meisoftcoltd f9100f0727 fix: restore INPUT_IS_LIST and update IncrementalVideoStitcher in video.py
Replaces `IncrementalVideoStitcher` with an updated version to fix issue where ComfyUI iterates over string text inputs like the `VHS_FILENAMES` trigger. Adds `INPUT_IS_LIST = True` to the node and unwraps the newly list-wrapped inputs in `stitch_incremental` to process correctly. Ensures `glob` is imported at the module level.
2026-03-22 00:09:50 +00:00
Meisoft 6bd37b9671 Merge pull request #14 from Meisoftcoltd/feature/incremental-video-stitcher-1024656265801320493
Add IncrementalVideoStitcher for automated intermediate stitching
2026-03-21 23:32:41 +01:00
Meisoftcoltd c0b1fcbea9 docs(video): update READMEs with IncrementalVideoStitcher details
- Added description for "🎞️ Incremental Auto-Stitcher" node in README.md.
- Added equivalent description in Spanish for README_ES.md.
- Explains the expected input behavior and how it differs from the FFmpeg Video Stitcher.
2026-03-21 22:32:15 +00:00
Meisoftcoltd 5171199a61 feat(video): add IncrementalVideoStitcher node
Add IncrementalVideoStitcher to seamlessly join video chunks generated by ComfyUI during Auto Queue cycles.
The node operates efficiently by looking for intermediate chunks generated so far using the supplied video prefix and joining them without re-encoding via FFmpeg.
Registered as "🎞️ Incremental Auto-Stitcher" in the NODE_DISPLAY_NAME_MAPPINGS.
2026-03-21 22:30:50 +00:00
Meisoft ab99598940 Merge pull request #13 from Meisoftcoltd/implement-ffmpeg-stitcher-16344698670511801985
Implement FFmpegVideoStitcher Node
2026-03-21 18:01:46 +01: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
21 changed files with 2907 additions and 1370 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 }}
+1
View File
@@ -0,0 +1 @@
__pycache__/
+75 -79
View File
@@ -1,101 +1,97 @@
# 🔁 ComfyUI Sequential Batcher & Video Loop Master (Beta v0.9.3)
# ♾️ ComfyUI Sequential Batcher
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
> [!IMPORTANT]
> This version is currently in **BETA**. We have fully transitioned the terminology from "Job" to **Batch** to align with ComfyUI standards and the project name.
Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada para sortear los límites extremos de VRAM en la generación de vídeo pesado (WanVideo, Hunyuan, LTX) mediante **Auto-Encolado Recursivo**, procesamiento autónomo lote por lote y gestión de memoria forense.
The ultimate tool for creating complex iterative workflows and frame-by-frame video processing in ComfyUI. Designed to handle huge tasks (like high-res video generation with Wan2.2 or LTX Video) without crashing your GPU by using intelligent sequential looping instead of VRAM-heavy batching.
## 🚀 Why use this?
Standard ComfyUI batching processes everything at once (4D tensors). For video or large batches, this leads to **Out of Memory (OOM)** errors.
**Sequential Batcher** allows you to "split" these tasks and process them **one-by-one** (sequentially) within a single "Queue Prompt" run, then "gather" the results back into a single batch or video file.
> 🌍 **Read in English:** [README_EN.md](README_EN.md)
---
## 🛠️ Installation
## ✨ Características Clave
1. Clone this repo into `custom_nodes/comfyui-sequential-batcher`.
2. Restart ComfyUI.
3. Dependencies (`torch`, `numpy`) will be handled automatically if using ComfyUI Manager.
* **Orquestación Autónoma:** Convierte tu ComfyUI en un motor de renderizado continuo. Pulsa "Queue Prompt" una sola vez y el flujo se retroalimentará modificando semillas e índices hasta terminar todo el vídeo.
* **Cortes Inteligentes (Smart Chunking):** Analiza el vídeo base y realiza cortes matemáticos protegiendo los frames donde los rostros son más nítidos, manteniendo la coherencia de identidad (Face Cuts).
* **Extracción de Audio Nativa:** Extrae la pista original directamente desde el nodo inicial y la inyecta de vuelta en el ensamblaje final.
* **Soporte Arquitecturas DiT (WanVideo & LTX 2.3):** Calculadores matemáticos específicos aseguran que los lotes de vídeo cumplan con las estrictas reglas de descompresión (regla de `4n + 1` para WanVideo, o regla `8n + 1` para LTX).
* **Resoluciones Protegidas (Megapixel Shield):** Herramientas matemáticas (`ResTool`) que escalan las dimensiones automáticamente protegiendo el *training floor* de cada modelo base.
---
## 📖 Key Concepts
## ⚠️ Requisitos del Sistema (Crítico)
- **SEQUENCE**: A simple list of values (numbers, strings, etc.).
- **BATCH**: A structured collection of "steps". Each step has named **Attributes**.
- **Iteration**: The magic happens in nodes like `Batch To List`, `Image Batch To List`, or `Latent Batch To List`. When ComfyUI sees a "List" output from these nodes, it executes all downstream nodes once for each item in that list.
Para que el nodo `VideoAnalyzerFaceDetector` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
* **🐧 Ubuntu / Linux / WSL2:**
El script intentará instalarlo por ti. Si falla, ejecuta manualmente:
```bash
sudo apt update && sudo apt install ffmpeg -y
```
* **🪟 Windows:**
Descarga los binarios precompilados de FFmpeg y añade la carpeta `bin` a tus variables de entorno (PATH).
### 🚨 Advertencia Crítica sobre el Uso de la VRAM
Se **desrecomienda encarecidamente** el uso del argumento `--highvram` al arrancar ComfyUI.
El argumento `--highvram` le da la orden estricta a ComfyUI de mantener los modelos bloqueados en la memoria de video. Esto sabotea directamente el funcionamiento de nuestro `VRAM Defragmenter`, ya que bloquea el comando `mm.unload_all_models()` impidiendo que la gráfica se vacíe entre ciclos. Para que esta suite funcione correctamente con modelos masivos como WanVideo y evite errores de OOM (Out Of Memory), los usuarios deben usar el arranque estándar (o `--normalvram`), permitiendo que el sistema libere dinámicamente la memoria hasta llegar a 0 GB entre lotes.
---
## 🎞️ Video Workflow (Wan2.2 / LTX-Video / Future Models)
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
Video models produce many frames that can easily exceed 24GB VRAM.
1. **Split**: Use `Latent Batch To List` to turn your video latent into a list of single frames.
2. **Process**: Connect to your KSampler/VAE Decoder. ComfyUI will process Frame 1, then Frame 2, then Frame 3...
3. **Gather**: Use `Latent List To Batch` (or `Image List To Batch` if you decoded first) to reconstruct the full video batch for saving.
4. **Stitch (VHS Combine)**: To stitch heavy video chunks sequentially:
- *Option A (Math)*: `Range` -> `MakeBatch` (attribute: "skip_frames") -> `BatchToList` -> `GetAttributeInt` (name: "skip_frames") connected to `VHS_LoadVideo`.
- *Option B (CSV)*: `LoadCSV` (with a `skip_frames` column) -> `BatchToList` -> `GetAttributeInt`.
- Connect the output of `VHS_VideoCombine` (specifically the `VHS_FILENAMES` output) to the **FFmpeg Video Stitcher** node (`video_paths`). The Stitcher will wait for all chunks to finish and combine them automatically!
El procesamiento se divide en roles altamente especializados, organizados en las siguientes categorías:
> [!WARNING]
> **OOM Prevention (Memory Warning):** While `BatchToList` solves the data passing issue, you must still manage PyTorch VRAM. It is highly recommended to use the **easy cleanGpuUsed** node (or similar VRAM clearing nodes) after the VAE Decoder in each cycle of the Batch. Without manual VRAM clearing in each iteration, the sequential loop will accumulate garbage in your graphics card and eventually cause an Out of Memory error.
### 🎼 Orquestación
* **🏁 Loop Start (Index):** Inicia la secuencia de bucle y mantiene el índice actual.
* **🚀 Loop Trigger (Auto-Queue):** Dispara la siguiente iteración de ComfyUI de manera autónoma hasta que se completa la tarea.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** Máquina de estados para orquestar metadatos (prompts, duraciones) en la preproducción (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Guarda y carga los frames clave de la escena para mantener la continuidad entre flujos separados.
### 📦 Lotes (Batches)
* **📂 Batch Audio Folder Loader:** Carga dinámicamente carpetas enteras de archivos (ej. lotes de audio).
* **🎛️ Audio Batch Selector:** Despacha los archivos del lote uno por uno sincronizándose con el bucle.
### 🔬 Análisis y Cálculos
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV/YOLO, extrayendo rostros, audio y frame de referencia.
* **🎬 Video Analyzer Scene detector:** Detecta cortes de escena en los vídeos.
* **📊 Auto Loop Calculators:** Nodos especializados (`Base`, `WanVideo`, `LTX`, `TTS`) que reciben metadatos y calculan matemáticamente las coordenadas de corte exactas (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Ensambla progresivamente los fragmentos de video renderizados en un único archivo.
* **🛡️ VAE Safe Frame Padder:** Rellena ("acolcha") tensores incompletos clonando el último frame para salvar al VAE de un colapso en modelos restrictivos (ej. reglas 4n+1).
* **💉 LTXV Single Frame Injector:** Inyecta frames individuales específicos requeridos por flujos de LTX.
* **⏱️ Auto FPS Limiter:** Limita de manera inteligente los cuadros por segundo para ahorrar VRAM manteniendo sincronía de audio.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Corta el audio de forma precisa (a nivel de muestra matemática) en base a los frames de vídeo correspondientes.
* **🎛️ Conditional Audio Router (Bypass):** Enruta el audio condicionalmente permitiendo saltar su procesamiento si no es necesario.
### 📐 Herramientas de Resolución
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculan resoluciones óptimas basadas en límites de VRAM y las reglas geométricas (multiplicadores) de diferentes modelos (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Gestión de Memoria / Lógica
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Evaluación perezosa nativa; amputan rutas inactivas para que los nodos pesados ni siquiera se carguen en memoria.
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al recolector de basura (GC) de Python entre ciclos.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retienen y transfieren imágenes en memoria a través de diferentes ciclos.
---
## 🔢 Detailed Node Reference
## 🔌 Guía de Cableado Rápido (Workflow Setup)
### 🔄 Loop Category (`🔁 Sequential Batcher/Loop`)
- **🔁 Sequential Loop Index**: The simplest way to start a loop.
- *Input*: `count` (How many times to run).
- *Output*: `index` (0, 1, 2...). Useful for seeding or selecting specific items.
- **🔁 Repeat**: Takes any input and repeats it N times.
- *Input*: `input` (Any), `count` (INT).
- *Output*: `output` (List of the same input).
### 🛠️ Batch Category (`🔁 Sequential Batcher/Batch`)
- **📂 Load CSV**: Loads a CSV file as a Batch. Now includes a table preview in the workflow.
- *Input*: `path` (File location), `delimiter`, `quotechar`.
- *Optional Input*: `index` (To pick a specific row).
- *Output*: `batch` (The full list), `current_attributes` (Dict of the selected row), `count` (Total rows).
- **📊 Preview Batch**: Displays a table of the batch content in the workflow.
- *Input*: `batch`, `index` (Highlight specific row), `max_rows`.
- **🛠️ Make Batch**: Turns a sequence into a "Batch" object.
- *Input*: `sequence` (The data), `name` (The attribute name, e.g., "cfg_scale").
- **🖇️ Combine Batches**: Merges multiple batches.
- *Modes*: `zip` (paired) or `product` (every combination).
- **🔄 Batch To List**: **CRITICAL**. Converts a Batch into a stream of attributes that triggers the sequential loop.
- **📥 Get Attribute**: Extracts a specific value from the current batch step by its name.
### 🖼️ Image & Latent Category (`🔁 Sequential Batcher/Image` & `/Latent`)
- **🖼️ Image Batch To List**: Splits a [N,H,W,C] tensor into N separate images.
- **🖼️ Image List To Batch**: Reconstructs a batch from iterated images.
- **🎞️ Latent Batch To List**: Splits video latents frame-by-frame for VRAM-safe processing.
- **🎞️ Latent List To Batch**: Merges individual frames back into a video latent batch.
- **⏳ Progress Bar**: Generates a visual progress indicator.
### 🎞️ Video Category (`🔁 Sequential Batcher/Video`)
- **🎞️ FFmpeg Video Stitcher**: Final node in a video loop. Waits for the entire sequential batch to finish and stitches the video chunks together using FFmpeg without re-encoding.
- *Input*: `video_paths` (List of VHS_FILENAMES), `output_filename` (String).
- *Output*: `final_video_path` (Path to the stitched video).
1. **El Explorador:** Coloca el `Video Analyzer` al principio. Sube un vídeo (o usa una ruta de YTDLP).
2. **Matemáticas:** Conecta `total_frames` y `safe_faces_list` hacia tu `Auto Loop Calculator` elegido.
3. **Extracción Segura:** Añade el nodo nativo `VHS_LoadVideo`. Conviértele la entrada a *video_name* y conéctala al Analyzer. Conecta su salida `IMAGE` al nuevo nodo `VAE Safe Frame Padder`.
4. **Passthrough de Audio:** Lleva el cable `source_audio` desde el Explorador directamente hasta el puerto `audio` de tu `Incremental Auto-Stitcher` (al final del flujo).
5. **El Gatillo:** Conecta la salida de tu Stitcher al nodo `Loop Trigger (Auto-Queue)`.
6. **Ejecución:** Conecta el `Loop Start (Index)` al Cerebro y al Stitcher.
7. **Dispara:** Haz clic en "Queue Prompt" **UNA SOLA VEZ**. ¡Disfruta de la magia autónoma!
---
## 💡 Pro Tips & Use Cases
### 📝 Using CSV for Prompts and Scenes
You can create a CSV with columns like `prompt`, `negative_prompt`, and `seed`.
1. Use **📂 Load CSV** to load your file.
2. Connect `batch` to **🔄 Batch To List**.
3. Use **📥 Get Attribute** to pull the `prompt` into your CLIP Text Encode.
4. Each row in your CSV will be processed as one "frame" or "job" in the sequence.
### 🎬 Scene Timings for Video
If you have a CSV with `frame_start` and `prompt`, you can use it to change prompts at specific points in a video generation loop.
### 🧪 XY Plots
Use **🖇️ Combine Batches** in `product` mode to create XY Plots (e.g., test every Prompt against every CFG scale).
### 🔍 Automatic Model Iteration
Use **🔍 Model Finder** to automatically iterate through a folder of LoRAs or Checkpoints.
## 🚀 Aceleración por GPU (Escaneo de Rostros)
El `VideoAnalyzerFaceDetector` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
1. Instala el *Impact Pack*.
2. Añade el nodo `UltralyticsDetectorProvider` y selecciona un modelo de rostros (ej. `face_yolov8m.pt`).
3. Conecta su salida al puerto del Analyzer.
*(El nodo cuenta con auto-descarga: destruirá este modelo de la VRAM al terminar para hacer hueco a la generación).*
+97
View File
@@ -0,0 +1,97 @@
# ♾️ ComfyUI Sequential Batcher
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through **Recursive Auto-Queuing**, autonomous batch-by-batch processing, and forensic memory management.
> 🇪🇸 **Leer en Español:** [README.md](README.md)
---
## ✨ Key Features
* **Autonomous Orchestration:** Turn your ComfyUI into a continuous rendering engine. Press "Queue Prompt" just once and the flow will feed itself by modifying seeds and indices until the entire video is finished.
* **Smart Chunking:** Analyzes the base video and makes mathematical cuts protecting the frames where faces are sharpest, maintaining identity coherence (Face Cuts).
* **Native Audio Extraction:** Extracts the original track directly from the initial node and injects it back into the final assembly.
* **DiT Architectures Support (WanVideo & LTX 2.3):** Specific mathematical calculators ensure video batches comply with strict decompression rules (multiples of 4 for WanVideo, or the `8n + 1` rule for LTX).
* **Protected Resolutions (Megapixel Shield):** Mathematical tools (`ResTool`) that automatically scale dimensions protecting the *training floor* of each base model.
---
## ⚠️ System Requirements (Critical)
For the `VideoAnalyzerFaceDetector` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
* **🐧 Ubuntu / Linux / WSL2:**
The script will attempt to install it for you. If it fails, run manually:
```bash
sudo apt update && sudo apt install ffmpeg -y
```
* **🪟 Windows:**
Download the precompiled FFmpeg binaries and add the `bin` folder to your environment variables (PATH).
### 🚨 Critical Warning Regarding VRAM Usage
The use of the `--highvram` argument when starting ComfyUI is **strongly discouraged**.
The `--highvram` argument gives ComfyUI the strict command to keep models locked in video memory. This directly sabotages the operation of our `VRAM Defragmenter`, as it blocks the `mm.unload_all_models()` command, preventing the graphics card from emptying between cycles. For this suite to work correctly with massive models like WanVideo and avoid OOM (Out Of Memory) errors, users must use the standard startup (or `--normalvram`), allowing the system to dynamically release memory down to 0 GB between batches.
---
## 🧠 The Hybrid Architecture (Node Breakdown)
Processing is divided into highly specialized roles, organized in the following categories:
### 🎼 Orchestration
* **🏁 Loop Start (Index):** Starts the loop sequence and tracks the current index.
* **🚀 Loop Trigger (Auto-Queue):** Autonomously triggers the next ComfyUI iteration until the task is complete.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** State machine to orchestrate metadata (prompts, durations) in preproduction (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Saves and loads the scene's keyframes to maintain continuity across decoupled flows.
### 📦 Batches
* **📂 Batch Audio Folder Loader:** Dynamically loads entire folders of files (e.g. audio batches).
* **🎛️ Audio Batch Selector:** Dispatches files from the batch one by one in sync with the loop.
### 🔬 Analysis and Calculations
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV/YOLO, extracting faces, audio, and the reference frame.
* **🎬 Video Analyzer Scene detector:** Detects scene cuts in videos.
* **📊 Auto Loop Calculators:** Specialized nodes (`Base`, `WanVideo`, `LTX`, `TTS`) that receive metadata and mathematically calculate the exact cut coordinates (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Progressively stitches rendered video chunks into a single final file.
* **🛡️ VAE Safe Frame Padder:** Pads incomplete tensors by cloning the last frame to save the VAE from a collapse in restrictive models (e.g. 4n+1 rules).
* **💉 LTXV Single Frame Injector:** Injects specific individual frames required by LTX flows.
* **⏱️ Auto FPS Limiter:** Smartly limits frames per second to save VRAM while maintaining audio sync.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Precisely slices audio (at mathematical sample level) based on the corresponding video frames.
* **🎛️ Conditional Audio Router (Bypass):** Conditionally routes audio allowing its processing to be bypassed if not needed.
### 📐 Resolution Tools
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculate optimal resolutions based on VRAM limits and geometric rules (multipliers) of different models (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Memory Management / Logic
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Native lazy evaluation; they cut inactive routes so heavy nodes aren't even loaded into memory.
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Python Garbage Collector (GC) between cycles.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retain and transfer in-memory images across different cycles.
---
## 🔌 Quick Wiring Guide (Workflow Setup)
1. **The Explorer:** Place the `Video Analyzer` at the beginning. Upload a video (or use a YTDLP path).
2. **Math:** Connect `total_frames` and `safe_faces_list` to your chosen `Auto Loop Calculator`.
3. **Safe Extraction:** Add the native `VHS_LoadVideo` node. Convert its input to *video_name* and connect it to the Analyzer. Connect its `IMAGE` output to the new `VAE Safe Frame Padder` node.
4. **Audio Passthrough:** Route the `source_audio` wire from the Explorer directly to the `audio` port of your `Incremental Auto-Stitcher` (at the end of the flow).
5. **The Trigger:** Connect the output of your Stitcher to the `Loop Trigger (Auto-Queue)` node.
6. **Execution:** Connect the `Loop Start (Index)` to the Brain and the Stitcher.
7. **Fire:** Click "Queue Prompt" **JUST ONCE**. Enjoy the autonomous magic!
---
## 🚀 GPU Acceleration (Face Scanning)
The `VideoAnalyzerFaceDetector` has a `bbox_detector` port to delegate facial scanning to the graphics card:
1. Install the *Impact Pack*.
2. Add the `UltralyticsDetectorProvider` node and select a face model (e.g. `face_yolov8m.pt`).
3. Connect its output to the Analyzer's port.
*(The node features auto-unload: it will destroy this model from VRAM upon completion to make room for generation).*
-101
View File
@@ -1,101 +0,0 @@
# 🔁 ComfyUI Sequential Batcher & Video Loop Master (Beta v0.9.3)
> [!IMPORTANT]
> Esta versión se encuentra actualmente en fase **BETA**. Hemos completado la transición de la terminología de "Job" a **Batch** (Lote) para alinearnos con los estándares de ComfyUI y el nombre del proyecto.
La herramienta definitiva para crear flujos de trabajo iterativos complejos y procesamiento de vídeo fotograma a fotograma en ComfyUI. Diseñada para manejar tareas pesadas (como generación de vídeo de alta resolución con Wan2.2 o LTX Video) sin colapsar tu GPU, utilizando bucles secuenciales inteligentes en lugar de procesamiento por lotes masivo que agota la VRAM.
## 🚀 ¿Por qué usar esto?
El procesamiento por lotes (batch) estándar de ComfyUI procesa todo a la vez (tensores 4D). Para vídeo o lotes grandes, esto provoca errores de **Falta de Memoria (OOM)**.
**Sequential Batcher** te permite "dividir" estas tareas y procesarlas **una a una** (secuencialmente) dentro de una sola ejecución de "Queue Prompt", para luego "reunir" los resultados en un solo lote o archivo de vídeo.
---
## 🛠️ Instalación
1. Clona este repositorio en `custom_nodes/comfyui-sequential-batcher`.
2. Reinicia ComfyUI.
3. Las dependencias (`torch`, `numpy`) se gestionarán automáticamente si usas ComfyUI Manager gracias al archivo `requirements.txt`.
---
## 📖 Conceptos Clave
- **SEQUENCE (Secuencia)**: Una lista simple de valores (números, textos, etc.).
- **BATCH (Lote)**: Una colección estructurada de "pasos". Cada paso tiene **Atributos** con nombre.
- **Iteración**: La magia ocurre en nodos como `Batch To List`, `Image Batch To List` o `Latent Batch To List`. Cuando ComfyUI detecta una salida tipo "Lista" de estos nodos, ejecuta todos los nodos conectados a continuación una vez por cada elemento de la lista.
---
## 🎞️ Flujo de Vídeo (Wan2.2 / LTX-Video / Modelos Futuros)
Los modelos de vídeo generan muchos fotogramas que pueden superar fácilmente los 24GB de VRAM.
1. **Split (Dividir)**: Usa `Latent Batch To List` para convertir tu latente de vídeo en una lista de fotogramas individuales.
2. **Process (Procesar)**: Conecta a tu KSampler o VAE Decoder. ComfyUI procesará el Fotograma 1, luego el 2, luego el 3... ahorrando memoria.
3. **Gather (Reunir)**: Usa `Latent List To Batch` (o `Image List To Batch` si decodificaste primero) para reconstruir el lote completo de vídeo para guardarlo.
4. **Stitch (Unir con VHS)**: Para unir chunks pesados de vídeo secuencialmente:
- *Opción A (Matemática)*: `Range` -> `MakeBatch` (atributo: "skip_frames") -> `BatchToList` -> `GetAttributeInt` (nombre: "skip_frames") conectado a `VHS_LoadVideo`.
- *Opción B (Por CSV)*: `LoadCSV` (con una columna `skip_frames`) -> `BatchToList` -> `GetAttributeInt`.
- Conecta la salida de `VHS_VideoCombine` (específicamente la salida `VHS_FILENAMES`) al nodo **FFmpeg Video Stitcher** (`video_paths`). ¡El Stitcher esperará a que terminen todos los chunks y los unirá automáticamente!
> [!WARNING]
> **Prevención OOM (Aviso de Memoria):** Aunque `BatchToList` soluciona el paso de datos, a nivel de VRAM de PyTorch, se debe seguir gestionando la memoria. Es altamente recomendable usar el nodo **easy cleanGpuUsed** (o similar) después del decodificador (VAE) en cada ciclo del Batch. Sin la liberación manual de VRAM en cada iteración, el bucle secuencial acumulará basura en la tarjeta gráfica y eventualmente provocará un error de "Out of Memory".
---
## 🔢 Referencia Detallada de Nodos
### 🔄 Categoría Bucles (`🔁 Sequential Batcher/Loop`)
- **🔁 Sequential Loop Index**: La forma más sencilla de iniciar un bucle.
- *Entrada*: `count` (Cuántas veces ejecutar).
- *Salida*: `index` (0, 1, 2...). Útil para semillas (seeds) o selección de elementos.
- **🔁 Repeat**: Toma cualquier entrada y la repite N veces.
- *Entrada*: `input` (Cualquiera), `count` (INT).
- *Salida*: `output` (Lista de la misma entrada repetida).
### 🛠️ Categoría Lote (`🔁 Sequential Batcher/Batch`)
- **📂 Load CSV**: Carga un archivo CSV como un Lote (Batch). Ahora incluye una vista previa de la tabla en el flujo de trabajo.
- *Entrada*: `path` (Ubicación del archivo), `delimiter`, `quotechar`.
- *Entrada Opcional*: `index` (Para elegir una fila específica).
- *Salida*: `batch` (La lista completa), `current_attributes` (Diccionario de la fila seleccionada), `count` (Total de filas).
- **📊 Preview Batch**: Muestra una tabla del contenido del lote en el flujo de trabajo.
- *Entrada*: `batch`, `index` (Resaltar fila específica), `max_rows`.
- **🛠️ Make Batch**: Convierte una secuencia en un objeto "Batch".
- *Entrada*: `sequence` (Los datos), `name` (El nombre del atributo, ej: "cfg_scale").
- **🖇️ Combine Batches**: Fusiona varios lotes.
- *Modos*: `zip` (por parejas) o `product` (todas las combinaciones posibles).
- **🔄 Batch To List**: **CRÍTICO**. Convierte un Lote en un flujo de atributos que activa el bucle secuencial.
- **📥 Get Attribute**: Extrae un valor específico del paso actual del lote por su nombre.
### 🖼️ Categoría Imagen y Latente (`🔁 Sequential Batcher/Image` & `/Latent`)
- **🖼️ Image Batch To List**: Divide un tensor [N,H,W,C] en N imágenes separadas.
- **🖼️ Image List To Batch**: Reconstruye un lote a partir de imágenes iteradas.
- **🎞️ Latent Batch To List**: Divide latentes de vídeo fotograma a fotograma para un procesamiento seguro en VRAM.
- **🎞️ Latent List To Batch**: Une fotogramas individuales de nuevo en un lote latente de vídeo.
- **⏳ Progress Bar**: Genera un indicador visual de progreso.
### 🎞️ Categoría Vídeo (`🔁 Sequential Batcher/Video`)
- **🎞️ FFmpeg Video Stitcher**: Nodo final en un bucle de vídeo. Espera a que termine todo el lote secuencial y une los fragmentos de vídeo utilizando FFmpeg sin recodificar.
- *Entrada*: `video_paths` (Lista de VHS_FILENAMES), `output_filename` (Cadena de texto).
- *Salida*: `final_video_path` (Ruta del vídeo ensamblado).
---
## 💡 Consejos Pro y Casos de Uso
### 📝 Uso de CSV para Prompts y Escenas
Puedes crear un CSV con columnas como `prompt`, `negative_prompt` y `seed`.
1. Usa **📂 Load CSV** para cargar tu archivo.
2. Conecta `batch` a **🔄 Batch To List**.
3. Usa **📥 Get Attribute** para pasar el `prompt` a tu CLIP Text Encode.
4. Cada fila de tu CSV se procesará como un "fotograma" o "trabajo" en la secuencia.
### 🎬 Tiempos de Escena para Vídeo
Si tienes un CSV con `frame_start` y `prompt`, puedes usarlo para cambiar los prompts en puntos específicos de un bucle de generación de vídeo.
### 🧪 XY Plots
Usa **🖇️ Combine Batches** en modo `product` para crear "XY Plots" (ej: probar cada Prompt contra cada valor de CFG).
### 🔍 Iteración Automática de Modelos
Usa **🔍 Model Finder** para iterar automáticamente a través de una carpeta de LoRAs o Checkpoints.
+43 -63
View File
@@ -1,75 +1,55 @@
__version__ = "0.9.3-beta"
__version__ = "0.8.3"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def register_node(c, display_name=None):
assert not isinstance(c.RETURN_TYPES, str), "Error: string found instead of tuple."
assert not isinstance(c.RETURN_NAMES, str), "Error: string found instead of tuple."
NODE_CLASS_MAPPINGS[c.__name__] = c
NODE_DISPLAY_NAME_MAPPINGS[c.__name__] = display_name or c.__name__
return c
# Importar tools
from . import image, loop, video, tools, vram_node
from .switch_node import MasterSwitch, LazySessionCache
from . import sequence, paths, batch, image, debug, loop, video
# Display name mappings with emojis
NODE_DISPLAY_NAME_MAPPINGS.update({
# Sequence
"Range": "🔢 Range",
"Literal": "📝 Literal",
"Reorder": "🔄 Reorder",
"Combinations": "🧬 Combinations",
"Permutations": "🔀 Permutations",
"Slice": "✂️ Slice",
"Join": "🔗 Join",
"ModelFinder": "🔍 Model Finder",
# Batch
"MakeBatch": "🛠️ Make Batch",
"CombineBatches": "🖇️ Combine Batches",
"EnumerateBatch": "🔢 Enumerate Batch",
"GetBatchStep": "📍 Get Batch Step",
"FormatAttributes": "📝 Format Attributes",
"GetAttribute": "📥 Get Attribute",
"GetAttributeInt": "📥 Get Attribute (Int)",
"GetAttributeFloat": "📥 Get Attribute (Float)",
"GetAttributeString": "📥 Get Attribute (String)",
"BatchToList": "🔄 Batch To List",
"LoadCSV": "📂 Load CSV",
"PreviewBatch": "📊 Preview Batch",
# Image
"JoinImageBatch": "🖼️ Join Image Batch",
"JoinImages": "🖼️ Join Images",
"SelectImageBatch": "🎯 Select Image Batch",
"SelectImageList": "🎯 Select Image List",
"GetImageSize": "📏 Get Image Size",
"StringToImage": "🔤 String To Image",
"ProgressBar": "⏳ Progress Bar",
"ImageBatchToList": "🖼️ Image Batch To List",
"ImageListToBatch": "🖼️ Image List To Batch",
"LatentBatchToList": "🎞️ Latent Batch To List",
"LatentListToBatch": "🎞️ Latent List To Batch",
# Video
"FFmpegVideoStitcher": "🎞️ FFmpeg Video Stitcher",
# Debug
"Stringify": "🧵 Stringify",
"Interact": "⌨️ Interact",
# Loop
"LoopIndex": "🔁 Sequential Loop Index",
"Repeat": "🔁 Repeat",
})
# Backward compatibility for "Job" naming
NODE_CLASS_MAPPINGS.update({
"MakeJob": batch.MakeBatch,
"CombineJobs": batch.CombineBatches,
"EnumerateJob": batch.EnumerateBatch,
"GetJobStep": batch.GetBatchStep,
"JobToList": batch.BatchToList,
"MasterSwitch": MasterSwitch,
"LazySessionCache": LazySessionCache,
})
NODE_DISPLAY_NAME_MAPPINGS.update({
"MasterSwitch": "🔀 Master Switch",
"LazySessionCache": "🗄️ Lazy Session Cache",
"MeisoftVRAMDefragmenter": "🧹 VRAM Defragmenter",
"SessionImageReceiver": "📥 Session Image Receiver",
"SessionImageSender": "📤 Session Image Sender",
"IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher",
"VideoAnalyzerFaceDetector": "🕵️ Video Analyzer Face detector + Audio",
"VideoAnalyzerSceneDetector": "🎬 Video Analyzer Scene detector",
"AutoLoopCalculator": "📊 Auto Loop Calculator",
"AutoLoopCalculatorWan": "📊 Auto Loop Calculator (WanVideo 3dVAE)",
"AutoLoopCalculatorLTX": "📊 Auto Loop Calculator (LTX 2.3)",
"AutoLoopCalculatorTTS": "📊 Auto Loop Calculator (TTS/Texto)",
"SequentialLoopStart": "🏁 Loop Start (Index)",
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
"BatchAudioFolderLoader": "📂 Batch Audio Folder Loader",
"AudioBatchSelector": "🎛️ Audio Batch Selector",
"DynamicSceneDirector": "🎬 Dynamic Scene Director",
# Tools
"AutoFPSLimiter": "⏱️ Auto FPS Limiter",
"VAESafeFramePadder": "🛡️ VAE Safe Frame Padder",
"ResTool8x": "📐 ResTool 8x (SD1.5)",
"ResTool16x": "📏 ResTool 16x (SDXL)",
"ResTool32x": "🎞️ ResTool 32x (WanVideo)",
"ResTool64x": "🎬 ResTool 64x (Hunyuan)",
"ResTool64xLTX": "🌌 ResTool 64x (LTX 2.3)",
"ConditionalAudioRouter": "🎛️ Conditional Audio Router (Bypass)",
"PreciseAudioSlicer": "✂️ Precise Audio Slicer",
"SaveSceneKeyframe": "💾 Save Scene Keyframe",
"LoadSceneKeyframe": "🖼️ Load Scene Keyframe",
"LTXVSingleFrameInjector": "💉 LTXV Single Frame Injector",
"ProjectPlanLoader": "📂 Project Plan Loader"
})
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
-305
View File
@@ -1,305 +0,0 @@
import collections
import itertools
import csv
import os
import folder_paths
from . import register_node
def format_batch_as_table(batch, current_index=-1, max_rows=20):
if not batch:
return "(Empty Batch)"
# Get all unique keys across all rows
keys = list(dict.fromkeys(itertools.chain.from_iterable(row.keys() for row in batch)))
if not keys:
return "(Empty rows)"
# Truncate values for display
def trunc(v, l=20):
s = str(v).replace('\n', ' ')
return (s[:l-3] + "...") if len(s) > l else s
# Calculate widths
widths = {k: len(str(k)) for k in keys}
rows_to_show = batch[:max_rows]
for row in rows_to_show:
for k in keys:
widths[k] = max(widths[k], len(trunc(row.get(k, ""))))
# Build header
header = "| " + " | ".join(str(k).ljust(widths[k]) for k in keys) + " |"
sep = "|-" + "-|-".join("-" * widths[k] for k in keys) + "-|"
idx_to_highlight = (current_index % len(batch)) if (len(batch) > 0 and current_index != -1) else (0 if len(batch) > 0 else -1)
lines = [header, sep]
for i, row in enumerate(rows_to_show):
prefix = ">" if i == idx_to_highlight else " "
line = f"{prefix}| " + " | ".join(trunc(row.get(k, "")).ljust(widths[k]) for k in keys) + " |"
lines.append(line)
if len(batch) > max_rows:
lines.append(f"... and {len(batch) - max_rows} more rows")
return "\n".join(lines)
@register_node
class MakeBatch:
"""Turns a sequence into a batch with one attribute."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sequence": ("SEQUENCE", ),
"name": ("STRING", {"default": ''}),
},
}
RETURN_TYPES = ("BATCH", "INT")
RETURN_NAMES = ("batch", "count")
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def merge_dicts(self, *dicts):
#return collections.ChainMap(*reversed(dicts))
return dict(itertools.chain.from_iterable(d.items() for d in dicts))
def go(self, sequence, name):
result = [{name: value} for value in sequence]
return (result, len(result))
@register_node
class CombineBatches(MakeBatch):
"""Combines multiple batches."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"a": ("BATCH", ),
"method": (("zip", "product"), {"default": "zip"}),
},
"optional": {
x: ("BATCH", ) for x in ('b', 'c', 'd', 'e')
}
}
def go(self, method, **kwargs):
method = {'product': itertools.product, 'zip': zip}[method]
result = [self.merge_dicts(*steps) for steps in method(*kwargs.values())]
return (result, len(result))
@register_node
class EnumerateBatch(MakeBatch):
"""Combines multiple batches."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"batch": ("BATCH", ),
"name": ("STRING", {"default": ''}),
},
}
def go(self, batch, name):
result = [self.merge_dicts(step, {name: n}) for n, step in enumerate(batch)]
return (result, len(result))
@register_node
class GetBatchStep:
"""Gets the batch step by number."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"batch": ("BATCH", ),
"step": ("INT", {"default": 0}),
"wrap": (("repeat", "clamp"), {"default": "repeat"}),
},
}
RETURN_TYPES = ("ATTRIBUTES", )
RETURN_NAMES = ("attributes", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def go(self, batch, step, wrap):
if wrap == 'repeat':
while step < 0:
step += len(batch)
step = step % len(batch)
elif wrap == 'clamp':
step = max(min(step, len(batch)-1), 0)
return (batch[step], )
@register_node
class FormatAttributes:
"""Applies attributes to a format string."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"attributes": ("ATTRIBUTES",),
"format": ("STRING", {'default': '', 'multiline': True, "dynamicPrompts": False})
},
}
RETURN_TYPES = ("STRING", )
RETURN_NAMES = ("string", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def go(self, attributes, format):
return (format.format(**attributes), )
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
@register_node
class GetAttribute:
"""Gets a named attribute from a step."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"attributes": ("ATTRIBUTES", ),
"name": ("STRING", {"default": ''}),
},
}
RETURN_TYPES = (AnyType("*"), )
RETURN_NAMES = ("value", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def go(self, attributes, name):
return (attributes[name], )
# Dynamically register typed attribute getters to avoid wildcard bug
# https://github.com/comfyanonymous/ComfyUI/pull/770
for t in ('INT', 'FLOAT', 'STRING'):
register_node(type(
'GetAttribute'+t.title(),
(GetAttribute, ),
{
'RETURN_TYPES': (t, ),
}
))
@register_node
class LoadCSV:
"""Loads a CSV file into a batch."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"path": ("STRING", {"default": "prompt.csv"}),
"delimiter": (["comma", "tab", "semicolon"], {"default": "comma"}),
"quotechar": (["\"", "'"], {"default": "\""}),
},
"optional": {
"index": ("INT", {"default": -1, "min": -1, "max": 999999}),
}
}
RETURN_TYPES = ("BATCH", "ATTRIBUTES", "INT")
RETURN_NAMES = ("batch", "current_attributes", "count")
OUTPUT_NODE = True
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def go(self, path, delimiter, quotechar, index=-1):
input_dir = folder_paths.get_input_directory()
if input_dir is None:
raise ValueError("Input directory not found in ComfyUI configuration.")
if os.path.isabs(path):
full_path = os.path.normpath(path)
else:
full_path = os.path.normpath(os.path.join(input_dir, path))
if os.path.commonpath([input_dir, full_path]) != os.path.normpath(input_dir):
raise ValueError(f"Access denied: Path '{path}' is outside the input directory.")
if not os.path.exists(full_path):
raise FileNotFoundError(f"CSV file not found: {path}")
delim = {
"comma": ",",
"tab": "\t",
"semicolon": ";",
}[delimiter]
batch = []
with open(full_path, mode='r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter=delim, quotechar=quotechar)
for row in reader:
# Clean up keys and values
clean_row = {k.strip() if k else k: v.strip() if v else v for k, v in row.items()}
batch.append(clean_row)
count = len(batch)
current_attributes = batch[index % count] if count > 0 and index != -1 else (batch[0] if count > 0 else {})
table = format_batch_as_table(batch, index)
return {"ui": {"text": [table]}, "result": (batch, current_attributes, count)}
@register_node
class PreviewBatch:
"""Shows a table of the batch."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"batch": ("BATCH",),
},
"optional": {
"index": ("INT", {"default": -1, "min": -1, "max": 999999}),
"max_rows": ("INT", {"default": 20, "min": 1, "max": 1000}),
}
}
RETURN_TYPES = ("BATCH",)
RETURN_NAMES = ("batch",)
OUTPUT_NODE = True
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def go(self, batch, index=-1, max_rows=20):
table = format_batch_as_table(batch, index, max_rows)
return {"ui": {"text": [table]}, "result": (batch,)}
@register_node
class BatchToList:
"""Converts a batch into a list."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"batch": ("BATCH",),
},
}
RETURN_TYPES = ("ATTRIBUTES",)
RETURN_NAMES = ("attributes",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Batch"
def go(self, batch):
return (batch,)
-106
View File
@@ -1,106 +0,0 @@
import sys
import os
import textwrap
import code
from . import register_node
@register_node
class Stringify:
"""Convert any input to str/repr."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"x": ("*", ),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("str", "repr")
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Debug"
def go(self, x):
return (str(x), repr(x))
class RestoreStdStreams(object):
# ComfyUI-Manager patches sys.stdout and sys.stder
# which breaks GNU Readline support and makes the
# REPL annoying to use. This context manager temporarily
# puts back the originals.
def __enter__(self):
self._stdout, self._stderr = sys.stdout, sys.stderr
sys.stdout, sys.stderr = sys.__stdout__, sys.__stderr__
self._stdout.flush(); self._stderr.flush()
def __exit__(self, exc_type, exc_value, traceback):
_stdout, _stderr = sys.stdout, sys.stderr
sys.stdout, sys.stderr = self._stdout, self._stderr
_stdout.flush(); _stderr.flush()
EOF = 'Ctrl-Z plus Return' if os.sep == '\\' else 'Ctrl-D (i.e. EOF)'
MESSAGE = f"Press {EOF} to continue processing."
class Quitter:
# Replace exit() and quit() to make sure the user exits with EOF
# otherwise the whole server gets terminated.
def __repr__(self):
return MESSAGE
def __call__(self):
print(MESSAGE)
@register_node
class Interact:
"""
Opens an interactive REPL whenever the node is evaluated.
SECURITY WARNING: This node allows arbitrary code execution.
To enable it, you must set the environment variable:
COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG=1
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": {x: "*" for x in ('a', 'b', 'c', 'd')},
}
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "interact"
CATEGORY = "🔁 Sequential Batcher/Debug"
OUTPUT_NODE = True
def interact(self, **kwargs):
if os.environ.get("COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG", "").lower() not in ("true", "1", "yes"):
print(f"[{self.__class__.__name__}] Interactive debugging is disabled. Set COMFYUI_SB_ENABLE_INTERACTIVE_DEBUG=1 to enable.")
return ()
if sys.__stdout__.isatty():
with RestoreStdStreams():
code.interact(
banner=textwrap.dedent(f"""
Interactive debugging started.
Try `print(a)`.
{MESSAGE}
"""),
exitmsg="Resuming workflow...",
local={
**kwargs,
'quit': Quitter(),
'exit': Quitter()
}
)
else:
# Don't block the server if there is no tty.
print("Skipping interactive prompt because there is no tty.")
return ()
+168 -204
View File
@@ -1,257 +1,221 @@
import torch
import os
import time
import random
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import torch
from PIL import Image
import folder_paths
from . import register_node
global_session_image = None
def tensor_to_temp_image(tensor_image, prefix="session_img"):
temp_dir = folder_paths.get_temp_directory()
filename = f"{prefix}_{random.randint(10000, 99999)}.png"
filepath = os.path.join(temp_dir, filename)
img_array = 255. * tensor_image[0].cpu().numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
img.save(filepath)
return {"filename": filename, "subfolder": "", "type": "temp"}
@register_node
class JoinImageBatch:
"""Turns an image batch into one big image."""
class SessionImageReceiver:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"mode": (("horizontal", "vertical"), {"default": "horizontal"}),
"initial_image": ("IMAGE",),
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
INPUT_IS_LIST = True
FUNCTION = "join"
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("current_image", "log")
OUTPUT_NODE = True
FUNCTION = "get_image"
CATEGORY = "🔁 Sequential Batcher/Image"
def join(self, images, mode):
# mode will be a list of strings, e.g. ["horizontal", "horizontal", ...]
mode = mode[0]
if isinstance(images, list):
# images is a list of [1, H, W, C] tensors or similar
images = torch.cat(images, dim=0)
n, h, w, c = images.shape
image = None
if mode == "vertical":
# for vertical we can just reshape
image = images.reshape(1, n * h, w, c)
elif mode == "horizontal":
# for horizontal we have to swap axes
image = torch.transpose(torch.transpose(images, 1, 2).reshape(1, n * w, h, c), 1, 2)
return (image,)
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()
def get_image(self, initial_image, current_loop_index):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
global global_session_image
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
is_first = (loop_idx == 0)
# Usamos getattr para evitar errores si el bucle aún no ha inicializado las variables
accumulated = getattr(loop, 'global_accumulated_frames', 0)
source_total = getattr(loop, 'global_source_frame_count', 1)
_log(f"\n{'='*50}")
_log(f"📥 [Secuencial Batcher] NODO: Image Receiver")
_log(f" -> Ciclo actual: {loop_idx} | Progreso global: {accumulated} / {source_total} frames")
if is_first or global_session_image is None:
global_session_image = initial_image.clone().cpu()
_log(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
selected = initial_image
else:
_log(f" -> ♻️ Usando el Keyframe validado y rescatado de la RAM.")
selected = global_session_image
_log(f" -> 🖼️ Tensor shape: {selected.shape}")
_log(f"{'='*50}\n")
ui_image = tensor_to_temp_image(selected, "receiver")
return {"ui": {"images": [ui_image]}, "result": (selected, "\n".join(log_output))}
@register_node
class JoinImages:
"""Turns joins two images into one big image."""
class SessionImageSender:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image_a": ("IMAGE",),
"image_b": ("IMAGE",),
"mode": (("horizontal", "vertical"), {"default": "horizontal"}),
"generated_images": ("IMAGE",),
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
"detect_faces": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "join"
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("VALIDATED_IMAGES", "log")
OUTPUT_NODE = True
FUNCTION = "set_image"
CATEGORY = "🔁 Sequential Batcher/Image"
def join(self, image_a, image_b, mode):
dim = {'horizontal': 2, 'vertical': 1}[mode]
return (torch.concat((image_a, image_b), dim), )
@register_node
class SelectImageBatch:
"""Selects one image from an image batch."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"select": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1}),
},
}
def IS_CHANGED(cls, **kwargs):
return time.time()
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "select"
CATEGORY = "🔁 Sequential Batcher/Image"
def set_image(self, generated_images, current_loop_index, detect_faces):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
import cv2
import numpy as np
import os, folder_paths, torch
from PIL import Image
def select(self, images, select):
n, h, w, c = images.shape
if select >= n:
select = n - 1
return (images[select].reshape(1, h, w, c),)
if generated_images is None:
raise ValueError("❌ ERROR CRÍTICO: No se recibieron imágenes en el Sender.")
global global_session_image
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
batch_size = generated_images.shape[0]
best_idx = batch_size - 1
@register_node
class SelectImageList:
"""Selects one image from an image list."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"select": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1}),
},
}
accumulated = getattr(loop, 'global_accumulated_frames', 0)
source_total = getattr(loop, 'global_source_frame_count', 1)
stride = getattr(loop, 'global_select_every_nth', 1)
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
INPUT_IS_LIST = True
FUNCTION = "select"
CATEGORY = "🔁 Sequential Batcher/Image"
_log(f"\n{'='*50}")
_log(f"📤 [Secuencial Batcher] NODO: Image Sender (Filtro Dinámico)")
_log(f" -> Frames recibidos de la IA: {batch_size}")
def select(self, images, select):
select = select[0]
n = len(images)
if select >= n:
select = n - 1
return (images[select],)
# 🧠 BYPASS IMPLACABLE: Si al sumar este lote llegamos al final del video, NO RECORTAMOS
advance_check = (batch_size - 1) * stride if ltx_mode_active else batch_size * stride
if (accumulated + advance_check) >= source_total:
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Desactivando recorte de rostros para forzar la salida de TODOS los frames.")
detect_faces = False
if detect_faces:
try:
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path)
@register_node
class GetImageSize:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
},
}
_log(f" -> 🕵️ Buscando el último rostro frontal en reversa (Desde frame {batch_size - 1} hasta 0)...")
RETURN_TYPES = ("INT", "INT", "INT")
RETURN_NAMES = ("width", "height", "batch_size")
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Image"
found = False
for i in range(batch_size - 1, -1, -1):
img_np = (generated_images[i].cpu().numpy() * 255.0).astype(np.uint8)
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(30, 30))
def go(self, images):
return (images.shape[2], images.shape[1], images.shape[0])
if len(faces) > 0:
best_idx = i
found = True
_log(f" -> ✅ Rostro detectado en el frame [{i}].")
break
if not found:
_log(f" -> ⚠️ ALERTA: No se detectó rostro frontal en NINGÚN frame. Aceptando todo el lote para evitar bucles infinitos.")
best_idx = batch_size - 1
except Exception as e:
_log(f" -> ⚠️ Error en OpenCV al validar: {e}")
else:
_log(f" -> ⏩ Detección de rostros desactivada. Usando el último frame absoluto del lote ({batch_size - 1}).")
best_idx = batch_size - 1
@register_node
class StringToImage:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"default": "Hello world!"}),
"width": ("INT", {"default": 384}),
"height": ("INT", {"default": 16}),
"colour": ("COLOR", {"default": "white"}),
"background": ("COLOR", {"default": "black"}),
}
}
# 1. TRUNCAR EL TENSOR
valid_images = generated_images[:best_idx + 1]
frames_accepted = best_idx + 1
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "render"
CATEGORY = "🔁 Sequential Batcher/Image"
# 2. AVANZAR LA MÁQUINA
stride = getattr(loop, 'global_select_every_nth', 1)
advanced_original_frames = frames_accepted * stride
def render(self, text, width, height, colour, background):
font = ImageFont.load_default()
img = Image.new("RGB", (width, height), background)
draw = ImageDraw.Draw(img)
_, _, w, h = draw.textbbox((0, 0), text, font=font)
draw.text(((width - w) / 2, ((height - h) / 2) - 1), text, font=font, fill=colour)
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
return (tensor,)
if not hasattr(loop, 'global_accumulated_frames'):
loop.global_accumulated_frames = 0
# LTX Mode Fix: Avoid counting the overlap frame in the progression
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
if ltx_mode_active:
# We subtract 1 from frames_accepted to account for the anchor frame
# The overlap frame shouldn't advance the global accumulated frames count
# since it will be reused as the start of the next chunk.
advanced_original_frames = max(1, (frames_accepted - 1) * stride)
@register_node
class ProgressBar:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"progress": ("FLOAT", {'default': 0, 'forceInput': True}),
"padding": ("INT", {'default': 3}),
"width": ("INT", {"default": 384}),
"height": ("INT", {"default": 16}),
"colour": ("COLOR", {"default": "white"}),
"background": ("COLOR", {"default": "black"}),
}
}
source_total = getattr(loop, 'global_source_frame_count', 1)
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "render"
CATEGORY = "🔁 Sequential Batcher/Image"
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
def render(self, progress, padding, width, height, colour, background):
img = Image.new("RGB", (width, height), background)
draw = ImageDraw.Draw(img)
draw.rectangle((padding, padding, width - padding - 1, height - padding - 1), outline=colour)
if progress > 0:
ip = padding + 2
draw.rectangle((ip, ip, max(ip+1, (width - ip - 1) * progress), height - ip - 1), outline=colour, fill=colour)
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
return (tensor,)
if is_chunk_mode:
# En modo TTS (Texto), el progreso se mide estrictamente en frases/chunks, no en frames de video.
is_final_cycle = is_final_chunk
_log(f" -> 🧩 Modo Texto activo: Progreso gestionado por frases ({loop.global_accumulated_frames} / {source_total}).")
else:
# Lógica original por frames de video
if is_final_chunk:
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Compensando mermas del VAE (Timeline forzado al 100%).")
loop.global_accumulated_frames = source_total
else:
loop.global_accumulated_frames += advanced_original_frames
is_final_cycle = loop.global_accumulated_frames >= source_total
_log(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total} frames")
@register_node
class ImageBatchToList:
"""Splits an image batch into a list for iteration."""
@classmethod
def INPUT_TYPES(s):
return {"required": {"images": ("IMAGE",)}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Image"
_log(f" -> ✂️ Tensor truncado a {frames_accepted} frames válidos.")
def go(self, images):
return (list(torch.split(images, 1, dim=0)),)
last_frame = valid_images[-1:].clone().cpu()
global_session_image = last_frame
# 🛡️ Solo guardamos el keyframe si habrá un próximo ciclo
if not is_final_cycle:
out_dir = folder_paths.get_output_directory()
filename = f"keyframe_{loop_idx:03d}.png"
filepath = os.path.join(out_dir, filename)
@register_node
class ImageListToBatch:
"""Gathers an iterated list of images into a single batch."""
@classmethod
def INPUT_TYPES(s):
return {"required": {"images": ("IMAGE",)}}
INPUT_IS_LIST = True
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Image"
img_array = 255. * last_frame[0].numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
img.save(filepath)
def go(self, images):
return (torch.cat(images, dim=0),)
_log(f" -> 💾 Keyframe seguro guardado: {filename}")
else:
_log(f" -> 🚀 Ciclo final/único detectado. Omitiendo guardado de keyframe.")
_log(f"{'='*50}\n")
@register_node
class LatentBatchToList:
"""Splits a latent batch into a list for iteration."""
@classmethod
def INPUT_TYPES(s):
return {"required": {"latents": ("LATENT",)}}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Latent"
def go(self, latents):
samples = latents["samples"]
return ([{"samples": samples[i:i+1]} for i in range(samples.shape[0])],)
@register_node
class LatentListToBatch:
"""Gathers an iterated list of latents into a single batch."""
@classmethod
def INPUT_TYPES(s):
return {"required": {"latents": ("LATENT",)}}
INPUT_IS_LIST = True
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latents",)
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Latent"
def go(self, latents):
return ({"samples": torch.cat([l["samples"] for l in latents], dim=0)},)
ui_image = tensor_to_temp_image(last_frame, "sender")
return {"ui": {"images": [ui_image]}, "result": (valid_images, "\n".join(log_output))}
+39
View File
@@ -0,0 +1,39 @@
import sys
import subprocess
import os
import shutil
def install_requirements():
req_file = os.path.join(os.path.dirname(__file__), "requirements.txt")
if os.path.exists(req_file):
print("📦 [Sequential Batcher] Instalando dependencias de Python...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file])
print("✅ Dependencias de Python instaladas.")
except Exception as e:
print(f"❌ Error instalando dependencias: {e}")
def check_and_install_ffmpeg():
if shutil.which("ffmpeg") is None:
print("⚠️ [Sequential Batcher] ADVERTENCIA CRÍTICA: FFmpeg NO está instalado.")
print("⚠️ La extracción de audio en Video Analyzer fallará.")
if sys.platform.startswith("linux"):
try:
print("⚙️ Intentando instalar FFmpeg automáticamente (Linux/WSL)...")
subprocess.check_call(["sudo", "apt-get", "update"])
subprocess.check_call(["sudo", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "ffmpeg"])
print("✅ FFmpeg instalado correctamente.")
except Exception as e:
print(f"❌ Falló la instalación automática de FFmpeg: {e}")
print("👉 EJECUTA MANUALMENTE: sudo apt update && sudo apt install ffmpeg -y")
elif sys.platform == "win32":
print("👉 ACCIÓN REQUERIDA: Descarga FFmpeg para Windows y añádelo al PATH.")
else:
print("✅ [Sequential Batcher] Motor FFmpeg detectado correctamente.")
if __name__ == "__main__":
print("🚀 Iniciando configuración de ComfyUI Sequential Batcher...")
install_requirements()
check_and_install_ffmpeg()
print("🎉 Configuración finalizada.")
+802 -27
View File
@@ -1,38 +1,813 @@
import random
import time
import urllib.request
import json
import gc
import torch
import comfy.model_management as mm
from . import register_node
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
global_loop_index = 0
global_accumulated_frames = 0
global_source_frame_count = 1
global_select_every_nth = 1
global_server_port = 8188 # 💡 NUEVO: Única fuente de la verdad para el puerto
global_ltx_mode = False
global_step_by_chunk = False
global_batch_index = 0
global_has_more_batches = False
global_is_batch_advancing = False
@register_node
class LoopIndex:
"""Iterates a number of times."""
@classmethod
def INPUT_TYPES(s):
return {"required": {"count": ("INT", {"default": 2, "min": 1, "max": 1000000})}}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("index",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Loop"
def go(self, count):
return (list(range(count)),)
@register_node
class Repeat:
"""Repeats an input N times."""
class SequentialLoopStart:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input": (AnyType("*"),),
"count": ("INT", {"default": 2, "min": 1, "max": 1000000}),
"reset_loop": ("BOOLEAN", {"default": False}),
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}),
"port": ("INT", {"default": 8188, "min": 1, "max": 65535}), # 💡 FAIL-FAST: Pedimos el puerto aquí
}
}
RETURN_TYPES = (AnyType("*"),)
RETURN_NAMES = ("output",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "go"
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("current_loop_index",)
FUNCTION = "get_index"
CATEGORY = "🔁 Sequential Batcher/Loop"
def go(self, input, count):
return ([input] * count,)
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()
def get_index(self, reset_loop, loop_idx, port):
global global_loop_index
global global_accumulated_frames
global global_server_port
global global_ltx_mode
global global_batch_index
global global_is_batch_advancing
import sys
import argparse
print(f"\n{'='*50}")
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
# --- PRE-FLIGHT CHECK (SECURITY SSRF FIX) ---
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8188)
try:
args, _ = parser.parse_known_args(sys.argv[1:])
system_port = args.port
except:
system_port = 8188
safe_port = system_port
if port != safe_port:
print(f" -> ⚠️ ATENCIÓN: El puerto de entrada ({port}) no coincide con el puerto del sistema ({safe_port}).")
print(f" -> 🔒 Por motivos de seguridad (Prevención SSRF), se forzará el puerto real del sistema: {safe_port}.")
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto seguro {safe_port}...")
max_retries = 3
for attempt in range(max_retries):
try:
req = urllib.request.Request(f"http://127.0.0.1:{safe_port}/system_stats")
urllib.request.urlopen(req, timeout=10)
global_server_port = safe_port
print(f" -> ✅ Conexión establecida. Puerto blindado y seguro.")
break
except Exception as e:
if attempt < max_retries - 1:
print(f" -> ⏳ Reintentando conexión ({attempt + 1}/{max_retries}) por alta carga del servidor...")
time.sleep(1)
error_msg = f"🚨 FATAL ERROR: EL PUERTO {safe_port} NO RESPONDE TRAS VARIOS INTENTOS. El servidor ComfyUI podría estar bloqueado o apagado. (Error original: {e})"
print(f" -> ❌ {error_msg}")
raise RuntimeError(error_msg)
# ------------------------------------
is_reset = str(reset_loop).lower() in ['true', '1', 't', 'y']
if is_reset or loop_idx == 0:
global_loop_index = 0
global_accumulated_frames = 0
global_ltx_mode = False
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
else:
global_loop_index = loop_idx
global_is_batch_advancing = False
print(f" -> 📍 Índice actual de bucle: {global_loop_index}")
print(f"{'='*50}\n")
return (global_loop_index,)
@register_node
class AutoLoopCalculatorTTSBatch:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text_list": ("STRING", {"forceInput": True}),
"file_names": ("STRING", {"forceInput": True}),
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
# 💡 FIX: Mantenemos INPUT_IS_LIST para absorber el batch, pero ELIMINAMOS OUTPUT_IS_LIST
# para que ComfyUI desempaquete las salidas automáticamente.
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING", "INT", "INT", "STRING", "STRING")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "current_file_name", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, text_list, file_names, split_mode, current_loop_index):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
from . import loop
import re
loop.global_step_by_chunk = True
texts = text_list[0] if (isinstance(text_list, list) and len(text_list) > 0 and isinstance(text_list[0], list)) else (text_list if isinstance(text_list, list) else [text_list])
names = file_names[0] if (isinstance(file_names, list) and len(file_names) > 0 and isinstance(file_names[0], list)) else (file_names if isinstance(file_names, list) else [file_names])
mode = split_mode[0] if isinstance(split_mode, list) else split_mode
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
# El batch index es extraído directamente de idx para evitar bloqueos
current_batch_idx = idx
if current_batch_idx >= len(texts):
current_batch_idx = len(texts) - 1
current_file_text = texts[current_batch_idx]
current_name = names[current_batch_idx] if current_batch_idx < len(names) else "unknown"
if mode == "Frases (Puntos)":
matches = re.findall(r'[^.!?\n]+[.!?\n]*', current_file_text)
temp_chunks = [m.strip() for m in matches if m.strip()]
MIN_WORDS = 5
raw_chunks = []
buffer_text = ""
for chunk in temp_chunks:
buffer_text = (buffer_text + " " + chunk).strip()
if len(buffer_text.split()) >= MIN_WORDS:
raw_chunks.append(buffer_text)
buffer_text = ""
if buffer_text:
if raw_chunks: raw_chunks[-1] += " " + buffer_text
else: raw_chunks.append(buffer_text)
chunk_type_name = "Frases Optimizadas"
raw_chunks = [p.strip() for p in current_file_text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
total_chunks = len(raw_chunks)
if total_chunks == 0:
raw_chunks = [""]
total_chunks = 1
safe_index = min(idx, total_chunks - 1)
current_chunk_text = raw_chunks[safe_index]
loop.global_source_frame_count = total_chunks
loop.global_accumulated_frames = safe_index + 1
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
loop.global_has_more_batches = (current_batch_idx < len(texts) - 1)
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS Batch)")
_log(f" -> Archivo {current_batch_idx + 1} de {len(texts)}: {current_name}")
_log(f" -> Modo de división: {mode}")
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
_log(f" -> 📜 Texto a procesar: {current_chunk_text[:75]}...")
_log(f"{'='*50}\n")
return (current_chunk_text, safe_index, total_chunks, current_name, "\n".join(log_output))
@register_node
class AutoLoopCalculatorLTX:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_frame_count": ("INT", {"forceInput": True}),
"target_frames_per_loop": ("INT", {"default": 81, "min": 9, "max": 257, "step": 8}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
"current_loop_index": ("INT", {"forceInput": True}),
},
"optional": {
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "INT")
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Loop"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
if source_frame_count <= 0 or target_frames_per_loop <= 0:
return (1, 0, select_every_nth)
source_frame_count = min(source_frame_count, 10000)
select_every_nth = max(1, select_every_nth)
# --- Ajuste Proporcional LTX (Regla DiT 8n + 1) ---
import math
from . import loop
loop.global_step_by_chunk = False
potential_effective_frames = source_frame_count // select_every_nth
if potential_effective_frames < 9:
safe_effective_frames = 9
safe_effective_frames = ((potential_effective_frames + 6) // 8) * 8 + 1
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
physical_source_frame_count = potential_effective_frames * select_every_nth
loop.global_source_frame_count = physical_source_frame_count
effective_padding = safe_effective_frames - potential_effective_frames
# Ajuste proporcional
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
if estimated_loops > 0:
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
adjusted_target = ((optimal_target - 1 + 7) // 8) * 8 + 1
target_frames_per_loop = adjusted_target
print(f" -> ⚖️ Ajuste Proporcional LTX: Target recalculado a {adjusted_target} frames por ciclo.")
print(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
if effective_padding > 0:
print(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
print(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
loop.global_select_every_nth = select_every_nth
global global_ltx_mode
global_ltx_mode = True
current_pos = loop.global_accumulated_frames
print(f"\n{'='*50}")
print(f"📊 [DEBUG] NODO: Auto Loop Calculator (LTX 2.3)")
print(f" -> Timeline físico ajustado a LTX: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
if current_pos >= physical_source_frame_count:
return (1, current_pos, select_every_nth)
frames_left = physical_source_frame_count - current_pos
equitable_target = target_frames_per_loop * select_every_nth
ideal_cut = current_pos + equitable_target
if frames_left <= equitable_target:
best_cut = physical_source_frame_count
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
if scene_cuts_list and len(scene_cuts_list) > 0:
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
next_cut = min(future_cuts)
if next_cut <= ideal_cut:
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
safe_chunk_from_scene = ((chunk_from_scene + 6) // 8) * 8 + 1
if safe_chunk_from_scene < 9: safe_chunk_from_scene = 9
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
print(f" -> 🎬 Bucle aislado por escena (Ajustado x8). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
best_cut = ideal_cut
print(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
best_cut = ideal_cut
print(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
safe_chunk_from_face = ((chunk_from_face + 6) // 8) * 8 + 1
if safe_chunk_from_face < 9: safe_chunk_from_face = 9
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
print(f" -> ✂️ Corte Facial Inteligente (Ajustado x8): {best_cut} (Meta equitativa: {ideal_cut})")
best_cut = ideal_cut
print(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x8: {ideal_cut}")
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
effective_chunk_frames = ((effective_chunk_frames + 6) // 8) * 8 + 1
if effective_chunk_frames < 9:
effective_chunk_frames = 9
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
print(f" -> 🏁 Chunk final LTX detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 8n+1.")
loop.global_is_final_chunk = True
loop.global_is_final_chunk = False
skip_frames = current_pos
print(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
print(f"{'='*50}\n")
return (effective_chunk_frames, skip_frames, select_every_nth)
@register_node
class SequentialLoopTrigger:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"trigger_dependency": ("*", ),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}
}
RETURN_TYPES = ()
RETURN_NAMES = ()
OUTPUT_NODE = True
FUNCTION = "trigger_next"
CATEGORY = "🔁 Sequential Batcher/Loop"
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()
def trigger_next(self, trigger_dependency, prompt=None, extra_pnginfo=None):
global global_loop_index
global global_accumulated_frames
global global_source_frame_count
global global_server_port
next_loop = global_loop_index + 1
is_final_chunk = global_accumulated_frames >= global_source_frame_count
from . import loop
has_more_batches = getattr(loop, 'global_has_more_batches', False)
print(f"\n{'='*50}")
print(f"🎯 [DEBUG] NODO: Loop Trigger")
print(f" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
# 💡 FIX: Reencolar si NO es el final del chunk, O si es el final pero hay más archivos
if not is_final_chunk or has_more_batches:
if prompt is not None:
estimated_chunk = 50
found_calculator = False
m_seeds = 0
master_switches = []
for node_id, node_data in prompt.items():
class_type = node_data.get("class_type", "")
inputs = node_data.get("inputs", {})
if not found_calculator and "AutoLoopCalculator" in class_type:
if "TTS" in class_type:
estimated_chunk = 1
estimated_chunk = inputs.get("target_frames_per_loop", 50)
found_calculator = True
for key in ["seed", "noise_seed"]:
if key in inputs and isinstance(inputs[key], (int, float)):
inputs[key] = random.randint(1, 0x7fffffff)
m_seeds += 1
if class_type == "SequentialLoopStart":
if is_final_chunk and has_more_batches:
inputs["loop_idx"] = 0
inputs["reset_loop"] = True
else:
inputs["loop_idx"] = next_loop
inputs["reset_loop"] = False
if class_type == "MasterSwitch":
master_switches.append(inputs)
# 🛡️ Desempaquetado defensivo universal (Unwrapping de listas de ComfyUI)
_acc_frames = global_accumulated_frames[0] if isinstance(global_accumulated_frames, list) else global_accumulated_frames
_src_frames = global_source_frame_count[0] if isinstance(global_source_frame_count, list) else global_source_frame_count
_est_chunk = estimated_chunk[0] if isinstance(estimated_chunk, list) else estimated_chunk
# Asegurar casteo a enteros puros para evitar colisiones matemáticas en cualquier flujo
_acc_frames = int(_acc_frames) if _acc_frames is not None else 0
_src_frames = int(_src_frames) if _src_frames is not None else 0
_est_chunk = int(_est_chunk) if _est_chunk is not None else 0
is_next_final = (_acc_frames + _est_chunk) >= _src_frames
if is_final_chunk and has_more_batches:
is_next_final = False
for inputs in master_switches:
inputs["is_final_cycle"] = is_next_final
if is_final_chunk and has_more_batches:
print(f" -> 📦 Archivo finalizado. Iniciando siguiente archivo del lote...")
else:
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
p = {"prompt": prompt}
if extra_pnginfo: p["extra_data"] = {"extra_pnginfo": extra_pnginfo}
data = json.dumps(p).encode('utf-8')
req = urllib.request.Request(f"http://127.0.0.1:{global_server_port}/prompt", data=data, headers={'Content-Type': 'application/json'})
try:
urllib.request.urlopen(req, timeout=5)
print(f" -> ✅ Siguiente ciclo inyectado en la cola.")
except Exception as e:
print(f" -> ❌ Error HTTP: {e}")
else:
print(f" -> 🏁 ¡Generación Finalizada! Todos los archivos del lote completados.")
print(f"{'='*50}\n")
return ()
@register_node
class BatchAudioFolderLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"folder_path": ("STRING", {"forceInput": True, "tooltip": "Conectar al output folder_path de Telegram WaitForMultipleFiles"}),
}
}
RETURN_TYPES = ("AUDIO_LIST", "STRING_LIST", "INT", "STRING")
RETURN_NAMES = ("audio_list", "file_names", "file_count", "log")
FUNCTION = "load_audios"
CATEGORY = "🔁 Sequential Batcher/Audio"
def load_audios(self, folder_path):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
import os
import torch
from .video import extract_and_standardize_audio
_log(f"\n📂 [Batch Audio Loader] Escaneando lote en: {folder_path}")
if not folder_path or not os.path.exists(folder_path):
_log(" -> ❌ ERROR: Directorio nulo o inexistente. Devolviendo silencio.")
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
return ([silent], ["error_silence.ogg"], 0, "\n".join(log_output))
valid_extensions = ('.mp3', '.wav', '.ogg', '.flac', '.m4a')
files = sorted([f for f in os.listdir(folder_path) if f.lower().endswith(valid_extensions)])
if not files:
_log(" -> ⚠️ No se encontraron archivos de audio en el lote.")
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
return ([silent], ["empty_silence.ogg"], 0, "\n".join(log_output))
audio_list = []
file_names = []
for f in files:
path = os.path.join(folder_path, f)
try:
_log(f" -> 🎵 Cargando y estandarizando: {f}")
audio_dict = extract_and_standardize_audio(path)
audio_list.append(audio_dict)
file_names.append(f)
except Exception as e:
_log(f" -> ❌ Error procesando {f}: {e}")
if not audio_list:
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
return ([silent], ["error_silence.ogg"], 0, "\n".join(log_output))
_log(f" -> ✅ Lote procesado: {len(audio_list)} audios listos en RAM.")
return (audio_list, file_names, len(audio_list), "\n".join(log_output))
@register_node
class AudioBatchSelector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio_list": ("AUDIO_LIST", {"forceInput": True}),
"file_names": ("STRING_LIST", {"forceInput": True}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
# 💡 AÑADIDOS LOS PINES PARA EL ÍNDICE DEL LOTE
RETURN_TYPES = ("AUDIO", "STRING", "INT", "INT", "STRING")
RETURN_NAMES = ("current_audio", "current_file_name", "batch_index", "total_batches", "log")
FUNCTION = "select"
CATEGORY = "🔁 Sequential Batcher/Audio"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def select(self, audio_list, file_names, current_loop_index):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
from . import loop
# Normalización de listas
audios = audio_list if isinstance(audio_list, list) else [audio_list]
names = file_names if isinstance(file_names, list) else [file_names]
if len(audios) == 1 and isinstance(audios[0], list): audios = audios[0]
if len(names) == 1 and isinstance(names[0], list): names = names[0]
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
# 🧠 FIX: El archivo lo dicta el índice de lote global, NO el índice de ciclo (que es para escenas)
current_batch_idx = getattr(loop, 'global_batch_index', 0)
if current_batch_idx >= len(audios):
current_batch_idx = len(audios) - 1
current_audio = audios[current_batch_idx]
current_name = names[current_batch_idx]
total_audios = len(audios)
loop.global_has_more_batches = (current_batch_idx < total_audios - 1)
_log(f"\n{'='*50}")
_log(f"🎛️ [Secuencial Batcher] NODO: Audio Batch Selector")
_log(f" -> Lote (Batch) Actual: {current_batch_idx + 1} de {total_audios}")
_log(f" -> Archivo activo: {current_name}")
_log(f"{'='*50}\n")
return (current_audio, current_name, current_batch_idx, total_audios, "\n".join(log_output))
import folder_paths
import math
@register_node
class DynamicSceneDirector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"agent_json": ("STRING", {"multiline": True, "default": '{"scenes": [{"scene_id": 1, "duration_seconds": 5.0, "flux_prompt": "A cinematic wide shot...", "wan_prompt": "Camera pans..."}]}'}),
"audio_filename": ("STRING", {"default": "audio_track"}),
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
"fps": ("INT", {"default": 12, "min": 8, "max": 60}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
RETURN_NAMES = ("flux_prompt", "wan_prompt", "chunk_frames", "image_load_path", "project_json_path")
FUNCTION = "direct_scene"
CATEGORY = "🔁 Sequential Batcher/Director"
def direct_scene(self, agent_json, audio_filename, current_loop_index, fps):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
from . import loop
import os, folder_paths, json, math, re
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
_log(f"\n{'='*50}")
_log(f"🎬 [Director] Evaluando Ciclo: {idx}")
base_output = folder_paths.get_output_directory()
safe_audio_name = "".join(c for c in audio_filename if c.isalnum() or c in " _-").strip() or "proyecto"
plan_path = os.path.join(base_output, f"{safe_audio_name}_plan.json")
# 1. 🛡️ GESTIÓN DEL JSON (Ignora a Ollama a partir del ciclo 1)
if idx == 0:
try:
# Extracción robusta por si el LLM añade texto extra
json_match = re.search(r'\{.*\}', agent_json, re.DOTALL)
raw_json = json_match.group() if json_match else agent_json
data = json.loads(raw_json)
with open(plan_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
_log(f" -> 💾 Plan maestro guardado en: {plan_path}")
except Exception as e:
raise ValueError(f"❌ Error al interpretar el JSON del Agente: {e}")
else:
try:
with open(plan_path, 'r', encoding='utf-8') as f:
data = json.load(f)
_log(f" -> ♻️ Plan recuperado del disco (Ignorando entrada del Agente).")
except Exception as e:
raise ValueError(f"❌ Error al leer el JSON guardado en {plan_path}: {e}")
scenes = data.get("scenes", [])
total_scenes = len(scenes)
if total_scenes == 0: raise ValueError("❌ JSON sin escenas.")
# 2. ⏱️ MÁQUINA DE ESTADOS (1 Ciclo = 1 Escena)
# El director es un observador pasivo, scene_idx se deriva directamente de idx
scene_idx = idx
if scene_idx >= total_scenes:
scene_idx = total_scenes - 1
scene = scenes[scene_idx]
# 🧠 REGLA APLICADA: Este nodo actúa como "Calculator" del flujo JSON
loop.global_step_by_chunk = True
loop.global_source_frame_count = total_scenes
loop.global_accumulated_frames = scene_idx + 1
loop.global_is_final_chunk = (scene_idx + 1) >= total_scenes
loop.global_is_absolute_video_final = loop.global_is_final_chunk
keyframes_dir = os.path.join(base_output, f"{safe_audio_name}_Keyframes")
os.makedirs(keyframes_dir, exist_ok=True)
image_path = os.path.join(keyframes_dir, f"scene_{scene.get('scene_id', scene_idx)}.png")
flux_prompt = scene.get("flux_prompt", "")
wan_prompt = scene.get("wan_prompt", "")
duration = scene.get("duration_seconds", 5.0)
chunk_frames = math.ceil(duration * fps)
_log(f" -> 🎬 Procesando Escena {scene_idx + 1}/{total_scenes} ({duration}s)")
_log(f"{'='*50}\n")
return (flux_prompt, wan_prompt, chunk_frames, image_path, plan_path)
@register_node
class IncrementalVideoStitcher:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
"current_loop_index": ("INT", {"default": 0}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "BOOLEAN", "BOOLEAN", "STRING")
RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT", "IS_FINAL_CYCLE", "IS_ABSOLUTE_FINAL", "log")
FUNCTION = "stitch"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time; return time.time()
def stitch(self, images, audio, current_loop_index):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
from . import loop
import os, folder_paths, torch, shutil, time
from PIL import Image
import numpy as np
from concurrent.futures import ThreadPoolExecutor
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
cache_dir = os.path.join(folder_paths.get_temp_directory(), "meisoft_video_cache")
if idx == 0:
if os.path.exists(cache_dir): shutil.rmtree(cache_dir, ignore_errors=True)
os.makedirs(cache_dir, exist_ok=True)
_log(f"\n🧹 [Stitcher] Ciclo 0 detectado. Caché general limpiada.")
else:
os.makedirs(cache_dir, exist_ok=True)
timestamp = int(time.time() * 1000)
for i in range(images.shape[0]):
filename = f"frame_{idx:04d}_{timestamp}_{i:04d}.png"
path = os.path.join(cache_dir, filename)
img_array = 255. * images[i].cpu().numpy()
Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)).save(path, format="PNG")
if audio is not None:
audio_path = os.path.join(cache_dir, f"audio_{idx:04d}_{timestamp}.safetensors")
from safetensors.torch import save_file
save_file({"waveform": audio["waveform"].contiguous(), "sample_rate": torch.tensor(audio["sample_rate"], dtype=torch.int32)}, audio_path)
frames_accepted = images.shape[0]
stride = getattr(loop, 'global_select_every_nth', 1)
ltx_mode = getattr(loop, 'global_ltx_mode', False)
advanced_frames = max(1, (frames_accepted - 1) * stride) if ltx_mode else frames_accepted * stride
# 🧠 PROTECCIÓN: Solo el Stitcher suma frames si NO estamos en modo Chunk (Director/TTS)
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
if not is_chunk_mode:
loop.global_accumulated_frames += advanced_frames
loop.global_is_final_chunk = loop.global_accumulated_frames >= getattr(loop, 'global_source_frame_count', 1)
is_absolute_final = getattr(loop, 'global_is_absolute_video_final', False) and getattr(loop, 'global_is_final_chunk', False)
has_more_batches = getattr(loop, 'global_has_more_batches', False)
if is_absolute_final:
_log(f" -> 🏁 ¡Guion Completo! Ensamblando todas las escenas juntas...")
png_files = sorted([f for f in os.listdir(cache_dir) if f.endswith('.png')])
if not png_files: return (images, audio, True, True, "\n".join(log_output))
def load_and_process(filename):
img_np = np.array(Image.open(os.path.join(cache_dir, filename)).convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(img_np).unsqueeze(0)
with ThreadPoolExecutor() as executor:
tensors_list = list(executor.map(load_and_process, png_files))
final_tensor = torch.cat(tensors_list, dim=0)
audio_files = sorted([f for f in os.listdir(cache_dir) if f.startswith('audio_') and f.endswith('.safetensors')])
final_audio = None
if audio_files:
waveforms = []
sample_rate = 44100
from safetensors.torch import load_file
for af in audio_files:
chunk_audio = load_file(os.path.join(cache_dir, af))
waveforms.append(chunk_audio["waveform"])
sample_rate = chunk_audio["sample_rate"].item()
final_audio = {"waveform": torch.cat(waveforms, dim=-1), "sample_rate": sample_rate}
final_audio = audio
try: shutil.rmtree(cache_dir)
except: pass
return (final_tensor, final_audio, True, not has_more_batches, "\n".join(log_output))
# 💡 FIX: Retorno de seguridad para ciclos intermedios
# Devolvemos el último frame procesado para que el flujo no rompa
return (images[-1:].clone(), None, False, False, "\n".join(log_output))
@register_node
class ProjectPlanLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio_filename": ("STRING", {"forceInput": True, "tooltip": "Conectar al 'current_file_name' del Audio Batch Selector"}),
}
}
RETURN_TYPES = ("STRING", "INT", "STRING")
RETURN_NAMES = ("agent_json", "scene_count", "plan_path")
FUNCTION = "load_plan"
CATEGORY = "🔁 Sequential Batcher/Director"
@classmethod
def IS_CHANGED(cls, **kwargs):
# Queremos que siempre lea el archivo más reciente del disco
import time
return time.time()
def load_plan(self, audio_filename):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
import os
import json
import folder_paths
_log(f"\n{'='*50}")
_log(f"📂 [Plan Loader] Buscando proyecto para: {audio_filename}")
# La misma lógica exacta del DynamicSceneDirector
base_output = folder_paths.get_output_directory()
safe_audio_name = "".join(c for c in audio_filename if c.isalnum() or c in " _-").strip() or "proyecto"
plan_path = os.path.join(base_output, f"{safe_audio_name}_plan.json")
if not os.path.exists(plan_path):
raise ValueError(f"❌ Error: No se encontró el plan de proyecto en {plan_path}. Asegúrate de que el Flujo 1 lo haya generado.")
with open(plan_path, 'r', encoding='utf-8') as f:
raw_json = f.read()
try:
data = json.loads(raw_json)
scenes = data.get("scenes", [])
scene_count = len(scenes)
except Exception as e:
raise ValueError(f"❌ Error al interpretar el JSON guardado en {plan_path}: {e}")
_log(f" -> ✅ Plan cargado con éxito. Escenas detectadas: {scene_count}")
_log(f"{'='*50}\n")
return (raw_json, scene_count, plan_path)
-70
View File
@@ -1,70 +0,0 @@
import pathlib
import glob
from folder_paths import folder_names_and_paths
from . import register_node
folder_types = tuple(sorted(folder_names_and_paths.keys()))
folder_default = "checkpoint" if "checkpoint" in folder_types else folder_types[0]
@register_node
class ModelFinder:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"filenames": ("SEQUENCE", ),
"model_type": (folder_types, {"default": folder_default}),
"recursive": ("BOOLEAN", {"default": True}),
"skip_missing": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("SEQUENCE", "SEQUENCE")
RETURN_NAMES = ("paths", "stems")
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def find_models(self, fn, paths, recursive):
for p in paths:
for f in (p.rglob(fn) if recursive else p.glob(fn)):
yield f.relative_to(p)
def go(self, filenames, model_type, recursive, skip_missing):
paths = [pathlib.Path(folder) for folder in folder_names_and_paths[model_type][0]]
available_files = []
file_index = {}
for p in paths:
for f in (p.rglob('*') if recursive else p.glob('*')):
try:
rel = f.relative_to(p)
available_files.append(rel)
if rel.name not in file_index:
file_index[rel.name] = []
file_index[rel.name].append(rel)
except ValueError:
pass
result = []
for fn in filenames:
if '..' in fn:
raise Exception(f'".." is not allowed: {fn}.')
candidates = []
if glob.has_magic(fn):
for f in available_files:
if f.match(fn):
candidates.append(f)
else:
potential = file_index.get(pathlib.Path(fn).name, [])
for f in potential:
if f.match(fn):
candidates.append(f)
if candidates:
result.extend(candidates)
elif not skip_missing:
raise Exception(f'Could not find file: {fn}')
return ([str(f) for f in result], [f.stem for f in result])
+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
+3
View File
@@ -1,3 +1,6 @@
torch
torchaudio
numpy
Pillow
opencv-python
safetensors
-159
View File
@@ -1,159 +0,0 @@
import ast
import functools
import itertools
from . import register_node
# Sequence creation.
@register_node
class Range:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"start": ("INT", {"default": 0, "min": -9999999, "max": 9999999, "step": 1}),
"stop": ("INT", {"default": 10, "min": -9999999, "max": 9999999, "step": 1}),
"step": ("INT", {"default": 1, "min": -9999999, "max": 9999999, "step": 1}),
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, start, stop, step):
return (range(start, stop, step), )
@register_node
class Literal:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"literal": ("STRING", {'default': '', 'multiline': True})
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, literal):
return (ast.literal_eval(literal), )
# Processing of existing sequences.
@register_node
class Reorder:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sequence": ("SEQUENCE", ),
"method": (("reverse", "sort", "reverse sort"), {"default": "sort"}),
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, sequence, method):
f = {"reverse": reversed, "sort": sorted, "reverse_sort": lambda x: sorted(x, reverse=True)}[method]
return (f(sequence), )
@register_node
class Combinations:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sequence": ("SEQUENCE", ),
"count": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
"replacement": ("BOOLEAN", {"default": False, "label_on": "Yes", "label_off": "No"}),
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, sequence, count, replacement):
if replacement:
return (itertools.combinations_with_replacement(sequence, count), )
else:
return (itertools.combinations(sequence, count), )
@register_node
class Permutations:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sequence": ("SEQUENCE", ),
"count": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, sequence, count):
return (itertools.permutations(sequence, count), )
@register_node
class Slice:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sequence": ("SEQUENCE",),
"start": ("INT", {"default": 0, "min": -9999999, "max": 9999999, "step": 1}),
"stop": ("INT", {"default": 9999999, "min": -9999999, "max": 9999999, "step": 1}),
"step": ("INT", {"default": 1, "min": -9999999, "max": 9999999, "step": 1}),
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, sequence, start, stop, step):
return (sequence[start:stop:step], )
@register_node
class Join:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"sequence": ("SEQUENCE", ),
"join_str": ("STRING", {'default': ', '}),
},
}
RETURN_TYPES = ("SEQUENCE", )
RETURN_NAMES = ("sequence", )
FUNCTION = "go"
CATEGORY = "🔁 Sequential Batcher/Sequence"
def go(self, sequence, join_str):
return ([join_str.join(x) for x in sequence], )
+113
View File
@@ -0,0 +1,113 @@
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
class MasterSwitch:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"is_final_cycle": ("BOOLEAN", {"default": False, "forceInput": False}),
},
"optional": {
"on_true": (any_type,),
"on_false": (any_type,),
}
}
RETURN_TYPES = (any_type, "STRING")
RETURN_NAMES = ("output", "log")
FUNCTION = "route"
CATEGORY = "🔁 Sequential Batcher/Logic"
# 🔥 Evaluación Perezosa Nativa (Protege el Ciclo 0)
def check_lazy_status(self, is_final_cycle, on_true=None, on_false=None):
if is_final_cycle:
return ["on_true"]
else:
return ["on_false"]
def route(self, is_final_cycle, on_true=None, on_false=None):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
if is_final_cycle:
return (on_true, "\n".join(log_output))
else:
return (on_false, "\n".join(log_output))
# Diccionario global para persistencia entre ciclos de ejecución
GLOBAL_SESSION_CACHE = {}
class LazySessionCache:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
# Mantenemos el índice como obligatorio para saber en qué ciclo estamos
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
},
"optional": {
# 'value_in' DEBE ser opcional para que la evaluación perezosa funcione
"value_in": (any_type, {"forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID"
}
}
RETURN_TYPES = (any_type, "STRING")
RETURN_NAMES = ("value_out", "log")
FUNCTION = "execute"
CATEGORY = "🔁 Sequential Batcher/Logic"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
# 🔥 LA CLAVE: Aquí es donde ComfyUI decide si "tira del cable" o no
def check_lazy_status(self, current_loop_index, **kwargs):
# El índice llega como una lista o un int dependiendo del nodo anterior
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
if idx == 0:
# Ciclo 0: Obligamos a ComfyUI a ejecutar todo lo que haya atrás
return ["value_in"]
else:
# Ciclos > 0: NO pedimos 'value_in'. ComfyUI NO ejecutará los nodos anteriores.
return []
def execute(self, current_loop_index, value_in=None, unique_id="0"):
global GLOBAL_SESSION_CACHE
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
_log(f"🧠 [Cache] Ciclo {idx} | Nodo ID: {unique_id}")
# --- LÓGICA DE CAPTURA (Ciclo 0) ---
if idx == 0:
if value_in is not None:
GLOBAL_SESSION_CACHE[unique_id] = value_in
_log(" -> 💾 Valor capturado y guardado en RAM.")
return (value_in, "\n".join(log_output))
else:
# Si es el ciclo 0 y no hay nada, algo va mal en el flujo
raise ValueError(f"❌ Error en LazySessionCache [{unique_id}]: No se recibió valor en el Ciclo 0.")
# --- LÓGICA DE RECUPERACIÓN (Ciclos > 0) ---
else:
if unique_id in GLOBAL_SESSION_CACHE:
_log(" -> ♻️ Recuperando valor de RAM (Upstream bloqueado con éxito).")
return (GLOBAL_SESSION_CACHE[unique_id], "\n".join(log_output))
else:
# Fallback de seguridad por si el caché se limpió o se saltó el ciclo 0
_log(" -> ⚠️ No hay caché. Intentando usar entrada directa.")
return (value_in, "\n".join(log_output))
-73
View File
@@ -1,73 +0,0 @@
import unittest
import sys
import os
import importlib.util
from unittest.mock import MagicMock
# --- Import setup (same as benchmark) ---
mock_folder_paths = MagicMock()
mock_folder_paths.folder_names_and_paths = {"checkpoint": ([], {})}
sys.modules['folder_paths'] = mock_folder_paths
sys.modules['torch'] = MagicMock()
sys.modules['numpy'] = MagicMock()
sys.modules['PIL'] = MagicMock()
sys.modules['PIL.Image'] = MagicMock()
sys.modules['PIL.ImageOps'] = MagicMock()
sys.modules['PIL.ImageSequence'] = MagicMock()
sys.modules['PIL.ImageFile'] = MagicMock()
repo_root = os.getcwd()
spec = importlib.util.spec_from_file_location("comfy_nodes", os.path.join(repo_root, "__init__.py"))
comfy_nodes = importlib.util.module_from_spec(spec)
sys.modules["comfy_nodes"] = comfy_nodes
spec.loader.exec_module(comfy_nodes)
format_batch_as_table = comfy_nodes.batch.format_batch_as_table
class TestBatchFormat(unittest.TestCase):
def test_basic_batch(self):
batch = [{'a': 1, 'b': 2}, {'a': 3, 'b': 4}]
result = format_batch_as_table(batch)
self.assertIn("| a | b |", result)
self.assertIn("| 1 | 2 |", result)
self.assertIn("| 3 | 4 |", result)
def test_order_preservation(self):
# Keys should appear in order of first appearance
# Row 1 introduces 'b', 'a'
# Row 2 introduces 'c'
# Expected order: b, a, c
batch = [{'b': 1, 'a': 2}, {'c': 3}]
result = format_batch_as_table(batch)
# Check header line
lines = result.split('\n')
header = lines[0]
# We expect something like "| b | a | c |"
# Spacing might vary depending on value lengths, so we check order in string
self.assertTrue(header.index('b') < header.index('a'))
self.assertTrue(header.index('a') < header.index('c'))
def test_disjoint_keys(self):
batch = [{'a': 1}, {'b': 2}]
result = format_batch_as_table(batch)
self.assertIn("| a | b |", result)
# Row 1 should have empty for b
# Row 2 should have empty for a
# Note: Current implementation puts empty string for missing keys
# Verify row 1 has '1' and empty for 'b'
# Since we don't know exact spacing easily, we trust the table formatter logic
# but ensure no crash and content is there.
self.assertIn("1", result)
self.assertIn("2", result)
def test_empty_batch(self):
result = format_batch_as_table([])
self.assertEqual(result, "(Empty Batch)")
def test_empty_rows(self):
result = format_batch_as_table([{}, {}])
self.assertEqual(result, "(Empty rows)")
if __name__ == '__main__':
unittest.main()
-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)
+583
View File
@@ -0,0 +1,583 @@
import math
import time
import torch
from . import register_node
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
@register_node
class PrimitiveDelay:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"delay_seconds": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3600.0, "step": 0.1, "tooltip": "Tiempo de pausa en segundos"}),
},
"optional": {
"any_in": (any_type, {"tooltip": "Conecta aquí el cable que quieres retrasar"}),
}
}
RETURN_TYPES = (any_type,)
RETURN_NAMES = ("* (passthrough)",)
FUNCTION = "execute_delay"
CATEGORY = "🔁 Sequential Batcher/Tools"
def execute_delay(self, delay_seconds, any_in=None):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
_log(f"\n{'='*50}")
_log(f"⏱️ [Secuencial Batcher] NODO: Primitive Delay")
_log(f" -> Congelando la ejecución durante {delay_seconds} segundos...")
time.sleep(delay_seconds)
_log(f" -> ✅ Pausa terminada. Reanudando flujo.")
_log(f"{'='*50}\n")
return (any_in,)
# 🧠 Clase Base Invisible (El molde matemático por Megapíxeles)
class BaseResolutionTool:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"aspect_ratio": (["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "2:3"],),
# 💡 Cambio clave: Dejamos de usar combo y pasamos a un campo INT libre
"base_resolution": ("INT", {"default": 1024, "min": 128, "max": 8192, "step": 8}),
}
}
RETURN_TYPES = ("INT", "INT", "STRING", "STRING")
RETURN_NAMES = ("width", "height", "debug_info", "log")
FUNCTION = "get_resolution"
CATEGORY = "🔁 Sequential Batcher/Tools"
def get_resolution(self, aspect_ratio, base_resolution):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
div = self.DIVISOR
min_pixels = getattr(self, "MIN_PIXELS", 0)
longest_side = int(base_resolution) # Mantenemos el casting por seguridad
w_str, h_str = aspect_ratio.split(':')
w_ratio = float(w_str)
h_ratio = float(h_str)
ratio = w_ratio / h_ratio
_log(f"\n{'='*50}")
_log(f"🛠️ [Secuencial Batcher] NODO: Resolution Tool {div}x")
_log(f" -> Petición Original: Lado mayor {longest_side}px | Ratio {aspect_ratio}")
# 1. Calcular resolución inicial teórica
if w_ratio >= h_ratio:
ideal_w = longest_side
ideal_h = ideal_w / ratio
else:
ideal_h = longest_side
ideal_w = ideal_h * ratio
current_pixels = ideal_w * ideal_h
# 2. 🛡️ Protección de Suelo por ÁREA TOTAL (Training Floor)
if current_pixels < min_pixels:
_log(f" -> ⚠️ ALERTA: La resolución pedida ({int(current_pixels)} píxeles) es inferior al mínimo vital del modelo ({min_pixels} píxeles).")
_log(f" -> 🛡️ Escalando proporcionalmente para evitar artefactos...")
# Factor de escala basado en área
scale_factor = math.sqrt(min_pixels / current_pixels)
ideal_w *= scale_factor
ideal_h *= scale_factor
# 3. Ajuste final de divisibilidad estricta
# Usamos round para asegurar que nos quedamos lo más cerca posible del área ideal
width = max(div, round(ideal_w / div) * div)
height = max(div, round(ideal_h / div) * div)
debug_msg = f"{width}x{height} (Div {div})"
_log(f" -> 🎯 Resultado Final Seguro: Ancho {width} | Alto {height} | Píxeles totales: {width*height}")
_log(f"{'='*50}\n")
return (width, height, debug_msg, "\n".join(log_output))
# 📦 Nodos Específicos Blindados por Píxeles
@register_node
class ResTool8x(BaseResolutionTool):
DIVISOR = 8
MIN_PIXELS = 262144 # SD1.5 (Equivale a 512x512)
@register_node
class ResTool16x(BaseResolutionTool):
DIVISOR = 16
MIN_PIXELS = 1048576 # SDXL (Equivale a 1024x1024)
@register_node
class ResTool32x(BaseResolutionTool):
DIVISOR = 32
MIN_PIXELS = 399360 # WanVideo (Equivale a 832x480)
@register_node
class ResTool64x(BaseResolutionTool):
DIVISOR = 64
MIN_PIXELS = 921600 # Hunyuan (Equivale a 1280x720)
@register_node
class ResTool64xLTX(BaseResolutionTool):
DIVISOR = 64
MIN_PIXELS = 393216 # LTX 2.3 (Equivale a 768x512)
@register_node
class AutoFPSLimiter:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_fps": ("FLOAT", {"forceInput": True}),
"target_max_fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}),
}
}
RETURN_TYPES = ("INT", "FLOAT", "STRING")
RETURN_NAMES = ("select_every_nth", "new_fps", "log")
FUNCTION = "calculate_fps"
CATEGORY = "🔁 Sequential Batcher/Tools"
def calculate_fps(self, source_fps, target_max_fps):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
import math
_log(f"\n{'='*50}")
_log(f"⏱️ [Secuencial Batcher] NODO: Auto FPS Limiter")
_log(f" -> FPS Originales: {source_fps:.2f} | Meta Máxima: {target_max_fps:.2f}")
if source_fps <= target_max_fps:
nth = 1
new_fps = source_fps
_log(f" -> ✅ Los FPS originales ya están dentro del límite.")
else:
# Calculamos el salto matemático (hacia arriba) para garantizar no superar la meta
nth = math.ceil(source_fps / target_max_fps)
new_fps = source_fps / nth
_log(f" -> ✂️ Reducción necesaria. Procesando 1 de cada {nth} frames.")
_log(f" -> 🎯 Resultado: {new_fps:.2f} FPS finales (Nth: {nth})")
_log(f"{'='*50}\n")
return (nth, new_fps, "\n".join(log_output))
@register_node
class ConditionalAudioRouter:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO",),
"is_final_cycle": ("BOOLEAN", {"forceInput": True}),
}
}
RETURN_TYPES = ("AUDIO", "STRING")
RETURN_NAMES = ("routed_audio", "log")
FUNCTION = "route"
CATEGORY = "🔁 Sequential Batcher/Tools"
def route(self, audio, is_final_cycle):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
if is_final_cycle:
_log(" -> 🔊 [Audio Router] Ciclo Final: Enviando pista de audio completa al Lip Sync.")
return (audio, "\n".join(log_output))
else:
_log(" -> 🔇 [Audio Router] Ciclo Intermedio: Generando micro-silencio para bypasear el Lip Sync...")
# Detectar el sample rate del audio original (estándar de ComfyUI es un dict)
sample_rate = 44100
if isinstance(audio, dict) and "sample_rate" in audio:
sample_rate = audio["sample_rate"]
# Creamos 0.1 segundos de silencio absoluto (1 canal, mínimos samples)
samples = int(sample_rate * 0.1)
dummy_waveform = torch.zeros((1, 1, samples), dtype=torch.float32)
dummy_audio = {"waveform": dummy_waveform, "sample_rate": sample_rate}
return (dummy_audio, "\n".join(log_output))
@register_node
class VAESafeFramePadder:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"rule": (["WanVideo (Regla 4n+1)", "LTX (Regla 8n+1)"],),
}
}
RETURN_TYPES = ("IMAGE", "INT", "STRING")
RETURN_NAMES = ("PADDED_IMAGES", "frames_added", "log")
FUNCTION = "pad"
CATEGORY = "🔁 Sequential Batcher/Tools"
def pad(self, images, rule):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
if not isinstance(images, torch.Tensor):
raise ValueError("[VAESafeFramePadder] Input is not a valid tensor.")
n_frames = images.shape[0]
target_frames = n_frames
if "WanVideo" in rule:
# 🚀 FIX: Regla 4n+1 para WanVideo (Evita la pérdida de 3 frames en el VAE)
if n_frames < 5:
target_frames = 5
else:
target_frames = ((n_frames + 2) // 4) * 4 + 1
elif "LTX" in rule:
if n_frames < 9:
target_frames = 9
else:
target_frames = ((n_frames + 6) // 8) * 8 + 1
diff = target_frames - n_frames
_log(f"\n{'='*50}")
_log(f"🛡️ [Secuencial Batcher] NODO: VAE Safe Frame Padder")
_log(f" -> Frames recibidos: {n_frames}")
_log(f" -> Regla aplicada: {rule}")
if diff > 0:
_log(f" -> 🧱 Tensor incompleto. Clonando el último frame {diff} vez/veces...")
last_frame = images[-1:] # Extraemos el último frame
padding = last_frame.repeat(diff, 1, 1, 1) # Lo multiplicamos
images = torch.cat([images, padding], dim=0) # Lo fusionamos al final
_log(f" -> ✅ Tensor final acolchado a: {target_frames} frames.")
else:
_log(f" -> ✅ Tensor perfecto. No requiere acolchado.")
_log(f"{'='*50}\n")
return (images, diff, "\n".join(log_output))
@register_node
class VHS_Path_Selector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"vhs_filenames": ("VHS_FILENAMES",),
"select_file": (["video_with_audio", "video_muted", "png_thumbnail"],),
}
}
# 🔄 Ahora devuelve la cadena y el objeto VHS reordenado
RETURN_TYPES = ("STRING", "VHS_FILENAMES", "STRING")
RETURN_NAMES = ("file_path", "vhs_filenames", "log")
FUNCTION = "select"
CATEGORY = "🔁 Sequential Batcher/Tools" # 📍 Ubicación corregida
def select(self, vhs_filenames, select_file):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
try:
if vhs_filenames is None or not isinstance(vhs_filenames, (list, tuple)) or len(vhs_filenames) < 2:
_log("⚠️ [Path Selector] Entrada vhs_filenames vacía o inválida.")
return ("", vhs_filenames, "\n".join(log_output))
paths = vhs_filenames[1]
if not isinstance(paths, list) or not paths:
return ("", vhs_filenames, "\n".join(log_output))
# Identificación de archivos
audio_path = next((s for s in paths if isinstance(s, str) and s.endswith("-audio.mp4")), None)
muted_path = next((s for s in paths if isinstance(s, str) and s.endswith(".mp4") and not s.endswith("-audio.mp4")), None)
png_path = next((s for s in paths if isinstance(s, str) and s.endswith(".png")), None)
# Selección del archivo principal (result)
if select_file == "video_with_audio":
result = audio_path if audio_path else (muted_path if muted_path else paths[0])
elif select_file == "video_muted":
result = muted_path if muted_path else (audio_path if audio_path else paths[0])
else: # png_thumbnail
result = png_path if png_path else paths[0]
# 🪄 REORDENADO: Ponemos el elegido en la posición 0
new_paths = list(paths)
if result in new_paths:
new_paths.remove(result)
new_paths.insert(0, result)
# Creamos el nuevo paquete VHS [bool, [lista_reordenada]]
reordered_vhs = [vhs_filenames[0], new_paths]
_log(f"📂 [Path Selector] Selección: {select_file} -> {result}")
return (str(result), reordered_vhs, "\n".join(log_output))
except Exception as e:
_log(f"❌ [Path Selector] Error crítico: {e}")
return ("", vhs_filenames, "\n".join(log_output))
@register_node
class AudioDurationCalculator:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO",),
}
}
RETURN_TYPES = ("FLOAT", "STRING")
RETURN_NAMES = ("duration_seconds", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Tools"
def calculate(self, audio):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
_log(f"\n{'='*50}")
_log(f"⏱️ [Secuencial Batcher] NODO: Audio Duration Calculator")
# 1. Validación de seguridad para evitar crashes
if not isinstance(audio, dict):
_log(" -> ⚠️ No se detectó un diccionario de audio válido. Devolviendo 0.0")
_log(f"{'='*50}\n")
return (0.0, "\n".join(log_output))
waveform = audio.get("waveform")
sample_rate = audio.get("sample_rate", 44100)
if waveform is None:
_log(" -> ⚠️ No se detectó 'waveform' en el audio. Devolviendo 0.0")
_log(f"{'='*50}\n")
return (0.0, "\n".join(log_output))
# 2. Cálculo matemático
samples = waveform.shape[-1]
duration = float(samples) / float(sample_rate)
_log(f" -> ✅ Duración calculada: {duration:.3f} segundos ({samples} samples @ {sample_rate}Hz)")
_log(f"{'='*50}\n")
return (duration, "\n".join(log_output))
@register_node
class PreciseAudioSlicer:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO",),
"skip_frames": ("INT", {"forceInput": True}),
"chunk_frames": ("INT", {"forceInput": True}),
"fps": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 120.0, "step": 1.0}),
}
}
RETURN_TYPES = ("AUDIO", "STRING")
RETURN_NAMES = ("sliced_audio", "log")
FUNCTION = "slice_audio"
CATEGORY = "🔁 Sequential Batcher/Tools"
def slice_audio(self, audio, skip_frames, chunk_frames, fps):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
import torch
from . import loop
# 🧠 COMPENSACIÓN MULTI-ESCENA
global_offset = getattr(loop, 'global_audio_offset_frames', 0)
absolute_skip_frames = skip_frames + global_offset
_log(f"\n{'='*50}")
_log(f"✂️ [Secuencial Batcher] NODO: Precise Audio Slicer")
if not isinstance(audio, dict) or "waveform" not in audio:
_log(" -> ⚠️ Audio no válido. Generando silencio...")
return ({"waveform": torch.zeros((1, 2, int((chunk_frames/fps)*44100)), dtype=torch.float32), "sample_rate": 44100}, "\n".join(log_output))
waveform = audio["waveform"]
sample_rate = audio.get("sample_rate", 44100)
start_sec = absolute_skip_frames / fps
duration_sec = chunk_frames / fps
start_sample = int(start_sec * sample_rate)
chunk_samples = int(duration_sec * sample_rate)
end_sample = start_sample + chunk_samples
total_samples = waveform.shape[-1]
_log(f" -> Sincronizando audio ({chunk_frames} frames a {fps} FPS)")
_log(f" -> Tramo de tiempo absoluto: {start_sec:.3f}s hasta {start_sec + duration_sec:.3f}s")
if start_sample >= total_samples:
sliced_waveform = torch.zeros((*waveform.shape[:-1], chunk_samples), dtype=waveform.dtype, device=waveform.device)
elif end_sample > total_samples:
valid_audio = waveform[..., start_sample:total_samples]
pad_tensor = torch.zeros((*waveform.shape[:-1], end_sample - total_samples), dtype=waveform.dtype, device=waveform.device)
sliced_waveform = torch.cat([valid_audio, pad_tensor], dim=-1)
else:
sliced_waveform = waveform[..., start_sample:end_sample]
sliced_waveform = sliced_waveform.contiguous()
_log(f"{'='*50}\n")
return ({"waveform": sliced_waveform, "sample_rate": sample_rate}, "\n".join(log_output))
@register_node
class SaveSceneKeyframe:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"file_path": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "file_path")
# 💡 Eliminado OUTPUT_NODE = True para permitir evaluación perezosa
FUNCTION = "save"
CATEGORY = "🔁 Sequential Batcher/Tools"
def save(self, image, file_path):
import os, numpy as np
from PIL import Image
from . import loop
img_array = 255. * image[0].cpu().numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
if os.path.isdir(file_path):
os.rmdir(file_path) # Borra la carpeta fantasma creada por WAS
os.makedirs(os.path.dirname(file_path), exist_ok=True)
img.save(file_path) # Ahora sí, guarda la imagen (sobrescribiendo si existe)
# 🧠 HACK DE RELOJ (Art Flow): Marca la escena como terminada para que el Director avance.
loop.global_is_final_chunk = True
print(f"💾 [Keyframe Saver] Guardado con éxito en: {file_path}")
return (image, file_path)
@register_node
class LoadSceneKeyframe:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"file_path": ("STRING", {"forceInput": True})
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "load"
CATEGORY = "🔁 Sequential Batcher/Tools"
def load(self, file_path):
import os
import torch
import numpy as np
from PIL import Image, ImageOps
if not os.path.exists(file_path):
print(f"⚠️ [Keyframe Loader] Archivo no encontrado: {file_path}. Generando tensor negro de seguridad.")
return (torch.zeros((1, 512, 512, 3), dtype=torch.float32),)
img = Image.open(file_path)
img = ImageOps.exif_transpose(img)
img = img.convert("RGB")
img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
print(f"📥 [Keyframe Loader] Imagen cargada desde: {file_path}")
return (img_tensor,)
@register_node
class LTXVSingleFrameInjector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"vae": ("VAE",),
"latent": ("LATENT",),
"image": ("IMAGE",),
}
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "inject"
CATEGORY = "🔁 Sequential Batcher/Tools"
def inject(self, vae, latent, image):
import torch
import comfy.utils
print(f"\n{'='*50}")
print(f"💉 [Secuencial Batcher] NODO: LTXV Single Frame Injector")
samples = latent["samples"].clone()
# Extraer factores de escala del VAE (Típicamente 8x32x32 para LTXV)
scale_factors = getattr(vae, "downscale_index_formula", (8, 32, 32))
height_scale_factor = scale_factors[1]
width_scale_factor = scale_factors[2]
batch, channels, latent_frames, latent_height, latent_width = samples.shape
width = latent_width * width_scale_factor
height = latent_height * height_scale_factor
if "noise_mask" in latent:
mask = latent["noise_mask"].clone()
else:
mask = torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=samples.device)
print(f" -> 📐 Adaptando resolución de la imagen ({image.shape[2]}x{image.shape[1]}) a la del Latent ({width}x{height})")
# Ajustar resolución de la imagen si no coincide con el latent
if image.shape[1] != height or image.shape[2] != width:
pixels = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1)
else:
pixels = image
encode_pixels = pixels[:, :, :, :3]
# Codificar imagen usando el VAE de LTX
t = vae.encode(encode_pixels)
# Compatibilidad 4D a 5D (Si el VAE devuelve [B, C, H, W] lo pasamos a [B, C, T, H, W])
if len(t.shape) == 4:
t = t.unsqueeze(2)
latent_idx = 0
end_index = min(latent_idx + t.shape[2], latent_frames)
# Inyección directa
samples[:, :, latent_idx:end_index] = t[:, :, :end_index - latent_idx]
mask[:, :, latent_idx:end_index] = 0.0 # 0.0 significa "Proteger este frame del ruido"
print(f" -> ✅ Imagen inyectada y sellada en el Frame 0 (Índice Latente {latent_idx}).")
print(f"{'='*50}\n")
return ({"samples": samples, "noise_mask": mask},)
+800 -65
View File
@@ -1,92 +1,827 @@
import os
import shutil
import subprocess
import logging
from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stdout, redirect_stderr
from tqdm import tqdm
import comfy.utils
import math
import torch
import torchaudio
import folder_paths
import time
from safetensors.torch import save_file, load_file
import uuid
import subprocess
import re
from . import register_node
@register_node
class FFmpegVideoStitcher:
"""Nodo final: Espera a que termine todo el lote secuencial y une los vídeos con FFmpeg."""
# Intento de carga de OpenCV para el Director de Fotografía
# Caché persistente para evitar re-escaneos pesados de vídeo
VIDEO_ANALYSIS_CACHE = {}
# 🎵 Helper para estandarizar audio a 44100Hz y 2 Canales (Estéreo)
def extract_and_standardize_audio(video_path, target_sr=44100):
import torchaudio.transforms as T
try:
waveform, sample_rate = torchaudio.load(video_path)
# 1. Resampling si la frecuencia es distinta
if sample_rate != target_sr:
resampler = T.Resample(orig_freq=sample_rate, new_freq=target_sr)
waveform = resampler(waveform)
sample_rate = target_sr
# 2. Downmix / Upmix a Estéreo (2 canales) estrictamente
channels = waveform.shape[0]
if channels > 2:
# Si tiene más de 2 canales (ej. 5.1), mezclamos a mono y duplicamos a estéreo
mono = torch.mean(waveform, dim=0, keepdim=True)
waveform = mono.repeat(2, 1)
elif channels == 1:
# Si es mono, duplicamos la pista para que sea estéreo
waveform = waveform.repeat(2, 1)
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
except Exception as e:
raise e
# 🎬 Helper para crear un Clon de Vídeo Físico Estandarizado (Soluciona el desvío VHS)
def standardize_video_file(video_path, _log):
import folder_paths
if "_std_audio.mp4" in video_path:
return video_path
filename = os.path.basename(video_path)
name, _ = os.path.splitext(filename)
out_filename = f"{name}_std_audio.mp4"
out_path = os.path.join(folder_paths.get_temp_directory(), out_filename)
if os.path.exists(out_path):
_log(f" -> ♻️ Usando clon de vídeo estandarizado existente en disco: {out_filename}")
return out_path
# Averiguar info original del audio
target_sr = None
target_ac = None
try:
info = torchaudio.info(video_path)
# Solo aplicamos limitador si se superan los umbrales máximos de ComfyUI
if info.sample_rate > 44100:
target_sr = "44100"
if info.num_channels > 2:
target_ac = "2"
except Exception as e:
_log(f" -> ⚠️ No se pudo leer metadata de audio con torchaudio. Forzando límites de seguridad. (Error: {e})")
target_sr = "44100"
target_ac = "2"
# Si el audio ya es seguro, no gastamos recursos en clonar
if target_sr is None and target_ac is None:
_log(f" -> ✅ El audio original ya está en parámetros seguros (<=44100Hz, <=2 Canales). Omitiendo FFmpeg.")
return video_path
_log(f" -> ⚙️ Reestructurando contenedor físico con FFmpeg (Vídeo: Copy | Audio: Limitando a parámetros seguros)...")
try:
cmd = [
"ffmpeg", "-y", "-i", video_path,
"-c:v", "copy",
"-c:a", "aac"
]
# Añadimos los filtros solo si son necesarios
if target_sr:
cmd.extend(["-ar", target_sr])
if target_ac:
cmd.extend(["-ac", target_ac])
cmd.extend(["-map", "0:v?", "-map", "0:a?", out_path])
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
_log(f" -> ✅ Contenedor clonado y blindado con éxito para los nodos de VHS.")
return out_path
except Exception as e:
raise Exception(f"❌ [Secuencial Batcher] Error crítico: Falló la reestructuración del vídeo. Asegúrate de tener FFmpeg instalado y accesible en el PATH del sistema. Detalles: {e}")
try:
import cv2
import numpy as np
HAS_OPENCV = True
except ImportError:
HAS_OPENCV = False
@register_node
class VideoAnalyzerFaceDetector:
@classmethod
def INPUT_TYPES(cls):
# 1. Obtenemos los archivos locales igual que VHS
input_dir = folder_paths.get_input_directory()
files = []
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov'] # Mismos que VHS
if os.path.exists(input_dir):
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split('.')
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
files.append(f)
# 2. El truco VHS: Una tupla con (lista_de_archivos,) pero configurado como STRING y con forceInput=False
return {
"required": {
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
"use_face_detector": ("BOOLEAN", {"default": True}),
"blur_threshold": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 1000.0, "step": 1.0}),
"unload_detector_after_analysis": ("BOOLEAN", {"default": True}),
"unload_detector": ("BOOLEAN", {"default": True}),
},
"optional": {
"bbox_detector": ("BBOX_DETECTOR", ), # 💡 Puerto para YOLO/ONNX
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
}
}
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "FACE_CUTS", "IMAGE", "STRING")
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "safe_faces_list", "reference_frame", "log")
OUTPUT_NODE = True
FUNCTION = "analyze"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, video, reference_frame_idx, use_face_detector, blur_threshold, **kwargs):
if isinstance(video, list):
video_str = "".join(video)
else:
video_str = str(video)
return f"{video_str}_{reference_frame_idx}_{use_face_detector}_{blur_threshold}"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
# Bypass para permitir conexiones dinámicas (como descargas en curso)
return True
def analyze(self, video, reference_frame_idx, use_face_detector, blur_threshold, unload_detector_after_analysis=True, bbox_detector=None, current_loop_index=0, **kwargs):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
# Resolución unificada de la ruta, tal como hace VHS
if os.path.exists(video):
video_path = video
else:
video_path = folder_paths.get_annotated_filepath(video)
# 🚀 CLONACIÓN FÍSICA PARA VHS
video_path = standardize_video_file(video_path, _log)
_log(f"\n{'='*50}")
_log(f"🕵️ [Secuencial Batcher] NODO: Video Analyzer (Explorador)")
_log(f" -> Archivo resuelto: {video_path}")
cache_key = f"{video_path}_face"
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
_log(f"♻️ [Face Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
cached = VIDEO_ANALYSIS_CACHE[cache_key]
frame_count, source_fps, source_audio, safe_faces = cached
else:
# Análisis completo (Solo Ciclo 0 o primer arranque)
_log(f"🎬 [Face Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
source_audio = None
try:
source_audio = extract_and_standardize_audio(video_path)
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
except Exception as e:
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
# 2. Escaneo de OpenCV (Frames, Rostros y Referencia)
frame_count = 0
source_fps = 0.0
safe_faces = []
if not HAS_OPENCV:
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
else:
cap = cv2.VideoCapture(video_path)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
_log(f" -> 🎞️ Total Frames detectados: {frame_count}")
_log(f" -> ⏱️ FPS detectados: {source_fps}")
# Escaneo de Rostros
if bbox_detector is not None or use_face_detector:
_log(f" -> 🤖 Iniciando escaneo de rostros (Umbral: {blur_threshold})...")
if bbox_detector is not None:
_log(f" -> ⚡ Usando detector de rostros por GPU (YOLO/ONNX).")
else:
_log(f" -> 🐢 Usando detector de rostros por CPU (OpenCV).")
if HAS_OPENCV:
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path)
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Volver al inicio
_log(f" -> 🤖 Iniciando escaneo de rostros por GPU...")
# Silenciamos el logger de ultralytics antes de empezar
logging.getLogger("ultralytics").setLevel(logging.ERROR)
# Barra de progreso profesional (desc=Descripción, unit=unidad, leave=True para que no desaparezca)
pbar = tqdm(total=frame_count, desc="🔍 Analizando Rostros", unit="frame", dynamic_ncols=True)
comfy_pbar = comfy.utils.ProgressBar(frame_count)
for idx in range(frame_count):
ret, frame = cap.read()
if not ret: break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
faces_found = False
if variance > blur_threshold:
if bbox_detector is not None:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img_tensor = torch.from_numpy(frame_rgb.astype(np.float32) / 255.0).unsqueeze(0).cpu()
try:
# 🤐 CÁPSULA DE VACÍO: Silenciamos TODO el ruido de la consola
with open(os.devnull, 'w') as fnull:
with redirect_stdout(fnull), redirect_stderr(fnull):
# Inferencia
res = bbox_detector.detect(img_tensor, 0.5, 10, 1.0, 10)
if isinstance(res, tuple):
segs = res[0]
else:
segs = res
if isinstance(segs, tuple) and len(segs) > 1 and isinstance(segs[1], list):
faces_found = len(segs[1]) > 0
elif isinstance(segs, list):
faces_found = len(segs) > 0
elif hasattr(segs, '__len__'):
faces_found = len(segs) > 0
except Exception as e:
_log(f" ❌ ERROR BBOX en frame {idx}: {e}")
else:
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
faces_found = len(faces) > 0
if faces_found:
safe_faces.append(idx)
# _log(f" ✨ Rostro nítido detectado en frame [{idx}] (Varianza: {variance:.2f})")
pbar.update(1)
comfy_pbar.update(1)
pbar.close()
_log(f" -> ✅ Encontrados {len(safe_faces)} frames válidos con rostros.")
cap.release()
# Guardar en caché
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, safe_faces)
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
ui_result = {}
if HAS_OPENCV:
cap = cv2.VideoCapture(video_path)
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1))
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
ret, frame = cap.read()
if ret:
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
ref_tensor = ref_tensor.unsqueeze(0)
_log(f" -> 🖼️ Frame de referencia extraído (Índice: {safe_ref_idx})")
from PIL import Image
preview_dir = folder_paths.get_temp_directory()
preview_name = f"preview_ref_{uuid.uuid4().hex[:5]}.png"
Image.fromarray(ref_frame_rgb).save(os.path.join(preview_dir, preview_name))
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
else:
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
cap.release()
else:
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
# Liberar el detector de VRAM si se solicita y si se usó
if unload_detector_after_analysis and bbox_detector is not None and getattr(kwargs, 'unload_detector', True):
_log(f" -> 🧹 Limpiando BBOX_DETECTOR de la VRAM para ahorrar memoria...")
import gc
import comfy.model_management as mm
# Romper referencias
del bbox_detector
bbox_detector = None
gc.collect()
torch.cuda.empty_cache()
mm.soft_empty_cache()
_log(f"{'='*50}\n")
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, safe_faces, ref_tensor, "\n".join(log_output))}
@register_node
class VideoAnalyzerSceneDetector:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = []
if os.path.exists(input_dir):
files = [f for f in os.listdir(input_dir) if f.split('.')[-1].lower() in ['webm', 'mp4', 'mkv', 'gif', 'mov']]
return {
"required": {
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000}),
"scene_threshold": ("FLOAT", {"default": 25.0, "min": 5.0, "max": 150.0}),
},
"optional": {
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
}
}
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "SCENE_CUTS", "IMAGE", "STRING")
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "scene_cuts_list", "reference_frame", "log")
OUTPUT_NODE = True
FUNCTION = "analyze"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, video, reference_frame_idx, scene_threshold, **kwargs):
if isinstance(video, list):
video_str = "".join(video)
else:
video_str = str(video)
return f"{video_str}_{reference_frame_idx}_{scene_threshold}"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def analyze(self, video, reference_frame_idx, scene_threshold, current_loop_index=0, **kwargs):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
video_path = video if os.path.exists(video) else folder_paths.get_annotated_filepath(video)
# 🚀 CLONACIÓN FÍSICA PARA VHS
video_path = standardize_video_file(video_path, _log)
cache_key = f"{video_path}_scene"
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
_log(f"♻️ [Scene Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
cached = VIDEO_ANALYSIS_CACHE[cache_key]
frame_count, source_fps, source_audio, scene_cuts = cached
else:
# Análisis completo (Solo Ciclo 0 o primer arranque)
_log(f"🎬 [Scene Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
frame_count = 0
source_fps = 0.0
source_audio = None
scene_cuts = []
if HAS_OPENCV:
cap = cv2.VideoCapture(video_path)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
source_audio = None
try:
source_audio = extract_and_standardize_audio(video_path)
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
except Exception as e:
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
# Detección de cortes
_log(f" -> 🎬 Iniciando escaneo de escenas (Umbral Diferencia: {scene_threshold})...")
prev_gray = None
pbar = tqdm(total=frame_count, desc="🎬 Analizando Escenas")
comfy_pbar = comfy.utils.ProgressBar(frame_count)
for idx in range(frame_count):
ret, frame = cap.read()
if not ret: break
small = cv2.resize(frame, (128, 128))
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
if prev_gray is not None:
score = np.mean(cv2.absdiff(gray, prev_gray))
if score > scene_threshold:
scene_cuts.append(idx)
# _log(f" 🎞️ Corte de escena detectado en frame [{idx}] (Puntuación: {score:.2f})")
prev_gray = gray
pbar.update(1)
comfy_pbar.update(1)
pbar.close()
cap.release()
_log(f" -> ✅ Encontrados {len(scene_cuts)} cambios de escena fuertes.")
else:
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
# Guardar en caché
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, scene_cuts)
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
ui_result = {}
if HAS_OPENCV:
cap = cv2.VideoCapture(video_path)
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1)) if frame_count > 0 else 0
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
ret, frame = cap.read()
if ret:
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
ref_tensor = ref_tensor.unsqueeze(0)
from PIL import Image
preview_name = f"preview_scene_{uuid.uuid4().hex[:5]}.png"
Image.fromarray(ref_frame_rgb).save(os.path.join(folder_paths.get_temp_directory(), preview_name))
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
else:
ref_tensor = torch.zeros((1, 64, 64, 3))
cap.release()
else:
ref_tensor = torch.zeros((1, 64, 64, 3))
_log(f"{'='*50}\n")
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, scene_cuts, ref_tensor, "\n".join(log_output))}
@register_node
class AutoLoopCalculator:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video_paths": ("VHS_FILENAMES", ),
"output_filename": ("STRING", {"default": "SCAIL_Final_Completo.mp4"}),
"source_frame_count": ("INT", {"forceInput": True}),
"target_frames_per_loop": ("INT", {"default": 50, "min": 1, "max": 10000}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
"current_loop_index": ("INT", {"forceInput": True}),
},
"optional": {
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING", )
RETURN_NAMES = ("final_video_path", )
# CRÍTICO: Obliga a ComfyUI a recolectar todas las ejecuciones del loop antes de actuar.
INPUT_IS_LIST = True
FUNCTION = "stitch_videos"
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
def stitch_videos(self, video_paths, output_filename):
# 1. Comprobación estricta de dependencias
if not shutil.which("ffmpeg"):
raise Exception("FFmpeg no está instalado o no se encuentra en el PATH del sistema. Por favor, instálalo para unir los vídeos.")
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
# Ensure video_paths is a valid list before iteration
if not video_paths or not isinstance(video_paths, list):
raise Exception("Error: 'video_paths' inválido. Se esperaba una lista.")
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
# 2. Aplanar la lista de rutas generadas (VHS_FILENAMES genera listas anidadas)
flat_paths = []
for path_group in video_paths:
if not isinstance(path_group, list):
# Sometimes path_group might be a string directly depending on how it's passed
if isinstance(path_group, str) and path_group.endswith(".mp4"):
flat_paths.append(path_group)
continue
if source_frame_count <= 0 or target_frames_per_loop <= 0:
_log("⚠️ [WARNING] Invalid frame count or target frames per loop.")
return (1, 0, select_every_nth, "\n".join(log_output))
for p in path_group:
if isinstance(p, str) and p.endswith(".mp4"):
flat_paths.append(p)
source_frame_count = min(source_frame_count, 10000)
select_every_nth = max(1, select_every_nth)
# Ensure output_filename is safe to read
if isinstance(output_filename, list):
if not output_filename:
out_name = "SCAIL_Final_Completo.mp4"
else:
out_name = output_filename[0]
# --- Ajuste Proporcional Base (Sin restricciones VAE) ---
import math
from . import loop
loop.global_step_by_chunk = False
potential_effective_frames = source_frame_count // select_every_nth
safe_effective_frames = potential_effective_frames # Sin restricciones
safe_source_frame_count = safe_effective_frames * select_every_nth
loop.global_source_frame_count = safe_source_frame_count
effective_loss = potential_effective_frames - safe_effective_frames
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
if estimated_loops > 0:
target_frames_per_loop = math.ceil(safe_effective_frames / estimated_loops)
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado a {target_frames_per_loop} frames por ciclo.")
# --- LOGS MEJORADOS Y TRANSPARENTES ---
_log(f" -> 🎞️ Capacidad del video: {potential_effective_frames} frames procesables (Nth: {select_every_nth})")
if effective_loss > 0:
_log(f" -> 🛡️ Ajuste VAE: Se usarán {safe_effective_frames} frames (Descarte técnico: {effective_loss} frame/s de proceso)")
else:
out_name = output_filename
_log(f" -> ✅ Ajuste VAE: Perfecto. Múltiplo de 4 detectado.") # Manteniendo el mismo string por ahora o podemos omitirlo, lo adapto a que es genérico
if not isinstance(out_name, str) or not out_name.strip():
out_name = "SCAIL_Final_Completo.mp4"
_log(f" -> 📊 Timeline final: 0 a {safe_source_frame_count} (de {source_frame_count} totales)")
# -------------------------------------------------------------
# Add .mp4 extension if not present
if not out_name.lower().endswith('.mp4'):
out_name += '.mp4'
loop.global_select_every_nth = select_every_nth
if not flat_paths:
return ("Error: No se encontraron vídeos .mp4 para unir.", )
current_pos = loop.global_accumulated_frames
# 3. Preparar directorio de salida estándar
out_dir = folder_paths.get_output_directory()
list_file_path = os.path.join(out_dir, "batch_concat_list.txt")
_log(f"\n{'='*50}")
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (Motor Dinámico)")
_log(f" -> Posición en el timeline original: {current_pos} / {source_frame_count}")
# 4. Crear archivo de concatenación seguro para FFmpeg
with open(list_file_path, 'w', encoding='utf-8') as f:
for video in sorted(flat_paths):
# Usar rutas absolutas aseguradas
f.write(f"file '{os.path.abspath(video)}'\n")
if current_pos >= safe_source_frame_count:
return (1, current_pos, select_every_nth, "\n".join(log_output))
# 5. Ejecutar unión sin recodificación (Copy codec)
final_output = os.path.join(out_dir, out_name)
ffmpeg_cmd = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", list_file_path, "-c", "copy", final_output
]
frames_left = safe_source_frame_count - current_pos
try:
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f"[Sequential Batcher] Vídeo ensamblado exitosamente en: {final_output}")
except subprocess.CalledProcessError as e:
raise Exception(f"Error crítico de FFmpeg al unir los fragmentos: {e.stderr.decode()}")
equitable_target = target_frames_per_loop * select_every_nth
return (final_output, )
ideal_cut = current_pos + equitable_target
if frames_left <= equitable_target:
best_cut = safe_source_frame_count
_log(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
else:
if scene_cuts_list and len(scene_cuts_list) > 0:
# 🎬 LÓGICA DE ESCENAS ESTRICTA (1 Escena = 1 Bucle)
# Buscar el PRIMER corte que venga inmediatamente después de la posición actual
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
next_cut = min(future_cuts)
# Verificamos si esta escena entera cabe en la VRAM
if next_cut <= ideal_cut:
best_cut = next_cut
_log(f" -> 🎬 Bucle aislado por escena. Cortando exacto en el frame: {best_cut}")
else:
best_cut = ideal_cut
_log(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
else:
best_cut = ideal_cut
_log(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
# 👤 LÓGICA DE ROSTROS (Fallback si no hay escenas conectadas)
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
best_cut = closest_face
_log(f" -> ✂️ Corte Facial Inteligente: {best_cut} (Meta: {ideal_cut})")
else:
best_cut = ideal_cut
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo: {ideal_cut}")
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
if current_pos + (effective_chunk_frames * select_every_nth) > safe_source_frame_count:
effective_chunk_frames = (safe_source_frame_count - current_pos) // select_every_nth
skip_frames = current_pos
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
_log(f"{'='*50}\n")
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
@register_node
class AutoLoopCalculatorTTS:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"multiline": True, "dynamicPrompts": False}),
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING", "INT", "INT", "STRING")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, text, split_mode, current_loop_index):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
loop.global_step_by_chunk = True
# 1. Separar el texto según el modo elegido
if split_mode == "Frases (Puntos)":
# PASO A: Separación cruda respetando todos los signos
matches = re.findall(r'[^.!?\n]+[.!?\n]*', text)
temp_chunks = [m.strip() for m in matches if m.strip()]
# PASO B: Fusión inteligente (Evitar micro-frases y roturas por "...")
MIN_WORDS = 5
raw_chunks = []
buffer_text = ""
for chunk in temp_chunks:
buffer_text = (buffer_text + " " + chunk).strip()
# Contamos las palabras reales acumuladas en el buffer
word_count = len(buffer_text.split())
# Si el bloque ya tiene suficiente "cuerpo", lo cerramos
if word_count >= MIN_WORDS:
raw_chunks.append(buffer_text)
buffer_text = ""
# PASO C: Si quedó texto corto huérfano al final, lo unimos al bloque anterior
if buffer_text:
if raw_chunks:
raw_chunks[-1] += " " + buffer_text
else:
raw_chunks.append(buffer_text)
chunk_type_name = "Frases Optimizadas"
else:
# Comportamiento original: Dividir por párrafos
raw_chunks = [p.strip() for p in text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
total_chunks = len(raw_chunks)
if total_chunks == 0:
raw_chunks = [""]
total_chunks = 1
# 2. Seguridad de índice
safe_index = min(current_loop_index, total_chunks - 1)
current_text = raw_chunks[safe_index]
# 3. 🧠 HACK CORE: Inyectamos los bloques como si fueran frames
loop.global_source_frame_count = total_chunks
loop.global_accumulated_frames = safe_index + 1
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS)")
_log(f" -> Modo de división: {split_mode}")
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
_log(f" -> 📜 Texto a procesar: {current_text[:75]}...")
_log(f"{'='*50}\n")
return (current_text, safe_index, total_chunks, "\n".join(log_output))
@register_node
class AutoLoopCalculatorWan:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_frame_count": ("INT", {"forceInput": True}),
"target_frames_per_loop": ("INT", {"default": 48, "min": 4, "max": 10000, "step": 4}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
"current_loop_index": ("INT", {"forceInput": True}),
},
"optional": {
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
if source_frame_count <= 0 or target_frames_per_loop <= 0:
_log("⚠️ [WARNING] Invalid frame count or target frames per loop.")
return (1, 0, select_every_nth, "\n".join(log_output))
source_frame_count = min(source_frame_count, 10000)
select_every_nth = max(1, select_every_nth)
import math
from . import loop
loop.global_step_by_chunk = False
# --- MEISOFT PATCH: Sincronización Matemática (Hold Last Frame) ---
# 1. Redondear hacia ARRIBA a la regla 4n+1 (Acolchado WanVideo)
potential_effective_frames = source_frame_count // select_every_nth
if potential_effective_frames < 5:
safe_effective_frames = 5
else:
safe_effective_frames = ((potential_effective_frames + 2) // 4) * 4 + 1
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
physical_source_frame_count = potential_effective_frames * select_every_nth
loop.global_source_frame_count = physical_source_frame_count
effective_padding = safe_effective_frames - potential_effective_frames
loop.global_select_every_nth = select_every_nth
# 2. Ajuste Proporcional Real (Evitar micro-ciclos al final)
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
if estimated_loops > 0:
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
adjusted_target = ((optimal_target + 2) // 4) * 4 + 1
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado de {target_frames_per_loop} a {adjusted_target} frames por ciclo (para {estimated_loops} ciclos)")
target_frames_per_loop = adjusted_target
_log(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
if effective_padding > 0:
_log(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
else:
_log(f" -> ✅ Ajuste VAE: Perfecto. Regla 4n+1 detectada.")
_log(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
current_pos = getattr(loop, 'global_accumulated_frames', 0)
_log(f"\n{'='*50}")
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (WanVideo 3D VAE)")
_log(f" -> Timeline físico ajustado: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
# Prevención de desbordamiento de bucle (Usando límite físico)
if current_pos >= physical_source_frame_count:
return (4, current_pos, select_every_nth, "\n".join(log_output))
frames_left = physical_source_frame_count - current_pos
equitable_target = target_frames_per_loop * select_every_nth
ideal_cut = current_pos + equitable_target
if frames_left <= equitable_target:
best_cut = physical_source_frame_count
_log(f" -> 🧮 Absorbiendo resto final seguro: meta fijada en frame {best_cut}")
else:
if scene_cuts_list and len(scene_cuts_list) > 0:
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
next_cut = min(future_cuts)
if next_cut <= ideal_cut:
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
safe_chunk_from_scene = (chunk_from_scene // 4) * 4
if safe_chunk_from_scene < 4: safe_chunk_from_scene = 4
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
_log(f" -> 🎬 Bucle aislado por escena (Ajustado x4). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
else:
best_cut = ideal_cut
_log(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
else:
best_cut = ideal_cut
_log(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
safe_chunk_from_face = (chunk_from_face // 4) * 4
if safe_chunk_from_face < 4: safe_chunk_from_face = 4
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
_log(f" -> ✂️ Corte Facial Inteligente (Ajustado x4): {best_cut} (Cara original detectada: {closest_face})")
else:
best_cut = ideal_cut
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x4: {ideal_cut}")
# 4. Cálculo final del chunk consolidado a regla 4n+1
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
effective_chunk_frames = ((effective_chunk_frames + 2) // 4) * 4 + 1
if effective_chunk_frames < 5:
effective_chunk_frames = 5
# Comprobación contra el límite físico para evitar pedir más allá del final
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
_log(f" -> 🏁 Chunk final detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 4n+1.")
loop.global_is_final_chunk = True
else:
loop.global_is_final_chunk = False
skip_frames = current_pos
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos a VHS (Saltando {skip_frames})")
_log(f"{'='*50}\n")
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
+118
View File
@@ -0,0 +1,118 @@
import torch
import gc
import comfy.model_management as mm
from . import register_node
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
def format_bytes(bytes_val):
return f"{bytes_val / (1024 ** 3):.2f} GB"
@register_node
class MeisoftVRAMDefragmenter:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"anything": (any_type,),
"force_model_unload": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = (any_type, "STRING")
RETURN_NAMES = ("anything", "log")
FUNCTION = "defragment"
CATEGORY = "🔁 Sequential Batcher/Tools"
def defragment(self, anything, force_model_unload):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
_log(f"\n{'='*65}")
_log(f"🧹 [Secuencial Batcher] NODO: VRAM Defragmenter (Modo Arquitectura Profunda)")
# --- ESTADÍSTICAS ANTES ---
alloc_before = 0
res_before = 0
if torch.cuda.is_available():
alloc_before = torch.cuda.memory_allocated()
res_before = torch.cuda.memory_reserved()
_log(f" -> 📊 Estado Inicial | Asignada: {format_bytes(alloc_before)} | Reservada (Caché): {format_bytes(res_before)}")
# --- FASE 1: CONFIGURACIÓN DINÁMICA DEL ALLOCATOR ---
try:
# Permite que la reserva de VRAM crezca en bloque elástico
torch.cuda.memory._set_allocator_settings("expandable_segments:True")
_log(" -> ⚙️ PyTorch Allocator ajustado a expandable_segments:True")
except Exception as e:
_log(f" -> ⚠️ Aviso: No se pudo ajustar expandable_segments: {e}")
# --- FASE 2: EVACUACIÓN (Opcional) ---
if force_model_unload:
_log(" -> 📦 Trasladando modelos inactivos a la RAM (unload_all_models)...")
mm.unload_all_models()
# --- FASE 3: LIMPIEZA INTERNA DE COMFYUI (LA CLAVE) ---
_log(" -> 🧹 Destruyendo referencias ocultas en el gestor de ComfyUI...")
if hasattr(mm, "cleanup_models"):
mm.cleanup_models()
if hasattr(mm, "cleanup_models_gc"):
mm.cleanup_models_gc()
# --- FASE 4: ROMPER REFERENCIAS PYTHON ---
_log(" -> 🗑️ Forzando Garbage Collector de Python (Doble pasada)...")
gc.collect()
gc.collect() # Doble pasada para romper referencias circulares rebeldes
# --- FASE 5: EVACUACIÓN SUAVE ---
mm.soft_empty_cache()
# --- FASE 6: PURGADO PROFUNDO DE PYTORCH ---
if torch.cuda.is_available():
_log(" -> 🧱 Desfragmentando Caching Allocator de CUDA...")
torch.cuda.ipc_collect()
torch.cuda.synchronize() # 🚀 OPTIMIZACIÓN: Esperar a que terminen los kernels pendientes PRIMERO
torch.cuda.empty_cache() # 🚀 Luego vaciamos la caché de forma segura
# --- FASE 7: LIBERACIÓN DE MEMORIA DEL SISTEMA (Multi-Plataforma) ---
import platform
try:
import ctypes
if platform.system() == "Windows":
# API de Windows: Reduce el 'Working Set' del proceso actual al mínimo
kernel32 = ctypes.windll.kernel32
current_process = kernel32.GetCurrentProcess()
kernel32.SetProcessWorkingSetSize(current_process, -1, -1)
_log(" -> 💻 Working Set reducido (RAM del sistema purgada en Windows).")
else:
# API de Linux/WSL: Libera la memoria de la librería C
ctypes.CDLL('libc.so.6').malloc_trim(0)
_log(" -> 💻 Malloc Trim ejecutado (RAM del sistema purgada en Linux/WSL).")
except Exception as e:
# Si algo falla a nivel de SO, fallamos silenciosamente sin romper el nodo
pass
# --- ESTADÍSTICAS DESPUÉS ---
if torch.cuda.is_available():
alloc_after = torch.cuda.memory_allocated()
res_after = torch.cuda.memory_reserved()
# Cálculos de liberación
freed_alloc = alloc_before - alloc_after
freed_res = res_before - res_after
sign_alloc = "+" if freed_alloc < 0 else "-"
sign_res = "+" if freed_res < 0 else "-"
_log(f" -> 📈 Estado Final | Asignada: {format_bytes(alloc_after)} | Reservada (Caché): {format_bytes(res_after)}")
_log(f" -> ✨ TOTAL LIBERADO | {sign_alloc}{abs(freed_alloc)/(1024**3):.2f} GB (Asignada) | {sign_res}{abs(freed_res)/(1024**3):.2f} GB (Reservada purgada)")
else:
_log(" -> ✨ Limpieza completada (Modo CPU/Otra arquitectura).")
_log(f"{'='*65}\n")
return (anything, "\n".join(log_output))