Compare commits

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

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

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

The brackets were removed from the `return` statement so the node now properly
outputs the exact primitive values.
2026-05-23 13:45:37 +00:00
Meisoft dc3f4c2c07 Merge pull request #129 from Meisoftcoltd/fix-tts-batch-output-unwrapping-5285393574070216027
Remove OUTPUT_IS_LIST from AutoLoopCalculatorTTSBatch to allow ComfyUI unwrap
2026-05-23 15:13:40 +02:00
Meisoftcoltd 35075fa357 fix(loop.py): Remove OUTPUT_IS_LIST to enable automatic unwrap in ComfyUI
The `OUTPUT_IS_LIST = (True, True, True, True, True)` assignment in `AutoLoopCalculatorTTSBatch` was preventing ComfyUI from automatically unwrapping 1-item lists, causing downstream nodes to crash with TypeErrors (expecting raw strings/integers but getting lists). By removing this attribute while keeping `INPUT_IS_LIST = True`, ComfyUI can now safely process inputs as batches while unwrapping and forwarding primitive values to the next nodes.
2026-05-23 13:13:27 +00:00
Meisoft 59f0be86ba Merge pull request #128 from Meisoftcoltd/fix-tts-batch-parallel-execution-5012071892428602725
Fix AutoLoopCalculatorTTSBatch parallel execution with list inputs
2026-05-23 14:28:54 +02:00
Meisoftcoltd f48f69ef43 Fix AutoLoopCalculatorTTSBatch parallel execution with list inputs
Restored INPUT_IS_LIST = True to prevent ComfyUI from mapping the node
in parallel when given multiple files, and added OUTPUT_IS_LIST with
five values to properly unpack outputs to the downstream nodes. Also
ensured that the return statement yields a tuple of lists.
2026-05-23 12:04:12 +00:00
Meisoft e449cdf0d1 Merge pull request #127 from Meisoftcoltd/feature/loop-calculator-tts-batch-update-15309557865844476534
Update AutoLoopCalculatorTTSBatch inputs and cleanup tests
2026-05-23 01:23:01 +02:00
Meisoftcoltd 5e2d368379 feat: update AutoLoopCalculatorTTSBatch to support multiple files
- Added `file_names` to INPUT_TYPES and `current_file_name` to RETURN_TYPES.
- Removed `INPUT_IS_LIST = True` to avoid double nesting in ComfyUI list processing.
- Cleaned up temp files (`test_loop_syntax.py`, test `.sh` scripts).
- Verified `BatchTextFileReader` does not exist in the repository.
2026-05-22 23:19:34 +00:00
Meisoft c1bc9b375a Merge pull request #126 from Meisoftcoltd/fix-import-loop-8733227099235388804
Fix absolute import in `loop.py`
2026-05-22 23:36:03 +02:00
Meisoftcoltd 1d9d3356fb Fix absolute import in loop.py 2026-05-22 21:34:27 +00:00
Meisoft 1bd0792655 Merge pull request #125 from Meisoftcoltd/fix-tts-batch-type-6614993364835646221
Fix AutoLoopCalculatorTTSBatch type bug
2026-05-22 22:57:09 +02:00
Meisoftcoltd fc547c5c18 Fix AutoLoopCalculatorTTSBatch data type bug for downstream TTS nodes
- Removed `INPUT_IS_LIST = True` to prevent ComfyUI from implicitly converting input values to lists and expecting lists as outputs.
- Refactored `calculate` to handle list inputs natively using `isinstance` while correctly interacting with the standard Text Reader's native Python lists.
- Changed the return signature to emit native primitive values (`str`, `int`) instead of wrapping them in lists, correctly accommodating downstream integration with TTS models (e.g. Fish Speech) that expect pure strings.
2026-05-22 20:45:25 +00:00
Meisoft 19c8d8ea51 Merge pull request #124 from Meisoftcoltd/fix-tts-double-wrapping-8736248982233625434
Fix: Unpack double-wrapped list in AutoLoopCalculatorTTSBatch
2026-05-22 21:31:47 +02:00
Meisoftcoltd 80a0ea3eb1 fix: handle double-wrapped list in AutoLoopCalculatorTTSBatch
ComfyUI's `INPUT_IS_LIST = True` behavior wraps incoming data in a list. When an upstream node (like `BatchTextFileReader`) already returns a list, ComfyUI wraps it again, creating a list of lists (e.g., `[["file1", "file2"]]`).

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

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

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

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

The refactored implementation uses a pure map function to return individual
tensors and concatenates them sequentially using `torch.cat`, remaining thread-safe
and compatible with PyTorch's `InferenceMode`. Security against deserialization
vulnerabilities is preserved by ensuring `weights_only=True` remains when loading
audio caches.
2026-05-02 21:55:05 +00:00
Meisoft b7e1fb6f89 Merge pull request #105 from Meisoftcoltd/test-video-audio-error-handling-369389381641730631
🧪 Add error handling and coverage tests for audio extraction
2026-05-02 22:46:31 +02:00
Meisoftcoltd ba6fc7b8fc 🧪 Add error handling and coverage tests for audio extraction
- Implemented unit tests for `extract_and_standardize_audio` in `video.py`.
- Added test case to verify exception handling when `torchaudio.load` fails.
- Expanded coverage to include resampling and channel standardization (mono/multichannel to stereo).
- Followed the repository's clean testing pattern for mocking dependencies and loading code under test.
2026-05-02 20:46:16 +00:00
Meisoft 41c2d7d34c Merge pull request #104 from Meisoftcoltd/performance-loop-optimization-4925127687414595231
⚡ Performance Optimization: Merge sequential loops in prompt iteration
2026-05-02 22:45:54 +02:00
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
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
15 changed files with 1083 additions and 495 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'Meisoftcoltd' }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
submodules: true
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+39 -25
View File
@@ -1,5 +1,5 @@
# ♾️ ComfyUI Sequential Batcher
**v1.6.0** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada para sortear los límites extremos de VRAM en la generación de vídeo pesado (WanVideo, Hunyuan, LTX) mediante **Auto-Encolado Recursivo**, procesamiento autónomo lote por lote y gestión de memoria forense.
@@ -7,12 +7,6 @@ Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada p
---
## 🌟 Lo Nuevo en v1.6.0 (Perfect Sync Update)
* **🛡️ VAE Safe Frame Padder (Hold Last Frame):** El motor ahora expande dinámicamente los tensores incompletos al final del vídeo. Si faltan frames para cumplir los estrictos requisitos de WanVideo (regla de `4n + 1`) o LTX (8n+1), clona el último fotograma de forma imperceptible. **Resultado: Cero cuelgues del VAE y 100% de sincronización de audio sin micro-cortes.**
* **📈 Inversión Matemática a "Expansión":** Los calculadores `AutoLoopCalculatorWan` y `AutoLoopCalculatorLTX` ahora redondean el timeline de forma segura hacia arriba, garantizando fluidez total en ciclos intermedios.
---
## ✨ Características Clave
* **Orquestación Autónoma:** Convierte tu ComfyUI en un motor de renderizado continuo. Pulsa "Queue Prompt" una sola vez y el flujo se retroalimentará modificando semillas e índices hasta terminar todo el vídeo.
@@ -25,7 +19,7 @@ Una suite de grado profesional de nodos personalizados para ComfyUI. Diseñada p
## ⚠️ Requisitos del Sistema (Crítico)
Para que el nodo `VideoAnalyzerWithAudio` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
Para que el nodo `VideoAnalyzerFaceDetector` pueda desempaquetar contenedores `.mp4` y extraer el audio con `torchaudio`, **FFmpeg es absolutamente obligatorio**.
* **🐧 Ubuntu / Linux / WSL2:**
El script intentará instalarlo por ti. Si falla, ejecuta manualmente:
@@ -43,16 +37,43 @@ El argumento `--highvram` le da la orden estricta a ComfyUI de mantener los mode
---
## 🧠 La Arquitectura Híbrida (Desglose de Nodos)
El procesamiento se divide en roles altamente especializados:
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV o mediante un modelo YOLO acelerado por GPU. Extrae rostros, el audio y el Frame de Referencia.
* **📊 Auto Loop Calculator (Base / WanVideo / LTX):** Recibe la biometría y calcula las coordenadas de corte (chunk_frames, skip_frames).
* **🛡️ VAE Safe Frame Padder:** Intercepta el tensor final de VHS y lo rellena ("acolcha") clonando el último frame si falta material, salvando al VAE de un colapso.
* **🎞️ Incremental Auto-Stitcher:** Recoge los lotes renderizados y la pista de audio, cosiendo el vídeo final de forma progresiva en la carpeta temporal.
* **📥 Receiver & 📤 Sender:** Retienen y transfieren el último keyframe válido a través de los ciclos para mantener la coherencia temporal.
* **⏱️ Auto FPS Limiter:** Reduce los FPS inteligentemente garantizando que el audio y el movimiento mantengan una sincronización perfecta sin romper la VRAM.
* **🔀 Master Switch (Evaluación Perezosa):** Amputa físicamente los cables de rutas inactivas en el JSON enviado a ComfyUI. Los nodos pesados que no se necesitan en un ciclo específico ni siquiera se cargan en memoria.
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al Garbage Collector entre ciclos pesados.
El procesamiento se divide en roles altamente especializados, organizados en las siguientes categorías:
### 🎼 Orquestación
* **🏁 Loop Start (Index):** Inicia la secuencia de bucle y mantiene el índice actual.
* **🚀 Loop Trigger (Auto-Queue):** Dispara la siguiente iteración de ComfyUI de manera autónoma hasta que se completa la tarea.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** Máquina de estados para orquestar metadatos (prompts, duraciones) en la preproducción (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Guarda y carga los frames clave de la escena para mantener la continuidad entre flujos separados.
### 📦 Lotes (Batches)
* **📂 Batch Audio Folder Loader:** Carga dinámicamente carpetas enteras de archivos (ej. lotes de audio).
* **🎛️ Audio Batch Selector:** Despacha los archivos del lote uno por uno sincronizándose con el bucle.
### 🔬 Análisis y Cálculos
* **🕵️ Video Analyzer Face detector + Audio:** Escanea el vídeo vía OpenCV/YOLO, extrayendo rostros, audio y frame de referencia.
* **🎬 Video Analyzer Scene detector:** Detecta cortes de escena en los vídeos.
* **📊 Auto Loop Calculators:** Nodos especializados (`Base`, `WanVideo`, `LTX`, `TTS`) que reciben metadatos y calculan matemáticamente las coordenadas de corte exactas (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Ensambla progresivamente los fragmentos de video renderizados en un único archivo.
* **🛡️ VAE Safe Frame Padder:** Rellena ("acolcha") tensores incompletos clonando el último frame para salvar al VAE de un colapso en modelos restrictivos (ej. reglas 4n+1).
* **💉 LTXV Single Frame Injector:** Inyecta frames individuales específicos requeridos por flujos de LTX.
* **⏱️ Auto FPS Limiter:** Limita de manera inteligente los cuadros por segundo para ahorrar VRAM manteniendo sincronía de audio.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Corta el audio de forma precisa (a nivel de muestra matemática) en base a los frames de vídeo correspondientes.
* **🎛️ Conditional Audio Router (Bypass):** Enruta el audio condicionalmente permitiendo saltar su procesamiento si no es necesario.
### 📐 Herramientas de Resolución
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculan resoluciones óptimas basadas en límites de VRAM y las reglas geométricas (multiplicadores) de diferentes modelos (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Gestión de Memoria / Lógica
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Evaluación perezosa nativa; amputan rutas inactivas para que los nodos pesados ni siquiera se carguen en memoria.
* **🧹 VRAM Defragmenter:** Purga forense de memoria (Secuencia Sagrada) que limpia la caché de CUDA/MPS y fuerza al recolector de basura (GC) de Python entre ciclos.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retienen y transfieren imágenes en memoria a través de diferentes ciclos.
---
@@ -69,15 +90,8 @@ El procesamiento se divide en roles altamente especializados:
---
## 🚀 Aceleración por GPU (Escaneo de Rostros)
El `VideoAnalyzerWithAudio` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
El `VideoAnalyzerFaceDetector` tiene un puerto `bbox_detector` para delegar el escaneo facial a la tarjeta gráfica:
1. Instala el *Impact Pack*.
2. Añade el nodo `UltralyticsDetectorProvider` y selecciona un modelo de rostros (ej. `face_yolov8m.pt`).
3. Conecta su salida al puerto del Analyzer.
*(El nodo cuenta con auto-descarga: destruirá este modelo de la VRAM al terminar para hacer hueco a la generación).*
---
## 📝 Changelog Reciente
* **v1.6.0:** Implementación del `VAESafeFramePadder` (Hold Last Frame) y rediseño matemático a modo Expansión. Adiós a los recortes de audio y a los cuelgues en el ciclo final de WanVideo y LTX.
* **v1.5.4:** Inyección de la variable de entorno de PyTorch `max_split_size_mb:128` para evitar la micro-fragmentación de VRAM con kernels de Triton (SageAttention). Auto-descarga temprana de modelos YOLO.
* **v1.5.3:** "Secuencia Sagrada" en el VRAM Defragmenter (Romper ciclos -> Evacuación Suave -> Limpieza IPC -> Sincronización de hilos).
+39 -25
View File
@@ -1,5 +1,5 @@
# ♾️ ComfyUI Sequential Batcher
**v1.6.0** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
**v0.8.3** | **VRAM-Optimized Generation** | **Recursive Self-Queuing** | **Perfect Audio Sync**
A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extreme VRAM limits in heavy video generation (WanVideo, Hunyuan, LTX) through **Recursive Auto-Queuing**, autonomous batch-by-batch processing, and forensic memory management.
@@ -7,12 +7,6 @@ A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extre
---
## 🌟 What's New in v1.6.0 (Perfect Sync Update)
* **🛡️ VAE Safe Frame Padder (Hold Last Frame):** The engine now dynamically expands incomplete tensors at the end of the video. If frames are missing to meet the strict requirements of WanVideo (multiples of 4) or LTX (8n+1), it clones the last frame imperceptibly. **Result: Zero VAE crashes and 100% audio sync with no micro-stutters.**
* **📈 Mathematical Inversion to "Expansion":** The `AutoLoopCalculatorWan` and `AutoLoopCalculatorLTX` calculators now safely round the timeline upwards, guaranteeing total fluidity in intermediate cycles.
---
## ✨ Key Features
* **Autonomous Orchestration:** Turn your ComfyUI into a continuous rendering engine. Press "Queue Prompt" just once and the flow will feed itself by modifying seeds and indices until the entire video is finished.
@@ -25,7 +19,7 @@ A professional-grade suite of custom nodes for ComfyUI. Designed to bypass extre
## ⚠️ System Requirements (Critical)
For the `VideoAnalyzerWithAudio` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
For the `VideoAnalyzerFaceDetector` node to unpack `.mp4` containers and extract audio with `torchaudio`, **FFmpeg is absolutely mandatory**.
* **🐧 Ubuntu / Linux / WSL2:**
The script will attempt to install it for you. If it fails, run manually:
@@ -43,16 +37,43 @@ The `--highvram` argument gives ComfyUI the strict command to keep models locked
---
## 🧠 The Hybrid Architecture (Node Breakdown)
Processing is divided into highly specialized roles:
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV or using a GPU-accelerated YOLO model. Extracts faces, audio, and the Reference Frame.
* **📊 Auto Loop Calculator (Base / WanVideo / LTX):** Receives the biometrics and calculates cut coordinates (chunk_frames, skip_frames).
* **🛡️ VAE Safe Frame Padder:** Intercepts the final VHS tensor and pads it by cloning the last frame if material is missing, saving the VAE from a collapse.
* **🎞️ Incremental Auto-Stitcher:** Collects rendered batches and the audio track, progressively stitching the final video in the temporary folder.
* **📥 Receiver & 📤 Sender:** Retain and transfer the last valid keyframe across cycles to maintain temporal coherence.
* **⏱️ Auto FPS Limiter:** Smartly reduces FPS guaranteeing audio and motion maintain perfect synchronization without breaking VRAM.
* **🔀 Master Switch (Lazy Evaluation):** Physically amputates wires from inactive routes in the JSON sent to ComfyUI. Heavy nodes not needed in a specific cycle aren't even loaded into memory.
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Garbage Collector between heavy cycles.
Processing is divided into highly specialized roles, organized in the following categories:
### 🎼 Orchestration
* **🏁 Loop Start (Index):** Starts the loop sequence and tracks the current index.
* **🚀 Loop Trigger (Auto-Queue):** Autonomously triggers the next ComfyUI iteration until the task is complete.
### 🎬 Director / Storyboard
* **🎬 Dynamic Scene Director:** State machine to orchestrate metadata (prompts, durations) in preproduction (Split-Workflow).
* **💾 Save Scene Keyframe / 🖼️ Load Scene Keyframe:** Saves and loads the scene's keyframes to maintain continuity across decoupled flows.
### 📦 Batches
* **📂 Batch Audio Folder Loader:** Dynamically loads entire folders of files (e.g. audio batches).
* **🎛️ Audio Batch Selector:** Dispatches files from the batch one by one in sync with the loop.
### 🔬 Analysis and Calculations
* **🕵️ Video Analyzer Face detector + Audio:** Scans the video via OpenCV/YOLO, extracting faces, audio, and the reference frame.
* **🎬 Video Analyzer Scene detector:** Detects scene cuts in videos.
* **📊 Auto Loop Calculators:** Specialized nodes (`Base`, `WanVideo`, `LTX`, `TTS`) that receive metadata and mathematically calculate the exact cut coordinates (`chunk_frames`, `skip_frames`).
### 🎞️ Video
* **🎞️ Incremental Auto-Stitcher:** Progressively stitches rendered video chunks into a single final file.
* **🛡️ VAE Safe Frame Padder:** Pads incomplete tensors by cloning the last frame to save the VAE from a collapse in restrictive models (e.g. 4n+1 rules).
* **💉 LTXV Single Frame Injector:** Injects specific individual frames required by LTX flows.
* **⏱️ Auto FPS Limiter:** Smartly limits frames per second to save VRAM while maintaining audio sync.
### 🎵 Audio
* **✂️ Precise Audio Slicer:** Precisely slices audio (at mathematical sample level) based on the corresponding video frames.
* **🎛️ Conditional Audio Router (Bypass):** Conditionally routes audio allowing its processing to be bypassed if not needed.
### 📐 Resolution Tools
* **ResTool (`8x`, `16x`, `32x`, `64x`, `64xLTX`):** Calculate optimal resolutions based on VRAM limits and geometric rules (multipliers) of different models (SD1.5, SDXL, WanVideo, Hunyuan, LTX).
### 🧠 Memory Management / Logic
* **🔀 Master Switch & 🗄️ Lazy Session Cache:** Native lazy evaluation; they cut inactive routes so heavy nodes aren't even loaded into memory.
* **🧹 VRAM Defragmenter:** Forensic memory purge (Sacred Sequence) that clears CUDA/MPS cache and forces the Python Garbage Collector (GC) between cycles.
* **📥 Session Image Receiver / 📤 Session Image Sender:** Retain and transfer in-memory images across different cycles.
---
@@ -69,15 +90,8 @@ Processing is divided into highly specialized roles:
---
## 🚀 GPU Acceleration (Face Scanning)
The `VideoAnalyzerWithAudio` has a `bbox_detector` port to delegate facial scanning to the graphics card:
The `VideoAnalyzerFaceDetector` has a `bbox_detector` port to delegate facial scanning to the graphics card:
1. Install the *Impact Pack*.
2. Add the `UltralyticsDetectorProvider` node and select a face model (e.g. `face_yolov8m.pt`).
3. Connect its output to the Analyzer's port.
*(The node features auto-unload: it will destroy this model from VRAM upon completion to make room for generation).*
---
## 📝 Recent Changelog
* **v1.6.0:** Implementation of `VAESafeFramePadder` (Hold Last Frame) and mathematical redesign to Expansion mode. Goodbye to audio cuts and crashes in the final cycle of WanVideo and LTX.
* **v1.5.4:** Injection of the PyTorch environment variable `max_split_size_mb:128` to avoid VRAM micro-fragmentation with Triton kernels (SageAttention). Early YOLO model auto-unload.
* **v1.5.3:** "Sacred Sequence" in the VRAM Defragmenter (Break cycles -> Soft Evacuation -> IPC Cleanup -> Thread Synchronization).
+10 -3
View File
@@ -1,6 +1,4 @@
import os
__version__ = "1.6.0"
__version__ = "0.8.3"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -34,6 +32,9 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
"AutoLoopCalculatorTTS": "📊 Auto Loop Calculator (TTS/Texto)",
"SequentialLoopStart": "🏁 Loop Start (Index)",
"SequentialLoopTrigger": "🚀 Loop Trigger (Auto-Queue)",
"BatchAudioFolderLoader": "📂 Batch Audio Folder Loader",
"AudioBatchSelector": "🎛️ Audio Batch Selector",
"DynamicSceneDirector": "🎬 Dynamic Scene Director",
# Tools
"AutoFPSLimiter": "⏱️ Auto FPS Limiter",
"VAESafeFramePadder": "🛡️ VAE Safe Frame Padder",
@@ -43,6 +44,12 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
"ResTool64x": "🎬 ResTool 64x (Hunyuan)",
"ResTool64xLTX": "🌌 ResTool 64x (LTX 2.3)",
"ConditionalAudioRouter": "🎛️ Conditional Audio Router (Bypass)",
"PreciseAudioSlicer": "✂️ Precise Audio Slicer",
"SaveSceneKeyframe": "💾 Save Scene Keyframe",
"LoadSceneKeyframe": "🖼️ Load Scene Keyframe",
"LTXVSingleFrameInjector": "💉 LTXV Single Frame Injector",
"ProjectPlanLoader": "📂 Project Plan Loader"
})
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+15 -8
View File
@@ -179,17 +179,24 @@ class SessionImageSender:
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
# 🚀 FIX: Compensar los frames destruidos por el VAE en el ciclo final
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_chunk_mode = getattr(loop, 'global_step_by_chunk', False)
# 🚀 NUEVO: Detectar si es el ciclo final o un ciclo único
is_final_cycle = loop.global_accumulated_frames >= source_total
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.")
_log(f" -> 📈 Timeline avanzado a {loop.global_accumulated_frames} / {source_total}")
last_frame = valid_images[-1:].clone().cpu()
global_session_image = last_frame
+565 -87
View File
@@ -13,6 +13,10 @@ 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:
@@ -40,37 +44,157 @@ class SequentialLoopStart:
global global_accumulated_frames
global global_server_port
global global_ltx_mode
global global_batch_index
global global_is_batch_advancing
import sys
import argparse
print(f"\n{'='*50}")
print(f"🚀 [DEBUG] NODO: Loop Start (Motor Dinámico)")
# --- PRE-FLIGHT CHECK (FAIL-FAST) ---
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto {port}...")
# --- PRE-FLIGHT CHECK (SECURITY SSRF FIX) ---
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8188)
try:
# Ping ultrarrápido a la API. Si falla, colapsa en 0.1 segundos.
req = urllib.request.Request(f"http://127.0.0.1:{port}/system_stats")
urllib.request.urlopen(req, timeout=2)
global_server_port = port # Guardamos el puerto correcto para el Trigger
print(f" -> ✅ Conexión establecida. Puerto blindado.")
except Exception as e:
print(f" -> ❌ ERROR FATAL: No se pudo conectar al puerto {port}.")
raise ValueError(f"🚨 EL PUERTO {port} ES INCORRECTO O COMFYUI NO RESPONDE. "
f"Cambia el puerto en el nodo 'Loop Start' antes de procesar. (Error: {e})")
args, _ = parser.parse_known_args(sys.argv[1:])
system_port = args.port
except:
system_port = 8188
safe_port = system_port
if port != safe_port:
print(f" -> ⚠️ ATENCIÓN: El puerto de entrada ({port}) no coincide con el puerto del sistema ({safe_port}).")
print(f" -> 🔒 Por motivos de seguridad (Prevención SSRF), se forzará el puerto real del sistema: {safe_port}.")
print(f" -> 📡 Verificando conexión con ComfyUI en el puerto seguro {safe_port}...")
max_retries = 3
for attempt in range(max_retries):
try:
req = urllib.request.Request(f"http://127.0.0.1:{safe_port}/system_stats")
urllib.request.urlopen(req, timeout=10)
global_server_port = safe_port
print(f" -> ✅ Conexión establecida. Puerto blindado y seguro.")
break
except Exception as e:
if attempt < max_retries - 1:
print(f" -> ⏳ Reintentando conexión ({attempt + 1}/{max_retries}) por alta carga del servidor...")
time.sleep(1)
error_msg = f"🚨 FATAL ERROR: EL PUERTO {safe_port} NO RESPONDE TRAS VARIOS INTENTOS. El servidor ComfyUI podría estar bloqueado o apagado. (Error original: {e})"
print(f" -> ❌ {error_msg}")
raise RuntimeError(error_msg)
# ------------------------------------
is_reset = str(reset_loop).lower() in ['true', '1', 't', 'y']
if is_reset or loop_idx == 0:
global_loop_index = 0
global_accumulated_frames = 0
global_ltx_mode = False # 💡 REINICIO DE SEGURIDAD PARA LTX
global_ltx_mode = False
print(" -> 🔄 Bucle y Acumulador reiniciados a 0.")
else:
global_loop_index = loop_idx
global_is_batch_advancing = False
print(f" -> 📍 Índice actual de bucle: {global_loop_index}")
print(f"{'='*50}\n")
return (global_loop_index,)
@register_node
class AutoLoopCalculatorTTSBatch:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text_list": ("STRING", {"forceInput": True}),
"file_names": ("STRING", {"forceInput": True}),
"split_mode": (["Párrafos (Saltos de línea)", "Frases (Puntos)"], {"default": "Párrafos (Saltos de línea)"}),
"current_loop_index": ("INT", {"forceInput": True}),
}
}
# 💡 FIX: Mantenemos INPUT_IS_LIST para absorber el batch, pero ELIMINAMOS OUTPUT_IS_LIST
# para que ComfyUI desempaquete las salidas automáticamente.
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING", "INT", "INT", "STRING", "STRING")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "current_file_name", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, text_list, file_names, split_mode, current_loop_index):
log_output = []
def _log(msg):
print(msg); log_output.append(str(msg))
from . import loop
import re
loop.global_step_by_chunk = True
texts = text_list[0] if (isinstance(text_list, list) and len(text_list) > 0 and isinstance(text_list[0], list)) else (text_list if isinstance(text_list, list) else [text_list])
names = file_names[0] if (isinstance(file_names, list) and len(file_names) > 0 and isinstance(file_names[0], list)) else (file_names if isinstance(file_names, list) else [file_names])
mode = split_mode[0] if isinstance(split_mode, list) else split_mode
idx = current_loop_index[0] if isinstance(current_loop_index, list) else current_loop_index
# El batch index es extraído directamente de idx para evitar bloqueos
current_batch_idx = idx
if current_batch_idx >= len(texts):
current_batch_idx = len(texts) - 1
current_file_text = texts[current_batch_idx]
current_name = names[current_batch_idx] if current_batch_idx < len(names) else "unknown"
if mode == "Frases (Puntos)":
matches = re.findall(r'[^.!?\n]+[.!?\n]*', current_file_text)
temp_chunks = [m.strip() for m in matches if m.strip()]
MIN_WORDS = 5
raw_chunks = []
buffer_text = ""
for chunk in temp_chunks:
buffer_text = (buffer_text + " " + chunk).strip()
if len(buffer_text.split()) >= MIN_WORDS:
raw_chunks.append(buffer_text)
buffer_text = ""
if buffer_text:
if raw_chunks: raw_chunks[-1] += " " + buffer_text
else: raw_chunks.append(buffer_text)
chunk_type_name = "Frases Optimizadas"
raw_chunks = [p.strip() for p in current_file_text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
total_chunks = len(raw_chunks)
if total_chunks == 0:
raw_chunks = [""]
total_chunks = 1
safe_index = min(idx, total_chunks - 1)
current_chunk_text = raw_chunks[safe_index]
loop.global_source_frame_count = total_chunks
loop.global_accumulated_frames = safe_index + 1
loop.global_is_final_chunk = (safe_index + 1) >= total_chunks
loop.global_has_more_batches = (current_batch_idx < len(texts) - 1)
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS Batch)")
_log(f" -> Archivo {current_batch_idx + 1} de {len(texts)}: {current_name}")
_log(f" -> Modo de división: {mode}")
_log(f" -> {chunk_type_name} detectados: {total_chunks}")
_log(f" -> Timeline: Bloque {safe_index + 1} de {total_chunks}")
_log(f" -> 📜 Texto a procesar: {current_chunk_text[:75]}...")
_log(f"{'='*50}\n")
return (current_chunk_text, safe_index, total_chunks, current_name, "\n".join(log_output))
@register_node
class AutoLoopCalculatorLTX:
@classmethod
@@ -93,16 +217,29 @@ class AutoLoopCalculatorLTX:
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Loop"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
if source_frame_count <= 0 or target_frames_per_loop <= 0:
return (1, 0, select_every_nth)
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
else:
safe_effective_frames = ((potential_effective_frames + 6) // 8) * 8 + 1
# 🚀 FIX: Conservamos el límite FÍSICO real del vídeo para el timeline global
@@ -121,7 +258,7 @@ class AutoLoopCalculatorLTX:
print(f" -> 🎞️ Capacidad del video original: {potential_effective_frames} frames (Nth: {select_every_nth})")
if effective_padding > 0:
print(f" -> 🛡️ Ajuste VAE: Se pedirán {safe_effective_frames} frames (Acolchado técnico: Se rellenarán {effective_padding} frames)")
else:
print(f" -> ✅ Ajuste VAE: Perfecto. Regla 8n+1 detectada.")
print(f" -> 📊 Timeline final: 0 a {physical_source_frame_count} (Límite Físico Real)")
@@ -147,7 +284,7 @@ class AutoLoopCalculatorLTX:
if frames_left <= equitable_target:
best_cut = physical_source_frame_count
print(f" -> 🧮 Absorbiendo resto final: meta fijada en frame {best_cut}")
else:
if scene_cuts_list and len(scene_cuts_list) > 0:
future_cuts = [c for c in scene_cuts_list if c > current_pos]
if future_cuts:
@@ -158,10 +295,10 @@ class AutoLoopCalculatorLTX:
if safe_chunk_from_scene < 9: safe_chunk_from_scene = 9
best_cut = current_pos + (safe_chunk_from_scene * select_every_nth)
print(f" -> 🎬 Bucle aislado por escena (Ajustado x8). Cortando en el frame: {best_cut} (Corte real: {next_cut})")
else:
best_cut = ideal_cut
print(f" -> ⚠️ Toma demasiado larga para la VRAM. Cortando por límite técnico en: {best_cut} (El plano real acaba en {next_cut})")
else:
best_cut = ideal_cut
print(f" -> 🎬 No quedan cortes de cámara por delante. Forzando corte final por VRAM en: {ideal_cut}")
elif safe_faces_list and len(safe_faces_list) > 0:
@@ -171,7 +308,7 @@ class AutoLoopCalculatorLTX:
if safe_chunk_from_face < 9: safe_chunk_from_face = 9
best_cut = current_pos + (safe_chunk_from_face * select_every_nth)
print(f" -> ✂️ Corte Facial Inteligente (Ajustado x8): {best_cut} (Meta equitativa: {ideal_cut})")
else:
best_cut = ideal_cut
print(f" -> ⚖️ Sin detectores conectados. Forzando corte equitativo x8: {ideal_cut}")
@@ -184,7 +321,7 @@ class AutoLoopCalculatorLTX:
if current_pos + (effective_chunk_frames * select_every_nth) >= physical_source_frame_count:
print(f" -> 🏁 Chunk final LTX detectado. Ajustando a {effective_chunk_frames} frames para mantener regla 8n+1.")
loop.global_is_final_chunk = True
else:
loop.global_is_final_chunk = False
skip_frames = current_pos
@@ -222,16 +359,18 @@ class SequentialLoopTrigger:
global global_server_port
next_loop = global_loop_index + 1
is_final = global_accumulated_frames >= global_source_frame_count
is_final_chunk = global_accumulated_frames >= global_source_frame_count
from . import loop
has_more_batches = getattr(loop, 'global_has_more_batches', False)
print(f"\n{'='*50}")
print(f"🎯 [DEBUG] NODO: Loop Trigger")
print(f" -> Progreso del vídeo: {global_accumulated_frames} / {global_source_frame_count} frames.")
print(f" -> Progreso: {global_accumulated_frames} / {global_source_frame_count} chunks/frames.")
if not is_final:
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:
# 🧠 HACKER MODE: Estimamos si el ciclo que vamos a encolar es el final
estimated_chunk = 50
found_calculator = False
m_seeds = 0
@@ -242,94 +381,433 @@ class SequentialLoopTrigger:
inputs = node_data.get("inputs", {})
if not found_calculator and "AutoLoopCalculator" in class_type:
estimated_chunk = inputs.get("target_frames_per_loop", 50)
if "TTS" in class_type:
estimated_chunk = 1
estimated_chunk = inputs.get("target_frames_per_loop", 50)
found_calculator = True
# Mutación de semillas
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
# Mutación de índice
if class_type == "SequentialLoopStart":
inputs["loop_idx"] = next_loop
inputs["reset_loop"] = False
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
# 🔪 Recolectamos Master Switch para cirugía diferida
if class_type == "MasterSwitch":
master_switches.append(inputs)
is_next_final = (global_accumulated_frames + estimated_chunk) >= global_source_frame_count
# 🛡️ Desempaquetado defensivo universal (Unwrapping de listas de ComfyUI)
_acc_frames = global_accumulated_frames[0] if isinstance(global_accumulated_frames, list) else global_accumulated_frames
_src_frames = global_source_frame_count[0] if isinstance(global_source_frame_count, list) else global_source_frame_count
_est_chunk = estimated_chunk[0] if isinstance(estimated_chunk, list) else estimated_chunk
# Asegurar casteo a enteros puros para evitar colisiones matemáticas en cualquier flujo
_acc_frames = int(_acc_frames) if _acc_frames is not None else 0
_src_frames = int(_src_frames) if _src_frames is not None else 0
_est_chunk = int(_est_chunk) if _est_chunk is not None else 0
is_next_final = (_acc_frames + _est_chunk) >= _src_frames
if is_final_chunk and has_more_batches:
is_next_final = False
for inputs in master_switches:
print(f" -> 🔀 [Cirugía de Grafo] Mutando Master Switch para Ciclo {next_loop}...")
# Sobrescribimos el cable que viene del Stitcher por un booleano estático
inputs["is_final_cycle"] = is_next_final
if is_next_final:
if "on_false" in inputs:
del inputs["on_false"]
print(f" ✂️ Cable 'on_false' eliminado (Ahorro de VRAM en ruta inactiva).")
else:
if "on_true" in inputs:
del inputs["on_true"]
print(f" ✂️ Cable 'on_true' eliminado (Ruta pesada desconectada).")
print(f" -> 🎲 Semillas mutadas: {m_seeds}")
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:{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}")
# Limpieza ligera por ciclo para evitar fragmentación
import gc
import torch
import comfy.model_management as mm
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
else:
print(f" -> 🏁 ¡Generación Finalizada! Todos los frames ensamblados.")
# --- LIMPIEZA EXTREMA DE VRAM AUTOMÁTICA (Multi-Plataforma) ---
print(f" -> 🧹 Iniciando vaciado automático de VRAM...")
import gc
import torch
import comfy.model_management as mm
try:
# 1. Obligamos al motor interno de ComfyUI a soltar los modelos
mm.unload_all_models()
mm.soft_empty_cache()
except Exception as e:
print(f" -> ⚠️ Aviso: No se pudo usar model_management: {e}")
# 2. Forzamos al recolector de basura de Python
gc.collect()
# 3. Le arrancamos a PyTorch la memoria reservada (CUDA, ROCm y Mac MPS)
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
# HACK: Forzar la desfragmentación de la memoria caché de PyTorch
import ctypes
try:
ctypes.CDLL('libc.so.6').malloc_trim(0)
except Exception:
pass
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
print(f" -> ✨ VRAM liberada con éxito. Gráfica lista para nuevos flujos.")
print(f" -> 🏁 ¡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
+1
View File
@@ -3,3 +3,4 @@ torchaudio
numpy
Pillow
opencv-python
safetensors
+5
View File
@@ -65,6 +65,11 @@ class LazySessionCache:
FUNCTION = "execute"
CATEGORY = "🔁 Sequential Batcher/Logic"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
# 🔥 LA CLAVE: Aquí es donde ComfyUI decide si "tira del cable" o no
def check_lazy_status(self, current_loop_index, **kwargs):
# El índice llega como una lista o un int dependiendo del nodo anterior
-34
View File
@@ -1,34 +0,0 @@
import unittest
from unittest.mock import patch, MagicMock
import sys
import os
# Mock sys.platform to linux for testing check_and_install_ffmpeg
@patch("sys.platform", "linux")
@patch("shutil.which", return_value=None)
class TestInstall(unittest.TestCase):
@patch("subprocess.check_call")
def test_check_and_install_ffmpeg_linux(self, mock_check_call, mock_which):
from install import check_and_install_ffmpeg
check_and_install_ffmpeg()
# Verify that subprocess.check_call was called twice with the correct lists
self.assertEqual(mock_check_call.call_count, 2)
expected_calls = [
unittest.mock.call(["sudo", "apt-get", "update"]),
unittest.mock.call(["sudo", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "ffmpeg"])
]
mock_check_call.assert_has_calls(expected_calls)
# Verify shell=True was NOT used
for call in mock_check_call.call_args_list:
args, kwargs = call
if "shell" in kwargs:
self.assertFalse(kwargs["shell"], "shell should be False if explicitly provided")
# If not in kwargs, it defaults to False for subprocess.check_call
if __name__ == "__main__":
unittest.main()
-55
View File
@@ -1,55 +0,0 @@
import sys
import types
import pytest
from unittest.mock import MagicMock
import os
# Mock dependencies before importing anything that might depend on them
mock_torch = types.ModuleType("torch")
mock_torch.cuda = MagicMock()
mock_torch.cuda.is_available.return_value = False
sys.modules["torch"] = mock_torch
sys.modules["comfy"] = types.ModuleType("comfy")
sys.modules["comfy.model_management"] = types.ModuleType("comfy.model_management")
def get_format_bytes():
# Read the file
vram_node_path = os.path.join(os.path.dirname(__file__), "..", "vram_node.py")
with open(vram_node_path, 'r') as f:
code = f.read()
# Mock 'from . import register_node' by replacing it
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
vram_module = types.ModuleType("vram_node")
exec(code, vram_module.__dict__)
return vram_module.format_bytes
@pytest.fixture
def format_bytes():
return get_format_bytes()
def test_format_bytes_zero(format_bytes):
assert format_bytes(0) == "0.00 GB"
def test_format_bytes_gigabyte(format_bytes):
assert format_bytes(1024**3) == "1.00 GB"
def test_format_bytes_fractional(format_bytes):
assert format_bytes(1024**3 * 0.5) == "0.50 GB"
assert format_bytes(1024**3 * 2.5) == "2.50 GB"
def test_format_bytes_small_values(format_bytes):
# 1 MB is 1/1024 GB ≈ 0.000976 GB -> 0.00 GB
assert format_bytes(1024**2) == "0.00 GB"
# 10 MB is 10/1024 GB ≈ 0.009765 GB -> 0.01 GB (due to rounding .2f)
assert format_bytes(1024**2 * 10) == "0.01 GB"
def test_format_bytes_large_values(format_bytes):
assert format_bytes(1024**3 * 1024) == "1024.00 GB"
assert format_bytes(1024**3 * 123.456) == "123.46 GB"
if __name__ == "__main__":
pytest.main([__file__])
-118
View File
@@ -1,118 +0,0 @@
import os
import sys
import csv
# Setup paths
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(TEST_DIR)
SANDBOX_DIR = os.path.join(TEST_DIR, "sandbox")
INPUT_DIR = os.path.join(SANDBOX_DIR, "input")
SECRET_FILE = os.path.join(SANDBOX_DIR, "secret.txt")
# Ensure sandbox exists
if not os.path.exists(INPUT_DIR):
os.makedirs(INPUT_DIR)
# Create secret file
with open(SECRET_FILE, 'w') as f:
f.write("secret_key,secret_value\nsuper,secret")
# Create valid csv
with open(os.path.join(INPUT_DIR, "valid.csv"), 'w') as f:
f.write("col1,col2\nval1,val2")
# Mock folder_paths
import types
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_input_directory = lambda: INPUT_DIR
folder_paths.get_annotated_filepath = lambda x: x # Mock implementation
sys.modules["folder_paths"] = folder_paths
# Load batch.py dynamically
def load_batch_module():
batch_path = os.path.join(REPO_ROOT, "batch.py")
with open(batch_path, 'r') as f:
code = f.read()
# Mock register_node by replacing the import
code = code.replace("from . import register_node", "def register_node(c, *args, **kwargs): return c")
module = types.ModuleType("batch")
exec(code, module.__dict__)
return module
batch_module = load_batch_module()
LoadCSV = batch_module.LoadCSV
def verify_fix():
print("--- Verifying Fix ---")
node = LoadCSV()
# Test 1: Exploit attempt (should fail)
print("Test 1: Exploit attempt (should raise ValueError)")
target_path = SECRET_FILE
try:
# Pass path relative to input, attempting traversal if we were naive,
# but here we can pass absolute path to check absolute path blocking.
# Or relative path "../secret.txt"
# Scenario A: Path traversal relative string
# node.go("../secret.txt", ...)
# Scenario B: Absolute path
# Testing Scenario B (absolute path)
node.go(target_path, "comma", '"', -1)
print("FAIL: Exploit succeeded (No exception raised)")
return False
except ValueError as e:
if "Access denied" in str(e):
print(f"SUCCESS: Blocked absolute path access: {e}")
else:
print(f"FAIL: Raised wrong ValueError: {e}")
return False
except Exception as e:
print(f"FAIL: Raised wrong exception: {type(e)} {e}")
return False
# Test 2: Valid access (should succeed)
print("\nTest 2: Valid access (should succeed)")
try:
# Can pass just filename or relative path
result = node.go("valid.csv", "comma", '"', -1)
batch_data = result["result"][0]
if batch_data[0]['col1'] == 'val1':
print("SUCCESS: Valid CSV read correctly")
else:
print(f"FAIL: content mismatch: {batch_data}")
return False
except Exception as e:
print(f"FAIL: Valid access raised exception: {e}")
return False
# Test 3: Relative path traversal attempt (should fail)
print("\nTest 3: Relative path traversal attempt (should raise ValueError)")
try:
node.go("../secret.txt", "comma", '"', -1)
print("FAIL: Exploit succeeded (No exception raised)")
return False
except ValueError as e:
if "Access denied" in str(e):
print(f"SUCCESS: Blocked relative path traversal: {e}")
else:
print(f"FAIL: Raised wrong ValueError: {e}")
return False
except Exception as e:
# Depending on how os.path.join works, "input/../secret.txt" -> "sandbox/secret.txt"
# commonpath should catch it.
print(f"FAIL: Raised wrong exception: {type(e)} {e}")
return False
return True
if __name__ == "__main__":
if verify_fix():
print("\nVerification Result: FIX VERIFIED")
sys.exit(0)
else:
print("\nVerification Result: FIX FAILED")
sys.exit(1)
+256 -1
View File
@@ -2,7 +2,12 @@ import math
import time
import torch
from . import register_node
from .switch_node import any_type
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
@register_node
class PrimitiveDelay:
@@ -233,6 +238,10 @@ class VAESafeFramePadder:
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
@@ -326,3 +335,249 @@ class VHS_Path_Selector:
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},)
+88 -138
View File
@@ -1,5 +1,4 @@
import os
import sys
import logging
from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stdout, redirect_stderr
@@ -9,15 +8,13 @@ import math
import torch
import torchaudio
import folder_paths
import nodes
import time
from safetensors.torch import save_file, load_file
import uuid
import subprocess
import re
from . import register_node
# Aseguramos que torch esté disponible globalmente para los bloques de limpieza
import torch
# Intento de carga de OpenCV para el Director de Fotografía
# Caché persistente para evitar re-escaneos pesados de vídeo
@@ -483,15 +480,30 @@ class AutoLoopCalculator:
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
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
@@ -582,168 +594,91 @@ class AutoLoopCalculatorTTS:
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_paragraph", "current_index", "total_paragraphs", "log")
RETURN_NAMES = ("current_text", "current_index", "total_chunks", "log")
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Text"
def calculate(self, text, current_loop_index):
log_output = []
def _log(msg):
print(msg)
log_output.append(str(msg))
from . import loop
# 1. Limpiar y separar párrafos ignorando saltos de línea vacíos
raw_paragraphs = [p.strip() for p in text.split('\n') if p.strip()]
total_paragraphs = len(raw_paragraphs)
if total_paragraphs == 0:
raw_paragraphs = [""]
total_paragraphs = 1
# 2. Seguridad de índice
safe_index = min(current_loop_index, total_paragraphs - 1)
current_paragraph = raw_paragraphs[safe_index]
# 3. 🧠 HACK CORE: Inyectamos los párrafos como si fueran frames
# para que el SequentialLoopTrigger entienda el progreso.
loop.global_source_frame_count = total_paragraphs
loop.global_accumulated_frames = safe_index + 1
loop.global_is_final_chunk = (safe_index + 1) >= total_paragraphs
_log(f"\n{'='*50}")
_log(f"🗣️ [Secuencial Batcher] NODO: Auto Loop Calculator (TTS)")
_log(f" -> Párrafos detectados: {total_paragraphs}")
_log(f" -> Timeline: Párrafo {safe_index + 1} de {total_paragraphs}")
_log(f" -> 📜 Texto a procesar: {current_paragraph[:75]}...")
_log(f"{'='*50}\n")
return (current_paragraph, safe_index, total_paragraphs, "\n".join(log_output))
@register_node
class IncrementalVideoStitcher:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
"current_loop_index": ("INT", {"default": 0}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "BOOLEAN", "STRING")
RETURN_NAMES = ("ALL_IMAGES", "AUDIO_OUT", "IS_FINAL_CYCLE", "log")
FUNCTION = "stitch"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def stitch(self, images, audio, current_loop_index):
def calculate(self, text, split_mode, 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
cache_dir = os.path.join(folder_paths.get_temp_directory(), "meisoft_video_cache")
loop.global_step_by_chunk = True
if current_loop_index == 0:
if os.path.exists(cache_dir):
try: shutil.rmtree(cache_dir)
except Exception as e: pass
os.makedirs(cache_dir, exist_ok=True)
_log(f"\n🧹 [Stitcher] Ciclo 0 detectado. Subcarpeta temporal limpiada.")
# 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:
os.makedirs(cache_dir, exist_ok=True)
# Comportamiento original: Dividir por párrafos
raw_chunks = [p.strip() for p in text.split('\n') if p.strip()]
chunk_type_name = "Párrafos"
timestamp = int(time.time() * 1000)
total_chunks = len(raw_chunks)
# 🖼️ GUARDAR IMÁGENES (PNG)
_log(f"📦 [Stitcher] Guardando {images.shape[0]} frames como PNGs de alta calidad...")
for i in range(images.shape[0]):
filename = f"frame_{current_loop_index:04d}_{timestamp}_{i:04d}.png"
path = os.path.join(cache_dir, filename)
img_array = 255. * images[i].cpu().numpy()
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
img.save(path, format="PNG")
if total_chunks == 0:
raw_chunks = [""]
total_chunks = 1
# 🎵 GUARDAR AUDIO EN CACHÉ (.pt)
if audio is not None:
audio_path = os.path.join(cache_dir, f"audio_{current_loop_index:04d}_{timestamp}.pt")
torch.save(audio, audio_path)
# 2. Seguridad de índice
safe_index = min(current_loop_index, total_chunks - 1)
current_text = raw_chunks[safe_index]
source_total = getattr(loop, 'global_source_frame_count', 1)
is_final_chunk = getattr(loop, 'global_is_final_chunk', False)
# 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
if is_final_chunk or loop.global_accumulated_frames >= source_total:
_log(f" -> 🏁 ¡Último ciclo detectado! Ensamblando recursos desde la caché temporal...")
_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")
# 1. ENSAMBLAR VÍDEO
png_files = sorted([f for f in os.listdir(cache_dir) if f.endswith('.png')])
if not png_files:
_log(" -> ❌ ERROR: No se encontraron frames en la subcarpeta.")
return (images, audio, True, "\n".join(log_output))
return (current_text, safe_index, total_chunks, "\n".join(log_output))
first_img = Image.open(os.path.join(cache_dir, png_files[0]))
H, W = first_img.height, first_img.width
total_frames = len(png_files)
_log(f" -> 🧩 Reservando bloque en RAM para {total_frames} frames de vídeo...")
final_tensor = torch.empty((total_frames, H, W, 3), dtype=torch.float32, device="cpu")
def load_and_process(idx, filename):
img = Image.open(os.path.join(cache_dir, filename)).convert("RGB")
img_np = np.array(img).astype(np.float32) / 255.0
final_tensor[idx] = torch.from_numpy(img_np)
with ThreadPoolExecutor() as executor:
list(executor.map(lambda p: load_and_process(*p), enumerate(png_files)))
# 2. ENSAMBLAR AUDIO
audio_files = sorted([f for f in os.listdir(cache_dir) if f.startswith('audio_') and f.endswith('.pt')])
final_audio = None
if audio_files:
_log(f" -> 🎵 Ensamblando {len(audio_files)} fragmentos de audio...")
waveforms = []
sample_rate = 44100
for af in audio_files:
chunk_audio = torch.load(os.path.join(cache_dir, af))
waveforms.append(chunk_audio["waveform"])
sample_rate = chunk_audio["sample_rate"]
# Concatenamos los audios en el eje del tiempo (dimensión -1)
final_waveform = torch.cat(waveforms, dim=-1)
final_audio = {"waveform": final_waveform, "sample_rate": sample_rate}
_log(f" -> ✅ Audio unificado. Duración total: {final_waveform.shape[-1] / sample_rate:.2f} segundos.")
else:
final_audio = audio
_log(f"✅ [Stitcher] VÍDEO COMPLETADO: {final_tensor.shape[0]} frames ensamblados con éxito.")
try:
shutil.rmtree(cache_dir)
_log(f"🧹 [Stitcher] Subcarpeta temporal destruida.")
except:
pass
return (final_tensor, final_audio, True, "\n".join(log_output))
else:
_log(f" -> ⏳ Ciclo intermedio. Recursos almacenados de forma segura. Pasando frames dummy...")
dummy_frame = images[-1:].clone()
return (dummy_frame, None, False, "\n".join(log_output))
@register_node
class AutoLoopCalculatorWan:
@@ -767,14 +702,29 @@ class AutoLoopCalculatorWan:
FUNCTION = "calculate"
CATEGORY = "🔁 Sequential Batcher/Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
import time
return time.time()
def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index, safe_faces_list=None, scene_cuts_list=None):
log_output = []
def _log(msg):
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
-1
View File
@@ -1,7 +1,6 @@
import torch
import gc
import comfy.model_management as mm
import os
from . import register_node
class AnyType(str):