Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b30f0a78 | ||
|
|
917421529b | ||
|
|
d29dcb8564 | ||
|
|
b95bf8e64a | ||
|
|
b8622e21da | ||
|
|
4c8f20ef88 | ||
|
|
26d135e106 | ||
|
|
db7d0dc86e | ||
|
|
b5e24dbe57 | ||
|
|
eb1e646453 | ||
|
|
793579a1dd | ||
|
|
eba899b30d | ||
|
|
914ba8e003 | ||
|
|
51b057982e | ||
|
|
3a3a6ebab9 | ||
|
|
886917d95c | ||
|
|
bf55338b61 | ||
|
|
c561e59650 | ||
|
|
9e67f06a21 | ||
|
|
90e4b57dc6 | ||
|
|
386e48c2e1 | ||
|
|
624ba92723 | ||
|
|
0ae29f576a | ||
|
|
ca9fd5153a | ||
|
|
c0d239f31f | ||
|
|
6d27520641 | ||
|
|
7cde5a5ceb | ||
|
|
ecdde6bbd7 | ||
|
|
0557c040f8 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: |
|
||||
|
||||
@@ -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 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.
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,7 @@ class ComfyAssetsBaseNode:
|
||||
- Consistent return type handling
|
||||
"""
|
||||
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets"
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -35,7 +35,7 @@ class ImageScaleDownByNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "scale_down"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)",
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user