Compare commits

..
Author SHA1 Message Date
Meisoft 98e25a8fe2 Fix UnboundLocalError in AutoLoopCalculatorTTSBatch: make raw_chunks initialization mutually exclusive with mode 2026-08-14 09:03:21 +02:00
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
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
16 changed files with 2814 additions and 513 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__/
+93 -50
View File
@@ -1,54 +1,97 @@
# ComfyUI Sequential Batcher (v1.1.0)
# ♾️ ComfyUI Sequential Batcher
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
Una suite altamente especializada de nodos personalizados para ComfyUI diseñada para el **Auto-Encolado Recursivo (Recursive Self-Queuing)** y el procesamiento secuencial autónomo. Esta arquitectura minimiza el uso de VRAM procesando tareas pesadas (como la generación de vídeo) de forma secuencial, lote por lote, orquestadas completamente desde dentro del propio grafo.
Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada para sortear los límites extremos de VRAM en la generación de vídeo pesado (WanVideo, Hunyuan, LTX) mediante **Auto-Encolado Recursivo**, procesamiento autónomo lote por lote y gestión de memoria forense.
> **Read in English:** [README_EN.md](README_EN.md)
## La Arquitectura "Motor de Fórmula 1"
A partir de la versión 1.0.0, este repositorio ha pivotado exclusivamente hacia la arquitectura de bucles secuenciales autónomos y memoria global. Toda la deuda técnica de los antiguos nodos (lotes, secuencias, depuración) ha sido eliminada, dejando un código base limpio y fácil de mantener enfocado en los 6 fantásticos.
En la **v1.1.0**, hemos introducido capacidades de guardado a disco progresivo, soporte para multiplexado de audio y un hackeo profundo a nivel de JSON para vencer la agresiva caché de ComfyUI.
## Los 6 Nodos Principales
El sistema se construye alrededor de tres categorías principales:
### 🔁 Loop (Orquestación Autónoma)
1. **🏁 Loop Start (Index) (`SequentialLoopStart`)**: Inicia el bucle, gestiona el índice global y provee el índice actual a los nodos de imagen y vídeo del flujo.
2. **🚀 Loop Trigger (Auto-Queue) (`SequentialLoopTrigger`)**: Se coloca al final del flujo de trabajo. Incrementa el contador y auto-encola el siguiente ciclo mediante un POST a la propia API de ComfyUI (`/prompt`).
- **Mutador de Semillas:** Escanea el lienzo, localiza nodos con una semilla (`seed` o `noise_seed`) y les inyecta una nueva (32 bits), rompiendo la caché hacia adelante en los samplers.
- **💉 Inyección Anti-Caché (¡Nuevo en v1.1.0!):** Busca específicamente al nodo `Loop Start` dentro de la carga útil (payload) JSON y le **inyecta forzosamente el nuevo índice**. Esto rompe el infame "caché inverso" (bottom-up) de ComfyUI que congelaba los nodos iniciales durante el Auto-Queue, garantizando un avance ininterrumpido.
### 🖼️ Image (Memoria de Sesión)
3. **📥 Session Image Receiver (`SessionImageReceiver`)**: Proporciona la imagen inicial o la última generada del ciclo anterior, detectando inteligentemente el inicio de una sesión en la memoria RAM.
4. **📤 Session Image Sender (`SessionImageSender`)**: Extrae la última imagen del lote y la asegura en la memoria del sistema para el siguiente ciclo.
- **💾 Guardado de Keyframes (¡Nuevo en v1.1.0!):** Ahora recibe el índice actual y realiza un volcado de seguridad en el disco duro, guardando progresivamente `keyframe_XXX.png` en cada ciclo para prevenir pérdidas de datos.
### 🎞️ Video (Ensamblaje y Validación)
5. **🛡️ Wan Frame Validator (`WanFrameValidator`)**: Valida y corrige el número objetivo de fotogramas para asegurar que encajen en la fórmula `4k+1` requerida por modelos específicos (ej. Wan).
6. **🎞️ Incremental Auto-Stitcher (`IncrementalVideoStitcher`)**: Ensambla secuencialmente los fragmentos de vídeo generados usando FFmpeg.
- **📦 Historial Progresivo (¡Nuevo en v1.1.0!):** Ya no sobrescribe el vídeo anterior. Ahora guarda MP4s acumulativos (`SCAIL_Final_0001.mp4`, `SCAIL_Final_0002.mp4`), protegiendo tu trabajo de cuelgues del servidor.
- **🎵 Multiplexado de Audio (¡Nuevo en v1.1.0!):** Acepta una entrada estándar `AUDIO`. Captura el sonido con `torchaudio` y emplea el comando `-shortest` de FFmpeg. Esto hace que el audio se sincronice a la perfección y se corte exactamente a la duración del ensamblaje del ciclo actual.
## Configuración y Uso
### Prerrequisitos
- **FFmpeg**: Debe estar instalado y disponible en el PATH del sistema para que el `Incremental Auto-Stitcher` funcione correctamente.
- **Torchaudio**: (Generalmente incluido en los entornos ComfyUI) es necesario para extraer la pista de audio antes del multiplexado.
### Instalación
1. Ve a la carpeta `custom_nodes` de ComfyUI.
2. Clona este repositorio: `git clone https://github.com/your-repo/ComfyUI-Sequential-Batcher.git`
3. Reinicia ComfyUI.
### Cómo Conectar tu Nueva Máquina Autónoma
1. **El Inicio:** Añade el nodo `🏁 Loop Start (Index)`.
- Conecta su salida `current_loop_index` a las entradas de índice de tu `SessionImageReceiver`, `SessionImageSender`, y tu `Incremental Auto-Stitcher`. *¡No olvides conectar el Sender para el guardado de los keyframes!*
- Asegúrate de que su interruptor `reset_loop` está en `False`.
2. **Conectando el Audio (Opcional):** Si tu flujo tiene sonido, saca un cable de la salida de audio de tu nodo inicial (ej. `VHS_LoadVideo`) y conéctalo al puerto `audio` azul de tu `Incremental Auto-Stitcher`.
3. **El Final:** Añade el nodo `🚀 Loop Trigger (Auto-Queue)`. Crucial: Conecta la salida de texto (`final_video_path`) de tu `Incremental Auto-Stitcher` en la entrada `trigger_dependency`. Esto obliga al trigger a esperar a que el vídeo se haya guardado físicamente antes de disparar. Configura la cantidad de lotes que quieres en `target_loops`.
4. **Ejecución:** **Ya no tienes que marcar la casilla "Auto Queue" nunca más.** Pulsa "Queue Prompt" **1 sola vez**. El lote 0 arranca y, al llegar al final, el nodo `Trigger` envía una señal invisible al servidor. Gracias a la Inyección Anti-Caché, la caché se destruye en cada iteración y el progreso fluye hasta completar tu vídeo.
> 🌍 **Read in English:** [README_EN.md](README_EN.md)
---
*Creado para llevar los límites de la automatización en ComfyUI un paso más allá.*
## ✨ Características Clave
* **Orquestación Autónoma:** Convierte tu ComfyUI en un motor de renderizado continuo. Pulsa "Queue Prompt" una sola vez y el flujo se retroalimentará modificando semillas e índices hasta terminar todo el vídeo.
* **Cortes Inteligentes (Smart Chunking):** Analiza el vídeo base y realiza cortes matemáticos protegiendo los frames donde los rostros son más nítidos, manteniendo la coherencia de identidad (Face Cuts).
* **Extracción de Audio Nativa:** Extrae la pista original directamente desde el nodo inicial y la inyecta de vuelta en el ensamblaje final.
* **Soporte Arquitecturas DiT (WanVideo & LTX 2.3):** Calculadores matemáticos específicos aseguran que los lotes de vídeo cumplan con las estrictas reglas de descompresión (regla de `4n + 1` para WanVideo, o regla `8n + 1` para LTX).
* **Resoluciones Protegidas (Megapixel Shield):** Herramientas matemáticas (`ResTool`) que escalan las dimensiones automáticamente protegiendo el *training floor* de cada modelo base.
---
## ⚠️ Requisitos del Sistema (Crítico)
Para que el nodo `VideoAnalyzerFaceDetector` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
* **🐧 Ubuntu / Linux / WSL2:**
El script intentará instalarlo por ti. Si falla, ejecuta manualmente:
```bash
sudo apt update && sudo apt install ffmpeg -y
```
* **🪟 Windows:**
Descarga los binarios precompilados de FFmpeg y añade la carpeta `bin` a tus variables de entorno (PATH).
### 🚨 Advertencia Crítica sobre el Uso de la VRAM
Se **desrecomienda encarecidamente** el uso del argumento `--highvram` al arrancar ComfyUI.
El argumento `--highvram` le da la orden estricta a ComfyUI de mantener los modelos bloqueados en la memoria de video. Esto sabotea directamente el funcionamiento de nuestro `VRAM Defragmenter`, ya que bloquea el comando `mm.unload_all_models()` impidiendo que la gráfica se vacíe entre ciclos. Para que esta suite funcione correctamente con modelos masivos como WanVideo y evite errores de OOM (Out Of Memory), los usuarios deben usar el arranque estándar (o `--normalvram`), permitiendo que el sistema libere dinámicamente la memoria hasta llegar a 0 GB entre lotes.
---
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
El procesamiento se divide en roles altamente especializados, organizados en las siguientes categorías:
### 🎼 Orquestación
* **🏁 Loop Start (Index):** Inicia la secuencia de bucle y mantiene el índice actual.
* **🚀 Loop Trigger (Auto-Queue):** Dispara la siguiente iteración de ComfyUI de manera autónoma hasta que se completa la tarea.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** Máquina de estados para orquestar metadatos (prompts, duraciones) en la preproducción (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Guarda y carga los frames clave de la escena para mantener la continuidad entre flujos separados.
### 📦 Lotes (Batches)
* **📂 Batch Audio Folder Loader:** Carga dinámicamente carpetas enteras de archivos (ej. lotes de audio).
* **🎛️ Audio Batch Selector:** Despacha los archivos del lote uno por uno sincronizándose con el bucle.
### 🔬 Análisis y Cálculos
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV/YOLO, extrayendo rostros, audio y frame de referencia.
* **🎬 Video Analyzer Scene detector:** Detecta cortes de escena en los vídeos.
* **📊 Auto Loop Calculators:** Nodos especializados (`Base`, `WanVideo`, `LTX`, `TTS`) que reciben metadatos y calculan matemáticamente las coordenadas de corte exactas (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Ensambla progresivamente los fragmentos de video renderizados en un único archivo.
* **🛡️ VAE Safe Frame Padder:** Rellena ("acolcha") tensores incompletos clonando el último frame para salvar al VAE de un colapso en modelos restrictivos (ej. reglas 4n+1).
* **💉 LTXV Single Frame Injector:** Inyecta frames individuales específicos requeridos por flujos de LTX.
* **⏱️ Auto FPS Limiter:** Limita de manera inteligente los cuadros por segundo para ahorrar VRAM manteniendo sincronía de audio.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Corta el audio de forma precisa (a nivel de muestra matemática) en base a los frames de vídeo correspondientes.
* **🎛️ Conditional Audio Router (Bypass):** Enruta el audio condicionalmente permitiendo saltar su procesamiento si no es necesario.
### 📐 Herramientas de Resolución
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculan resoluciones óptimas basadas en límites de VRAM y las reglas geométricas (multiplicadores) de diferentes modelos (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Gestión de Memoria / Lógica
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Evaluación perezosa nativa; amputan rutas inactivas para que los nodos pesados ni siquiera se carguen en memoria.
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al recolector de basura (GC) de Python entre ciclos.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retienen y transfieren imágenes en memoria a través de diferentes ciclos.
---
## 🔌 Guía de Cableado Rápido (Workflow Setup)
1. **El Explorador:** Coloca el `Video Analyzer` al principio. Sube un vídeo (o usa una ruta de YTDLP).
2. **Matemáticas:** Conecta `total_frames` y `safe_faces_list` hacia tu `Auto Loop Calculator` elegido.
3. **Extracción Segura:** Añade el nodo nativo `VHS_LoadVideo`. Conviértele la entrada a *video_name* y conéctala al Analyzer. Conecta su salida `IMAGE` al nuevo nodo `VAE Safe Frame Padder`.
4. **Passthrough de Audio:** Lleva el cable `source_audio` desde el Explorador directamente hasta el puerto `audio` de tu `Incremental Auto-Stitcher` (al final del flujo).
5. **El Gatillo:** Conecta la salida de tu Stitcher al nodo `Loop Trigger (Auto-Queue)`.
6. **Ejecución:** Conecta el `Loop Start (Index)` al Cerebro y al Stitcher.
7. **Dispara:** Haz clic en "Queue Prompt" **UNA SOLA VEZ**. ¡Disfruta de la magia autónoma!
---
## 🚀 Aceleración por GPU (Escaneo de Rostros)
El `VideoAnalyzerFaceDetector` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
1. Instala el *Impact Pack*.
2. Añade el nodo `UltralyticsDetectorProvider` y selecciona un modelo de rostros (ej. `face_yolov8m.pt`).
3. Conecta su salida al puerto del Analyzer.
*(El nodo cuenta con auto-descarga: destruirá este modelo de la VRAM al terminar para hacer hueco a la generación).*
+93 -50
View File
@@ -1,54 +1,97 @@
# ComfyUI Sequential Batcher (v1.1.0)
# ♾️ ComfyUI Sequential Batcher
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
A highly specialized suite of custom nodes for ComfyUI designed for **Recursive Self-Queuing** and autonomous sequential processing. This architecture minimizes VRAM usage by processing heavy tasks (like video generation) sequentially, batch-by-batch, orchestrated entirely from within the graph.
A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through **Recursive Auto-Queuing**, autonomous batch-by-batch processing, and forensic memory management.
> **Leer en Español:** [README.md](README.md)
## The "Formula 1" Engine Architecture
Starting with v1.0.0, this repository has pivoted exclusively to the autonomous sequential loops and global memory architecture. All legacy technical debt (deprecated batch/sequence/debug nodes) has been pruned, leaving a clean, highly maintainable codebase focused on 6 core nodes.
In **v1.1.0**, we have introduced progressive disk saving capabilities, audio multiplexing support, and a deep JSON hack to defeat ComfyUI's aggressive cache.
## The 6 Core Nodes
The system is built around three major categories:
### 🔁 Loop (Autonomous Orchestration)
1. **🏁 Loop Start (Index) (`SequentialLoopStart`)**: Initiates the loop, manages the global loop index, and provides the current iteration index to downstream nodes.
2. **🚀 Loop Trigger (Auto-Queue) (`SequentialLoopTrigger`)**: Placed at the very end of your workflow. It increments the loop counter and autonomously triggers an HTTP POST request to the ComfyUI API (`/prompt`) to queue the next batch cycle.
- **Seed Mutator:** Scans the canvas, locates nodes with a seed (`seed` or `noise_seed`), and injects a new 32-bit random seed, breaking forward cache in samplers.
- **💉 Anti-Cache Injection (New in v1.1.0!):** Specifically searches for the `Loop Start` node within the JSON payload and **forcefully injects the new index**. This shatters ComfyUI's infamous "reverse cache" (bottom-up) that froze initial nodes during Auto-Queue, guaranteeing uninterrupted progression.
### 🖼️ Image (Session Memory)
3. **📥 Session Image Receiver (`SessionImageReceiver`)**: Retrieves the initial image or the last generated frame from the previous cycle, intelligently detecting the start of a RAM session.
4. **📤 Session Image Sender (`SessionImageSender`)**: Extracts the final image of a batch and secures it in system memory for the next cycle.
- **💾 Keyframe Dumping (New in v1.1.0!):** Now receives the current index and performs a safety dump to the hard drive, progressively saving `keyframe_XXX.png` every cycle to prevent data loss.
### 🎞️ Video (Assembly and Validation)
5. **🛡️ Wan Frame Validator (`WanFrameValidator`)**: Validates and corrects the target number of frames to ensure they fit the `4k+1` formula required by specific models (e.g., Wan).
6. **🎞️ Incremental Auto-Stitcher (`IncrementalVideoStitcher`)**: Sequentially assembles the generated video chunks using FFmpeg.
- **📦 Progressive History (New in v1.1.0!):** No longer overwrites the previous video. It now saves cumulative MP4s (`SCAIL_Final_0001.mp4`, `SCAIL_Final_0002.mp4`), protecting your work from server crashes.
- **🎵 Audio Multiplexing (New in v1.1.0!):** Accepts a standard `AUDIO` input. Captures the sound with `torchaudio` and employs FFmpeg's `-shortest` command. This ensures the audio synchronizes perfectly and cuts exactly to the duration of the current cycle's assembly.
## Setup & Usage
### Prerequisites
- **FFmpeg**: Must be installed and available in your system's PATH for the `Incremental Auto-Stitcher` to function correctly.
- **Torchaudio**: (Usually included in ComfyUI environments) is required to extract the audio track before multiplexing.
### Installation
1. Navigate to your ComfyUI `custom_nodes` folder.
2. Clone this repository: `git clone https://github.com/your-repo/ComfyUI-Sequential-Batcher.git`
3. Restart ComfyUI.
### How to use the Autonomous Machine
1. **The Start:** Add the `🏁 Loop Start (Index)` node.
- Connect its `current_loop_index` to the index inputs of your `SessionImageReceiver`, `SessionImageSender`, and `Incremental Auto-Stitcher`. *Don't forget the Sender for keyframe saving!*
- Ensure `reset_loop` is set to `False`.
2. **Connecting Audio (Optional):** If your workflow has sound, pull a cable from your initial node's audio output (e.g., `VHS_LoadVideo`) and connect it to the blue `audio` port on your `Incremental Auto-Stitcher`.
3. **The End:** Add the `🚀 Loop Trigger (Auto-Queue)` node. Crucially, connect the text output (`final_video_path`) from your `Incremental Auto-Stitcher` to the `trigger_dependency` input. This forces the trigger to wait until the video is physically saved. Set your desired `target_loops`.
4. **Execution:** **You no longer need to check "Auto Queue".** Just press "Queue Prompt" **once**. Batch 0 starts, and upon finishing, the trigger invisibly signals the server. Thanks to Anti-Cache Injection, cache is destroyed on every iteration, and progress flows until your video is complete.
> 🇪🇸 **Leer en Español:** [README.md](README.md)
---
*Created to push the boundaries of ComfyUI automation.*
## ✨ Key Features
* **Autonomous Orchestration:** Turn your ComfyUI into a continuous rendering engine. Press "Queue Prompt" just once and the flow will feed itself by modifying seeds and indices until the entire video is finished.
* **Smart Chunking:** Analyzes the base video and makes mathematical cuts protecting the frames where faces are sharpest, maintaining identity coherence (Face Cuts).
* **Native Audio Extraction:** Extracts the original track directly from the initial node and injects it back into the final assembly.
* **DiT Architectures Support (WanVideo & LTX 2.3):** Specific mathematical calculators ensure video batches comply with strict decompression rules (multiples of 4 for WanVideo, or the `8n + 1` rule for LTX).
* **Protected Resolutions (Megapixel Shield):** Mathematical tools (`ResTool`) that automatically scale dimensions protecting the *training floor* of each base model.
---
## ⚠️ System Requirements (Critical)
For the `VideoAnalyzerFaceDetector` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
* **🐧 Ubuntu / Linux / WSL2:**
The script will attempt to install it for you. If it fails, run manually:
```bash
sudo apt update && sudo apt install ffmpeg -y
```
* **🪟 Windows:**
Download the precompiled FFmpeg binaries and add the `bin` folder to your environment variables (PATH).
### 🚨 Critical Warning Regarding VRAM Usage
The use of the `--highvram` argument when starting ComfyUI is **strongly discouraged**.
The `--highvram` argument gives ComfyUI the strict command to keep models locked in video memory. This directly sabotages the operation of our `VRAM Defragmenter`, as it blocks the `mm.unload_all_models()` command, preventing the graphics card from emptying between cycles. For this suite to work correctly with massive models like WanVideo and avoid OOM (Out Of Memory) errors, users must use the standard startup (or `--normalvram`), allowing the system to dynamically release memory down to 0 GB between batches.
---
## 🧠 The Hybrid Architecture (Node Breakdown)
Processing is divided into highly specialized roles, organized in the following categories:
### 🎼 Orchestration
* **🏁 Loop Start (Index):** Starts the loop sequence and tracks the current index.
* **🚀 Loop Trigger (Auto-Queue):** Autonomously triggers the next ComfyUI iteration until the task is complete.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** State machine to orchestrate metadata (prompts, durations) in preproduction (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Saves and loads the scene's keyframes to maintain continuity across decoupled flows.
### 📦 Batches
* **📂 Batch Audio Folder Loader:** Dynamically loads entire folders of files (e.g. audio batches).
* **🎛️ Audio Batch Selector:** Dispatches files from the batch one by one in sync with the loop.
### 🔬 Analysis and Calculations
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV/YOLO, extracting faces, audio, and the reference frame.
* **🎬 Video Analyzer Scene detector:** Detects scene cuts in videos.
* **📊 Auto Loop Calculators:** Specialized nodes (`Base`, `WanVideo`, `LTX`, `TTS`) that receive metadata and mathematically calculate the exact cut coordinates (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Progressively stitches rendered video chunks into a single final file.
* **🛡️ VAE Safe Frame Padder:** Pads incomplete tensors by cloning the last frame to save the VAE from a collapse in restrictive models (e.g. 4n+1 rules).
* **💉 LTXV Single Frame Injector:** Injects specific individual frames required by LTX flows.
* **⏱️ Auto FPS Limiter:** Smartly limits frames per second to save VRAM while maintaining audio sync.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Precisely slices audio (at mathematical sample level) based on the corresponding video frames.
* **🎛️ Conditional Audio Router (Bypass):** Conditionally routes audio allowing its processing to be bypassed if not needed.
### 📐 Resolution Tools
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculate optimal resolutions based on VRAM limits and geometric rules (multipliers) of different models (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Memory Management / Logic
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Native lazy evaluation; they cut inactive routes so heavy nodes aren't even loaded into memory.
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Python Garbage Collector (GC) between cycles.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retain and transfer in-memory images across different cycles.
---
## 🔌 Quick Wiring Guide (Workflow Setup)
1. **The Explorer:** Place the `Video Analyzer` at the beginning. Upload a video (or use a YTDLP path).
2. **Math:** Connect `total_frames` and `safe_faces_list` to your chosen `Auto Loop Calculator`.
3. **Safe Extraction:** Add the native `VHS_LoadVideo` node. Convert its input to *video_name* and connect it to the Analyzer. Connect its `IMAGE` output to the new `VAE Safe Frame Padder` node.
4. **Audio Passthrough:** Route the `source_audio` wire from the Explorer directly to the `audio` port of your `Incremental Auto-Stitcher` (at the end of the flow).
5. **The Trigger:** Connect the output of your Stitcher to the `Loop Trigger (Auto-Queue)` node.
6. **Execution:** Connect the `Loop Start (Index)` to the Brain and the Stitcher.
7. **Fire:** Click "Queue Prompt" **JUST ONCE**. Enjoy the autonomous magic!
---
## 🚀 GPU Acceleration (Face Scanning)
The `VideoAnalyzerFaceDetector` has a `bbox_detector` port to delegate facial scanning to the graphics card:
1. Install the *Impact Pack*.
2. Add the `UltralyticsDetectorProvider` node and select a face model (e.g. `face_yolov8m.pt`).
3. Connect its output to the Analyzer's port.
*(The node features auto-unload: it will destroy this model from VRAM upon completion to make room for generation).*
+43 -18
View File
@@ -1,30 +1,55 @@
__version__ = "1.0.0" # Subimos a versión 1.0 ya que es una arquitectura nueva
__version__ = "0.8.3"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def register_node(c, display_name=None):
assert not isinstance(c.RETURN_TYPES, str), "Error: string found instead of tuple."
assert not isinstance(c.RETURN_NAMES, str), "Error: string found instead of tuple."
NODE_CLASS_MAPPINGS[c.__name__] = c
NODE_DISPLAY_NAME_MAPPINGS[c.__name__] = display_name or c.__name__
return c
from . import image, loop, video
# Importar tools
from . import image, loop, video, tools, vram_node
from .switch_node import MasterSwitch, LazySessionCache
# Display name mappings with emojis
NODE_DISPLAY_NAME_MAPPINGS.update({
# Image (Memoria de Sesión)
"SessionImageReceiver": "📥 Session Image Receiver",
"SessionImageSender": "📤 Session Image Sender",
# Video (Ensamblaje y Validación)
"IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher",
"WanFrameValidator": "🛡️ Wan Frame Validator",
# Loop (Orquestación Autónoma)
"SequentialLoopStart": "🏁 Loop Start (Index)",
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
NODE_CLASS_MAPPINGS.update({
"MasterSwitch": MasterSwitch,
"LazySessionCache": LazySessionCache,
})
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
NODE_DISPLAY_NAME_MAPPINGS.update({
"MasterSwitch": "🔀 Master Switch",
"LazySessionCache": "🗄️ Lazy Session Cache",
"MeisoftVRAMDefragmenter": "🧹 VRAM Defragmenter",
"SessionImageReceiver": "📥 Session Image Receiver",
"SessionImageSender": "📤 Session Image Sender",
"IncrementalVideoStitcher": "🎞️ Incremental Auto-Stitcher",
"VideoAnalyzerFaceDetector": "🕵️ Video Analyzer Face detector + Audio",
"VideoAnalyzerSceneDetector": "🎬 Video Analyzer Scene detector",
"AutoLoopCalculator": "📊 Auto Loop Calculator",
"AutoLoopCalculatorWan": "📊 Auto Loop Calculator (WanVideo 3dVAE)",
"AutoLoopCalculatorLTX": "📊 Auto Loop Calculator (LTX 2.3)",
"AutoLoopCalculatorTTS": "📊 Auto Loop Calculator (TTS/Texto)",
"SequentialLoopStart": "🏁 Loop Start (Index)",
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
"BatchAudioFolderLoader": "📂 Batch Audio Folder Loader",
"AudioBatchSelector": "🎛️ Audio Batch Selector",
"DynamicSceneDirector": "🎬 Dynamic Scene Director",
# Tools
"AutoFPSLimiter": "⏱️ Auto FPS Limiter",
"VAESafeFramePadder": "🛡️ VAE Safe Frame Padder",
"ResTool8x": "📐 ResTool 8x (SD1.5)",
"ResTool16x": "📏 ResTool 16x (SDXL)",
"ResTool32x": "🎞️ ResTool 32x (WanVideo)",
"ResTool64x": "🎬 ResTool 64x (Hunyuan)",
"ResTool64xLTX": "🌌 ResTool 64x (LTX 2.3)",
"ConditionalAudioRouter": "🎛️ Conditional Audio Router (Bypass)",
"PreciseAudioSlicer": "✂️ Precise Audio Slicer",
"SaveSceneKeyframe": "💾 Save Scene Keyframe",
"LoadSceneKeyframe": "🖼️ Load Scene Keyframe",
"LTXVSingleFrameInjector": "💉 LTXV Single Frame Injector",
"ProjectPlanLoader": "📂 Project Plan Loader"
})
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+136 -32
View File
@@ -29,8 +29,8 @@ class SessionImageReceiver:
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("current_image",)
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("current_image", "log")
OUTPUT_NODE = True
FUNCTION = "get_image"
CATEGORY = "🔁 Sequential Batcher/Image"
@@ -40,27 +40,37 @@ class SessionImageReceiver:
return time.time()
def get_image(self, initial_image, current_loop_index):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
global global_session_image
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
is_first = (loop_idx == 0)
print(f"\n{'='*50}")
print(f"📥 [DEBUG] NODO: Image Receiver")
print(f" -> Ciclo actual detectado: {loop_idx}")
# Usamos getattr para evitar errores si el bucle aún no ha inicializado las variables
accumulated = getattr(loop, 'global_accumulated_frames', 0)
source_total = getattr(loop, 'global_source_frame_count', 1)
_log(f"\n{'='*50}")
_log(f"📥 [Secuencial Batcher] NODO: Image Receiver")
_log(f" -> Ciclo actual: {loop_idx} | Progreso global: {accumulated} / {source_total} frames")
if is_first or global_session_image is None:
global_session_image = initial_image.clone().cpu()
print(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
_log(f" -> 🆕 Iniciando sesión con la imagen ORIGINAL.")
selected = initial_image
else:
print(f" -> ♻️ Usando el fotograma rescatado de la RAM.")
_log(f" -> ♻️ Usando el Keyframe validado y rescatado de la RAM.")
selected = global_session_image
print(f" -> 🖼️ Tensor shape: {selected.shape}")
print(f"{'='*50}\n")
_log(f" -> 🖼️ Tensor shape: {selected.shape}")
_log(f"{'='*50}\n")
ui_image = tensor_to_temp_image(selected, "receiver")
return {"ui": {"images": [ui_image]}, "result": (selected, )}
return {"ui": {"images": [ui_image]}, "result": (selected, "\n".join(log_output))}
@register_node
class SessionImageSender:
@@ -70,11 +80,12 @@ class SessionImageSender:
"required": {
"generated_images": ("IMAGE",),
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
"detect_faces": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ()
RETURN_NAMES = ()
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("VALIDATED_IMAGES", "log")
OUTPUT_NODE = True
FUNCTION = "set_image"
CATEGORY = "🔁 Sequential Batcher/Image"
@@ -83,35 +94,128 @@ class SessionImageSender:
def IS_CHANGED(cls, **kwargs):
return time.time()
def set_image(self, generated_images, current_loop_index):
if generated_images is None:
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Session Image Sender' no está recibiendo imágenes. Conecta la salida de tu VAE Decode o Sampler.")
def set_image(self, generated_images, current_loop_index, detect_faces):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
import cv2
import numpy as np
import os, folder_paths, torch
from PIL import Image
if current_loop_index is None:
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Session Image Sender' necesita el cable de 'current_loop_index' desde el Loop Start para nombrar los archivos.")
if generated_images is None:
raise ValueError("❌ ERROR CRÍTICO: No se recibieron imágenes en el Sender.")
global global_session_image
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
batch_size = generated_images.shape[0]
best_idx = batch_size - 1
print(f"\n{'='*50}")
print(f"📤 [DEBUG] NODO: Image Sender")
print(f" -> Ciclo actual: {loop_idx} | Frames recibidos: {generated_images.shape[0]}")
accumulated = getattr(loop, 'global_accumulated_frames', 0)
source_total = getattr(loop, 'global_source_frame_count', 1)
stride = getattr(loop, 'global_select_every_nth', 1)
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
last_frame = generated_images[-1:].clone().cpu()
_log(f"\n{'='*50}")
_log(f"📤 [Secuencial Batcher] NODO: Image Sender (Filtro Dinámico)")
_log(f" -> Frames recibidos de la IA: {batch_size}")
# 🧠 BYPASS IMPLACABLE: Si al sumar este lote llegamos al final del video, NO RECORTAMOS
advance_check = (batch_size - 1) * stride if ltx_mode_active else batch_size * stride
if (accumulated + advance_check) >= source_total:
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Desactivando recorte de rostros para forzar la salida de TODOS los frames.")
detect_faces = False
if detect_faces:
try:
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path)
_log(f" -> 🕵️ Buscando el último rostro frontal en reversa (Desde frame {batch_size - 1} hasta 0)...")
found = False
for i in range(batch_size - 1, -1, -1):
img_np = (generated_images[i].cpu().numpy() * 255.0).astype(np.uint8)
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(30, 30))
if len(faces) > 0:
best_idx = i
found = True
_log(f" -> ✅ Rostro detectado en el frame [{i}].")
break
if not found:
_log(f" -> ⚠️ ALERTA: No se detectó rostro frontal en NINGÚN frame. Aceptando todo el lote para evitar bucles infinitos.")
best_idx = batch_size - 1
except Exception as e:
_log(f" -> ⚠️ Error en OpenCV al validar: {e}")
else:
_log(f" -> ⏩ Detección de rostros desactivada. Usando el último frame absoluto del lote ({batch_size - 1}).")
best_idx = batch_size - 1
# 1. TRUNCAR EL TENSOR
valid_images = generated_images[:best_idx + 1]
frames_accepted = best_idx + 1
# 2. AVANZAR LA MÁQUINA
stride = getattr(loop, 'global_select_every_nth', 1)
advanced_original_frames = frames_accepted * stride
if not hasattr(loop, 'global_accumulated_frames'):
loop.global_accumulated_frames = 0
# LTX Mode Fix: Avoid counting the overlap frame in the progression
ltx_mode_active = getattr(loop, 'global_ltx_mode', False)
if ltx_mode_active:
# We subtract 1 from frames_accepted to account for the anchor frame
# The overlap frame shouldn't advance the global accumulated frames count
# since it will be reused as the start of the next chunk.
advanced_original_frames = max(1, (frames_accepted - 1) * stride)
source_total = getattr(loop, 'global_source_frame_count', 1)
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
if is_chunk_mode:
# En modo TTS (Texto), el progreso se mide estrictamente en frases/chunks, no en frames de video.
is_final_cycle = is_final_chunk
_log(f" -> 🧩 Modo Texto activo: Progreso gestionado por frases ({loop.global_accumulated_frames} / {source_total}).")
else:
# Lógica original por frames de video
if is_final_chunk:
_log(f" -> 🏁 LOTE FINAL ABSOLUTO DETECTADO. Compensando mermas del VAE (Timeline forzado al 100%).")
loop.global_accumulated_frames = source_total
else:
loop.global_accumulated_frames += advanced_original_frames
is_final_cycle = loop.global_accumulated_frames >= source_total
_log(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total} frames")
_log(f" -> ✂️ Tensor truncado a {frames_accepted} frames válidos.")
last_frame = valid_images[-1:].clone().cpu()
global_session_image = last_frame
# Guardar disco progresivo
out_dir = folder_paths.get_output_directory()
filename = f"keyframe_{loop_idx:03d}.png"
filepath = os.path.join(out_dir, filename)
# 🛡️ Solo guardamos el keyframe si habrá un próximo ciclo
if not is_final_cycle:
out_dir = folder_paths.get_output_directory()
filename = f"keyframe_{loop_idx:03d}.png"
filepath = os.path.join(out_dir, filename)
img_array = 255. * last_frame[0].numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
img.save(filepath)
img_array = 255. * last_frame[0].numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
img.save(filepath)
print(f" -> 💾 Keyframe guardado físicamente: {filename}")
print(f" -> 🧠 RAM asegurada para el próximo ciclo.")
print(f"{'='*50}\n")
_log(f" -> 💾 Keyframe seguro guardado: {filename}")
else:
_log(f" -> 🚀 Ciclo final/único detectado. Omitiendo guardado de keyframe.")
_log(f"{'='*50}\n")
ui_image = tensor_to_temp_image(last_frame, "sender")
return {"ui": {"images": [ui_image]}}
return {"ui": {"images": [ui_image]}, "result": (valid_images, "\n".join(log_output))}
+39
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.")
+729 -32
View File
@@ -2,9 +2,21 @@ import random
import time
import urllib.request
import json
import gc
import torch
import comfy.model_management as mm
from . import register_node
global_loop_index = 0
global_accumulated_frames = 0
global_source_frame_count = 1
global_select_every_nth = 1
global_server_port = 8188 # 💡 NUEVO: Única fuente de la verdad para el puerto
global_ltx_mode = False
global_step_by_chunk = False
global_batch_index = 0
global_has_more_batches = False
global_is_batch_advancing = False
@register_node
class SequentialLoopStart:
@@ -13,7 +25,8 @@ class SequentialLoopStart:
return {
"required": {
"reset_loop": ("BOOLEAN", {"default": False}),
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}), # Inyectado por el Trigger
"loop_idx": ("INT", {"default": 0, "min": 0, "max": 10000}),
"port": ("INT", {"default": 8188, "min": 1, "max": 65535}), # 💡 FAIL-FAST: Pedimos el puerto aquí
}
}
@@ -26,25 +39,297 @@ class SequentialLoopStart:
def IS_CHANGED(cls, **kwargs):
return time.time()
def get_index(self, reset_loop, loop_idx):
def get_index(self, reset_loop, loop_idx, port):
global global_loop_index
global global_accumulated_frames
global global_server_port
global global_ltx_mode
global global_batch_index
global global_is_batch_advancing
import sys
import argparse
print(f"\n{'='*50}")
print(f"🚀 [DEBUG] NODO: Loop Start")
print(f" -> Input loop_idx (UI/Trigger): {loop_idx}")
print(f" -> Input reset_loop: {reset_loop}")
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
# --- PRE-FLIGHT CHECK (SECURITY SSRF FIX) ---
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8188)
try:
args, _ = parser.parse_known_args(sys.argv[1:])
system_port = args.port
except:
system_port = 8188
safe_port = system_port
if port != safe_port:
print(f" -> ⚠️ ATENCIÓN: El puerto de entrada ({port}) no coincide con el puerto del sistema ({safe_port}).")
print(f" -> 🔒 Por motivos de seguridad (Prevención SSRF), se forzará el puerto real del sistema: {safe_port}.")
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto seguro {safe_port}...")
max_retries = 3
for attempt in range(max_retries):
try:
req = urllib.request.Request(f"http://127.0.0.1:{safe_port}/system_stats")
urllib.request.urlopen(req, timeout=10)
global_server_port = safe_port
print(f" -> ✅ Conexión establecida. Puerto blindado y seguro.")
break
except Exception as e:
if attempt < max_retries - 1:
print(f" -> ⏳ Reintentando conexión ({attempt + 1}/{max_retries}) por alta carga del servidor...")
time.sleep(1)
error_msg = f"🚨 FATAL ERROR: EL PUERTO {safe_port} NO RESPONDE TRAS VARIOS INTENTOS. El servidor ComfyUI podría estar bloqueado o apagado. (Error original: {e})"
print(f" -> ❌ {error_msg}")
raise RuntimeError(error_msg)
# ------------------------------------
is_reset = str(reset_loop).lower() in ['true', '1', 't', 'y']
if is_reset:
if is_reset or loop_idx == 0:
global_loop_index = 0
print(" -> 🔄 Bucle reiniciado a 0 manualmente.")
global_accumulated_frames = 0
global_ltx_mode = False
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
else:
global_loop_index = loop_idx
global_is_batch_advancing = False
print(f" -> 📤 OUTPUT current_loop_index: {global_loop_index}")
print(f" -> 📍 Índice actual de bucle: {global_loop_index}")
print(f"{'='*50}\n")
return (global_loop_index,)
@register_node
class AutoLoopCalculatorTTSBatch:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text_list": ("STRING", {"forceInput": True}),
"file_names": ("STRING", {"forceInput": True}),
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
# 💡 FIX: Mantenemos INPUT_IS_LIST para absorber el batch, pero ELIMINAMOS OUTPUT_IS_LIST
# para que ComfyUI desempaquete las salidas automáticamente.
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING", "INT", "INT", "STRING", "STRING")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "current_file_name", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, text_list, file_names, split_mode, current_loop_index):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
from . import loop
import re
loop.global_step_by_chunk = True
texts = text_list[0] if (isinstance(text_list, list) and len(text_list) > 0 and isinstance(text_list[0], list)) else (text_list if isinstance(text_list, list) else [text_list])
names = file_names[0] if (isinstance(file_names, list) and len(file_names) > 0 and isinstance(file_names[0], list)) else (file_names if isinstance(file_names, list) else [file_names])
mode = split_mode[0] if isinstance(split_mode, list) else split_mode
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
# El batch index es extraído directamente de idx para evitar bloqueos
current_batch_idx = idx
if current_batch_idx >= len(texts):
current_batch_idx = len(texts) - 1
current_file_text = texts[current_batch_idx]
current_name = names[current_batch_idx] if current_batch_idx < len(names) else "unknown"
if mode == "Frases (Puntos)":
matches = re.findall(r'[^.!?\n]+[.!?\n]*', current_file_text)
temp_chunks = [m.strip() for m in matches if m.strip()]
MIN_WORDS = 5
raw_chunks = []
buffer_text = ""
for chunk in temp_chunks:
buffer_text = (buffer_text + " " + chunk).strip()
if len(buffer_text.split()) >= MIN_WORDS:
raw_chunks.append(buffer_text)
buffer_text = ""
if buffer_text:
if raw_chunks: raw_chunks[-1] += " " + buffer_text
else: raw_chunks.append(buffer_text)
chunk_type_name = "Frases Optimizadas"
else:
raw_chunks = [p.strip() for p in current_file_text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
total_chunks = len(raw_chunks)
if total_chunks == 0:
raw_chunks = [""]
total_chunks = 1
safe_index = min(idx, total_chunks - 1)
current_chunk_text = raw_chunks[safe_index]
loop.global_source_frame_count = total_chunks
loop.global_accumulated_frames = safe_index + 1
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
loop.global_has_more_batches = (current_batch_idx < len(texts) - 1)
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS Batch)")
_log(f" -> Archivo {current_batch_idx + 1} de {len(texts)}: {current_name}")
_log(f" -> Modo de división: {mode}")
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
_log(f" -> 📜 Texto a procesar: {current_chunk_text[:75]}...")
_log(f"{'='*50}\n")
return (current_chunk_text, safe_index, total_chunks, current_name, "\n".join(log_output))
@register_node
class AutoLoopCalculatorLTX:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_frame_count": ("INT", {"forceInput": True}),
"target_frames_per_loop": ("INT", {"default": 81, "min": 9, "max": 257, "step": 8}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
"current_loop_index": ("INT", {"forceInput": True}),
},
"optional": {
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "INT")
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Loop"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
if source_frame_count <= 0 or target_frames_per_loop <= 0:
return (1, 0, select_every_nth)
source_frame_count = min(source_frame_count, 10000)
select_every_nth = max(1, select_every_nth)
# --- Ajuste Proporcional LTX (Regla DiT 8n + 1) ---
import math
from . import loop
loop.global_step_by_chunk = False
potential_effective_frames = source_frame_count // select_every_nth
if potential_effective_frames < 9:
safe_effective_frames = 9
safe_effective_frames = ((potential_effective_frames + 6) // 8) * 8 + 1
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
physical_source_frame_count = potential_effective_frames * select_every_nth
loop.global_source_frame_count = physical_source_frame_count
effective_padding = safe_effective_frames - potential_effective_frames
# Ajuste proporcional
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
if estimated_loops > 0:
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
adjusted_target = ((optimal_target - 1 + 7) // 8) * 8 + 1
target_frames_per_loop = adjusted_target
print(f" -> ⚖️ Ajuste Proporcional LTX: Target recalculado a {adjusted_target} frames por ciclo.")
print(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
if effective_padding > 0:
print(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
print(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
loop.global_select_every_nth = select_every_nth
global global_ltx_mode
global_ltx_mode = True
current_pos = loop.global_accumulated_frames
print(f"\n{'='*50}")
print(f"📊 [DEBUG] NODO: Auto Loop Calculator (LTX 2.3)")
print(f" -> Timeline físico ajustado a LTX: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
if current_pos >= physical_source_frame_count:
return (1, current_pos, select_every_nth)
frames_left = physical_source_frame_count - current_pos
equitable_target = target_frames_per_loop * select_every_nth
ideal_cut = current_pos + equitable_target
if frames_left <= equitable_target:
best_cut = physical_source_frame_count
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
if scene_cuts_list and len(scene_cuts_list) > 0:
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
next_cut = min(future_cuts)
if next_cut <= ideal_cut:
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
safe_chunk_from_scene = ((chunk_from_scene + 6) // 8) * 8 + 1
if safe_chunk_from_scene < 9: safe_chunk_from_scene = 9
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
print(f" -> 🎬 Bucle aislado por escena (Ajustado x8). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
best_cut = ideal_cut
print(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
best_cut = ideal_cut
print(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
safe_chunk_from_face = ((chunk_from_face + 6) // 8) * 8 + 1
if safe_chunk_from_face < 9: safe_chunk_from_face = 9
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
print(f" -> ✂️ Corte Facial Inteligente (Ajustado x8): {best_cut} (Meta equitativa: {ideal_cut})")
best_cut = ideal_cut
print(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x8: {ideal_cut}")
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
effective_chunk_frames = ((effective_chunk_frames + 6) // 8) * 8 + 1
if effective_chunk_frames < 9:
effective_chunk_frames = 9
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
print(f" -> 🏁 Chunk final LTX detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 8n+1.")
loop.global_is_final_chunk = True
loop.global_is_final_chunk = False
skip_frames = current_pos
print(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
print(f"{'='*50}\n")
return (global_loop_index, )
return (effective_chunk_frames, skip_frames, select_every_nth)
@register_node
class SequentialLoopTrigger:
@@ -53,8 +338,6 @@ class SequentialLoopTrigger:
return {
"required": {
"trigger_dependency": ("*", ),
"target_loops": ("INT", {"default": 4, "min": 1, "max": 1000}),
"port": ("INT", {"default": 8188, "min": 1000, "max": 9999}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}
}
@@ -69,48 +352,462 @@ class SequentialLoopTrigger:
def IS_CHANGED(cls, **kwargs):
return time.time()
def trigger_next(self, trigger_dependency, target_loops, port, prompt=None, extra_pnginfo=None):
if trigger_dependency is None:
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Loop Trigger' no tiene nada conectado en 'trigger_dependency'. Debes conectar la salida del Stitcher para que el bucle pueda continuar.")
def trigger_next(self, trigger_dependency, prompt=None, extra_pnginfo=None):
global global_loop_index
global global_accumulated_frames
global global_source_frame_count
global global_server_port
next_loop = global_loop_index + 1
is_final_chunk = global_accumulated_frames >= global_source_frame_count
from . import loop
has_more_batches = getattr(loop, 'global_has_more_batches', False)
print(f"\n{'='*50}")
print(f"🎯 [DEBUG] NODO: Loop Trigger")
print(f" -> Ciclo terminado: {global_loop_index} | Target: {target_loops}")
print(f" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
if next_loop < target_loops:
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
# 💡 FIX: Reencolar si NO es el final del chunk, O si es el final pero hay más archivos
if not is_final_chunk or has_more_batches:
if prompt is not None:
estimated_chunk = 50
found_calculator = False
m_seeds = 0
master_switches = []
for node_id, node_data in prompt.items():
class_type = node_data.get("class_type", "")
inputs = node_data.get("inputs", {})
# Mutar Semillas
if not found_calculator and "AutoLoopCalculator" in class_type:
if "TTS" in class_type:
estimated_chunk = 1
estimated_chunk = inputs.get("target_frames_per_loop", 50)
found_calculator = True
for key in ["seed", "noise_seed"]:
if key in inputs and isinstance(inputs[key], (int, float)):
inputs[key] = random.randint(1, 0xffffffff)
inputs[key] = random.randint(1, 0x7fffffff)
m_seeds += 1
# 💥 INYECCIÓN ANTI-CACHÉ: Forzar a Loop Start a leer el nuevo índice
if node_data.get("class_type") == "SequentialLoopStart":
inputs["loop_idx"] = next_loop
inputs["reset_loop"] = False
print(f" -> 💉 Inyectado loop_idx={next_loop} en LoopStart (Nodo {node_id})")
print(f" -> 🎲 Semillas mutadas: {m_seeds}")
if class_type == "SequentialLoopStart":
if is_final_chunk and has_more_batches:
inputs["loop_idx"] = 0
inputs["reset_loop"] = True
else:
inputs["loop_idx"] = next_loop
inputs["reset_loop"] = False
if class_type == "MasterSwitch":
master_switches.append(inputs)
# 🛡️ Desempaquetado defensivo universal (Unwrapping de listas de ComfyUI)
_acc_frames = global_accumulated_frames[0] if isinstance(global_accumulated_frames, list) else global_accumulated_frames
_src_frames = global_source_frame_count[0] if isinstance(global_source_frame_count, list) else global_source_frame_count
_est_chunk = estimated_chunk[0] if isinstance(estimated_chunk, list) else estimated_chunk
# Asegurar casteo a enteros puros para evitar colisiones matemáticas en cualquier flujo
_acc_frames = int(_acc_frames) if _acc_frames is not None else 0
_src_frames = int(_src_frames) if _src_frames is not None else 0
_est_chunk = int(_est_chunk) if _est_chunk is not None else 0
is_next_final = (_acc_frames + _est_chunk) >= _src_frames
if is_final_chunk and has_more_batches:
is_next_final = False
for inputs in master_switches:
inputs["is_final_cycle"] = is_next_final
if is_final_chunk and has_more_batches:
print(f" -> 📦 Archivo finalizado. Iniciando siguiente archivo del lote...")
else:
print(f" -> ⚙️ Preparando Ciclo {next_loop}...")
p = {"prompt": prompt}
if extra_pnginfo:
p["extra_data"] = {"extra_pnginfo": extra_pnginfo}
if extra_pnginfo: p["extra_data"] = {"extra_pnginfo": extra_pnginfo}
data = json.dumps(p).encode('utf-8')
req = urllib.request.Request(f"http://127.0.0.1:{port}/prompt", data=data, headers={'Content-Type': 'application/json'})
req = urllib.request.Request(f"http://127.0.0.1:{global_server_port}/prompt", data=data, headers={'Content-Type': 'application/json'})
try:
urllib.request.urlopen(req, timeout=5)
print(f" -> ✅ Ciclo {next_loop} inyectado en la cola.")
print(f" -> ✅ Siguiente ciclo inyectado en la cola.")
except Exception as e:
print(f" -> ❌ Error HTTP: {e}")
else:
print(f" -> 🏁 ¡Generación finalizada! ({target_loops} ciclos)")
global_loop_index = 0
print(f" -> 🏁 ¡Generación Finalizada! Todos los archivos del lote completados.")
print(f"{'='*50}\n")
return ()
@register_node
class BatchAudioFolderLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"folder_path": ("STRING", {"forceInput": True, "tooltip": "Conectar al output folder_path de Telegram WaitForMultipleFiles"}),
}
}
RETURN_TYPES = ("AUDIO_LIST", "STRING_LIST", "INT", "STRING")
RETURN_NAMES = ("audio_list", "file_names", "file_count", "log")
FUNCTION = "load_audios"
CATEGORY = "🔁 Sequential Batcher/Audio"
def load_audios(self, folder_path):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
import os
import torch
from .video import extract_and_standardize_audio
_log(f"\n📂 [Batch Audio Loader] Escaneando lote en: {folder_path}")
if not folder_path or not os.path.exists(folder_path):
_log(" -> ❌ ERROR: Directorio nulo o inexistente. Devolviendo silencio.")
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
return ([silent], ["error_silence.ogg"], 0, "\n".join(log_output))
valid_extensions = ('.mp3', '.wav', '.ogg', '.flac', '.m4a')
files = sorted([f for f in os.listdir(folder_path) if f.lower().endswith(valid_extensions)])
if not files:
_log(" -> ⚠️ No se encontraron archivos de audio en el lote.")
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
return ([silent], ["empty_silence.ogg"], 0, "\n".join(log_output))
audio_list = []
file_names = []
for f in files:
path = os.path.join(folder_path, f)
try:
_log(f" -> 🎵 Cargando y estandarizando: {f}")
audio_dict = extract_and_standardize_audio(path)
audio_list.append(audio_dict)
file_names.append(f)
except Exception as e:
_log(f" -> ❌ Error procesando {f}: {e}")
if not audio_list:
silent = {"waveform": torch.zeros((1, 2, int(0.1 * 44100)), dtype=torch.float32), "sample_rate": 44100}
return ([silent], ["error_silence.ogg"], 0, "\n".join(log_output))
_log(f" -> ✅ Lote procesado: {len(audio_list)} audios listos en RAM.")
return (audio_list, file_names, len(audio_list), "\n".join(log_output))
@register_node
class AudioBatchSelector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio_list": ("AUDIO_LIST", {"forceInput": True}),
"file_names": ("STRING_LIST", {"forceInput": True}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
# 💡 AÑADIDOS LOS PINES PARA EL ÍNDICE DEL LOTE
RETURN_TYPES = ("AUDIO", "STRING", "INT", "INT", "STRING")
RETURN_NAMES = ("current_audio", "current_file_name", "batch_index", "total_batches", "log")
FUNCTION = "select"
CATEGORY = "🔁 Sequential Batcher/Audio"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def select(self, audio_list, file_names, current_loop_index):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
from . import loop
# Normalización de listas
audios = audio_list if isinstance(audio_list, list) else [audio_list]
names = file_names if isinstance(file_names, list) else [file_names]
if len(audios) == 1 and isinstance(audios[0], list): audios = audios[0]
if len(names) == 1 and isinstance(names[0], list): names = names[0]
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
# 🧠 FIX: El archivo lo dicta el índice de lote global, NO el índice de ciclo (que es para escenas)
current_batch_idx = getattr(loop, 'global_batch_index', 0)
if current_batch_idx >= len(audios):
current_batch_idx = len(audios) - 1
current_audio = audios[current_batch_idx]
current_name = names[current_batch_idx]
total_audios = len(audios)
loop.global_has_more_batches = (current_batch_idx < total_audios - 1)
_log(f"\n{'='*50}")
_log(f"🎛️ [Secuencial Batcher] NODO: Audio Batch Selector")
_log(f" -> Lote (Batch) Actual: {current_batch_idx + 1} de {total_audios}")
_log(f" -> Archivo activo: {current_name}")
_log(f"{'='*50}\n")
return (current_audio, current_name, current_batch_idx, total_audios, "\n".join(log_output))
import folder_paths
import math
@register_node
class DynamicSceneDirector:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"agent_json": ("STRING", {"multiline": True, "default": '{"scenes": [{"scene_id": 1, "duration_seconds": 5.0, "flux_prompt": "A cinematic wide shot...", "wan_prompt": "Camera pans..."}]}'}),
"audio_filename": ("STRING", {"default": "audio_track"}),
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
"fps": ("INT", {"default": 12, "min": 8, "max": 60}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
RETURN_NAMES = ("flux_prompt", "wan_prompt", "chunk_frames", "image_load_path", "project_json_path")
FUNCTION = "direct_scene"
CATEGORY = "🔁 Sequential Batcher/Director"
def direct_scene(self, agent_json, audio_filename, current_loop_index, fps):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
from . import loop
import os, folder_paths, json, math, re
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
_log(f"\n{'='*50}")
_log(f"🎬 [Director] Evaluando Ciclo: {idx}")
base_output = folder_paths.get_output_directory()
safe_audio_name = "".join(c for c in audio_filename if c.isalnum() or c in " _-").strip() or "proyecto"
plan_path = os.path.join(base_output, f"{safe_audio_name}_plan.json")
# 1. 🛡️ GESTIÓN DEL JSON (Ignora a Ollama a partir del ciclo 1)
if idx == 0:
try:
# Extracción robusta por si el LLM añade texto extra
json_match = re.search(r'\{.*\}', agent_json, re.DOTALL)
raw_json = json_match.group() if json_match else agent_json
data = json.loads(raw_json)
with open(plan_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
_log(f" -> 💾 Plan maestro guardado en: {plan_path}")
except Exception as e:
raise ValueError(f"❌ Error al interpretar el JSON del Agente: {e}")
else:
try:
with open(plan_path, 'r', encoding='utf-8') as f:
data = json.load(f)
_log(f" -> ♻️ Plan recuperado del disco (Ignorando entrada del Agente).")
except Exception as e:
raise ValueError(f"❌ Error al leer el JSON guardado en {plan_path}: {e}")
scenes = data.get("scenes", [])
total_scenes = len(scenes)
if total_scenes == 0: raise ValueError("❌ JSON sin escenas.")
# 2. ⏱️ MÁQUINA DE ESTADOS (1 Ciclo = 1 Escena)
# El director es un observador pasivo, scene_idx se deriva directamente de idx
scene_idx = idx
if scene_idx >= total_scenes:
scene_idx = total_scenes - 1
scene = scenes[scene_idx]
# 🧠 REGLA APLICADA: Este nodo actúa como "Calculator" del flujo JSON
loop.global_step_by_chunk = True
loop.global_source_frame_count = total_scenes
loop.global_accumulated_frames = scene_idx + 1
loop.global_is_final_chunk = (scene_idx + 1) >= total_scenes
loop.global_is_absolute_video_final = loop.global_is_final_chunk
keyframes_dir = os.path.join(base_output, f"{safe_audio_name}_Keyframes")
os.makedirs(keyframes_dir, exist_ok=True)
image_path = os.path.join(keyframes_dir, f"scene_{scene.get('scene_id', scene_idx)}.png")
flux_prompt = scene.get("flux_prompt", "")
wan_prompt = scene.get("wan_prompt", "")
duration = scene.get("duration_seconds", 5.0)
chunk_frames = math.ceil(duration * fps)
_log(f" -> 🎬 Procesando Escena {scene_idx + 1}/{total_scenes} ({duration}s)")
_log(f"{'='*50}\n")
return (flux_prompt, wan_prompt, chunk_frames, image_path, plan_path)
@register_node
class IncrementalVideoStitcher:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
"current_loop_index": ("INT", {"default": 0}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "BOOLEAN", "BOOLEAN", "STRING")
RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT", "IS_FINAL_CYCLE", "IS_ABSOLUTE_FINAL", "log")
FUNCTION = "stitch"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time; return time.time()
def stitch(self, images, audio, current_loop_index):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
from . import loop
import os, folder_paths, torch, shutil, time
from PIL import Image
import numpy as np
from concurrent.futures import ThreadPoolExecutor
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
cache_dir = os.path.join(folder_paths.get_temp_directory(), "meisoft_video_cache")
if idx == 0:
if os.path.exists(cache_dir): shutil.rmtree(cache_dir, ignore_errors=True)
os.makedirs(cache_dir, exist_ok=True)
_log(f"\n🧹 [Stitcher] Ciclo 0 detectado. Caché general limpiada.")
else:
os.makedirs(cache_dir, exist_ok=True)
timestamp = int(time.time() * 1000)
for i in range(images.shape[0]):
filename = f"frame_{idx:04d}_{timestamp}_{i:04d}.png"
path = os.path.join(cache_dir, filename)
img_array = 255. * images[i].cpu().numpy()
Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)).save(path, format="PNG")
if audio is not None:
audio_path = os.path.join(cache_dir, f"audio_{idx:04d}_{timestamp}.safetensors")
from safetensors.torch import save_file
save_file({"waveform": audio["waveform"].contiguous(), "sample_rate": torch.tensor(audio["sample_rate"], dtype=torch.int32)}, audio_path)
frames_accepted = images.shape[0]
stride = getattr(loop, 'global_select_every_nth', 1)
ltx_mode = getattr(loop, 'global_ltx_mode', False)
advanced_frames = max(1, (frames_accepted - 1) * stride) if ltx_mode else frames_accepted * stride
# 🧠 PROTECCIÓN: Solo el Stitcher suma frames si NO estamos en modo Chunk (Director/TTS)
is_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
if not is_chunk_mode:
loop.global_accumulated_frames += advanced_frames
loop.global_is_final_chunk = loop.global_accumulated_frames >= getattr(loop, 'global_source_frame_count', 1)
is_absolute_final = getattr(loop, 'global_is_absolute_video_final', False) and getattr(loop, 'global_is_final_chunk', False)
has_more_batches = getattr(loop, 'global_has_more_batches', False)
if is_absolute_final:
_log(f" -> 🏁 ¡Guion Completo! Ensamblando todas las escenas juntas...")
png_files = sorted([f for f in os.listdir(cache_dir) if f.endswith('.png')])
if not png_files: return (images, audio, True, True, "\n".join(log_output))
def load_and_process(filename):
img_np = np.array(Image.open(os.path.join(cache_dir, filename)).convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(img_np).unsqueeze(0)
with ThreadPoolExecutor() as executor:
tensors_list = list(executor.map(load_and_process, png_files))
final_tensor = torch.cat(tensors_list, dim=0)
audio_files = sorted([f for f in os.listdir(cache_dir) if f.startswith('audio_') and f.endswith('.safetensors')])
final_audio = None
if audio_files:
waveforms = []
sample_rate = 44100
from safetensors.torch import load_file
for af in audio_files:
chunk_audio = load_file(os.path.join(cache_dir, af))
waveforms.append(chunk_audio["waveform"])
sample_rate = chunk_audio["sample_rate"].item()
final_audio = {"waveform": torch.cat(waveforms, dim=-1), "sample_rate": sample_rate}
final_audio = audio
try: shutil.rmtree(cache_dir)
except: pass
return (final_tensor, final_audio, True, not has_more_batches, "\n".join(log_output))
# 💡 FIX: Retorno de seguridad para ciclos intermedios
# Devolvemos el último frame procesado para que el flujo no rompa
return (images[-1:].clone(), None, False, False, "\n".join(log_output))
@register_node
class ProjectPlanLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio_filename": ("STRING", {"forceInput": True, "tooltip": "Conectar al 'current_file_name' del Audio Batch Selector"}),
}
}
RETURN_TYPES = ("STRING", "INT", "STRING")
RETURN_NAMES = ("agent_json", "scene_count", "plan_path")
FUNCTION = "load_plan"
CATEGORY = "🔁 Sequential Batcher/Director"
@classmethod
def IS_CHANGED(cls, **kwargs):
# Queremos que siempre lea el archivo más reciente del disco
import time
return time.time()
def load_plan(self, audio_filename):
log_output = []
def _log(msg): print(msg); log_output.append(str(msg))
import os
import json
import folder_paths
_log(f"\n{'='*50}")
_log(f"📂 [Plan Loader] Buscando proyecto para: {audio_filename}")
# La misma lógica exacta del DynamicSceneDirector
base_output = folder_paths.get_output_directory()
safe_audio_name = "".join(c for c in audio_filename if c.isalnum() or c in " _-").strip() or "proyecto"
plan_path = os.path.join(base_output, f"{safe_audio_name}_plan.json")
if not os.path.exists(plan_path):
raise ValueError(f"❌ Error: No se encontró el plan de proyecto en {plan_path}. Asegúrate de que el Flujo 1 lo haya generado.")
with open(plan_path, 'r', encoding='utf-8') as f:
raw_json = f.read()
try:
data = json.loads(raw_json)
scenes = data.get("scenes", [])
scene_count = len(scenes)
except Exception as e:
raise ValueError(f"❌ Error al interpretar el JSON guardado en {plan_path}: {e}")
_log(f" -> ✅ Plan cargado con éxito. Escenas detectadas: {scene_count}")
_log(f"{'='*50}\n")
return (raw_json, scene_count, plan_path)
+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
+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},)
+798 -140
View File
@@ -1,169 +1,827 @@
import os
import glob
import shutil
import subprocess
import logging
from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stdout, redirect_stderr
from tqdm import tqdm
import comfy.utils
import math
import torch
import torchaudio
import folder_paths
import time
from safetensors.torch import save_file, load_file
import uuid
import subprocess
import re
from . import register_node
# Intento de carga de OpenCV para el Director de Fotografía
# Caché persistente para evitar re-escaneos pesados de vídeo
VIDEO_ANALYSIS_CACHE = {}
# 🎵 Helper para estandarizar audio a 44100Hz y 2 Canales (Estéreo)
def extract_and_standardize_audio(video_path, target_sr=44100):
import torchaudio.transforms as T
try:
waveform, sample_rate = torchaudio.load(video_path)
# 1. Resampling si la frecuencia es distinta
if sample_rate != target_sr:
resampler = T.Resample(orig_freq=sample_rate, new_freq=target_sr)
waveform = resampler(waveform)
sample_rate = target_sr
# 2. Downmix / Upmix a Estéreo (2 canales) estrictamente
channels = waveform.shape[0]
if channels > 2:
# Si tiene más de 2 canales (ej. 5.1), mezclamos a mono y duplicamos a estéreo
mono = torch.mean(waveform, dim=0, keepdim=True)
waveform = mono.repeat(2, 1)
elif channels == 1:
# Si es mono, duplicamos la pista para que sea estéreo
waveform = waveform.repeat(2, 1)
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
except Exception as e:
raise e
# 🎬 Helper para crear un Clon de Vídeo Físico Estandarizado (Soluciona el desvío VHS)
def standardize_video_file(video_path, _log):
import folder_paths
if "_std_audio.mp4" in video_path:
return video_path
filename = os.path.basename(video_path)
name, _ = os.path.splitext(filename)
out_filename = f"{name}_std_audio.mp4"
out_path = os.path.join(folder_paths.get_temp_directory(), out_filename)
if os.path.exists(out_path):
_log(f" -> ♻️ Usando clon de vídeo estandarizado existente en disco: {out_filename}")
return out_path
# Averiguar info original del audio
target_sr = None
target_ac = None
try:
info = torchaudio.info(video_path)
# Solo aplicamos limitador si se superan los umbrales máximos de ComfyUI
if info.sample_rate > 44100:
target_sr = "44100"
if info.num_channels > 2:
target_ac = "2"
except Exception as e:
_log(f" -> ⚠️ No se pudo leer metadata de audio con torchaudio. Forzando límites de seguridad. (Error: {e})")
target_sr = "44100"
target_ac = "2"
# Si el audio ya es seguro, no gastamos recursos en clonar
if target_sr is None and target_ac is None:
_log(f" -> ✅ El audio original ya está en parámetros seguros (<=44100Hz, <=2 Canales). Omitiendo FFmpeg.")
return video_path
_log(f" -> ⚙️ Reestructurando contenedor físico con FFmpeg (Vídeo: Copy | Audio: Limitando a parámetros seguros)...")
try:
cmd = [
"ffmpeg", "-y", "-i", video_path,
"-c:v", "copy",
"-c:a", "aac"
]
# Añadimos los filtros solo si son necesarios
if target_sr:
cmd.extend(["-ar", target_sr])
if target_ac:
cmd.extend(["-ac", target_ac])
cmd.extend(["-map", "0:v?", "-map", "0:a?", out_path])
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
_log(f" -> ✅ Contenedor clonado y blindado con éxito para los nodos de VHS.")
return out_path
except Exception as e:
raise Exception(f"❌ [Secuencial Batcher] Error crítico: Falló la reestructuración del vídeo. Asegúrate de tener FFmpeg instalado y accesible en el PATH del sistema. Detalles: {e}")
try:
import cv2
import numpy as np
HAS_OPENCV = True
except ImportError:
HAS_OPENCV = False
@register_node
class WanFrameValidator:
"""Valida y corrige el número de fotogramas para que encaje en la fórmula 4k+1 de Wan."""
class VideoAnalyzerFaceDetector:
@classmethod
def INPUT_TYPES(s):
def INPUT_TYPES(cls):
# 1. Obtenemos los archivos locales igual que VHS
input_dir = folder_paths.get_input_directory()
files = []
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov'] # Mismos que VHS
if os.path.exists(input_dir):
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split('.')
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
files.append(f)
# 2. El truco VHS: Una tupla con (lista_de_archivos,) pero configurado como STRING y con forceInput=False
return {
"required": {
"target_frames": ("INT", {"default": 49, "min": 1, "max": 10000, "step": 1}),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("valid_frames",)
FUNCTION = "validate"
CATEGORY = "🔁 Sequential Batcher/Video"
def validate(self, target_frames):
# 1. Aplicamos la fórmula para redondear a la baja al 4k+1 más cercano
k = (target_frames - 1) // 4
corrected_frames = (4 * k) + 1
# 2. Límite de seguridad por si ponen 0 o negativo
if corrected_frames < 1:
corrected_frames = 1
# 3. Chivato por consola
if corrected_frames != target_frames:
print(f"🛡️ [Wan Validator] Aviso: {target_frames} no es válido para Wan. Redondeado a la baja a -> {corrected_frames}")
else:
print(f"🛡️ [Wan Validator] Fotogramas perfectos: {corrected_frames}")
return (corrected_frames, )
session_video_list = []
@register_node
class IncrementalVideoStitcher:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"trigger": ("VHS_FILENAMES", ),
"output_prefix": ("STRING", {"default": "SCAIL_Final"}),
"current_loop_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
"use_face_detector": ("BOOLEAN", {"default": True}),
"blur_threshold": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 1000.0, "step": 1.0}),
"unload_detector_after_analysis": ("BOOLEAN", {"default": True}),
"unload_detector": ("BOOLEAN", {"default": True}),
},
"optional": {
"audio": ("AUDIO", ), # Nueva entrada de audio opcional
"bbox_detector": ("BBOX_DETECTOR", ), # 💡 Puerto para YOLO/ONNX
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
}
}
RETURN_TYPES = ("STRING", )
RETURN_NAMES = ("final_video_path", )
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "FACE_CUTS", "IMAGE", "STRING")
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "safe_faces_list", "reference_frame", "log")
OUTPUT_NODE = True
INPUT_IS_LIST = True
FUNCTION = "stitch_incremental"
FUNCTION = "analyze"
CATEGORY = "🔁 Sequential Batcher/Video"
def stitch_incremental(self, trigger, output_prefix, current_loop_index, audio=None):
if not trigger or trigger[0] is None:
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Incremental Auto-Stitcher' no recibe archivos de vídeo. Conecta la salida 'filenames' de tu Video Combine.")
@classmethod
def IS_CHANGED(cls, video, reference_frame_idx, use_face_detector, blur_threshold, **kwargs):
if isinstance(video, list):
video_str = "".join(video)
else:
video_str = str(video)
return f"{video_str}_{reference_frame_idx}_{use_face_detector}_{blur_threshold}"
if not current_loop_index or current_loop_index[0] is None:
raise ValueError("❌ ERROR CRÍTICO: El nodo 'Incremental Auto-Stitcher' necesita el cable de 'current_loop_index' para gestionar la sesión.")
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
# Bypass para permitir conexiones dinámicas (como descargas en curso)
return True
global session_video_list
def analyze(self, video, reference_frame_idx, use_face_detector, blur_threshold, unload_detector_after_analysis=True, bbox_detector=None, current_loop_index=0, **kwargs):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
# Resolución unificada de la ruta, tal como hace VHS
if os.path.exists(video):
video_path = video
else:
video_path = folder_paths.get_annotated_filepath(video)
out_dir = folder_paths.get_output_directory()
prefix = output_prefix[0] if isinstance(output_prefix, list) else output_prefix
loop_idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
# 🚀 CLONACIÓN FÍSICA PARA VHS
video_path = standardize_video_file(video_path, _log)
print(f"\n{'='*50}")
print(f"🎞️ [DEBUG] NODO: Incremental Auto-Stitcher")
print(f" -> Ciclo actual: {loop_idx} | Prefix: {prefix}")
_log(f"\n{'='*50}")
_log(f"🕵️ [Secuencial Batcher] NODO: Video Analyzer (Explorador)")
_log(f" -> Archivo resuelto: {video_path}")
if loop_idx == 0:
print(f" -> 🧹 Limpiando lista de vídeos de sesiones anteriores.")
session_video_list.clear()
cache_key = f"{video_path}_face"
current_mp4s = []
def extract_mp4s(data):
if isinstance(data, str) and data.endswith(".mp4"):
current_mp4s.append(data)
elif isinstance(data, (list, tuple)):
for item in data: extract_mp4s(item)
elif isinstance(data, dict):
for item in data.values(): extract_mp4s(item)
extract_mp4s(trigger)
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
_log(f"♻️ [Face Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
cached = VIDEO_ANALYSIS_CACHE[cache_key]
frame_count, source_fps, source_audio, safe_faces = cached
else:
# Análisis completo (Solo Ciclo 0 o primer arranque)
_log(f"🎬 [Face Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
source_audio = None
try:
source_audio = extract_and_standardize_audio(video_path)
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
except Exception as e:
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
for v in current_mp4s:
abs_path = v if os.path.isabs(v) else os.path.join(out_dir, v)
if abs_path not in session_video_list and os.path.exists(abs_path):
session_video_list.append(abs_path)
print(f" -> ➕ Añadido al ensamblaje: {os.path.basename(abs_path)}")
# 2. Escaneo de OpenCV (Frames, Rostros y Referencia)
frame_count = 0
source_fps = 0.0
safe_faces = []
print(f" -> 📦 Total de vídeos a ensamblar: {len(session_video_list)}")
if not session_video_list:
print(f"{'='*50}\n")
return ("", )
list_file_path = os.path.join(out_dir, "batch_concat_list.txt")
out_name = f"{prefix}_{loop_idx:04d}.mp4"
temp_concat = os.path.join(out_dir, f"temp_concat_video_{loop_idx:04d}.mp4")
final_output = os.path.join(out_dir, out_name)
try:
with open(list_file_path, 'w', encoding='utf-8') as f:
for video in session_video_list: f.write(f"file '{video}'\n")
# Paso 1: Ensamblar los vídeos de forma silenciosa
ffmpeg_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file_path, "-c", "copy", temp_concat]
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Paso 2: Procesar e inyectar el Audio (si existe)
has_audio = audio is not None and isinstance(audio, list) and len(audio) > 0 and audio[0] is not None
if has_audio:
print(f" -> 🎵 Audio detectado. Sincronizando y multiplexando...")
audio_data = audio[0]
waveform = audio_data.get("waveform")
sample_rate = audio_data.get("sample_rate", 44100)
if waveform is not None:
# ComfyUI a veces envía el tensor como [Batch, Canales, Muestras]. Lo pasamos a [Canales, Muestras]
if waveform.dim() == 3:
waveform = waveform.squeeze(0)
temp_audio_path = os.path.join(out_dir, f"temp_audio_{loop_idx:04d}.wav")
torchaudio.save(temp_audio_path, waveform, sample_rate)
# Multiplexamos usando -shortest para que el audio se corte donde termina el vídeo
ffmpeg_mux_cmd = [
"ffmpeg", "-y",
"-i", temp_concat,
"-i", temp_audio_path,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-shortest", final_output
]
subprocess.run(ffmpeg_mux_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if os.path.exists(temp_audio_path): os.remove(temp_audio_path)
if os.path.exists(temp_concat): os.remove(temp_concat)
print(f" -> ✅ Ensamblaje progresivo CON AUDIO exitoso: {out_name}")
else:
shutil.move(temp_concat, final_output)
print(f" -> ⚠️ Audio detectado pero sin waveform. Ensamblaje sin audio: {out_name}")
if not HAS_OPENCV:
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
else:
# Si no hay audio conectado, simplemente renombramos el archivo temporal
shutil.move(temp_concat, final_output)
print(f" -> ✅ Ensamblaje progresivo exitoso (Mudo): {out_name}")
cap = cv2.VideoCapture(video_path)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
_log(f" -> 🎞️ Total Frames detectados: {frame_count}")
_log(f" -> ⏱️ FPS detectados: {source_fps}")
except Exception as e:
print(f" -> ❌ Error FFmpeg: {e}")
finally:
if os.path.exists(list_file_path): os.remove(list_file_path)
# Escaneo de Rostros
if bbox_detector is not None or use_face_detector:
_log(f" -> 🤖 Iniciando escaneo de rostros (Umbral: {blur_threshold})...")
if bbox_detector is not None:
_log(f" -> ⚡ Usando detector de rostros por GPU (YOLO/ONNX).")
else:
_log(f" -> 🐢 Usando detector de rostros por CPU (OpenCV).")
print(f"{'='*50}\n")
return (final_output, )
if HAS_OPENCV:
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path)
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Volver al inicio
_log(f" -> 🤖 Iniciando escaneo de rostros por GPU...")
# Silenciamos el logger de ultralytics antes de empezar
logging.getLogger("ultralytics").setLevel(logging.ERROR)
# Barra de progreso profesional (desc=Descripción, unit=unidad, leave=True para que no desaparezca)
pbar = tqdm(total=frame_count, desc="🔍 Analizando Rostros", unit="frame", dynamic_ncols=True)
comfy_pbar = comfy.utils.ProgressBar(frame_count)
for idx in range(frame_count):
ret, frame = cap.read()
if not ret: break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
faces_found = False
if variance > blur_threshold:
if bbox_detector is not None:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img_tensor = torch.from_numpy(frame_rgb.astype(np.float32) / 255.0).unsqueeze(0).cpu()
try:
# 🤐 CÁPSULA DE VACÍO: Silenciamos TODO el ruido de la consola
with open(os.devnull, 'w') as fnull:
with redirect_stdout(fnull), redirect_stderr(fnull):
# Inferencia
res = bbox_detector.detect(img_tensor, 0.5, 10, 1.0, 10)
if isinstance(res, tuple):
segs = res[0]
else:
segs = res
if isinstance(segs, tuple) and len(segs) > 1 and isinstance(segs[1], list):
faces_found = len(segs[1]) > 0
elif isinstance(segs, list):
faces_found = len(segs) > 0
elif hasattr(segs, '__len__'):
faces_found = len(segs) > 0
except Exception as e:
_log(f" ❌ ERROR BBOX en frame {idx}: {e}")
else:
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
faces_found = len(faces) > 0
if faces_found:
safe_faces.append(idx)
# _log(f" ✨ Rostro nítido detectado en frame [{idx}] (Varianza: {variance:.2f})")
pbar.update(1)
comfy_pbar.update(1)
pbar.close()
_log(f" -> ✅ Encontrados {len(safe_faces)} frames válidos con rostros.")
cap.release()
# Guardar en caché
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, safe_faces)
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
ui_result = {}
if HAS_OPENCV:
cap = cv2.VideoCapture(video_path)
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1))
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
ret, frame = cap.read()
if ret:
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
ref_tensor = ref_tensor.unsqueeze(0)
_log(f" -> 🖼️ Frame de referencia extraído (Índice: {safe_ref_idx})")
from PIL import Image
preview_dir = folder_paths.get_temp_directory()
preview_name = f"preview_ref_{uuid.uuid4().hex[:5]}.png"
Image.fromarray(ref_frame_rgb).save(os.path.join(preview_dir, preview_name))
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
else:
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
cap.release()
else:
ref_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
# Liberar el detector de VRAM si se solicita y si se usó
if unload_detector_after_analysis and bbox_detector is not None and getattr(kwargs, 'unload_detector', True):
_log(f" -> 🧹 Limpiando BBOX_DETECTOR de la VRAM para ahorrar memoria...")
import gc
import comfy.model_management as mm
# Romper referencias
del bbox_detector
bbox_detector = None
gc.collect()
torch.cuda.empty_cache()
mm.soft_empty_cache()
_log(f"{'='*50}\n")
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, safe_faces, ref_tensor, "\n".join(log_output))}
@register_node
class VideoAnalyzerSceneDetector:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = []
if os.path.exists(input_dir):
files = [f for f in os.listdir(input_dir) if f.split('.')[-1].lower() in ['webm', 'mp4', 'mkv', 'gif', 'mov']]
return {
"required": {
"video": (sorted(files), {"forceInput": False, "video_upload": True}),
"reference_frame_idx": ("INT", {"default": 0, "min": 0, "max": 100000}),
"scene_threshold": ("FLOAT", {"default": 25.0, "min": 5.0, "max": 150.0}),
},
"optional": {
"current_loop_index": ("INT", {"default": 0, "forceInput": True}),
}
}
RETURN_TYPES = ("*", "INT", "FLOAT", "AUDIO", "SCENE_CUTS", "IMAGE", "STRING")
RETURN_NAMES = ("video_path", "total_frames", "source_fps", "source_audio", "scene_cuts_list", "reference_frame", "log")
OUTPUT_NODE = True
FUNCTION = "analyze"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, video, reference_frame_idx, scene_threshold, **kwargs):
if isinstance(video, list):
video_str = "".join(video)
else:
video_str = str(video)
return f"{video_str}_{reference_frame_idx}_{scene_threshold}"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def analyze(self, video, reference_frame_idx, scene_threshold, current_loop_index=0, **kwargs):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
video_path = video if os.path.exists(video) else folder_paths.get_annotated_filepath(video)
# 🚀 CLONACIÓN FÍSICA PARA VHS
video_path = standardize_video_file(video_path, _log)
cache_key = f"{video_path}_scene"
# --- LÓGICA DE CACHÉ / RECUPERACIÓN ---
if current_loop_index > 0 and cache_key in VIDEO_ANALYSIS_CACHE:
_log(f"♻️ [Scene Detector] Ciclo {current_loop_index}: Recuperando análisis del caché.")
cached = VIDEO_ANALYSIS_CACHE[cache_key]
frame_count, source_fps, source_audio, scene_cuts = cached
else:
# Análisis completo (Solo Ciclo 0 o primer arranque)
_log(f"🎬 [Scene Detector] Ciclo {current_loop_index}: Iniciando análisis profundo...")
frame_count = 0
source_fps = 0.0
source_audio = None
scene_cuts = []
if HAS_OPENCV:
cap = cv2.VideoCapture(video_path)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
source_fps = float(cap.get(cv2.CAP_PROP_FPS))
# 1. Extracción de Audio Íntegro (Estandarizado a 44100Hz Estéreo)
source_audio = None
try:
source_audio = extract_and_standardize_audio(video_path)
_log(f" -> 🎵 Audio extraído y estandarizado correctamente (44100Hz, Estéreo)")
except Exception as e:
_log(f" -> ⚠️ Sin audio o error al extraer: {e}")
# Detección de cortes
_log(f" -> 🎬 Iniciando escaneo de escenas (Umbral Diferencia: {scene_threshold})...")
prev_gray = None
pbar = tqdm(total=frame_count, desc="🎬 Analizando Escenas")
comfy_pbar = comfy.utils.ProgressBar(frame_count)
for idx in range(frame_count):
ret, frame = cap.read()
if not ret: break
small = cv2.resize(frame, (128, 128))
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
if prev_gray is not None:
score = np.mean(cv2.absdiff(gray, prev_gray))
if score > scene_threshold:
scene_cuts.append(idx)
# _log(f" 🎞️ Corte de escena detectado en frame [{idx}] (Puntuación: {score:.2f})")
prev_gray = gray
pbar.update(1)
comfy_pbar.update(1)
pbar.close()
cap.release()
_log(f" -> ✅ Encontrados {len(scene_cuts)} cambios de escena fuertes.")
else:
_log(" -> ❌ ERROR: OpenCV no está instalado. Ejecuta: pip install opencv-python")
# Guardar en caché
VIDEO_ANALYSIS_CACHE[cache_key] = (frame_count, source_fps, source_audio, scene_cuts)
# --- EXTRACCIÓN DINÁMICA DEL FRAME DE REFERENCIA (Siempre se ejecuta) ---
ui_result = {}
if HAS_OPENCV:
cap = cv2.VideoCapture(video_path)
safe_ref_idx = min(reference_frame_idx, max(0, frame_count - 1)) if frame_count > 0 else 0
cap.set(cv2.CAP_PROP_POS_FRAMES, safe_ref_idx)
ret, frame = cap.read()
if ret:
ref_frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
ref_tensor = torch.from_numpy(ref_frame_rgb).float() / 255.0
ref_tensor = ref_tensor.unsqueeze(0)
from PIL import Image
preview_name = f"preview_scene_{uuid.uuid4().hex[:5]}.png"
Image.fromarray(ref_frame_rgb).save(os.path.join(folder_paths.get_temp_directory(), preview_name))
ui_result = {"images": [{"filename": preview_name, "subfolder": "", "type": "temp"}]}
else:
ref_tensor = torch.zeros((1, 64, 64, 3))
cap.release()
else:
ref_tensor = torch.zeros((1, 64, 64, 3))
_log(f"{'='*50}\n")
return {"ui": ui_result, "result": (video_path, frame_count, source_fps, source_audio, scene_cuts, ref_tensor, "\n".join(log_output))}
@register_node
class AutoLoopCalculator:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_frame_count": ("INT", {"forceInput": True}),
"target_frames_per_loop": ("INT", {"default": 50, "min": 1, "max": 10000}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
"current_loop_index": ("INT", {"forceInput": True}),
},
"optional": {
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
if source_frame_count <= 0 or target_frames_per_loop <= 0:
_log("⚠️ [WARNING] Invalid frame count or target frames per loop.")
return (1, 0, select_every_nth, "\n".join(log_output))
source_frame_count = min(source_frame_count, 10000)
select_every_nth = max(1, select_every_nth)
# --- Ajuste Proporcional Base (Sin restricciones VAE) ---
import math
from . import loop
loop.global_step_by_chunk = False
potential_effective_frames = source_frame_count // select_every_nth
safe_effective_frames = potential_effective_frames # Sin restricciones
safe_source_frame_count = safe_effective_frames * select_every_nth
loop.global_source_frame_count = safe_source_frame_count
effective_loss = potential_effective_frames - safe_effective_frames
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
if estimated_loops > 0:
target_frames_per_loop = math.ceil(safe_effective_frames / estimated_loops)
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado a {target_frames_per_loop} frames por ciclo.")
# --- LOGS MEJORADOS Y TRANSPARENTES ---
_log(f" -> 🎞️ Capacidad del video: {potential_effective_frames} frames procesables (Nth: {select_every_nth})")
if effective_loss > 0:
_log(f" -> 🛡️ Ajuste VAE: Se usarán {safe_effective_frames} frames (Descarte técnico: {effective_loss} frame/s de proceso)")
else:
_log(f" -> ✅ Ajuste VAE: Perfecto. Múltiplo de 4 detectado.") # Manteniendo el mismo string por ahora o podemos omitirlo, lo adapto a que es genérico
_log(f" -> 📊 Timeline final: 0 a {safe_source_frame_count} (de {source_frame_count} totales)")
# -------------------------------------------------------------
loop.global_select_every_nth = select_every_nth
current_pos = loop.global_accumulated_frames
_log(f"\n{'='*50}")
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (Motor Dinámico)")
_log(f" -> Posición en el timeline original: {current_pos} / {source_frame_count}")
if current_pos >= safe_source_frame_count:
return (1, current_pos, select_every_nth, "\n".join(log_output))
frames_left = safe_source_frame_count - current_pos
equitable_target = target_frames_per_loop * select_every_nth
ideal_cut = current_pos + equitable_target
if frames_left <= equitable_target:
best_cut = safe_source_frame_count
_log(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
else:
if scene_cuts_list and len(scene_cuts_list) > 0:
# 🎬 LÓGICA DE ESCENAS ESTRICTA (1 Escena = 1 Bucle)
# Buscar el PRIMER corte que venga inmediatamente después de la posición actual
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
next_cut = min(future_cuts)
# Verificamos si esta escena entera cabe en la VRAM
if next_cut <= ideal_cut:
best_cut = next_cut
_log(f" -> 🎬 Bucle aislado por escena. Cortando exacto en el frame: {best_cut}")
else:
best_cut = ideal_cut
_log(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
else:
best_cut = ideal_cut
_log(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
# 👤 LÓGICA DE ROSTROS (Fallback si no hay escenas conectadas)
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
best_cut = closest_face
_log(f" -> ✂️ Corte Facial Inteligente: {best_cut} (Meta: {ideal_cut})")
else:
best_cut = ideal_cut
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo: {ideal_cut}")
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
if current_pos + (effective_chunk_frames * select_every_nth) > safe_source_frame_count:
effective_chunk_frames = (safe_source_frame_count - current_pos) // select_every_nth
skip_frames = current_pos
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos (Saltando {skip_frames})")
_log(f"{'='*50}\n")
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
@register_node
class AutoLoopCalculatorTTS:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"multiline": True, "dynamicPrompts": False}),
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING", "INT", "INT", "STRING")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, text, split_mode, current_loop_index):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
loop.global_step_by_chunk = True
# 1. Separar el texto según el modo elegido
if split_mode == "Frases (Puntos)":
# PASO A: Separación cruda respetando todos los signos
matches = re.findall(r'[^.!?\n]+[.!?\n]*', text)
temp_chunks = [m.strip() for m in matches if m.strip()]
# PASO B: Fusión inteligente (Evitar micro-frases y roturas por "...")
MIN_WORDS = 5
raw_chunks = []
buffer_text = ""
for chunk in temp_chunks:
buffer_text = (buffer_text + " " + chunk).strip()
# Contamos las palabras reales acumuladas en el buffer
word_count = len(buffer_text.split())
# Si el bloque ya tiene suficiente "cuerpo", lo cerramos
if word_count >= MIN_WORDS:
raw_chunks.append(buffer_text)
buffer_text = ""
# PASO C: Si quedó texto corto huérfano al final, lo unimos al bloque anterior
if buffer_text:
if raw_chunks:
raw_chunks[-1] += " " + buffer_text
else:
raw_chunks.append(buffer_text)
chunk_type_name = "Frases Optimizadas"
else:
# Comportamiento original: Dividir por párrafos
raw_chunks = [p.strip() for p in text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
total_chunks = len(raw_chunks)
if total_chunks == 0:
raw_chunks = [""]
total_chunks = 1
# 2. Seguridad de índice
safe_index = min(current_loop_index, total_chunks - 1)
current_text = raw_chunks[safe_index]
# 3. 🧠 HACK CORE: Inyectamos los bloques como si fueran frames
loop.global_source_frame_count = total_chunks
loop.global_accumulated_frames = safe_index + 1
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS)")
_log(f" -> Modo de división: {split_mode}")
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
_log(f" -> 📜 Texto a procesar: {current_text[:75]}...")
_log(f"{'='*50}\n")
return (current_text, safe_index, total_chunks, "\n".join(log_output))
@register_node
class AutoLoopCalculatorWan:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_frame_count": ("INT", {"forceInput": True}),
"target_frames_per_loop": ("INT", {"default": 48, "min": 4, "max": 10000, "step": 4}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}),
"current_loop_index": ("INT", {"forceInput": True}),
},
"optional": {
"safe_faces_list": ("FACE_CUTS", {"forceInput": True}),
"scene_cuts_list": ("SCENE_CUTS", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "STRING")
RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
if source_frame_count <= 0 or target_frames_per_loop <= 0:
_log("⚠️ [WARNING] Invalid frame count or target frames per loop.")
return (1, 0, select_every_nth, "\n".join(log_output))
source_frame_count = min(source_frame_count, 10000)
select_every_nth = max(1, select_every_nth)
import math
from . import loop
loop.global_step_by_chunk = False
# --- MEISOFT PATCH: Sincronización Matemática (Hold Last Frame) ---
# 1. Redondear hacia ARRIBA a la regla 4n+1 (Acolchado WanVideo)
potential_effective_frames = source_frame_count // select_every_nth
if potential_effective_frames < 5:
safe_effective_frames = 5
else:
safe_effective_frames = ((potential_effective_frames + 2) // 4) * 4 + 1
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
physical_source_frame_count = potential_effective_frames * select_every_nth
loop.global_source_frame_count = physical_source_frame_count
effective_padding = safe_effective_frames - potential_effective_frames
loop.global_select_every_nth = select_every_nth
# 2. Ajuste Proporcional Real (Evitar micro-ciclos al final)
estimated_loops = math.ceil(safe_effective_frames / target_frames_per_loop)
if estimated_loops > 0:
optimal_target = math.ceil(safe_effective_frames / estimated_loops)
adjusted_target = ((optimal_target + 2) // 4) * 4 + 1
_log(f" -> ⚖️ Ajuste Proporcional: Target recalculado de {target_frames_per_loop} a {adjusted_target} frames por ciclo (para {estimated_loops} ciclos)")
target_frames_per_loop = adjusted_target
_log(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
if effective_padding > 0:
_log(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
else:
_log(f" -> ✅ Ajuste VAE: Perfecto. Regla 4n+1 detectada.")
_log(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
current_pos = getattr(loop, 'global_accumulated_frames', 0)
_log(f"\n{'='*50}")
_log(f"📊 [Secuencial Batcher] NODO: Auto Loop Calculator (WanVideo 3D VAE)")
_log(f" -> Timeline físico ajustado: {current_pos} / {physical_source_frame_count} (Original: {source_frame_count})")
# Prevención de desbordamiento de bucle (Usando límite físico)
if current_pos >= physical_source_frame_count:
return (4, current_pos, select_every_nth, "\n".join(log_output))
frames_left = physical_source_frame_count - current_pos
equitable_target = target_frames_per_loop * select_every_nth
ideal_cut = current_pos + equitable_target
if frames_left <= equitable_target:
best_cut = physical_source_frame_count
_log(f" -> 🧮 Absorbiendo resto final seguro: meta fijada en frame {best_cut}")
else:
if scene_cuts_list and len(scene_cuts_list) > 0:
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
next_cut = min(future_cuts)
if next_cut <= ideal_cut:
chunk_from_scene = math.ceil((next_cut - current_pos) / select_every_nth)
safe_chunk_from_scene = (chunk_from_scene // 4) * 4
if safe_chunk_from_scene < 4: safe_chunk_from_scene = 4
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
_log(f" -> 🎬 Bucle aislado por escena (Ajustado x4). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
else:
best_cut = ideal_cut
_log(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
else:
best_cut = ideal_cut
_log(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
closest_face = min(safe_faces_list, key=lambda x: abs(x - ideal_cut))
chunk_from_face = math.ceil((closest_face - current_pos) / select_every_nth)
safe_chunk_from_face = (chunk_from_face // 4) * 4
if safe_chunk_from_face < 4: safe_chunk_from_face = 4
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
_log(f" -> ✂️ Corte Facial Inteligente (Ajustado x4): {best_cut} (Cara original detectada: {closest_face})")
else:
best_cut = ideal_cut
_log(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x4: {ideal_cut}")
# 4. Cálculo final del chunk consolidado a regla 4n+1
effective_chunk_frames = math.ceil((best_cut - current_pos) / select_every_nth)
effective_chunk_frames = ((effective_chunk_frames + 2) // 4) * 4 + 1
if effective_chunk_frames < 5:
effective_chunk_frames = 5
# Comprobación contra el límite físico para evitar pedir más allá del final
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
_log(f" -> 🏁 Chunk final detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 4n+1.")
loop.global_is_final_chunk = True
else:
loop.global_is_final_chunk = False
skip_frames = current_pos
_log(f" -> 🚀 Ciclo {current_loop_index}: Solicitando {effective_chunk_frames} frames efectivos a VHS (Saltando {skip_frames})")
_log(f"{'='*50}\n")
return (effective_chunk_frames, skip_frames, select_every_nth, "\n".join(log_output))
+118
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))