Compare commits

...
Author SHA1 Message Date
Vito Sansevero d2b30f0a78 feat: Add auto-batching support for large LoRA collections
- Add auto-batching functionality to split large LoRA collections into manageable chunks
- Implement batch_size parameter to control number of LoRAs per batch (default: 25)
- Add batch_index parameter to select which batch to process
- Include batch tracking metadata in LORA_PARAMS for visualization
- Display batch info in plot parameters when available
- Update lora_list output to show batch header when auto-batching is enabled
- Add comprehensive tests for batching functionality
- Update documentation with detailed auto-batching usage instructions

This feature prevents UI disconnection issues when processing large numbers of LoRAs
by allowing users to process them in smaller batches sequentially.
2025-08-16 17:27:59 -07:00
Vito Sansevero 917421529b chore(pyproject): bump version to 1.0.18 2025-08-14 19:12:49 -07:00
Vito d29dcb8564 Merge pull request #35 from ComfyAssets/feature/remove-cfg-custom-slider
refactor: Remove custom CFG slider display in Sampler Combo nodes, requested from user
2025-08-14 19:11:05 -07:00
Vito Sansevero b95bf8e64a refactor: Remove custom CFG slider display in Sampler Combo nodes
- Remove "display": "slider" parameter from CFG input in SamplerComboNode
- Remove "display": "slider" parameter from CFG input in SamplerComboCompactNode
- Both nodes now use ComfyUI's standard float input instead of custom slider
- All tests passing (354 unit tests)
2025-08-14 19:06:44 -07:00
Vito Sansevero b8622e21da chore: bump version to 1.0.17 in pyproject.toml 2025-08-13 07:08:43 -07:00
Vito 4c8f20ef88 Merge pull request #34 from ComfyAssets/bugfix/display-any
Bugfix/display any
2025-08-13 07:08:12 -07:00
Vito 26d135e106 Merge pull request #33 from ComfyAssets/dependabot/github_actions/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-08-13 07:05:21 -07:00
Vito Sansevero db7d0dc86e test: Add line break and spacing adjustments 2025-08-13 07:04:55 -07:00
Vito Sansevero b5e24dbe57 test: Update test categories with emoji prefix 2025-08-13 07:01:21 -07:00
Vito Sansevero eb1e646453 test: Update CATEGORY assertion emoji in test 2025-08-13 07:01:07 -07:00
Vito Sansevero 793579a1dd refactor(web): remove redundant title update code 2025-08-13 07:00:55 -07:00
dependabot[bot] eba899b30d build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-11 22:33:21 +00:00
Vito Sansevero 914ba8e003 chore: bump version to 1.0.16 in pyproject.toml 2025-08-10 11:38:38 -07:00
Vito 51b057982e Merge pull request #32 from ComfyAssets/feature/follow-execution-and-custom-colors
feat: add follow execution and custom colors UI features
2025-08-10 11:37:58 -07:00
Vito Sansevero 3a3a6ebab9 feat: add follow execution and custom colors UI features
- Add follow execution feature with settings integration
  - Automatically centers canvas on currently executing node
  - Configurable via ComfyUI settings panel
  - Supports auto-start on workflow execution
  - Adds right-click menu options when enabled

- Add custom colors feature with extended options
  - Based on ComfyUI-Custom-Scripts with enhancements from PR #433
  - Three color picker modes: Full, Title only, Background only
  - Multi-node selection support
  - Configurable via settings to enable/disable individual options
  - Auto-shading option for better contrast

Both features are disabled by default and can be enabled in ComfyUI settings under the 🫶 KikoTools sections.
2025-08-10 11:35:22 -07:00
Vito Sansevero 886917d95c chore: bump version to 1.0.15 in pyproject.toml 2025-08-10 06:58:30 -07:00
Vito Sansevero bf55338b61 fix: update all remaining category assertions to handle emoji prefixes
- Change startswith checks to contains checks for ComfyAssets/
- All nodes use '🫶 ComfyAssets/...' format with emoji prefix
2025-08-10 06:51:43 -07:00
Vito Sansevero c561e59650 fix: update test assertions to match emoji prefixes in node categories 2025-08-10 06:50:30 -07:00
Vito Sansevero 9e67f06a21 fix: update base node test to handle emoji prefix in category 2025-08-10 06:44:51 -07:00
Vito Sansevero 90e4b57dc6 style: add missing newline to any_type.py for black formatting 2025-08-10 06:33:12 -07:00
Vito Sansevero 386e48c2e1 feat: add Kiko Purge VRAM node for intelligent memory management
- Add comprehensive VRAM management tool with 4 purge modes (soft, aggressive, models_only, cache_only)
- Implement smart memory threshold triggering to avoid unnecessary purges
- Add detailed memory reporting showing before/after stats and freed MB
- Support passthrough design for seamless workflow integration
- Include graceful CPU fallback for non-CUDA environments
- Add comprehensive test suite with 14 tests covering all functionality
- Update documentation with detailed usage examples and parameters
- Create reusable AnyType class for wildcard input matching

The node provides essential memory management capabilities for complex workflows,
helping prevent OOM errors and optimize multi-model processing pipelines.
2025-08-10 06:25:48 -07:00
Vito Sansevero 624ba92723 feat: add Kiko Purge VRAM node for intelligent memory management
- Add comprehensive VRAM management tool with 4 purge modes (soft, aggressive, models_only, cache_only)
- Implement smart memory threshold triggering to avoid unnecessary purges
- Add detailed memory reporting showing before/after stats and freed MB
- Support passthrough design for seamless workflow integration
- Include graceful CPU fallback for non-CUDA environments
- Add comprehensive test suite with 14 tests covering all functionality
- Update documentation with detailed usage examples and parameters
- Create reusable AnyType class for wildcard input matching

The node provides essential memory management capabilities for complex workflows,
helping prevent OOM errors and optimize multi-model processing pipelines.
2025-08-10 06:17:42 -07:00
Vito Sansevero 0ae29f576a chore: bump version to 1.0.14 in pyproject.toml 2025-08-08 21:02:43 -07:00
Vito Sansevero ca9fd5153a style(kikotools): Update category names and refactor nodes 2025-08-08 21:01:48 -07:00
Vito c0d239f31f Update README.md
Update into.
2025-08-08 20:34:20 -07:00
Vito Sansevero 6d27520641 docs: Update README with new image tools added 2025-08-08 18:33:55 -07:00
Vito Sansevero 7cde5a5ceb docs: update image URLs in README.md 2025-08-08 18:31:02 -07:00
Vito Sansevero ecdde6bbd7 chore: bump version to 1.0.13 in pyproject.toml 2025-08-08 18:28:29 -07:00
Vito 0557c040f8 Merge pull request #31 from ComfyAssets/feature/embedding-autocomplete
Feature/embedding autocomplete
2025-08-08 18:28:04 -07:00
57 changed files with 1647 additions and 158 deletions
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v5
@@ -133,7 +133,7 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v5
@@ -164,7 +164,7 @@ jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v5
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository_owner == 'ComfyAssets' }}
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
submodules: true
- name: Publish Custom Node
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v5
+10 -10
View File
@@ -17,7 +17,7 @@ jobs:
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
@@ -53,7 +53,7 @@ jobs:
print('✓ All imports successful')
# Test base node
assert ComfyAssetsBaseNode.CATEGORY.startswith('ComfyAssets')
assert 'ComfyAssets' in ComfyAssetsBaseNode.CATEGORY
print('✓ Base node tests passed')
# Test dimension extraction
@@ -168,7 +168,7 @@ jobs:
assert node.RETURN_TYPES[2] == 'INT'
assert node.RETURN_TYPES[3] == 'FLOAT'
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
assert node.CATEGORY == 'ComfyAssets/🌀 Samplers'
assert node.CATEGORY == '🫶 ComfyAssets/🌀 Samplers'
print('✓ Sampler Combo return types tests passed')
# Test sampler combo functionality
@@ -217,7 +217,7 @@ jobs:
# Test return types
assert node.RETURN_TYPES == ('INT',)
assert node.RETURN_NAMES == ('seed',)
assert node.CATEGORY == 'ComfyAssets/🌱 Seeds'
assert node.CATEGORY == '🫶 ComfyAssets/🌱 Seeds'
print('✓ Seed History return types tests passed')
# Test seed output functionality
@@ -333,7 +333,7 @@ jobs:
assert res_class.RETURN_TYPES == ('INT', 'INT')
assert res_class.RETURN_NAMES == ('width', 'height')
assert res_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in res_class.CATEGORY
print('✓ Resolution Calculator ComfyUI integration passed')
# Test Width Height Selector
@@ -354,7 +354,7 @@ jobs:
assert wh_class.RETURN_TYPES == ('INT', 'INT')
assert wh_class.RETURN_NAMES == ('width', 'height')
assert wh_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in wh_class.CATEGORY
print('✓ Width Height Selector ComfyUI integration passed')
# Test Sampler Combo
@@ -374,7 +374,7 @@ jobs:
assert 'steps' in input_types['required']
assert 'cfg' in input_types['required']
assert sampler_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in sampler_class.CATEGORY
print('✓ Sampler Combo ComfyUI integration passed')
# Test Seed History
@@ -393,7 +393,7 @@ jobs:
assert seed_class.RETURN_TYPES == ('INT',)
assert seed_class.RETURN_NAMES == ('seed',)
assert seed_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in seed_class.CATEGORY
print('✓ Seed History ComfyUI integration passed')
print('🎉 All tools ComfyUI integration readiness tests passed!')
@@ -402,7 +402,7 @@ jobs:
test-package-structure:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v5
@@ -458,7 +458,7 @@ jobs:
test-documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Test documentation completeness
run: |
+95 -7
View File
@@ -8,7 +8,14 @@
> A modular collection of essential custom ComfyUI nodes missing from the standard release.
ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped under the **"ComfyAssets"** category. Each tool is designed with clean interfaces, comprehensive testing, and optimized performance for SDXL and FLUX workflows.
ComfyUI-KikoTools provides carefully crafted, production-ready nodes under the "ComfyAssets" category.
Each tool is built with clean interfaces, thorough testing, and optimized performance for SDXL and FLUX workflows.
This project started out of frustration with keeping ComfyUI up to date and waiting for dozens of custom nodes to update—most of which I didn’t even use. After taking a hard look at my workflow, I realized I only needed one or two features from these nodes, many of which were abandoned or stuck in maintenance mode.
I tried forking, patching, and submitting merge requests, but eventually decided to create my own curated collection of tools—fully supported and maintained by me. That’s how Kiko’s Tools was born.
I’m sharing them here with the community, and I hope you find them as useful as I do.
## 🚀 Features
@@ -26,7 +33,10 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | ✍️ Text |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
| [🎬 Film Grain](#-film-grain) | Add realistic film grain effects to images | 💾 Images |
| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | 🔧 Utils |
| [🧹 Kiko Purge VRAM](#-kiko-purge-vram) | Intelligent VRAM management with detailed reporting | 🛠️ Utils |
### 🧰 xyz-helpers Tools
@@ -219,6 +229,41 @@ Adjusts image dimensions to be multiples of a specified value for model compatib
![Image to Multiple Of Example](examples/workflows/image_to_multiple_of_example.png)
#### 📉 Image Scale Down By
Efficiently scale images down by a specified factor with quality preservation.
- **Proportional Scaling**: Reduces both width and height by the same factor
- **Quality Preservation**: Uses bilinear interpolation with antialiasing
- **Batch Support**: Process multiple images simultaneously
- **Memory Efficient**: Optimized for large image batches
- **Flexible Factor**: Scale from 0.01x to 1.0x with 0.01 precision
**Use Cases:**
- Create thumbnails or preview images
- Reduce memory usage for large workflows
- Generate image pyramids for multi-scale processing
- Quick downsampling for performance optimization
- Prepare images for web display or transmission
#### 🎬 Film Grain
Add realistic analog film grain effects to generated images.
- **Realistic Grain Simulation**: Mimics actual film photography characteristics
- **Grain Size Control**: Fine to coarse grain patterns (0.25x to 2.0x)
- **Intensity Adjustment**: Variable strength from subtle to pronounced (0-10)
- **Color Saturation**: Monochrome to full color grain (0-2)
- **Shadow Lifting (Toe)**: Film-like shadow response curves
- **Red Multiplier**: Adjust red channel independently for vintage looks
- **Alpha Preservation**: Maintains transparency when present
- **ITU-R BT.709 Color Space**: Professional color handling
**Use Cases:**
- Add vintage film aesthetic to AI-generated images
- Create cinematic looks with authentic grain patterns
- Simulate different film stocks (35mm, 16mm, etc.)
- Add texture to overly smooth AI renders
- Match grain from reference photography
#### 🎛️ Flux Sampler Params
FLUX-optimized parameter generator with intelligent batch processing capabilities.
@@ -317,9 +362,9 @@ Unified interface for text encoding and sampler parameter management.
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
<div align="center">
<img src="ac-emb.png" width="30%" alt="Embedding Autocomplete" />
<img src="ac-lora.png" width="30%" alt="LoRA Autocomplete" />
<img src="ac-tag.png" width="30%" alt="Tag Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-emb.png?raw=true" width="30%" alt="Embedding Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-lora.png?raw=true" width="30%" alt="LoRA Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-tag.png?raw=true" width="30%" alt="Tag Autocomplete" />
</div>
This feature is an enhanced fork of the autocomplete functionality from [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss. We've modernized the codebase, fixed existing bugs, and added robust security features.
@@ -351,6 +396,46 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
- Original autocomplete concept by [pythongosssss](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
- Enhanced and modernized by KikoTools team
### 🧹 Kiko Purge VRAM
**Intelligent GPU memory management with threshold-based triggering and detailed reporting.**
**Key Features:**
- **4 Purge Modes**:
- `soft`: Basic garbage collection and cache clearing
- `aggressive`: Multiple GC passes with full CUDA cache clearing
- `models_only`: Unload all models and clear model cache
- `cache_only`: Clear CUDA cache without garbage collection
- **Smart Thresholds**: Only purge when memory usage exceeds specified MB limit
- **Detailed Reporting**: Shows before/after memory usage, freed MB, and timing
- **Passthrough Design**: Acts as workflow checkpoint without disrupting data flow
- **CPU Fallback**: Gracefully handles non-CUDA environments
**Use Cases:**
- Free memory between heavy processing stages
- Prevent OOM errors in complex workflows
- Debug memory usage patterns
- Optimize multi-model workflows
- Clean up after batch processing
**Parameters:**
- **anything**: Any input (passed through unchanged)
- **mode**: Purge strategy selection
- **report_memory**: Generate detailed memory statistics
- **memory_threshold_mb**: Only purge if usage exceeds (0 = always purge)
**Example Output:**
```
Memory usage (5000.0 MB) exceeds threshold (4000 MB)
Memory Purge Report
-------------------
Mode: soft
Memory Freed: 2500.0 MB
Before: 5000.0 MB used (62.5%)
After: 2500.0 MB used (31.3%)
Time: 150.0ms
```
### 💾 Kiko Save Image Features
**Use Cases:**
@@ -585,6 +670,8 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) |
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
@@ -884,8 +971,9 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 16 (10 core tools + 6 xyz-helpers)
- **Categories**: 8 emoji-based categories for better organization
- **Nodes**: 19 (13 core tools + 6 xyz-helpers)
- **Features**: Embedding Autocomplete (settings-based, not a node)
- **Categories**: 9 emoji-based categories for better organization
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
@@ -13,6 +13,7 @@ This node is based on work from [comfyui-essentials-nodes](https://github.com/cu
- **Flexible Strength Control**: Single, multiple, or range-based strength values
- **Batch Modes**: Sequential or combinatorial strength application
- **Epoch Detection**: Automatically extracts epoch numbers from filenames
- **Auto-Batching**: Automatically splits large LoRA collections into manageable chunks to prevent UI disconnection
## Node Properties
- **Category**: `ComfyAssets/🧰 xyz-helpers`
@@ -33,6 +34,11 @@ This node is based on work from [comfyui-essentials-nodes](https://github.com/cu
|-----------|------|---------|-------------|
| `include_pattern` | STRING | "" | Regex pattern to include files |
| `exclude_pattern` | STRING | "" | Regex pattern to exclude files |
| `max_loras` | INT | 50 | Maximum LoRAs to process (when auto_batch disabled) |
| `sort_order` | DROPDOWN | natural | Sorting method [natural, alphabetical, newest, oldest] |
| `auto_batch` | DROPDOWN | disabled | Enable auto-batching for large collections [disabled, enabled] |
| `batch_size` | INT | 25 | Number of LoRAs per batch when auto-batching |
| `batch_index` | INT | 0 | Which batch to output (0-based) when auto-batching |
### Strength Format Options
- **Single**: `"1.0"` - Apply same strength to all LoRAs
@@ -72,6 +78,16 @@ LoRAFolderBatch → Processing Pipeline
strength: "0.8...1.2+0.1"
```
### Auto-Batch Large Collections
```
LoRAFolderBatch → FluxSamplerParams → KSampler
folder_path: "massive_lora_collection" # 100+ files
strength: "1.0"
auto_batch: enabled
batch_size: 25
batch_index: 0 # Change to 1, 2, 3... for subsequent batches
```
## Batch Modes Explained
### Sequential Mode
@@ -86,6 +102,41 @@ Each LoRA is tested with ALL strength values:
- LoRA2 → [0.5, 0.75, 1.0]
- LoRA3 → [0.5, 0.75, 1.0]
## Auto-Batching for Large Collections
### Overview
When testing large numbers of LoRAs (e.g., 75+ files), ComfyUI can experience UI disconnections or memory issues. Auto-batching solves this by automatically splitting your LoRA collection into smaller, manageable chunks.
### How It Works
1. **Enable Auto-Batching**: Set `auto_batch` to "enabled"
2. **Set Batch Size**: Configure `batch_size` (default: 25, range: 5-100)
3. **Select Batch**: Use `batch_index` to choose which batch to process
### Example: Testing 75 LoRAs
With 75 LoRAs and batch_size=25, the system creates 3 batches:
- **Batch 0**: LoRAs 1-25 (set batch_index=0)
- **Batch 1**: LoRAs 26-50 (set batch_index=1)
- **Batch 2**: LoRAs 51-75 (set batch_index=2)
Run your workflow 3 times, changing only the `batch_index` each time.
### Visual Feedback
When auto-batching is enabled, the `lora_list` output includes batch information:
```
=== Batch 1/3 (LoRAs 1-25) ===
style-epoch-001
style-epoch-002
...
```
### Best Practices for Auto-Batching
1. **Start with Default**: Use batch_size=25 for most scenarios
2. **Adjust for Memory**: Decrease batch_size if you still experience issues
3. **Combinatorial Mode**: Be extra careful - 25 LoRAs × 3 strengths = 75 combinations
4. **Save Between Batches**: Save your results after each batch to avoid data loss
5. **Use Plot Parameters**: The batch info appears in plot visualizations for easy tracking
## File Naming Patterns
### Supported Epoch Formats
@@ -207,6 +258,7 @@ batch_mode: sequential
- **1.0.1**: Added natural sorting for epochs
- **1.0.2**: Enhanced pattern filtering
- **1.0.3**: Improved batch modes and strength parsing
- **1.0.4**: Added auto-batching for large LoRA collections
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
+7 -3
View File
@@ -11,6 +11,7 @@ from .tools.gemini_prompt import GeminiPromptNode
from .tools.image_scale_down_by import ImageScaleDownByNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.kiko_film_grain import KikoFilmGrainNode
from .tools.kiko_purge_vram import KikoPurgeVRAM
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
@@ -40,13 +41,15 @@ NODE_CLASS_MAPPINGS = {
"DisplayAny": DisplayAnyNode,
"DisplayText": DisplayTextNode,
"KikoFilmGrain": KikoFilmGrainNode,
"KikoPurgeVRAM": KikoPurgeVRAM,
"SamplerSelectHelper": SamplerSelectHelperNode,
"SchedulerSelectHelper": SchedulerSelectHelperNode,
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"KikoEmbeddingAutocomplete": KikoEmbeddingAutocomplete,
# Note: KikoEmbeddingAutocomplete is not registered as a node
# It's a settings-only feature accessed through ComfyUI settings menu
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -62,14 +65,15 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GeminiPrompt": "Gemini Prompt Engineer",
"DisplayAny": "Display Any",
"DisplayText": "Display Text",
"KikoFilmGrain": "Kiko Film Grain",
"KikoFilmGrain": "Film Grain",
"KikoPurgeVRAM": "Kiko Purge VRAM",
"SamplerSelectHelper": "Sampler Select Helper",
"SchedulerSelectHelper": "Scheduler Select Helper",
"TextEncodeSamplerParams": "Text Encode for Sampler Params",
"FluxSamplerParams": "Flux Sampler Parameters",
"PlotParameters+": "Plot Parameters",
"LoRAFolderBatch": "LoRA Folder Batch",
"KikoEmbeddingAutocomplete": "🫶 Embedding Autocomplete Configuration",
# KikoEmbeddingAutocomplete removed - settings only, not a node
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+8
View File
@@ -0,0 +1,8 @@
"""AnyType for wildcard input matching in ComfyUI nodes."""
class AnyType(str):
"""A special type that matches any input type in ComfyUI."""
def __ne__(self, other):
return False
+1 -1
View File
@@ -20,7 +20,7 @@ class ComfyAssetsBaseNode:
- Consistent return type handling
"""
CATEGORY = "ComfyAssets"
CATEGORY = "🫶 ComfyAssets"
def validate_inputs(self, **kwargs) -> None:
"""
+1 -1
View File
@@ -38,7 +38,7 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
return True
RETURN_TYPES = ("STRING",)
CATEGORY = "ComfyAssets/👁️ Display"
CATEGORY = "🫶 ComfyAssets/👁️ Display"
RETURN_NAMES = ("display_text",)
FUNCTION = "display"
OUTPUT_NODE = True # This node displays output in the UI
+1 -1
View File
@@ -19,7 +19,7 @@ class DisplayTextNode(ComfyAssetsBaseNode):
RETURN_NAMES = ("text",)
OUTPUT_NODE = True
FUNCTION = "display_text"
CATEGORY = "ComfyAssets/👁️ Display"
CATEGORY = "🫶 ComfyAssets/👁️ Display"
DESCRIPTION = """
Displays text in the UI with a copy-to-clipboard feature.
@@ -17,7 +17,7 @@ class KikoEmbeddingAutocomplete:
"""Node that provides embedding autocomplete functionality."""
DISPLAY_NAME = "🫶 Embedding Autocomplete Settings"
CATEGORY = "ComfyAssets"
CATEGORY = "🫶 ComfyAssets"
# Settings definition for the settings registry
SETTINGS = {
+1 -1
View File
@@ -96,7 +96,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
FUNCTION = "create_empty_latent"
CATEGORY = "ComfyAssets/📦 Latents"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def create_empty_latent(
self, preset: str, width: int, height: int, batch_size: int
+1 -1
View File
@@ -51,7 +51,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "negative_prompt")
FUNCTION = "generate_prompt"
CATEGORY = "ComfyAssets/🧠 Prompts"
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = """
Analyzes images using Google's Gemini AI to generate optimized prompts.
+1 -1
View File
@@ -35,7 +35,7 @@ class ImageScaleDownByNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("IMAGE",)
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("images",)
FUNCTION = "scale_down"
+1 -1
View File
@@ -36,7 +36,7 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("IMAGE",)
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("image",)
FUNCTION = "process"
+1 -1
View File
@@ -85,7 +85,7 @@ class KikoFilmGrainNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "apply_grain"
CATEGORY = "ComfyAssets/image"
CATEGORY = "🫶 ComfyAssets/💾 Images"
DESCRIPTION = "Apply realistic film grain effect with customizable parameters"
def apply_grain(
@@ -0,0 +1,3 @@
from .node import KikoPurgeVRAM
__all__ = ["KikoPurgeVRAM"]
+130
View File
@@ -0,0 +1,130 @@
import gc
from typing import Dict, Tuple
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
try:
import comfy.model_management as mm
COMFY_AVAILABLE = True
except ImportError:
COMFY_AVAILABLE = False
def get_memory_stats() -> Dict[str, float]:
stats = {
"cuda_available": False,
"free_mb": 0,
"total_mb": 0,
"used_mb": 0,
"used_percent": 0,
}
if TORCH_AVAILABLE and torch.cuda.is_available():
stats["cuda_available"] = True
free, total = torch.cuda.mem_get_info()
free_mb = free / (1024 * 1024)
total_mb = total / (1024 * 1024)
used_mb = total_mb - free_mb
stats["free_mb"] = free_mb
stats["total_mb"] = total_mb
stats["used_mb"] = used_mb
stats["used_percent"] = (used_mb / total_mb) * 100 if total_mb > 0 else 0
return stats
def purge_memory(mode: str = "soft", unload_models: bool = False) -> float:
before_stats = get_memory_stats()
if mode == "soft":
# Basic garbage collection and cache clearing
gc.collect()
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.empty_cache()
elif mode == "aggressive":
# Multiple passes of garbage collection with full cache clearing
gc.collect()
gc.collect()
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
elif mode == "models_only":
# Only unload models
if COMFY_AVAILABLE:
mm.unload_all_models()
mm.soft_empty_cache()
gc.collect()
elif mode == "cache_only":
# Only clear cache without garbage collection
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.empty_cache()
# Handle model unloading for non-model-specific modes
if unload_models and mode not in ["models_only"]:
if COMFY_AVAILABLE:
mm.unload_all_models()
mm.soft_empty_cache()
after_stats = get_memory_stats()
freed_mb = before_stats["used_mb"] - after_stats["used_mb"]
return max(0, freed_mb)
def format_memory_report(
before: Dict[str, float], after: Dict[str, float], mode: str, elapsed_ms: float
) -> str:
if not before.get("cuda_available", True):
return (
"Memory Purge Report\n"
"-------------------\n"
"CUDA not available - CPU memory management only\n"
f"Mode: {mode}\n"
f"Time: {elapsed_ms:.1f}ms"
)
freed_mb = before["used_mb"] - after["used_mb"]
report = [
"Memory Purge Report",
"-------------------",
f"Mode: {mode}",
f"Memory Freed: {freed_mb:.1f} MB",
f"Before: {before['used_mb']:.1f} MB used ({before['used_percent']:.1f}%)",
f"After: {after['used_mb']:.1f} MB used ({after['used_percent']:.1f}%)",
f"Time: {elapsed_ms:.1f}ms",
]
return "\n".join(report)
def should_purge(threshold_mb: int) -> Tuple[bool, str]:
if threshold_mb <= 0:
return True, ""
stats = get_memory_stats()
if not stats["cuda_available"]:
return True, "CUDA not available, proceeding with CPU memory management"
if stats["used_mb"] >= threshold_mb:
return (
True,
f"Memory usage ({stats['used_mb']:.1f} MB) exceeds threshold ({threshold_mb} MB)",
)
else:
return (
False,
f"Memory usage ({stats['used_mb']:.1f} MB) below threshold ({threshold_mb} MB)",
)
+102
View File
@@ -0,0 +1,102 @@
import time
from typing import Any, Dict, Tuple
try:
from ...base.base_node import ComfyAssetsBaseNode as BaseNode
from ...base.any_type import AnyType
except ImportError:
# Fallback for testing environment
from kikotools.base.base_node import ComfyAssetsBaseNode as BaseNode
from kikotools.base.any_type import AnyType
from .logic import get_memory_stats, purge_memory, format_memory_report, should_purge
any_type = AnyType("*")
class KikoPurgeVRAM(BaseNode):
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"anything": (any_type, {}),
"mode": (
["soft", "aggressive", "models_only", "cache_only"],
{
"default": "soft",
"tooltip": "Purge mode: soft (basic), aggressive (thorough), models_only (unload models), cache_only (clear cache)",
},
),
"report_memory": (
"BOOLEAN",
{
"default": True,
"tooltip": "Generate detailed memory usage report",
},
),
},
"optional": {
"memory_threshold_mb": (
"INT",
{
"default": 0,
"min": 0,
"max": 48000,
"step": 100,
"tooltip": "Only purge if memory usage exceeds this threshold (0 = always purge)",
},
),
},
}
RETURN_TYPES = (any_type, "STRING")
RETURN_NAMES = ("passthrough", "memory_report")
FUNCTION = "purge_vram"
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
OUTPUT_NODE = True
DESCRIPTION = "Purge VRAM to free up GPU memory during workflow execution. Passes through any input unchanged."
def purge_vram(
self,
anything: Any,
mode: str,
report_memory: bool,
memory_threshold_mb: int = 0,
) -> Tuple[Any, str]:
# Check if we should purge based on threshold
should_run, threshold_msg = should_purge(memory_threshold_mb)
if not should_run:
if report_memory:
return anything, f"Memory purge skipped: {threshold_msg}"
else:
return anything, ""
# Get before stats
before_stats = get_memory_stats() if report_memory else None
start_time = time.time()
# Determine if we should unload models
unload_models = mode in ["models_only", "aggressive"]
# Perform memory purge
purge_memory(mode=mode, unload_models=unload_models)
# Calculate elapsed time
elapsed_ms = (time.time() - start_time) * 1000
# Generate report if requested
if report_memory:
after_stats = get_memory_stats()
report = format_memory_report(before_stats, after_stats, mode, elapsed_ms)
if threshold_msg and memory_threshold_mb > 0:
report = f"{threshold_msg}\n\n{report}"
else:
report = ""
# Pass through the input unchanged
return anything, report
NODE_CLASS_MAPPINGS = {"KikoPurgeVRAM": KikoPurgeVRAM}
NODE_DISPLAY_NAME_MAPPINGS = {"KikoPurgeVRAM": "Kiko Purge VRAM"}
+1 -1
View File
@@ -95,7 +95,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ()
CATEGORY = "ComfyAssets/💾 Images"
CATEGORY = "🫶 ComfyAssets/💾 Images"
FUNCTION = "save_images"
OUTPUT_NODE = True
@@ -60,7 +60,7 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("INT", "INT")
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("width", "height")
FUNCTION = "calculate_resolution"
@@ -53,7 +53,6 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
"min": 1.0,
"max": 15.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG",
},
),
@@ -63,7 +62,7 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
FUNCTION = "get_combo"
CATEGORY = "ComfyAssets/🌀 Samplers"
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
def get_combo(
self, sampler: str, sched: str, steps: int, cfg: float
+1 -2
View File
@@ -58,7 +58,6 @@ class SamplerComboNode(ComfyAssetsBaseNode):
"min": 0.0,
"max": 20.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG scale (0-20)",
},
),
@@ -68,7 +67,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
FUNCTION = "get_sampler_combo"
CATEGORY = "ComfyAssets/🌀 Samplers"
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
def get_sampler_combo(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
+1 -1
View File
@@ -38,7 +38,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "output_seed"
CATEGORY = "ComfyAssets/🌱 Seeds"
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
def output_seed(self, seed: int) -> Tuple[int]:
"""
@@ -85,7 +85,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "get_dimensions"
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
def get_dimensions(self, preset: str, width: int, height: int) -> Tuple[int, int]:
"""
@@ -125,7 +125,7 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("LATENT", "SAMPLER_PARAMS")
RETURN_NAMES = ("latent", "params")
FUNCTION = "process_batch"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def process_batch(
self,
@@ -352,6 +352,12 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
< len(lora_strength[lora_file_idx])
else 0
)
# Add batch info if available
if "batch_info" in loras:
param_record["lora_batch"] = (
f"Batch {loras['batch_info']['index'] + 1}/"
f"{loras['batch_info']['total']}"
)
out_params.append(param_record)
@@ -2,8 +2,7 @@
import os
import re
from typing import List, Dict, Any, Tuple, Optional
from pathlib import Path
from typing import List, Dict, Any
import logging
logger = logging.getLogger(__name__)
@@ -36,7 +35,7 @@ def get_lora_folders() -> List[str]:
return [".", "flux", "sdxl", "sd15"]
def scan_folder_for_loras(folder_path: str) -> List[str]:
def scan_folder_for_loras(folder_path: str) -> List[str]: # noqa: C901
"""
Scan a folder for LoRA files (.safetensors).
@@ -52,70 +51,85 @@ def scan_folder_for_loras(folder_path: str) -> List[str]:
# Get all LoRA paths from ComfyUI (includes extra_model_paths)
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
# Determine the full path and base lora path
full_path = None
base_lora_path = None
# Check if this is an absolute path
if os.path.isabs(folder_path):
full_path = folder_path
# Try to find which lora base path this belongs to
rel_folder = None
# Check if this path is inside any of the known lora directories
for lora_base in lora_paths:
try:
potential_rel = os.path.relpath(full_path, lora_base)
if not potential_rel.startswith(".."):
# This path is inside this lora base
rel_folder = potential_rel
break
except ValueError:
# Different drives on Windows
continue
# Normalize paths for comparison
norm_full = os.path.normpath(full_path)
norm_base = os.path.normpath(lora_base)
if rel_folder is None:
# Path is outside all known lora directories
# Try to extract a relative path that might work
# Check if path contains common lora folder structures
path_parts = full_path.replace("\\", "/").split("/")
if "lora" in path_parts or "loras" in path_parts:
# Find index after lora/loras
# Check if full_path starts with this lora_base
if norm_full.startswith(norm_base):
base_lora_path = lora_base
break
# Also check if the path is a subdirectory under lora/loras
if "lora" in norm_full.lower():
# Find the lora or loras directory in the path
path_parts = norm_full.replace("\\", "/").split("/")
for i, part in enumerate(path_parts):
if part in ["lora", "loras"]:
# Use everything after lora/loras as relative path
rel_folder = "/".join(path_parts[i + 1 :])
break
if rel_folder is None:
# Last resort: use last two directories as relative path
rel_folder = (
"/".join(path_parts[-2:])
if len(path_parts) >= 2
else path_parts[-1]
)
if part.lower() in ["lora", "loras"]:
# Check if this matches our lora_base
potential_base = "/".join(path_parts[: i + 1])
if os.path.normpath(potential_base) == norm_base:
base_lora_path = lora_base
break
if base_lora_path:
break
else:
# Relative path provided
full_path = (
os.path.join(lora_paths[0], folder_path) if lora_paths else folder_path
)
rel_folder = folder_path if folder_path != "." else ""
base_lora_path = lora_paths[0] if lora_paths else ""
full_path = os.path.join(base_lora_path, folder_path)
if not os.path.exists(full_path):
logger.warning(f"Folder does not exist: {full_path}")
return []
# Scan for .safetensors files
# Scan for .safetensors files recursively
lora_files = []
for file in os.listdir(full_path):
if file.endswith(".safetensors"):
# Store relative path from lora base
if rel_folder and rel_folder != ".":
lora_files.append(os.path.join(rel_folder, file).replace("\\", "/"))
else:
lora_files.append(file)
for root, _, files in os.walk(full_path):
for file in files:
if file.endswith(".safetensors"):
# Get the full path to the file
file_full_path = os.path.join(root, file)
# Calculate the correct relative path for ComfyUI
if base_lora_path:
# Path is inside a known lora directory
try:
rel_path = os.path.relpath(file_full_path, base_lora_path)
lora_files.append(rel_path.replace("\\", "/"))
except ValueError:
# Different drives on Windows, use path relative to scan folder
rel_path = os.path.relpath(file_full_path, full_path)
if rel_path == ".":
lora_files.append(file)
else:
lora_files.append(rel_path.replace("\\", "/"))
else:
# Path is outside known lora directories
# Return path relative to the scanned folder
rel_path = os.path.relpath(file_full_path, full_path)
if rel_path == ".":
lora_files.append(file)
else:
lora_files.append(rel_path.replace("\\", "/"))
# Sort naturally (handles epoch numbers properly)
lora_files = natural_sort(lora_files)
logger.info(
f"Found {len(lora_files)} LoRA files in {folder_path}, returning paths relative to lora base"
)
logger.info(f"Found {len(lora_files)} LoRA files in {folder_path}")
if lora_files and logger.isEnabledFor(logging.DEBUG):
logger.debug(f"Base lora path: {base_lora_path}")
logger.debug(f"Full scan path: {full_path}")
logger.debug(f"First few LoRA paths returned: {lora_files[:3]}")
return lora_files
except Exception as e:
@@ -123,6 +137,34 @@ def scan_folder_for_loras(folder_path: str) -> List[str]:
return []
def sort_lora_files(lora_files: List[str], sort_order: str) -> List[str]:
"""
Sort LoRA files based on the specified order.
Args:
lora_files: List of LoRA file paths
sort_order: Type of sorting ("natural", "alphabetical", "newest", "oldest")
Returns:
Sorted list of LoRA files
"""
if sort_order == "natural":
return natural_sort(lora_files)
elif sort_order == "alphabetical":
return sorted(lora_files)
elif sort_order in ["newest", "oldest"]:
# For time-based sorting, we need the actual file stats
# Since we only have relative paths, we'll sort by name for now
# This could be enhanced if we have access to file stats
sorted_files = natural_sort(lora_files)
if sort_order == "oldest":
return sorted_files
else: # newest
return sorted_files[::-1]
else:
return lora_files
def natural_sort(items: List[str]) -> List[str]:
"""
Sort strings naturally, handling numbers properly.
@@ -140,10 +182,17 @@ def natural_sort(items: List[str]) -> List[str]:
# Split on digits and filter out empty strings
parts = [atoi(c) for c in re.split(r"(\d+)", text) if c]
# Put files without numbers first
if not any(isinstance(p, int) for p in parts):
return [0] + parts
return parts
# Convert to tuple of (type_order, value) to ensure consistent comparison
# Integers get type_order 0, strings get type_order 1
typed_parts = []
for part in parts:
if isinstance(part, int):
typed_parts.append((0, part))
else:
typed_parts.append((1, part))
return typed_parts
return sorted(items, key=natural_key)
@@ -183,7 +232,7 @@ def filter_loras_by_pattern(
return filtered
def parse_strength_string(strength_str: str) -> List[float]:
def parse_strength_string(strength_str: str) -> List[float]: # noqa: C901
"""
Parse strength string into list of values.
@@ -278,6 +327,57 @@ def create_lora_params(
return {"loras": lora_files, "strengths": strength_lists}
def create_lora_params_batched(
lora_files: List[str],
strengths: List[float],
batch_mode: str = "sequential",
batch_size: int = 25,
) -> List[Dict[str, Any]]:
"""
Create multiple LORA_PARAMS structures for FluxSamplerParams, batched for stability.
Args:
lora_files: List of LoRA file paths
strengths: List of strength values to test
batch_mode: How to batch ("sequential" or "combinatorial")
batch_size: Maximum number of LoRAs per batch
Returns:
List of LORA_PARAMS dictionaries, each with batch info
"""
if not lora_files:
logger.warning("No LoRA files provided")
return [{"loras": [], "strengths": [], "batch_info": {"index": 0, "total": 0}}]
# Split lora_files into batches
batches = []
total_batches = (len(lora_files) + batch_size - 1) // batch_size
for i in range(0, len(lora_files), batch_size):
batch_loras = lora_files[i : i + batch_size]
batch_index = i // batch_size
# Create params for this batch
params = create_lora_params(batch_loras, strengths, batch_mode)
# Add batch tracking info
params["batch_info"] = {
"index": batch_index,
"total": total_batches,
"start_idx": i,
"end_idx": min(i + batch_size, len(lora_files)),
"size": len(batch_loras),
}
batches.append(params)
logger.info(f"Created {total_batches} batches of LoRAs (batch size: {batch_size})")
for i, batch in enumerate(batches):
logger.info(f" Batch {i}: {batch['batch_info']['size']} LoRAs")
return batches
def get_lora_info(lora_file: str) -> Dict[str, Any]:
"""
Extract information from LoRA filename.
@@ -313,15 +413,25 @@ def validate_folder_path(folder_path: str) -> bool:
Validate that the folder path exists and is accessible.
Args:
folder_path: Folder path to validate
folder_path: Folder path to validate (absolute or relative)
Returns:
True if valid
"""
try:
# Handle absolute paths
if os.path.isabs(folder_path):
return os.path.exists(folder_path) and os.path.isdir(folder_path)
# Handle relative paths
import folder_paths
lora_base_path = folder_paths.folder_names_and_paths["loras"][0][0]
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
if not lora_paths:
return False
lora_base_path = lora_paths[0]
if folder_path == ".":
full_path = lora_base_path
@@ -1,15 +1,14 @@
"""LoRA Folder Batch node for ComfyUI."""
from typing import Tuple, Any, Dict, List
import os
from typing import Tuple, Any, Dict
import logging
from ....base.base_node import ComfyAssetsBaseNode
from .logic import (
get_lora_folders,
scan_folder_for_loras,
filter_loras_by_pattern,
parse_strength_string,
create_lora_params,
create_lora_params_batched,
get_lora_info,
validate_folder_path,
)
@@ -74,21 +73,67 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
"tooltip": "Regex pattern to exclude files (e.g., 'test|backup')",
},
),
"max_loras": (
"INT",
{
"default": 50,
"min": 1,
"max": 500,
"tooltip": "Maximum number of LoRAs to process (to prevent UI disconnection)",
},
),
"auto_batch": (
["disabled", "enabled"],
{
"default": "disabled",
"tooltip": "Auto-batch large sets into chunks of 25 LoRAs",
},
),
"batch_size": (
"INT",
{
"default": 25,
"min": 5,
"max": 100,
"tooltip": "Number of LoRAs per batch when auto-batching",
},
),
"batch_index": (
"INT",
{
"default": 0,
"min": 0,
"max": 100,
"tooltip": "Which batch to output (0-based index)",
},
),
"sort_order": (
["natural", "alphabetical", "newest", "oldest"],
{
"default": "natural",
"tooltip": "How to sort the LoRA files",
},
),
},
}
RETURN_TYPES = ("LORA_PARAMS", "STRING", "INT")
RETURN_NAMES = ("lora_params", "lora_list", "lora_count")
FUNCTION = "batch_loras"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def batch_loras(
def batch_loras( # noqa: C901
self,
folder_path: str,
strength: str,
batch_mode: str,
include_pattern: str = "",
exclude_pattern: str = "",
max_loras: int = 50,
sort_order: str = "natural",
auto_batch: str = "disabled",
batch_size: int = 25,
batch_index: int = 0,
) -> Tuple[Dict[str, Any], str, int]:
"""
Batch process LoRAs from a folder.
@@ -137,37 +182,98 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
self.log_info("No LoRAs left after filtering")
return ({"loras": [], "strengths": []}, "", 0)
# Apply sorting based on sort_order
if sort_order != "natural":
from .logic import sort_lora_files
lora_files = sort_lora_files(lora_files, sort_order)
# Only limit if NOT auto-batching
if auto_batch == "disabled" and len(lora_files) > max_loras:
self.log_info(
f"⚠️ Limiting to {max_loras} LoRAs (found {len(lora_files)}). "
f"Enable auto_batch or increase max_loras to process more."
)
lora_files = lora_files[:max_loras]
# Parse strength values
strengths = parse_strength_string(strength)
self.log_info(f"Using strength values: {strengths}")
# Create LORA_PARAMS
lora_params = create_lora_params(lora_files, strengths, batch_mode)
# Create LORA_PARAMS with auto-batching if enabled
if auto_batch == "enabled" and len(lora_files) > batch_size:
all_batches = create_lora_params_batched(
lora_files, strengths, batch_mode, batch_size
)
# Create info string
# Check if batch_index is valid
if batch_index >= len(all_batches):
self.log_info(
f"⚠️ Batch index {batch_index} out of range. "
f"Only {len(all_batches)} batches available. Using batch 0."
)
batch_index = 0
lora_params = all_batches[batch_index]
# Update lora_files to only include current batch for list display
batch_start = lora_params["batch_info"]["start_idx"]
batch_end = lora_params["batch_info"]["end_idx"]
lora_files_for_display = lora_files[batch_start:batch_end]
else:
# Regular single batch mode
lora_params = create_lora_params(lora_files, strengths, batch_mode)
lora_files_for_display = lora_files
# Create info string for current batch only
lora_list = []
for lora_file in lora_files:
for lora_file in lora_files_for_display:
info = get_lora_info(lora_file)
if info["epoch"] is not None:
lora_list.append(f"{info['name']} (epoch {info['epoch']})")
else:
lora_list.append(info["name"])
lora_list_str = "\n".join(lora_list)
# Calculate total combinations
if batch_mode == "combinatorial":
total_combos = len(lora_files) * len(strengths)
# Add batch info to the list string if auto-batching
if auto_batch == "enabled" and "batch_info" in lora_params:
batch_header = (
f"=== Batch {batch_index + 1}/{lora_params['batch_info']['total']} "
f"(LoRAs {lora_params['batch_info']['start_idx'] + 1}-"
f"{lora_params['batch_info']['end_idx']}) ===\n\n"
)
lora_list_str = batch_header + "\n".join(lora_list)
else:
total_combos = len(lora_files)
lora_list_str = "\n".join(lora_list)
self.log_info(
f"Created batch with {len(lora_files)} LoRAs, "
f"{len(strengths)} strength values, "
f"{total_combos} total combinations"
)
# Calculate total combinations for current batch
current_batch_loras = len(lora_files_for_display)
if batch_mode == "combinatorial":
total_combos = current_batch_loras * len(strengths)
else:
total_combos = current_batch_loras
return (lora_params, lora_list_str, len(lora_files))
# Warn if generating many combinations
if total_combos > 100:
self.log_info(
f"⚠️ WARNING: Generating {total_combos} combinations! "
f"This may cause UI disconnection. Consider reducing max_loras or strength values."
)
if auto_batch == "enabled" and "batch_info" in lora_params:
self.log_info(
f"Output batch {batch_index + 1}/{lora_params['batch_info']['total']} "
f"with {current_batch_loras} LoRAs, "
f"{len(strengths)} strength values, "
f"{total_combos} total combinations"
)
else:
self.log_info(
f"Created batch with {current_batch_loras} LoRAs, "
f"{len(strengths)} strength values, "
f"{total_combos} total combinations"
)
return (lora_params, lora_list_str, current_batch_loras)
except Exception as e:
self.handle_error(f"Error creating LoRA batch: {str(e)}", e)
@@ -203,7 +203,11 @@ def format_parameter_text(param: Dict, mode: str = "full") -> str:
# Optional LoRA line
if "lora" in param and param["lora"]:
lora_name = param["lora"][:32] if len(param["lora"]) > 32 else param["lora"]
lines.append(f"LoRA: {lora_name}, str: {param.get('lora_strength', 'N/A')}")
lora_line = f"LoRA: {lora_name}, str: {param.get('lora_strength', 'N/A')}"
# Add batch info if available
if "lora_batch" in param:
lora_line += f" [{param['lora_batch']}]"
lines.append(lora_line)
return "\n".join(lines)
@@ -113,7 +113,7 @@ class PlotParametersNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "plot_parameters"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def plot_parameters(
self,
@@ -30,7 +30,7 @@ class SamplerSelectHelperNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_samplers",)
FUNCTION = "select_samplers"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def select_samplers(self, **sampler_flags) -> Tuple[str]:
"""
@@ -30,7 +30,7 @@ class SchedulerSelectHelperNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_schedulers",)
FUNCTION = "select_schedulers"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def select_schedulers(self, **scheduler_flags) -> Tuple[str]:
"""
@@ -40,7 +40,7 @@ class TextEncodeSamplerParamsNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditioning",)
FUNCTION = "encode_prompts"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def encode_prompts(self, text: str, clip: Any) -> Tuple[Any]:
"""
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.12"
version = "1.0.18"
license = {text = "MIT"}
dependencies = []
+1 -1
View File
@@ -12,7 +12,7 @@ def test_import():
assert (
KikoEmbeddingAutocomplete.DISPLAY_NAME == "🫶 Embedding Autocomplete Settings"
)
assert KikoEmbeddingAutocomplete.CATEGORY == "ComfyAssets"
assert KikoEmbeddingAutocomplete.CATEGORY == "🫶 ComfyAssets"
def test_settings_defined():
+4 -4
View File
@@ -14,7 +14,7 @@ class TestComfyAssetsBaseNode:
def test_category_is_comfy_assets(self):
"""Test that CATEGORY is set to ComfyAssets"""
assert ComfyAssetsBaseNode.CATEGORY == "ComfyAssets"
assert ComfyAssetsBaseNode.CATEGORY == "🫶 ComfyAssets"
def test_validate_inputs_default_implementation(self):
"""Test default validate_inputs does nothing"""
@@ -69,7 +69,7 @@ class TestComfyAssetsBaseNode:
assert isinstance(info, dict)
assert info["class_name"] == "ComfyAssetsBaseNode"
assert info["category"] == "ComfyAssets"
assert info["category"] == "🫶 ComfyAssets"
assert info["function"] == "Unknown" # Base class doesn't have FUNCTION
assert info["return_types"] == ()
assert info["return_names"] == ()
@@ -91,14 +91,14 @@ class TestConcreteNodeInheritance:
def test_concrete_node_inherits_category(self):
"""Test concrete node inherits ComfyAssets category"""
assert MockConcreteNode.CATEGORY == "ComfyAssets"
assert MockConcreteNode.CATEGORY == "🫶 ComfyAssets"
def test_concrete_node_get_info_includes_specific_attributes(self):
"""Test concrete node info includes its specific attributes"""
info = MockConcreteNode.get_node_info()
assert info["class_name"] == "MockConcreteNode"
assert info["category"] == "ComfyAssets"
assert info["category"] == "🫶 ComfyAssets"
assert info["function"] == "mock_function"
assert info["return_types"] == ("STRING", "INT")
assert info["return_names"] == ("text", "number")
+1 -1
View File
@@ -40,7 +40,7 @@ class TestDisplayAnyNode:
def test_node_properties(self):
"""Test node has correct properties."""
assert DisplayAnyNode.CATEGORY == "ComfyAssets/👁️ Display"
assert DisplayAnyNode.CATEGORY == "🫶 ComfyAssets/👁️ Display"
assert DisplayAnyNode.FUNCTION == "display"
assert DisplayAnyNode.RETURN_TYPES == ("STRING",)
assert DisplayAnyNode.RETURN_NAMES == ("display_text",)
+1 -1
View File
@@ -134,7 +134,7 @@ class TestEmptyLatentBatchNode:
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT")
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent", "width", "height")
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
assert EmptyLatentBatchNode.CATEGORY == "ComfyAssets/📦 Latents"
assert EmptyLatentBatchNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
def test_create_empty_latent_basic(self):
"""Test basic empty latent creation through node."""
+1 -1
View File
@@ -26,7 +26,7 @@ class TestGeminiPromptNode:
def test_node_properties(self):
"""Test node has correct properties."""
assert GeminiPromptNode.CATEGORY == "ComfyAssets/🧠 Prompts"
assert GeminiPromptNode.CATEGORY == "🫶 ComfyAssets/🧠 Prompts"
assert GeminiPromptNode.FUNCTION == "generate_prompt"
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
+1 -1
View File
@@ -145,7 +145,7 @@ class TestImageScaleDownByNode:
def test_category_is_comfyassets(self):
"""Test that the node is in the ComfyAssets category."""
assert ImageScaleDownByNode.CATEGORY == "ComfyAssets/🖼️ Resolution"
assert ImageScaleDownByNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
def test_scale_down_with_batch(self, node):
"""Test scaling down with batch of images."""
@@ -118,7 +118,7 @@ class TestImageToMultipleOfNode:
assert ImageToMultipleOfNode.RETURN_TYPES == ("IMAGE",)
assert ImageToMultipleOfNode.RETURN_NAMES == ("image",)
assert ImageToMultipleOfNode.FUNCTION == "process"
assert ImageToMultipleOfNode.CATEGORY == "ComfyAssets/🖼️ Resolution"
assert ImageToMultipleOfNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
def test_node_process_center_crop(self):
"""Test node processing with center crop."""
+301
View File
@@ -0,0 +1,301 @@
import sys
from unittest.mock import patch, MagicMock
import pytest
# Mock comfy modules
mock_mm = MagicMock()
sys.modules["comfy"] = MagicMock()
sys.modules["comfy.model_management"] = mock_mm
from kikotools.tools.kiko_purge_vram.logic import (
purge_memory,
get_memory_stats,
format_memory_report,
)
# Ensure mm is available in the logic module after import
import kikotools.tools.kiko_purge_vram.logic as logic_module
logic_module.mm = mock_mm
class TestMemoryStats:
@patch("torch.cuda.is_available")
@patch("torch.cuda.mem_get_info")
def test_get_memory_stats_with_cuda(self, mock_mem_info, mock_cuda_available):
mock_cuda_available.return_value = True
mock_mem_info.return_value = (4000000000, 8000000000) # 4GB free, 8GB total
stats = get_memory_stats()
assert stats["cuda_available"] is True
assert stats["free_mb"] == pytest.approx(3814.7, rel=0.1)
assert stats["total_mb"] == pytest.approx(7629.4, rel=0.1)
assert stats["used_mb"] == pytest.approx(3814.7, rel=0.1)
assert stats["used_percent"] == pytest.approx(50.0, rel=0.1)
@patch("torch.cuda.is_available")
def test_get_memory_stats_without_cuda(self, mock_cuda_available):
mock_cuda_available.return_value = False
stats = get_memory_stats()
assert stats["cuda_available"] is False
assert stats["free_mb"] == 0
assert stats["total_mb"] == 0
assert stats["used_mb"] == 0
assert stats["used_percent"] == 0
class TestMemoryPurge:
@patch("torch.cuda.is_available")
@patch("torch.cuda.empty_cache")
@patch("torch.cuda.ipc_collect")
@patch("gc.collect")
def test_purge_memory_soft_mode(
self, mock_gc, mock_ipc, mock_empty_cache, mock_cuda
):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 4000, "free_mb": 4000},
{"used_mb": 2000, "free_mb": 6000},
]
freed_mb = purge_memory(mode="soft", unload_models=False)
mock_gc.assert_called_once()
mock_empty_cache.assert_called_once()
mock_ipc.assert_not_called()
assert freed_mb == 2000
@patch("torch.cuda.is_available")
@patch("torch.cuda.empty_cache")
@patch("torch.cuda.ipc_collect")
@patch("torch.cuda.synchronize")
@patch("gc.collect")
def test_purge_memory_aggressive_mode(
self, mock_gc, mock_sync, mock_ipc, mock_empty_cache, mock_cuda
):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 4000, "free_mb": 4000},
{"used_mb": 1500, "free_mb": 6500},
]
freed_mb = purge_memory(mode="aggressive", unload_models=False)
assert mock_gc.call_count == 2
mock_empty_cache.assert_called()
mock_ipc.assert_called_once()
mock_sync.assert_called_once()
assert freed_mb == 2500
@patch("kikotools.tools.kiko_purge_vram.logic.COMFY_AVAILABLE", True)
@patch("torch.cuda.is_available")
@patch("gc.collect")
def test_purge_memory_models_only(self, mock_gc, mock_cuda):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 6000, "free_mb": 2000},
{"used_mb": 1000, "free_mb": 7000},
]
freed_mb = purge_memory(mode="models_only", unload_models=True)
mock_mm.unload_all_models.assert_called_once()
mock_mm.soft_empty_cache.assert_called_once()
mock_gc.assert_called()
assert freed_mb == 5000
@patch("torch.cuda.is_available")
@patch("torch.cuda.empty_cache")
@patch("gc.collect")
def test_purge_memory_cache_only(self, mock_gc, mock_empty_cache, mock_cuda):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 3000, "free_mb": 5000},
{"used_mb": 2500, "free_mb": 5500},
]
freed_mb = purge_memory(mode="cache_only", unload_models=False)
mock_gc.assert_not_called()
mock_empty_cache.assert_called_once()
assert freed_mb == 500
@patch("torch.cuda.is_available")
def test_purge_memory_no_cuda(self, mock_cuda):
mock_cuda.return_value = False
with patch("gc.collect") as mock_gc:
freed_mb = purge_memory(mode="soft", unload_models=False)
mock_gc.assert_called_once()
assert freed_mb == 0
class TestMemoryReport:
def test_format_memory_report_with_improvement(self):
before = {
"used_mb": 4000,
"free_mb": 4000,
"total_mb": 8000,
"used_percent": 50,
}
after = {"used_mb": 2000, "free_mb": 6000, "total_mb": 8000, "used_percent": 25}
report = format_memory_report(before, after, mode="soft", elapsed_ms=150)
assert "Memory Purge Report" in report
assert "Mode: soft" in report
assert "Memory Freed: 2000.0 MB" in report
assert "Before: 4000.0 MB used (50.0%)" in report
assert "After: 2000.0 MB used (25.0%)" in report
assert "Time: 150.0ms" in report
def test_format_memory_report_no_improvement(self):
before = {
"used_mb": 2000,
"free_mb": 6000,
"total_mb": 8000,
"used_percent": 25,
}
after = {"used_mb": 2000, "free_mb": 6000, "total_mb": 8000, "used_percent": 25}
report = format_memory_report(before, after, mode="cache_only", elapsed_ms=50)
assert "Memory Freed: 0.0 MB" in report
assert "Time: 50.0ms" in report
def test_format_memory_report_no_cuda(self):
before = {
"used_mb": 0,
"free_mb": 0,
"total_mb": 0,
"used_percent": 0,
"cuda_available": False,
}
after = {
"used_mb": 0,
"free_mb": 0,
"total_mb": 0,
"used_percent": 0,
"cuda_available": False,
}
report = format_memory_report(before, after, mode="soft", elapsed_ms=10)
assert "CUDA not available" in report
class TestKikoPurgeVRAMNode:
@patch("kikotools.tools.kiko_purge_vram.node.format_memory_report")
@patch("kikotools.tools.kiko_purge_vram.node.purge_memory")
@patch("kikotools.tools.kiko_purge_vram.node.get_memory_stats")
@patch("kikotools.tools.kiko_purge_vram.node.should_purge")
def test_node_execute_with_threshold(
self, mock_should_purge, mock_stats, mock_purge, mock_format
):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
mock_should_purge.return_value = (
True,
"Memory usage (5000.0 MB) exceeds threshold (4000 MB)",
)
mock_stats.side_effect = [
{
"used_mb": 5000,
"free_mb": 3000,
"total_mb": 8000,
"used_percent": 62.5,
"cuda_available": True,
},
{
"used_mb": 2000,
"free_mb": 6000,
"total_mb": 8000,
"used_percent": 25,
"cuda_available": True,
},
]
mock_purge.return_value = 3000
mock_format.return_value = "Memory Purge Report\n-------------------\nMode: soft\nMemory Freed: 3000.0 MB"
node = KikoPurgeVRAM()
test_input = "test_data"
result, report = node.purge_vram(
anything=test_input,
mode="soft",
report_memory=True,
memory_threshold_mb=4000,
)
assert result == test_input
assert "Memory Freed: 3000.0 MB" in report
mock_purge.assert_called_once_with(mode="soft", unload_models=False)
@patch("kikotools.tools.kiko_purge_vram.logic.get_memory_stats")
def test_node_skip_below_threshold(self, mock_stats):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
mock_stats.return_value = {
"used_mb": 2000,
"free_mb": 6000,
"total_mb": 8000,
"used_percent": 25,
"cuda_available": True,
}
node = KikoPurgeVRAM()
test_input = "test_data"
with patch("kikotools.tools.kiko_purge_vram.logic.purge_memory") as mock_purge:
result, report = node.purge_vram(
anything=test_input,
mode="soft",
report_memory=True,
memory_threshold_mb=3000,
)
assert result == test_input
assert "below threshold" in report.lower()
mock_purge.assert_not_called()
def test_node_input_types(self):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
input_types = KikoPurgeVRAM.INPUT_TYPES()
assert "required" in input_types
assert "optional" in input_types
assert "anything" in input_types["required"]
assert "mode" in input_types["required"]
assert "report_memory" in input_types["required"]
assert "memory_threshold_mb" in input_types["optional"]
def test_node_properties(self):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
assert KikoPurgeVRAM.FUNCTION == "purge_vram"
assert KikoPurgeVRAM.CATEGORY == "🫶 ComfyAssets/🛠️ Utils"
assert KikoPurgeVRAM.OUTPUT_NODE is True
assert len(KikoPurgeVRAM.RETURN_TYPES) == 2
assert KikoPurgeVRAM.RETURN_NAMES == ("passthrough", "memory_report")
+2 -2
View File
@@ -329,7 +329,7 @@ class TestKikoSaveImageNode:
assert KikoSaveImageNode.RETURN_TYPES == ()
assert KikoSaveImageNode.FUNCTION == "save_images"
assert KikoSaveImageNode.OUTPUT_NODE is True
assert KikoSaveImageNode.CATEGORY == "ComfyAssets/💾 Images"
assert KikoSaveImageNode.CATEGORY == "🫶 ComfyAssets/💾 Images"
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
def test_save_images_success(self, mock_process):
@@ -436,7 +436,7 @@ class TestKikoSaveImageNode:
info = self.node.get_node_info()
assert info["class_name"] == "KikoSaveImageNode"
assert info["category"] == "ComfyAssets/💾 Images"
assert info["category"] == "🫶 ComfyAssets/💾 Images"
assert info["function"] == "save_images"
@@ -179,7 +179,7 @@ class TestResolutionCalculatorNode:
assert hasattr(ResolutionCalculatorNode, "CATEGORY")
# Check category is correct
assert ResolutionCalculatorNode.CATEGORY == "ComfyAssets/🖼️ Resolution"
assert ResolutionCalculatorNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
# Check return types
assert ResolutionCalculatorNode.RETURN_TYPES == ("INT", "INT")
@@ -281,7 +281,7 @@ class TestResolutionCalculatorNode:
node = ResolutionCalculatorNode()
node_info = node.get_node_info()
assert node_info["category"] == "ComfyAssets/🖼️ Resolution"
assert node_info["category"] == "🫶 ComfyAssets/🖼️ Resolution"
assert node_info["class_name"] == "ResolutionCalculatorNode"
+1 -1
View File
@@ -186,7 +186,7 @@ class TestSamplerComboNode:
"cfg",
)
assert SamplerComboNode.FUNCTION == "get_sampler_combo"
assert SamplerComboNode.CATEGORY == "ComfyAssets/🌀 Samplers"
assert SamplerComboNode.CATEGORY == "🫶 ComfyAssets/🌀 Samplers"
def test_get_sampler_combo_valid_inputs(self):
"""Test get_sampler_combo with valid inputs."""
+1 -1
View File
@@ -49,7 +49,7 @@ class TestSeedHistoryNode:
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
assert SeedHistoryNode.RETURN_NAMES == ("seed",)
assert SeedHistoryNode.FUNCTION == "output_seed"
assert SeedHistoryNode.CATEGORY == "ComfyAssets/🌱 Seeds"
assert SeedHistoryNode.CATEGORY == "🫶 ComfyAssets/🌱 Seeds"
def test_output_seed_valid_input(self):
"""Test seed output with valid input."""
@@ -37,7 +37,7 @@ class TestWidthHeightSelectorNode:
assert self.node.RETURN_TYPES == ("INT", "INT")
assert self.node.RETURN_NAMES == ("width", "height")
assert self.node.FUNCTION == "get_dimensions"
assert self.node.CATEGORY == "ComfyAssets/🖼️ Resolution"
assert self.node.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
def test_custom_dimensions(self):
"""Test custom dimensions."""
@@ -182,7 +182,7 @@ class TestFluxSamplerParamsNode:
def test_node_properties(self):
"""Test node properties."""
assert FluxSamplerParamsNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
assert FluxSamplerParamsNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
assert FluxSamplerParamsNode.FUNCTION == "process_batch"
assert FluxSamplerParamsNode.RETURN_TYPES == ("LATENT", "SAMPLER_PARAMS")
assert FluxSamplerParamsNode.RETURN_NAMES == ("latent", "params")
@@ -1,8 +1,9 @@
"""Tests for LoRA Folder Batch node."""
import pytest
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import patch, MagicMock
import os
import tempfile
from kikotools.tools.xyz_helpers.lora_folder_batch import LoRAFolderBatchNode
from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
scan_folder_for_loras,
@@ -10,8 +11,8 @@ from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
filter_loras_by_pattern,
parse_strength_string,
create_lora_params,
create_lora_params_batched,
get_lora_info,
validate_folder_path,
)
@@ -35,6 +36,27 @@ class TestLoRAFolderBatchLogic:
# Base file could be first or last depending on implementation
assert "model-v1.safetensors" in sorted_files
def test_natural_sort_with_paths(self):
"""Test natural sorting with subdirectory paths."""
files = [
"subdir2/model-10.safetensors",
"model-2.safetensors",
"subdir1/model-20.safetensors",
"subdir1/model-3.safetensors",
"model-100.safetensors",
]
sorted_files = natural_sort(files)
# Should handle mixed paths and numbers correctly
assert len(sorted_files) == 5
# Files with smaller numbers should come first within their directories
assert sorted_files.index("model-2.safetensors") < sorted_files.index(
"model-100.safetensors"
)
assert sorted_files.index("subdir1/model-3.safetensors") < sorted_files.index(
"subdir1/model-20.safetensors"
)
def test_filter_loras_by_pattern(self):
"""Test filtering LoRAs by patterns."""
files = [
@@ -114,6 +136,59 @@ class TestLoRAFolderBatchLogic:
assert info["epoch"] is None
assert info["version"] is None
def test_scan_folder_recursive(self):
"""Test recursive scanning of LoRA files in subdirectories."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create nested directory structure
os.makedirs(os.path.join(temp_dir, "flux", "style"))
os.makedirs(os.path.join(temp_dir, "flux", "character"))
os.makedirs(os.path.join(temp_dir, "sdxl"))
# Create test files
test_files = [
os.path.join(temp_dir, "root-lora.safetensors"),
os.path.join(temp_dir, "flux", "flux-lora.safetensors"),
os.path.join(temp_dir, "flux", "style", "style-lora.safetensors"),
os.path.join(temp_dir, "flux", "character", "char-lora.safetensors"),
os.path.join(temp_dir, "sdxl", "sdxl-lora.safetensors"),
os.path.join(temp_dir, "not-a-lora.txt"), # Should be ignored
]
for file_path in test_files:
with open(file_path, "w") as f:
f.write("test")
# Create a mock folder_paths module
mock_folder_paths = MagicMock()
mock_folder_paths.folder_names_and_paths = {"loras": [[temp_dir]]}
# Mock the import
import sys
sys.modules["folder_paths"] = mock_folder_paths
try:
# Test scanning from root - should find all .safetensors files
results = scan_folder_for_loras(".")
assert len(results) == 5
assert "root-lora.safetensors" in results
assert "flux/flux-lora.safetensors" in results
assert "flux/style/style-lora.safetensors" in results
assert "flux/character/char-lora.safetensors" in results
assert "sdxl/sdxl-lora.safetensors" in results
assert "not-a-lora.txt" not in str(results)
# Test scanning from subdirectory
results = scan_folder_for_loras("flux")
assert len(results) == 3
assert "flux/flux-lora.safetensors" in results
assert "flux/style/style-lora.safetensors" in results
assert "flux/character/char-lora.safetensors" in results
finally:
# Clean up the mock
if "folder_paths" in sys.modules:
del sys.modules["folder_paths"]
class TestLoRAFolderBatchNode:
"""Test the LoRA Folder Batch node."""
@@ -137,6 +212,9 @@ class TestLoRAFolderBatchNode:
optional = input_types["optional"]
assert "include_pattern" in optional
assert "exclude_pattern" in optional
assert "auto_batch" in optional
assert "batch_size" in optional
assert "batch_index" in optional
def test_batch_loras_empty_folder(self, node):
"""Test with empty folder."""
@@ -183,9 +261,13 @@ class TestLoRAFolderBatchNode:
def test_node_properties(self):
"""Test node properties."""
assert LoRAFolderBatchNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
assert LoRAFolderBatchNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
assert LoRAFolderBatchNode.FUNCTION == "batch_loras"
assert LoRAFolderBatchNode.RETURN_TYPES == ("LORA_PARAMS", "STRING", "INT")
assert LoRAFolderBatchNode.RETURN_TYPES == (
"LORA_PARAMS",
"STRING",
"INT",
)
assert LoRAFolderBatchNode.RETURN_NAMES == (
"lora_params",
"lora_list",
@@ -200,3 +282,90 @@ class TestLoRAFolderBatchNode:
time.sleep(0.01)
result2 = LoRAFolderBatchNode.IS_CHANGED()
assert result1 != result2
def test_create_lora_params_batched(self):
"""Test the batched LoRA params creation."""
lora_files = [f"lora_{i:03d}.safetensors" for i in range(75)]
strengths = [0.5, 1.0]
# Test with batch size of 25
batches = create_lora_params_batched(lora_files, strengths, "sequential", 25)
assert len(batches) == 3 # 75 / 25 = 3 batches
# Check first batch
assert len(batches[0]["loras"]) == 25
assert batches[0]["batch_info"]["index"] == 0
assert batches[0]["batch_info"]["total"] == 3
assert batches[0]["batch_info"]["start_idx"] == 0
assert batches[0]["batch_info"]["end_idx"] == 25
assert batches[0]["batch_info"]["size"] == 25
# Check second batch
assert len(batches[1]["loras"]) == 25
assert batches[1]["batch_info"]["index"] == 1
assert batches[1]["batch_info"]["start_idx"] == 25
assert batches[1]["batch_info"]["end_idx"] == 50
# Check third batch
assert len(batches[2]["loras"]) == 25
assert batches[2]["batch_info"]["index"] == 2
assert batches[2]["batch_info"]["start_idx"] == 50
assert batches[2]["batch_info"]["end_idx"] == 75
def test_auto_batch_node_integration(self, node):
"""Test auto-batching in the node."""
# Create mock LoRA files
lora_files = [f"lora_{i:03d}.safetensors" for i in range(75)]
with patch(
"kikotools.tools.xyz_helpers.lora_folder_batch.node.validate_folder_path"
) as mock_validate:
with patch(
"kikotools.tools.xyz_helpers.lora_folder_batch.node.scan_folder_for_loras"
) as mock_scan:
mock_validate.return_value = True
mock_scan.return_value = lora_files
# Test batch 0
params, lora_list, count = node.batch_loras(
folder_path="test",
strength="1.0",
batch_mode="sequential",
auto_batch="enabled",
batch_size=25,
batch_index=0,
)
assert count == 25
assert "Batch 1/3" in lora_list
assert len(params["loras"]) == 25
assert params["loras"][0] == "lora_000.safetensors"
# Test batch 1
params, lora_list, count = node.batch_loras(
folder_path="test",
strength="1.0",
batch_mode="sequential",
auto_batch="enabled",
batch_size=25,
batch_index=1,
)
assert count == 25
assert "Batch 2/3" in lora_list
assert params["loras"][0] == "lora_025.safetensors"
# Test batch 2
params, lora_list, count = node.batch_loras(
folder_path="test",
strength="1.0",
batch_mode="sequential",
auto_batch="enabled",
batch_size=25,
batch_index=2,
)
assert count == 25
assert "Batch 3/3" in lora_list
assert params["loras"][0] == "lora_050.safetensors"
@@ -227,7 +227,7 @@ class TestPlotParametersNode:
def test_node_properties(self):
"""Test node properties."""
assert PlotParametersNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
assert PlotParametersNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
assert PlotParametersNode.FUNCTION == "plot_parameters"
assert PlotParametersNode.RETURN_TYPES == ("IMAGE",)
assert PlotParametersNode.RETURN_NAMES == ("image",)
@@ -85,7 +85,7 @@ class TestSamplerSelectHelperNode:
def test_node_properties(self):
"""Test node properties."""
assert SamplerSelectHelperNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
assert SamplerSelectHelperNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
assert SamplerSelectHelperNode.FUNCTION == "select_samplers"
assert SamplerSelectHelperNode.RETURN_TYPES == ("STRING",)
assert SamplerSelectHelperNode.RETURN_NAMES == ("selected_samplers",)
@@ -99,7 +99,7 @@ class TestSchedulerSelectHelperNode:
def test_node_properties(self):
"""Test node properties."""
assert SchedulerSelectHelperNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
assert SchedulerSelectHelperNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
assert SchedulerSelectHelperNode.FUNCTION == "select_schedulers"
assert SchedulerSelectHelperNode.RETURN_TYPES == ("STRING",)
assert SchedulerSelectHelperNode.RETURN_NAMES == ("selected_schedulers",)
@@ -138,7 +138,7 @@ class TestTextEncodeSamplerParamsNode:
def test_node_properties(self):
"""Test node properties."""
assert TextEncodeSamplerParamsNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
assert TextEncodeSamplerParamsNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
assert TextEncodeSamplerParamsNode.FUNCTION == "encode_prompts"
assert TextEncodeSamplerParamsNode.RETURN_TYPES == ("CONDITIONING",)
assert TextEncodeSamplerParamsNode.RETURN_NAMES == ("conditioning",)
-7
View File
@@ -58,13 +58,6 @@ app.registerExtension({
}
};
this.addCustomWidget(copyWidget);
// Update node title with condensed info
const firstLine = text ? text.split('\n')[0] : '';
const condensed = firstLine.length > 50
? firstLine.substring(0, 50) + "..."
: firstLine;
this.title = `DisplayAny: ${condensed}`;
requestAnimationFrame(() => {
const sz = this.computeSize();
+258
View File
@@ -0,0 +1,258 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
// Custom colors feature with extended options based on PR #433
// Adds custom color pickers for nodes with full, title, and background options
const colorShade = (col, amt) => {
col = col.replace(/^#/, "");
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
let [r, g, b] = col.match(/.{2}/g);
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};
app.registerExtension({
name: "kikotools.customColors",
async init() {
// Register settings
app.ui.settings.addSetting({
id: "kikotools.custom_colors.enabled",
name: "🫶 Custom Colors: Enable",
type: "boolean",
defaultValue: false,
tooltip: "Enable custom color picker options in node context menu",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.show_full",
name: "🫶 Custom Colors: Show Full Color Option",
type: "boolean",
defaultValue: true,
tooltip: "Show option to change both title and background colors",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.show_title",
name: "🫶 Custom Colors: Show Title Color Option",
type: "boolean",
defaultValue: true,
tooltip: "Show option to change only title color",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.show_bg",
name: "🫶 Custom Colors: Show Background Color Option",
type: "boolean",
defaultValue: true,
tooltip: "Show option to change only background color",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.auto_shade",
name: "🫶 Custom Colors: Auto-shade Title",
type: "boolean",
defaultValue: true,
tooltip: "Automatically apply shading to title color for better contrast",
});
},
setup() {
let pickerFull, pickerTitle, pickerBG;
let activeNode;
// Check if feature is enabled
const isEnabled = () => {
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
return setting !== undefined ? setting : false;
};
const getSettings = () => ({
showFull: app.ui.settings.getSettingValue("kikotools.custom_colors.show_full") !== false,
showTitle: app.ui.settings.getSettingValue("kikotools.custom_colors.show_title") !== false,
showBG: app.ui.settings.getSettingValue("kikotools.custom_colors.show_bg") !== false,
autoShade: app.ui.settings.getSettingValue("kikotools.custom_colors.auto_shade") !== false,
});
// Helper function to apply color to node(s)
const applyColorToNodes = (colorValue, colorType, node) => {
const settings = getSettings();
const graphcanvas = LGraphCanvas.active_canvas;
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
? [node]
: Object.values(graphcanvas.selected_nodes);
nodes.forEach(n => {
if (colorValue) {
if (n.constructor === LiteGraph.LGraphGroup) {
// For groups, only set the main color
if (colorType === 'full' || colorType === 'bg') {
n.color = colorValue;
}
} else {
// For regular nodes
switch(colorType) {
case 'full':
n.color = settings.autoShade ? colorShade(colorValue, 20) : colorValue;
n.bgcolor = colorValue;
break;
case 'title':
n.color = colorValue;
break;
case 'bg':
n.bgcolor = colorValue;
break;
}
}
}
});
node.setDirtyCanvas(true, true);
};
// Create color picker input if not exists
const createPicker = (type) => {
const picker = $el("input", {
type: "color",
parent: document.body,
style: {
display: "none",
},
});
picker.onchange = () => {
if (activeNode) {
applyColorToNodes(picker.value, type, activeNode);
}
};
return picker;
};
// Hook into the node colors menu
const onMenuNodeColors = LGraphCanvas.onMenuNodeColors;
LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) {
const r = onMenuNodeColors.apply(this, arguments);
// Only add custom options if enabled
if (!isEnabled()) return r;
const settings = getSettings();
requestAnimationFrame(() => {
const menus = document.querySelectorAll(".litecontextmenu");
for (let i = menus.length - 1; i >= 0; i--) {
if (menus[i].firstElementChild.textContent.includes("No color") ||
menus[i].firstElementChild.value?.content?.includes("No color")) {
// Add Custom Full option
if (settings.showFull) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!pickerFull) {
pickerFull = createPicker('full');
}
activeNode = null;
pickerFull.value = node.bgcolor || "#000000";
activeNode = node;
pickerFull.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🫶 Custom Full",
}),
]
);
}
// Add Custom Title option
if (settings.showTitle) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!pickerTitle) {
pickerTitle = createPicker('title');
}
activeNode = null;
pickerTitle.value = node.color || "#000000";
activeNode = node;
pickerTitle.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🫶 Custom Title",
}),
]
);
}
// Add Custom BG option
if (settings.showBG) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!pickerBG) {
pickerBG = createPicker('bg');
}
activeNode = null;
pickerBG.value = node.bgcolor || "#000000";
activeNode = node;
pickerBG.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🫶 Custom BG",
}),
]
);
}
break;
}
}
});
return r;
};
},
});
+157
View File
@@ -0,0 +1,157 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Adds follow execution feature when enabled in settings
// Adds menu options to toggle follow execution and go to executing node
app.registerExtension({
name: "kikotools.followExecution",
async init() {
// Register settings in ComfyUI's settings panel
app.ui.settings.addSetting({
id: "kikotools.follow_execution.enabled",
name: "🫶 Follow Execution: Enable",
type: "boolean",
defaultValue: false,
tooltip: "Enable follow execution feature in canvas right-click menu",
});
app.ui.settings.addSetting({
id: "kikotools.follow_execution.show_goto_node",
name: "🫶 Follow Execution: Show 'Go to node' menu",
type: "boolean",
defaultValue: true,
tooltip: "Show 'Go to node' submenu in canvas menu",
});
app.ui.settings.addSetting({
id: "kikotools.follow_execution.auto_start",
name: "🫶 Follow Execution: Auto-start",
type: "boolean",
defaultValue: false,
tooltip: "Automatically start following execution when workflow starts",
});
},
async setup() {
let followExecution = false;
let isEnabled = false;
// Check if the feature is enabled in settings
const checkEnabled = () => {
const setting = app.ui.settings.getSettingValue("kikotools.follow_execution.enabled");
isEnabled = setting !== undefined ? setting : false;
// If disabled, turn off follow execution
if (!isEnabled && followExecution) {
followExecution = false;
}
};
// Check for auto-start setting
const checkAutoStart = () => {
const autoStart = app.ui.settings.getSettingValue("kikotools.follow_execution.auto_start");
if (autoStart && isEnabled) {
followExecution = true;
}
};
// Initialize settings on startup
checkEnabled();
checkAutoStart();
// Center on the executing node
const centerNode = (id) => {
if (!followExecution || !id || !isEnabled) return;
const node = app.graph.getNodeById(id);
if (!node) return;
app.canvas.centerOnNode(node);
};
// Listen for execution events
api.addEventListener("executing", ({ detail }) => centerNode(detail));
// Listen for execution start to handle auto-start
api.addEventListener("execution_start", () => {
checkEnabled();
checkAutoStart();
});
// Extend canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
// Check if feature is enabled before adding menu items
checkEnabled();
if (!isEnabled) return options;
// Add separator
options.push(null);
// Add follow execution toggle
options.push({
content: followExecution ? "🫶 Stop following execution" : "🫶 Follow execution",
callback: () => {
followExecution = !followExecution;
if (followExecution) {
centerNode(app.runningNodeId);
}
},
});
// Add go to executing node option if a node is currently executing
if (app.runningNodeId) {
options.push({
content: "🫶 Show executing node",
callback: () => {
const node = app.graph.getNodeById(app.runningNodeId);
if (!node) return;
app.canvas.centerOnNode(node);
},
});
}
// Add go to node by type submenu
const showGoToNode = app.ui.settings.getSettingValue("kikotools.follow_execution.show_goto_node");
if (showGoToNode !== false) { // Default to true if not set
const nodes = app.graph._nodes;
const types = nodes.reduce((p, n) => {
if (n.type in p) {
p[n.type].push(n);
} else {
p[n.type] = [n];
}
return p;
}, {});
options.push({
content: "🫶 Go to node",
has_submenu: true,
submenu: {
options: Object.keys(types)
.sort()
.map((t) => ({
content: t,
has_submenu: true,
submenu: {
options: types[t]
.sort((a, b) => {
return a.pos[0] - b.pos[0];
})
.map((n) => ({
content: `${n.getTitle()} - #${n.id} (${Math.round(n.pos[0])}, ${Math.round(n.pos[1])})`,
callback: () => {
app.canvas.centerOnNode(n);
},
})),
},
})),
},
});
}
return options;
};
},
});