Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11931a2e51 | ||
|
|
9da65b7e38 | ||
|
|
0868b32318 | ||
|
|
de49b8abec | ||
|
|
64f2b0530d | ||
|
|
1308d0055e | ||
|
|
f484482e2c | ||
|
|
8c44b99d37 | ||
|
|
c035313e99 | ||
|
|
0d9d6d1872 | ||
|
|
40bfccc952 | ||
|
|
7b87b01535 | ||
|
|
cb61b60c27 | ||
|
|
c45c9c0b91 | ||
|
|
b3c510b90d | ||
|
|
417e99b172 | ||
|
|
59fe64d04f | ||
|
|
19666de804 | ||
|
|
60a3104a38 | ||
|
|
01c14ea363 | ||
|
|
63c9d81ebd | ||
|
|
94200003ee | ||
|
|
f1e73d1e94 | ||
|
|
429f3f067a | ||
|
|
84138cd46e | ||
|
|
978580924a | ||
|
|
be560ad2f5 | ||
|
|
0e7747d248 | ||
|
|
012c0e698c | ||
|
|
b18f7cad38 | ||
|
|
4bab6eb73e | ||
|
|
b14b86fc85 |
@@ -53,7 +53,7 @@ jobs:
|
||||
print('✓ All imports successful')
|
||||
|
||||
# Test base node
|
||||
assert ComfyAssetsBaseNode.CATEGORY.startswith('ComfyAssets')
|
||||
assert ComfyAssetsBaseNode.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Base node tests passed')
|
||||
|
||||
# Test dimension extraction
|
||||
@@ -162,13 +162,9 @@ jobs:
|
||||
print('✓ Sampler Combo interface tests passed')
|
||||
|
||||
# Test return types
|
||||
# RETURN_TYPES[1] is the actual SCHEDULERS list
|
||||
assert node.RETURN_TYPES[0] == 'SAMPLER'
|
||||
assert isinstance(node.RETURN_TYPES[1], list) # SCHEDULERS is a list
|
||||
assert node.RETURN_TYPES[2] == 'INT'
|
||||
assert node.RETURN_TYPES[3] == 'FLOAT'
|
||||
assert node.RETURN_TYPES == ('SAMPLER', SCHEDULERS, 'INT', 'FLOAT')
|
||||
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
|
||||
assert node.CATEGORY == 'ComfyAssets/🌀 Samplers'
|
||||
assert node.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Sampler Combo return types tests passed')
|
||||
|
||||
# Test sampler combo functionality
|
||||
@@ -217,7 +213,7 @@ jobs:
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == ('INT',)
|
||||
assert node.RETURN_NAMES == ('seed',)
|
||||
assert node.CATEGORY == 'ComfyAssets/🌱 Seeds'
|
||||
assert node.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Seed History return types tests passed')
|
||||
|
||||
# Test seed output functionality
|
||||
@@ -333,7 +329,7 @@ jobs:
|
||||
|
||||
assert res_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert res_class.RETURN_NAMES == ('width', 'height')
|
||||
assert res_class.CATEGORY.startswith('ComfyAssets/')
|
||||
assert res_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Resolution Calculator ComfyUI integration passed')
|
||||
|
||||
# Test Width Height Selector
|
||||
@@ -354,7 +350,7 @@ jobs:
|
||||
|
||||
assert wh_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert wh_class.RETURN_NAMES == ('width', 'height')
|
||||
assert wh_class.CATEGORY.startswith('ComfyAssets/')
|
||||
assert wh_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Width Height Selector ComfyUI integration passed')
|
||||
|
||||
# Test Sampler Combo
|
||||
@@ -374,7 +370,7 @@ jobs:
|
||||
assert 'steps' in input_types['required']
|
||||
assert 'cfg' in input_types['required']
|
||||
|
||||
assert sampler_class.CATEGORY.startswith('ComfyAssets/')
|
||||
assert sampler_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Sampler Combo ComfyUI integration passed')
|
||||
|
||||
# Test Seed History
|
||||
@@ -393,7 +389,7 @@ jobs:
|
||||
|
||||
assert seed_class.RETURN_TYPES == ('INT',)
|
||||
assert seed_class.RETURN_NAMES == ('seed',)
|
||||
assert seed_class.CATEGORY.startswith('ComfyAssets/')
|
||||
assert seed_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Seed History ComfyUI integration passed')
|
||||
|
||||
print('🎉 All tools ComfyUI integration readiness tests passed!')
|
||||
|
||||
+1
-1
@@ -162,4 +162,4 @@ experiments/
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
referance/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ComfyUI-KikoTools is a planned modular collection of custom ComfyUI nodes that will provide essential tools missing from the standard ComfyUI release. All nodes will be grouped under "ComfyAssets" in the ComfyUI interface. The project is designed for extensibility, allowing new tools to be added easily while maintaining clean separation of concerns.
|
||||
|
||||
**Current Status**: Project is in initial planning phase. Only documentation and licensing files exist.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Principles
|
||||
- **Modular Design**: Each tool is a separate, self-contained module
|
||||
- **ComfyAssets Grouping**: All nodes appear under the "ComfyAssets" category
|
||||
- **Test-Driven Development**: Every tool includes comprehensive tests
|
||||
- **Clean Interfaces**: Standardized input/output patterns across tools
|
||||
|
||||
### Core Components
|
||||
- **Tool Registry**: Central registration system for all KikoTools nodes
|
||||
- **Base Classes**: Shared functionality for consistent tool behavior
|
||||
- **Individual Tools**: Self-contained modules for specific functionality
|
||||
|
||||
### Current Tools
|
||||
|
||||
#### 1. Resolution Calculator (First Tool)
|
||||
- **Purpose**: Calculate upscale resolution from image or latent inputs
|
||||
- **Inputs**:
|
||||
- Image or Latent tensor
|
||||
- Scale factor (1, 2, 3, 1.2, 1.5, 2.0)
|
||||
- **Outputs**:
|
||||
- Width (INT)
|
||||
- Height (INT)
|
||||
- **Target Models**: Flux and SDXL optimized
|
||||
- **Use Case**: Connect calculated dimensions to upscaler nodes
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Python with ComfyUI node patterns
|
||||
- **Node Framework**: ComfyUI INPUT_TYPES, RETURN_TYPES, execute() patterns
|
||||
- **Testing**: pytest with ComfyUI test fixtures
|
||||
- **Code Quality**: black, flake8, mypy
|
||||
- **Integration**: ComfyUI execution queue and tensor systems
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Note**: These commands are planned for when the project structure is implemented.
|
||||
|
||||
### Initial Setup
|
||||
```bash
|
||||
# Create basic project structure
|
||||
mkdir -p kikotools/{base,tools} tests/{unit,integration,fixtures} scripts examples
|
||||
|
||||
# Create entry point files
|
||||
touch __init__.py kikotools/__init__.py
|
||||
```
|
||||
|
||||
### Code Quality (Future)
|
||||
```bash
|
||||
# Format Python code
|
||||
black .
|
||||
|
||||
# Python linting
|
||||
flake8 .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
### Testing (Future TDD Workflow)
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/
|
||||
|
||||
# Run tests for specific tool
|
||||
pytest tests/unit/tools/test_{tool_name}.py
|
||||
|
||||
# Test coverage
|
||||
pytest --cov=kikotools tests/
|
||||
```
|
||||
|
||||
## Project Structure (Planned)
|
||||
|
||||
**Current State**: Only `CLAUDE.md` and `LICENSE` files exist.
|
||||
|
||||
**Planned Structure**:
|
||||
```
|
||||
├── __init__.py # ComfyUI node registration entry point
|
||||
├── kikotools/ # Main package
|
||||
│ ├── __init__.py # Package initialization and tool registry
|
||||
│ ├── base/ # Base classes and shared utilities
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_node.py # Base node class with ComfyAssets grouping
|
||||
│ │ └── utils.py # Shared utility functions
|
||||
│ ├── tools/ # Individual tool implementations
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── resolution_calculator/ # First planned tool
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── node.py # ResolutionCalculatorNode implementation
|
||||
│ │ │ └── logic.py # Core calculation logic
|
||||
│ │ └── template/ # Template for new tools
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── node.py
|
||||
│ │ └── logic.py
|
||||
├── tests/ # Comprehensive test suite (TDD approach)
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # pytest fixtures and ComfyUI test setup
|
||||
│ ├── unit/ # Unit tests for individual components
|
||||
│ │ ├── test_base_node.py
|
||||
│ │ └── tools/
|
||||
│ │ └── test_resolution_calculator.py
|
||||
│ ├── integration/ # ComfyUI integration tests
|
||||
│ │ ├── test_node_registration.py
|
||||
│ │ └── test_workflow_execution.py
|
||||
│ └── fixtures/ # Test data and workflow files
|
||||
│ ├── workflows/ # .json workflow files for testing
|
||||
│ ├── images/ # Test images
|
||||
│ └── latents/ # Test latent tensors
|
||||
├── scripts/ # Development automation
|
||||
│ ├── create_tool.py # Tool template generator
|
||||
│ ├── register_tool.py # Tool registration helper
|
||||
│ └── validate_nodes.py # Node validation script
|
||||
├── examples/ # Usage examples and demonstrations
|
||||
│ ├── workflows/ # Example workflow .json files
|
||||
│ └── documentation/ # Usage documentation per tool
|
||||
└── requirements-dev.txt # Development dependencies
|
||||
```
|
||||
|
||||
## Key ComfyUI Integration Points
|
||||
|
||||
### Node Registration Pattern
|
||||
```python
|
||||
# Each tool follows this pattern in kikotools/tools/{tool_name}/node.py
|
||||
class ResolutionCalculatorNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"scale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"latent": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
CATEGORY = "ComfyAssets" # All tools use this category
|
||||
|
||||
def calculate_resolution(self, scale_factor, image=None, latent=None):
|
||||
# Implementation here
|
||||
pass
|
||||
```
|
||||
|
||||
### Base Node Class
|
||||
- Provides consistent "ComfyAssets" categorization
|
||||
- Standardizes error handling and logging
|
||||
- Implements common validation patterns
|
||||
- Ensures consistent return type handling
|
||||
|
||||
### Tool Registry System
|
||||
- Automatic discovery of tools in `kikotools/tools/`
|
||||
- Dynamic node registration during ComfyUI startup
|
||||
- Version compatibility checking
|
||||
- Dependency validation
|
||||
|
||||
## Test-Driven Development (TDD) Workflow
|
||||
|
||||
### 1. Write Tests First
|
||||
```python
|
||||
# tests/unit/tools/test_resolution_calculator.py
|
||||
def test_resolution_calculator_with_image():
|
||||
"""Test resolution calculation with image input."""
|
||||
# Arrange
|
||||
node = ResolutionCalculatorNode()
|
||||
test_image = create_test_image(512, 512) # fixture
|
||||
scale_factor = 2.0
|
||||
|
||||
# Act
|
||||
width, height = node.calculate_resolution(scale_factor, image=test_image)
|
||||
|
||||
# Assert
|
||||
assert width == 1024
|
||||
assert height == 1024
|
||||
|
||||
def test_resolution_calculator_with_latent():
|
||||
"""Test resolution calculation with latent input."""
|
||||
# Similar pattern for latent inputs
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. Run Tests (Should Fail)
|
||||
```bash
|
||||
pytest tests/unit/tools/test_resolution_calculator.py -v
|
||||
```
|
||||
|
||||
### 3. Implement Minimal Code
|
||||
```python
|
||||
# kikotools/tools/resolution_calculator/logic.py
|
||||
def calculate_upscale_resolution(input_tensor, scale_factor):
|
||||
"""Calculate new resolution based on input and scale factor."""
|
||||
# Minimal implementation to pass tests
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. Refactor and Expand
|
||||
- Add error handling
|
||||
- Optimize for Flux/SDXL specific requirements
|
||||
- Add comprehensive validation
|
||||
- Implement edge case handling
|
||||
|
||||
### 5. Integration Testing
|
||||
```python
|
||||
# tests/integration/test_workflow_execution.py
|
||||
def test_resolution_calculator_in_workflow():
|
||||
"""Test resolution calculator in full ComfyUI workflow."""
|
||||
workflow = load_test_workflow("resolution_calculator_example.json")
|
||||
result = execute_comfyui_workflow(workflow)
|
||||
assert result.success
|
||||
```
|
||||
|
||||
## Tool-Specific Implementation Notes
|
||||
|
||||
### Resolution Calculator
|
||||
- **Input Validation**: Handle both image and latent tensors
|
||||
- **Scale Factors**: Support integer (1, 2, 3) and float (1.2, 1.5, 2.0) multipliers
|
||||
- **Model Optimization**: Consider Flux and SDXL specific resolution requirements
|
||||
- **Output Format**: Integer width/height suitable for upscaler node connections
|
||||
- **Error Handling**: Graceful handling of invalid inputs or edge cases
|
||||
|
||||
### Future Tools (Planned)
|
||||
- Batch Image Processor
|
||||
- Advanced Prompt Utilities
|
||||
- Model Management Tools
|
||||
- Custom Sampling Methods
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New Tool
|
||||
1. **Plan**: Define tool purpose, inputs, outputs, and test cases
|
||||
2. **Generate**: Use `python scripts/create_tool.py --name "NewTool"`
|
||||
3. **Test**: Write comprehensive tests following TDD principles
|
||||
4. **Implement**: Build tool logic with proper ComfyUI integration
|
||||
5. **Register**: Add tool to registry and validate registration
|
||||
6. **Document**: Update examples and documentation
|
||||
7. **Validate**: Test in real ComfyUI environment with actual workflows
|
||||
|
||||
### Code Quality Standards
|
||||
- **Type Hints**: Full type annotation for all functions
|
||||
- **Documentation**: Docstrings for all public methods and classes
|
||||
- **Testing**: Minimum 90% test coverage for all tools
|
||||
- **Linting**: Pass all flake8 and mypy checks
|
||||
- **Formatting**: Auto-formatted with black
|
||||
|
||||
### Release Process
|
||||
1. Run full test suite: `pytest tests/`
|
||||
2. Validate in ComfyUI: `python scripts/validate_nodes.py`
|
||||
3. Update version numbers and changelog
|
||||
4. Create example workflows demonstrating new features
|
||||
5. Update ComfyUI-Manager compatibility metadata
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
### ComfyUI Compatibility
|
||||
- Follow ComfyUI tensor format conventions
|
||||
- Implement proper memory management for large tensors
|
||||
- Handle ComfyUI execution context correctly
|
||||
- Ensure compatibility with ComfyUI's automatic typing system
|
||||
|
||||
### Performance Considerations
|
||||
- Optimize for real-time workflow execution
|
||||
- Minimize memory allocation during processing
|
||||
- Cache expensive computations when appropriate
|
||||
- Profile performance with typical Flux/SDXL workflows
|
||||
|
||||
### User Experience
|
||||
- Clear, descriptive node names and parameter labels
|
||||
- Helpful tooltips and parameter descriptions
|
||||
- Consistent visual styling within ComfyAssets group
|
||||
- Robust error messages with actionable guidance
|
||||
|
||||
### Extensibility
|
||||
- Plugin architecture for easy tool addition
|
||||
- Shared utilities for common operations
|
||||
- Consistent API patterns across all tools
|
||||
- Future-proof design for ComfyUI updates
|
||||
@@ -16,30 +16,16 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
|
||||
|
||||
| Tool | Description | Category |
|
||||
|------|-------------|----------|
|
||||
| [📐 Resolution Calculator](#-resolution-calculator) | Calculate upscaled dimensions with model optimization | 🖼️ Resolution |
|
||||
| [📏 Width Height Selector](#-width-height-selector) | Preset-based dimension selection with visual swap | 🖼️ Resolution |
|
||||
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | 🌱 Seeds |
|
||||
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | 🌀 Samplers |
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
|
||||
| [🤖 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 |
|
||||
|
||||
### 🧰 xyz-helpers Tools
|
||||
|
||||
Advanced parameter management tools adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode):
|
||||
|
||||
| Tool | Description | Category |
|
||||
|------|-------------|----------|
|
||||
| [🎛️ Flux Sampler Params](#️-flux-sampler-params) | FLUX-optimized parameter generator with batch support | 🧰 xyz-helpers |
|
||||
| [📁 LoRA Folder Batch](#-lora-folder-batch) | Batch process multiple LoRAs from folders | 🧰 xyz-helpers |
|
||||
| [📊 Plot Parameters](#-plot-parameters) | Visualize parameter effects with graphs | 🧰 xyz-helpers |
|
||||
| [🎯 Sampler Select Helper](#-sampler-select-helper) | Intelligent sampler selection with recommendations | 🧰 xyz-helpers |
|
||||
| [📅 Scheduler Select Helper](#-scheduler-select-helper) | Optimal scheduler selection for samplers | 🧰 xyz-helpers |
|
||||
| [✍️ Text Encode Sampler Params](#️-text-encode-sampler-params) | Combined text encoding and parameter management | 🧰 xyz-helpers |
|
||||
| [📐 Resolution Calculator](#-resolution-calculator) | Calculate upscaled dimensions with model optimization | Image Processing |
|
||||
| [📏 Width Height Selector](#-width-height-selector) | Preset-based dimension selection with visual swap | Dimension Control |
|
||||
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | Generation Control |
|
||||
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | Sampling |
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | Latent Generation |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | File Management |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | Text Display |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | AI Integration |
|
||||
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | Debugging |
|
||||
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | Image Processing |
|
||||
|
||||
#### 📐 Resolution Calculator
|
||||
Calculate upscaled dimensions from image or latent inputs with precision.
|
||||
@@ -219,138 +205,6 @@ Adjusts image dimensions to be multiples of a specified value for model compatib
|
||||
|
||||

|
||||
|
||||
#### 🎛️ Flux Sampler Params
|
||||
FLUX-optimized parameter generator with intelligent batch processing capabilities.
|
||||
|
||||
- **FLUX-Specific Tuning**: Optimized guidance, shift values, and step counts for FLUX models
|
||||
- **Batch Parameter Testing**: Generate multiple parameter sets for comparative analysis
|
||||
- **LoRA Integration**: Seamlessly combine with LoRA Folder Batch for comprehensive testing
|
||||
- **Smart Defaults**: Pre-configured optimal settings based on extensive FLUX testing
|
||||
- **Range Syntax Support**: Use `start...end+step` notation for parameter sweeps
|
||||
|
||||
**Use Cases:**
|
||||
- Test different guidance and shift value combinations
|
||||
- Batch process with varying parameters
|
||||
- Optimize FLUX generation quality
|
||||
- Integrate with LoRA testing workflows
|
||||
|
||||
#### 📁 LoRA Folder Batch
|
||||
Automated batch processing for multiple LoRA models from folders.
|
||||
|
||||
- **Automatic Scanning**: Discovers all .safetensors files in specified folders
|
||||
- **Natural Epoch Sorting**: Intelligently sorts training epochs (epoch_004, epoch_020, etc.)
|
||||
- **Pattern Filtering**: Include/exclude LoRAs using powerful regex patterns
|
||||
- **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
|
||||
|
||||
**Use Cases:**
|
||||
- Test all epochs from a training run
|
||||
- Compare different LoRA versions
|
||||
- Evaluate strength variations
|
||||
- Batch process style transfers
|
||||
|
||||

|
||||
|
||||
#### 📊 Plot Parameters
|
||||
Visual analysis tool for understanding parameter relationships and effects.
|
||||
|
||||
- **Multiple Plot Types**: Line, bar, scatter, and heatmap visualizations
|
||||
- **Parameter Correlation**: Analyze relationships between settings and quality
|
||||
- **Statistical Analysis**: Calculate means, deviations, and trends
|
||||
- **Export Capabilities**: Save plots as images or CSV data
|
||||
- **Real-time Updates**: Dynamic graph generation during workflow execution
|
||||
|
||||
**Use Cases:**
|
||||
- Visualize parameter impact on quality
|
||||
- Compare batch generation results
|
||||
- Analyze optimal parameter ranges
|
||||
- Document generation experiments
|
||||
|
||||
#### 🎯 Sampler Select Helper
|
||||
Intelligent sampler selection with model-aware recommendations.
|
||||
|
||||
- **Model Detection**: Automatic identification of SDXL, SD1.5, or FLUX models
|
||||
- **Quality Presets**: Fast, balanced, quality, and extreme presets
|
||||
- **Compatibility Checking**: Ensures optimal sampler-scheduler pairs
|
||||
- **Performance Profiles**: Pre-configured settings for different use cases
|
||||
- **Dynamic Discovery**: Adapts to newly available samplers
|
||||
|
||||
**Use Cases:**
|
||||
- Automatic optimal sampler selection
|
||||
- Quick quality vs speed adjustments
|
||||
- Model-specific optimization
|
||||
- A/B testing different samplers
|
||||
|
||||
#### 📅 Scheduler Select Helper
|
||||
Optimal scheduler selection based on sampler and model requirements.
|
||||
|
||||
- **Sampler-Aware**: Recommends best schedulers for each sampler
|
||||
- **Noise Schedule Visualization**: Preview and compare schedule curves
|
||||
- **Model Optimization**: Specific tuning for SDXL, SD1.5, and FLUX
|
||||
- **Schedule Types**: Smooth, sharp, linear, and custom curves
|
||||
- **Beta Schedule Support**: Advanced control with custom beta values
|
||||
|
||||
**Use Cases:**
|
||||
- Find optimal scheduler for your sampler
|
||||
- Visualize noise reduction curves
|
||||
- Compare different schedule types
|
||||
- Fine-tune generation behavior
|
||||
|
||||
#### ✍️ Text Encode Sampler Params
|
||||
Unified interface for text encoding and sampler parameter management.
|
||||
|
||||
- **All-in-One Node**: Combine prompt encoding with sampling configuration
|
||||
- **Template System**: Pre-configured settings for portraits, landscapes, etc.
|
||||
- **Prompt Syntax Support**: Wildcards, emphasis, and alternation
|
||||
- **Batch Processing**: Handle multiple prompts efficiently
|
||||
- **Model-Aware Encoding**: Optimize for different text encoders
|
||||
|
||||
**Use Cases:**
|
||||
- Streamline text-to-image workflows
|
||||
- Apply consistent settings across prompts
|
||||
- Quick template-based generation
|
||||
- Batch prompt processing
|
||||
|
||||
### 🔤 Embedding Autocomplete
|
||||
|
||||
**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" />
|
||||
</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.
|
||||
|
||||
**Key Features:**
|
||||
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
|
||||
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
|
||||
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
|
||||
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
|
||||
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
|
||||
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
|
||||
|
||||
**Settings Include:**
|
||||
- Enable/disable autocomplete for embeddings, LoRAs, and custom tags
|
||||
- Configurable trigger phrases (e.g., `emb:`, `lora:`, custom shortcuts)
|
||||
- Auto-insert comma after completion
|
||||
- Replace underscores with spaces in tags
|
||||
- Choose insertion keys (Tab, Enter, or both)
|
||||
- Load custom word lists from URLs with security validation
|
||||
|
||||
**Security Features:**
|
||||
- Validates all loaded content to prevent script injection
|
||||
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
|
||||
- Safe character whitelist for tags
|
||||
- File size limits to prevent memory exhaustion
|
||||
- Clear error messages for rejected content
|
||||
|
||||
**Credits:**
|
||||
- Original autocomplete concept by [pythongosssss](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
|
||||
- Enhanced and modernized by KikoTools team
|
||||
|
||||
### 💾 Kiko Save Image Features
|
||||
|
||||
**Use Cases:**
|
||||
@@ -554,21 +408,6 @@ Load Image → Image to Multiple Of → VAE Encode → KSampler
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LoRA Testing with xyz-helpers</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow": "Scan LoRA folder → Apply strength ranges → Generate grid → Plot parameters",
|
||||
"strength_range": "0.9...1.2+0.1",
|
||||
"batch_mode": "combinatorial",
|
||||
"features": ["automatic epoch sorting", "parameter visualization", "batch generation"]
|
||||
}
|
||||
```
|
||||
|
||||
Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/xyz_helpers_lora_testing.json)
|
||||
</details>
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Available Tools
|
||||
@@ -585,12 +424,6 @@ 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) |
|
||||
| **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) |
|
||||
| **Sampler Select Helper** | Intelligent sampler selection with recommendations | ✅ Complete | [Docs](examples/documentation/sampler_select_helper.md) |
|
||||
| **Scheduler Select Helper** | Optimal scheduler selection for samplers | ✅ Complete | [Docs](examples/documentation/scheduler_select_helper.md) |
|
||||
| **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) |
|
||||
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
|
||||
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
|
||||
|
||||
@@ -884,32 +717,16 @@ 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**: 10 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image, Display Text, Gemini Prompt Engineer, Display Any, Image to Multiple Of)
|
||||
- **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)
|
||||
- **Interactive Features**: 6 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer, Display Text Split View, Gemini Model Refresh)
|
||||
- **AI Integration**: Gemini API with 40+ model support
|
||||
- **Test Coverage**: 100% (300+ comprehensive tests)
|
||||
- **Test Coverage**: 100% (200+ comprehensive tests)
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
|
||||
## 🙏 Attribution
|
||||
|
||||
### xyz-helpers Tools
|
||||
The xyz-helpers collection was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted these essential tools to ensure continued support and compatibility with modern ComfyUI workflows. We're grateful for cubiq's original work and contributions to the ComfyUI community.
|
||||
|
||||
The following tools are based on comfyui-essentials-nodes:
|
||||
- Flux Sampler Params
|
||||
- LoRA Folder Batch
|
||||
- Plot Parameters
|
||||
- Sampler Select Helper
|
||||
- Scheduler Select Helper
|
||||
- Text Encode Sampler Params
|
||||
|
||||
All adaptations maintain compatibility while adding new features and optimizations for the ComfyAssets ecosystem.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
+1
-85
@@ -13,91 +13,7 @@ except ImportError:
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# Tell ComfyUI where to find our JavaScript extensions
|
||||
import os
|
||||
|
||||
WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
||||
|
||||
# Import server components at module level to ensure they're available
|
||||
try:
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
|
||||
print("[KikoTools] Server imports successful")
|
||||
|
||||
# Register autocomplete endpoints directly
|
||||
@PromptServer.instance.routes.get("/kikotools/autocomplete/embeddings")
|
||||
async def get_embeddings(request):
|
||||
"""API endpoint for getting list of embeddings with full paths."""
|
||||
print("[KikoTools] Embeddings endpoint called")
|
||||
try:
|
||||
embedding_files = folder_paths.get_filename_list("embeddings")
|
||||
print(f"[KikoTools] Found {len(embedding_files)} embedding files")
|
||||
# Return embeddings with their subdirectory paths, without extensions
|
||||
embeddings = []
|
||||
for f in embedding_files:
|
||||
# Remove extension but keep subdirectory path
|
||||
clean_path = os.path.splitext(f)[0]
|
||||
embeddings.append(
|
||||
{
|
||||
"file_name": clean_path,
|
||||
"model_name": clean_path,
|
||||
"name": os.path.basename(clean_path),
|
||||
"path": clean_path,
|
||||
}
|
||||
)
|
||||
if len(embeddings) > 0:
|
||||
print(f"[KikoTools] Sample embedding: {embeddings[0]}")
|
||||
print(f"[KikoTools] Returning {len(embeddings)} embeddings with paths")
|
||||
return web.json_response(embeddings)
|
||||
except Exception as e:
|
||||
print(f"[KikoTools] Error getting embeddings: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return web.json_response([])
|
||||
|
||||
@PromptServer.instance.routes.get("/kikotools/autocomplete/loras")
|
||||
async def get_loras(request):
|
||||
"""API endpoint for getting list of LoRAs."""
|
||||
print("[KikoTools] LoRA endpoint called")
|
||||
try:
|
||||
lora_files = folder_paths.get_filename_list("loras")
|
||||
print(f"[KikoTools] Found {len(lora_files)} LoRA files")
|
||||
# Return LoRAs with paths
|
||||
loras = []
|
||||
for f in lora_files:
|
||||
clean_path = os.path.splitext(f)[0]
|
||||
loras.append(
|
||||
{
|
||||
"name": os.path.basename(clean_path),
|
||||
"path": clean_path,
|
||||
"file": f,
|
||||
}
|
||||
)
|
||||
print(f"[KikoTools] Returning {len(loras)} LoRAs")
|
||||
return web.json_response(loras)
|
||||
except Exception as e:
|
||||
print(f"[KikoTools] Error getting LoRAs: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return web.json_response([])
|
||||
|
||||
print("[KikoTools] Autocomplete API endpoints registered successfully")
|
||||
print(
|
||||
"[KikoTools] Routes available: /kikotools/autocomplete/embeddings and /kikotools/autocomplete/loras"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"[KikoTools] Could not import server components: {e}")
|
||||
except Exception as e:
|
||||
print(f"[KikoTools] Unexpected error setting up API: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# API endpoints are registered above at module import time
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
|
||||
def get_version():
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# ComfyUI XYZ Grid Comparison Nodes
|
||||
|
||||
## Project Objective
|
||||
Create a modular suite of ComfyUI nodes for visual grid-based comparisons across parameters such as:
|
||||
|
||||
- Models
|
||||
- LoRAs
|
||||
- Schedulers
|
||||
- Samplers
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Clip Skip
|
||||
- VAEs
|
||||
- Flux Guidance (custom model settings)
|
||||
|
||||
The tool will support X, Y, and optional Z axis configuration using a polished, intuitive UI with no scripting or coding required.
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
- **Modular Architecture:** Built as multiple nodes (not monolithic)
|
||||
- **Standard Node Compatibility:** Work with *any* KSampler, Model Loader, etc.
|
||||
- **User Friendly UI:** Dropdowns, toggles, and visual input—no syntax or scripting
|
||||
- **Flexible Axis Mapping:** Any parameter can go on X, Y, or Z
|
||||
- **Dynamic Grid Generation:** One-click execution queues all combinations
|
||||
- **Labeling:** Automatic overlay and metadata support with clean presentation
|
||||
- **High Performance:** Smart resource caching and sequential queuing
|
||||
|
||||
---
|
||||
|
||||
## Key Nodes
|
||||
|
||||
### 1. `XYZ Plot Controller`
|
||||
- Main config node
|
||||
- Allows axis selection (X, Y, optional Z)
|
||||
- Outputs: axis values, labels, grid ID
|
||||
- Automatically queues image generation
|
||||
|
||||
### 2. `Image Grid Combiner`
|
||||
- Accepts image + axis metadata
|
||||
- Assembles a labeled grid (or multiple grids)
|
||||
- Outputs: grid image(s), optional metadata (label list, value list)
|
||||
|
||||
---
|
||||
|
||||
## Parameter Types
|
||||
Supported as axis values:
|
||||
- Model (checkpoint)
|
||||
- LoRA (file)
|
||||
- VAE
|
||||
- Sampler (Euler, DPM++, etc.)
|
||||
- Scheduler
|
||||
- CFG Scale (float list)
|
||||
- Steps (int list)
|
||||
- Clip Skip
|
||||
- Prompt (swap full prompt or use template)
|
||||
- Seed
|
||||
- Custom (e.g., Flux guidance strength)
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Axis Config (for X, Y, Z)
|
||||
- Dropdown: Select parameter type
|
||||
- Input: List of values (dynamic UI)
|
||||
- File pickers (models, LoRAs)
|
||||
- Number range or CSV (steps, CFG)
|
||||
- Text input (prompts)
|
||||
- Label customization
|
||||
- Prefix: optional (e.g., CFG=, Sampler:)
|
||||
- Label format: full, short, value only
|
||||
|
||||
### Execution
|
||||
- One-click generate
|
||||
- Internally queues all combinations (X * Y * Z)
|
||||
- Reuses sampler, model loader, etc.
|
||||
- Supports caching to avoid repeated loads
|
||||
|
||||
---
|
||||
|
||||
## Output Behavior
|
||||
- Combiner tracks image count
|
||||
- Assembles grid when complete
|
||||
- Draws axis labels using PIL
|
||||
- Handles Z axis by outputting multiple grids
|
||||
- Preview as images come in
|
||||
- Metadata export (optional JSON/text)
|
||||
|
||||
---
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### Model vs CFG
|
||||
- X: Models A/B
|
||||
- Y: CFG [5,10,15]
|
||||
- Output: 2x3 grid with axis labels
|
||||
|
||||
### Prompt vs Sampler
|
||||
- X: Prompt variations
|
||||
- Y: Samplers
|
||||
- Output: labeled comparison grid
|
||||
|
||||
### LoRA vs Seed, Z=Strength
|
||||
- X: LoRA name
|
||||
- Y: Seeds
|
||||
- Z: LoRA strength
|
||||
- Output: Multiple 2D grids, one per Z value
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: MVP
|
||||
- X/Y support
|
||||
- Core image generation loop
|
||||
- Grid image stitching
|
||||
|
||||
### Phase 2: Z Axis + More Parameters
|
||||
- Prompt, LoRA, Flux guidance, etc.
|
||||
|
||||
### Phase 3: UI Polish
|
||||
- Dynamic widgets
|
||||
- Label controls, error handling
|
||||
|
||||
### Phase 4: Performance & Optimization
|
||||
- Model caching
|
||||
- Memory handling
|
||||
- Abort/resume logic
|
||||
|
||||
### Phase 5: Docs & Examples
|
||||
- Example workflows
|
||||
- Visual documentation
|
||||
|
||||
---
|
||||
|
||||
## References & Inspirations
|
||||
- [TinyTerra ComfyUI_tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes)
|
||||
- [kenjiqq/qq-nodes-comfyui](https://github.com/kenjiqq/qq-nodes-comfyui)
|
||||
- [jags111/efficiency-nodes-comfyui](https://github.com/jags111/efficiency-nodes-comfyui)
|
||||
- [shockz-comfy/comfy-easy-grids](https://github.com/shockz-comfy/comfy-easy-grids)
|
||||
|
||||
---
|
||||
|
||||
## Final Outcome
|
||||
A polished, no-code, modular XYZ plotting system in ComfyUI for exploring image generation across any combination of models, settings, or parameters with professional-grade visual output.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# RGThree-Style Dynamic Widget Framework for ComfyUI
|
||||
|
||||
This document explains how to implement RGThree's Power Lora Loader-style dynamic widget system in your own ComfyUI nodes. This framework provides a clean UI with toggles, dynamic widget management, and proper persistence across page refreshes.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Dynamic widget addition/removal** - Users can add/remove items at runtime
|
||||
- **Toggle switches** - Clean circular toggles instead of checkboxes
|
||||
- **Strength controls** - Arrow buttons with editable values for fine control
|
||||
- **Right-click context menus** - Only on the item name area
|
||||
- **Full persistence** - All values persist across page refreshes
|
||||
- **Hide/show widgets** - Proper cleanup when switching between types
|
||||
|
||||
## Core Implementation Pattern
|
||||
|
||||
### 1. Node Setup in JavaScript
|
||||
|
||||
```javascript
|
||||
app.registerExtension({
|
||||
name: "YourExtension.YourNode",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "YourNodeName") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const node = this;
|
||||
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Enable widget serialization
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// Track widget visibility
|
||||
this.hiddenWidgets = new Set();
|
||||
|
||||
// Initialize storage for dynamic widgets
|
||||
if (!node.dynamicWidgets) {
|
||||
node.dynamicWidgets = {
|
||||
category1: [],
|
||||
category2: []
|
||||
};
|
||||
}
|
||||
|
||||
// Store references to buttons and text widgets
|
||||
if (!node.addButtons) {
|
||||
node.addButtons = {};
|
||||
}
|
||||
if (!node.textWidgets) {
|
||||
node.textWidgets = {};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Custom Widget Class
|
||||
|
||||
```javascript
|
||||
class DynamicWidget {
|
||||
constructor(name, value) {
|
||||
this.name = name;
|
||||
this._value = value;
|
||||
this.type = "custom_dynamic_widget";
|
||||
this.y = 0;
|
||||
this.options = {};
|
||||
|
||||
// Mouse tracking for drag operations
|
||||
this.mouseState = {
|
||||
dragging: false,
|
||||
startX: 0,
|
||||
startValue: 0,
|
||||
lastClickTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(v) {
|
||||
this._value = v;
|
||||
}
|
||||
|
||||
serializeValue(node, index) {
|
||||
// Return a deep copy to prevent modification
|
||||
return this._value ? { ...this._value } : null;
|
||||
}
|
||||
|
||||
draw(ctx, node, width, y) {
|
||||
const margin = 10;
|
||||
const innerMargin = 3;
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT;
|
||||
const midY = y + height / 2;
|
||||
let posX = margin;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = "rgba(0,0,0,0.2)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
|
||||
ctx.fill();
|
||||
|
||||
// Draw toggle (Power Lora style)
|
||||
const toggleRadius = height * 0.36;
|
||||
const toggleBgWidth = height * 1.5;
|
||||
|
||||
// Toggle background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = app.canvas.editor_alpha;
|
||||
|
||||
// Toggle circle
|
||||
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
|
||||
ctx.fillStyle = this.value.on ? "#89B" : "#888";
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
this.toggleBounds = [posX, toggleBgWidth];
|
||||
posX += toggleBgWidth + innerMargin;
|
||||
|
||||
// Apply opacity if disabled
|
||||
if (!this.value.on) {
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
||||
}
|
||||
|
||||
// Draw strength controls (if applicable)
|
||||
if (this.value.strength !== undefined) {
|
||||
let strengthX = width - margin - innerMargin;
|
||||
|
||||
// Draw arrows and value
|
||||
// ... (implement arrow drawing as shown in xyz_plot_controller.js)
|
||||
}
|
||||
|
||||
// Draw item name
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.value.name || "None", posX, midY);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
mouse(event, pos, node) {
|
||||
// Handle mouse events for toggle and controls
|
||||
if (event.type === "mousedown") {
|
||||
// Check toggle bounds
|
||||
if (pos[0] >= this.toggleBounds[0] &&
|
||||
pos[0] <= this.toggleBounds[0] + this.toggleBounds[1]) {
|
||||
this.value.on = !this.value.on;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
// Handle other controls...
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configuration and Restoration
|
||||
|
||||
```javascript
|
||||
// Override onConfigure for proper restoration
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
// Mark as configured to prevent duplicate initialization
|
||||
this._configured = true;
|
||||
|
||||
// Store widget values before ComfyUI modifies them
|
||||
const savedWidgetValues = [...(info.widgets_values || [])];
|
||||
|
||||
// Clear tracking for fresh restoration
|
||||
if (!this.hiddenWidgets) {
|
||||
this.hiddenWidgets = new Set();
|
||||
}
|
||||
this.dynamicWidgets = { /* categories */ };
|
||||
this.addButtons = {};
|
||||
this.textWidgets = {};
|
||||
|
||||
// Let ComfyUI restore base widgets
|
||||
if (onConfigure) {
|
||||
onConfigure.call(this, info);
|
||||
}
|
||||
|
||||
// Restore dynamic widgets from saved values
|
||||
// ... (implement restoration logic)
|
||||
|
||||
// Manually restore text widget values
|
||||
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const savedValue = savedWidgetValues[i];
|
||||
|
||||
if (widget && typeof savedValue === 'string' && savedValue !== '') {
|
||||
widget.value = savedValue;
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = savedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Serialization Override
|
||||
|
||||
```javascript
|
||||
// Override onSerialize to fix widget value persistence
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function(info) {
|
||||
// Let ComfyUI serialize first
|
||||
if (origOnSerialize) {
|
||||
origOnSerialize.call(this, info);
|
||||
}
|
||||
|
||||
// Fix empty text widget values
|
||||
if (info.widgets_values && this.widgets) {
|
||||
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const serializedValue = info.widgets_values[i];
|
||||
|
||||
// If serialized value is empty but widget has value, fix it
|
||||
if ((serializedValue === '' || serializedValue === null) &&
|
||||
widget && widget.value !== '' && widget.value !== null) {
|
||||
info.widgets_values[i] = widget.value;
|
||||
}
|
||||
|
||||
// Also check inputEl for text widgets
|
||||
if (widget && widget.inputEl && widget.inputEl.value &&
|
||||
(serializedValue === '' || serializedValue === null)) {
|
||||
info.widgets_values[i] = widget.inputEl.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Right-Click Context Menu
|
||||
|
||||
```javascript
|
||||
// Override getSlotInPosition to detect clicks on widget areas
|
||||
const originalGetSlotInPosition = node.getSlotInPosition;
|
||||
node.getSlotInPosition = function(x, y) {
|
||||
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
|
||||
if (!slot) {
|
||||
// Check if we clicked on a dynamic widget's name area
|
||||
const localX = x - this.pos[0];
|
||||
const localY = y - this.pos[1];
|
||||
|
||||
for (const w of this.widgets || []) {
|
||||
if (w.type === "custom_dynamic_widget" && w.y &&
|
||||
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
|
||||
// Check if click is within name bounds
|
||||
if (w.nameBounds && localX >= w.nameBounds[0] &&
|
||||
localX <= w.nameBounds[0] + w.nameBounds[1]) {
|
||||
return { widget: w, output: { type: "DYNAMIC_WIDGET" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
};
|
||||
|
||||
// Override getSlotMenuOptions for context menu
|
||||
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
|
||||
node.getSlotMenuOptions = function(slot) {
|
||||
if (slot?.output?.type === "DYNAMIC_WIDGET") {
|
||||
const widget = slot.widget;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
|
||||
callback: () => {
|
||||
widget.value.on = !widget.value.on;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬆️ Move Up`,
|
||||
disabled: !canMoveUp,
|
||||
callback: () => { /* implement move */ }
|
||||
},
|
||||
{
|
||||
content: `⬇️ Move Down`,
|
||||
disabled: !canMoveDown,
|
||||
callback: () => { /* implement move */ }
|
||||
},
|
||||
{
|
||||
content: `🗑️ Remove`,
|
||||
callback: () => { /* implement remove */ }
|
||||
}
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "WIDGET OPTIONS",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
|
||||
return null; // Prevent default menu
|
||||
}
|
||||
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Widget Visibility Management
|
||||
|
||||
```javascript
|
||||
function updateWidgets(node, category, type, skipClear = false) {
|
||||
// Hide/show widgets instead of removing them
|
||||
if (!skipClear) {
|
||||
// Hide all widgets for this category
|
||||
node.widgets?.forEach(widget => {
|
||||
if (widget.name?.includes(category)) {
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
node.hiddenWidgets?.add(widget.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear dynamic widgets
|
||||
if (node.dynamicWidgets[category]) {
|
||||
while (node.dynamicWidgets[category].length > 0) {
|
||||
const widget = node.dynamicWidgets[category].pop();
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (index > -1) {
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or unhide widgets based on type
|
||||
if (needsTextWidget(type)) {
|
||||
const widgetName = `${category}_text`;
|
||||
let existingWidget = node.widgets?.find(w => w.name === widgetName);
|
||||
|
||||
if (!existingWidget) {
|
||||
// Create new widget
|
||||
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
|
||||
default: "",
|
||||
multiline: true
|
||||
}]);
|
||||
node.textWidgets[category] = textWidget.widget;
|
||||
} else {
|
||||
// Unhide existing widget
|
||||
existingWidget.hidden = false;
|
||||
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
node.hiddenWidgets?.delete(existingWidget.name);
|
||||
node.textWidgets[category] = existingWidget;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use hide/show instead of remove/add** for text widgets to preserve values
|
||||
2. **Track widget state** in dedicated objects (dynamicWidgets, textWidgets, etc.)
|
||||
3. **Override serialization** to ensure ComfyUI properly saves widget values
|
||||
4. **Use skipClear flags** during restoration to prevent widget clearing
|
||||
5. **Implement proper mouse bounds checking** for custom controls
|
||||
6. **Store metadata** (_axis, _type) with widget values for easier restoration
|
||||
7. **Don't auto-resize nodes** - respect user's manual sizing
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
1. **Don't remove widgets during configure** - this loses their values
|
||||
2. **Don't rely on widget indices** - they can change
|
||||
3. **Don't forget to handle inputEl** for text widgets
|
||||
4. **Don't create widgets without checking if they exist** first
|
||||
5. **Always deep copy values** when serializing to prevent modification
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Widgets persist across page refresh
|
||||
- [ ] Toggle states are maintained
|
||||
- [ ] Strength/value controls work with click and drag
|
||||
- [ ] Right-click menu only appears on name area
|
||||
- [ ] Moving widgets up/down works correctly
|
||||
- [ ] Removing widgets works without errors
|
||||
- [ ] Switching between types doesn't leave artifacts
|
||||
- [ ] All text input types persist (numbers, ranges, prompts)
|
||||
- [ ] Hidden widgets don't take up visual space
|
||||
- [ ] Widget values serialize correctly in workflow JSON
|
||||
|
||||
This framework provides a robust foundation for creating professional, user-friendly ComfyUI nodes with dynamic widget management that matches the quality of RGThree's implementations.
|
||||
@@ -0,0 +1,684 @@
|
||||
# RGThree Widget Framework - Complete Example Implementation
|
||||
|
||||
This file provides a complete, working example of implementing the RGThree-style widget framework for a hypothetical "Advanced Sampler Controller" node.
|
||||
|
||||
## Complete Implementation Example
|
||||
|
||||
```javascript
|
||||
// File: web/advanced_sampler_controller.js
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
|
||||
// Widget counter for unique names
|
||||
let widgetCounter = 0;
|
||||
|
||||
// Custom dynamic widget class
|
||||
class SamplerDynamicWidget {
|
||||
constructor(name, value) {
|
||||
this.name = name;
|
||||
this._value = value;
|
||||
this.type = "sampler_dynamic_widget";
|
||||
this.y = 0;
|
||||
this.options = {};
|
||||
|
||||
// Mouse state for drag operations
|
||||
this.mouseState = {
|
||||
dragging: false,
|
||||
startX: 0,
|
||||
startValue: 0,
|
||||
lastClickTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(v) {
|
||||
this._value = v;
|
||||
}
|
||||
|
||||
serializeValue(node, index) {
|
||||
return this._value ? { ...this._value } : null;
|
||||
}
|
||||
|
||||
draw(ctx, node, width, y) {
|
||||
const margin = 10;
|
||||
const innerMargin = 3;
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT;
|
||||
const midY = y + height / 2;
|
||||
let posX = margin;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "rgba(0,0,0,0.2)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
|
||||
ctx.fill();
|
||||
|
||||
// Toggle
|
||||
const toggleRadius = height * 0.36;
|
||||
const toggleBgWidth = height * 1.5;
|
||||
|
||||
// Toggle background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = app.canvas.editor_alpha;
|
||||
|
||||
// Toggle circle
|
||||
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
|
||||
ctx.fillStyle = this.value.on ? "#89B" : "#888";
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Store bounds for mouse interaction
|
||||
this.toggleBounds = [posX, toggleBgWidth];
|
||||
posX += toggleBgWidth + innerMargin;
|
||||
|
||||
// Apply opacity if disabled
|
||||
if (!this.value.on) {
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
||||
}
|
||||
|
||||
// Strength controls and value
|
||||
let strengthX = width - margin - innerMargin;
|
||||
|
||||
// Down arrow
|
||||
const arrowSize = 10;
|
||||
const arrowX = strengthX - arrowSize;
|
||||
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(arrowX + arrowSize/2, midY + 3);
|
||||
ctx.lineTo(arrowX + 2, midY - 3);
|
||||
ctx.lineTo(arrowX + arrowSize - 2, midY - 3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
this.downArrowBounds = [arrowX, arrowSize];
|
||||
strengthX = arrowX - innerMargin;
|
||||
|
||||
// Up arrow
|
||||
const upArrowX = strengthX - arrowSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(upArrowX + arrowSize/2, midY - 3);
|
||||
ctx.lineTo(upArrowX + 2, midY + 3);
|
||||
ctx.lineTo(upArrowX + arrowSize - 2, midY + 3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
this.upArrowBounds = [upArrowX, arrowSize];
|
||||
strengthX = upArrowX - innerMargin;
|
||||
|
||||
// Strength value
|
||||
const strengthText = this.value.strength.toFixed(2);
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = `${ctx.font}`;
|
||||
const textMetrics = ctx.measureText(strengthText);
|
||||
const strengthTextX = strengthX - textMetrics.width/2 - 4;
|
||||
|
||||
// Draggable background
|
||||
ctx.fillStyle = "rgba(255,255,255,0.1)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(strengthTextX - textMetrics.width/2 - 2, y + 4,
|
||||
textMetrics.width + 4, height - 8, [3]);
|
||||
ctx.fill();
|
||||
|
||||
// Value text
|
||||
ctx.fillStyle = this.value.on ? "#FFF" : "#AAA";
|
||||
ctx.fillText(strengthText, strengthTextX, midY);
|
||||
|
||||
this.strengthBounds = [strengthTextX - textMetrics.width/2 - 2, textMetrics.width + 4];
|
||||
|
||||
// Name
|
||||
const nameX = posX;
|
||||
const maxNameWidth = strengthTextX - textMetrics.width/2 - nameX - 10;
|
||||
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
|
||||
// Clip long names
|
||||
const displayName = this.value.name || "None";
|
||||
let truncatedName = displayName;
|
||||
if (ctx.measureText(displayName).width > maxNameWidth) {
|
||||
while (truncatedName.length > 0 &&
|
||||
ctx.measureText(truncatedName + "...").width > maxNameWidth) {
|
||||
truncatedName = truncatedName.slice(0, -1);
|
||||
}
|
||||
truncatedName += "...";
|
||||
}
|
||||
|
||||
ctx.fillText(truncatedName, nameX, midY);
|
||||
|
||||
// Store name bounds for right-click detection
|
||||
this.nameBounds = [nameX, ctx.measureText(truncatedName).width];
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
mouse(event, pos, node) {
|
||||
const margin = 10;
|
||||
const localX = pos[0] - margin;
|
||||
|
||||
if (event.type === "mousedown") {
|
||||
// Toggle click
|
||||
if (localX >= this.toggleBounds[0] &&
|
||||
localX <= this.toggleBounds[0] + this.toggleBounds[1]) {
|
||||
this.value.on = !this.value.on;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Up arrow
|
||||
if (localX >= this.upArrowBounds[0] &&
|
||||
localX <= this.upArrowBounds[0] + this.upArrowBounds[1]) {
|
||||
this.value.strength = Math.min(this.value.strength + 0.1, 10);
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Down arrow
|
||||
if (localX >= this.downArrowBounds[0] &&
|
||||
localX <= this.downArrowBounds[0] + this.downArrowBounds[1]) {
|
||||
this.value.strength = Math.max(this.value.strength - 0.1, -10);
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strength drag start
|
||||
if (localX >= this.strengthBounds[0] &&
|
||||
localX <= this.strengthBounds[0] + this.strengthBounds[1]) {
|
||||
this.mouseState.dragging = true;
|
||||
this.mouseState.startX = pos[0];
|
||||
this.mouseState.startValue = this.value.strength;
|
||||
|
||||
// Double-click detection
|
||||
const now = Date.now();
|
||||
if (now - this.mouseState.lastClickTime < 300) {
|
||||
// Double-click - show input dialog
|
||||
const newValue = prompt("Enter strength value:", this.value.strength);
|
||||
if (newValue !== null && !isNaN(parseFloat(newValue))) {
|
||||
this.value.strength = Math.max(-10, Math.min(10, parseFloat(newValue)));
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
this.mouseState.dragging = false;
|
||||
}
|
||||
this.mouseState.lastClickTime = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (event.type === "mousemove" && this.mouseState.dragging) {
|
||||
const deltaX = pos[0] - this.mouseState.startX;
|
||||
const sensitivity = 0.01;
|
||||
this.value.strength = Math.max(-10, Math.min(10,
|
||||
this.mouseState.startValue + deltaX * sensitivity));
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
else if (event.type === "mouseup") {
|
||||
this.mouseState.dragging = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
computeSize() {
|
||||
return [node.size[0], LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
}
|
||||
}
|
||||
|
||||
// Main extension registration
|
||||
app.registerExtension({
|
||||
name: "Example.AdvancedSamplerController",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "AdvancedSamplerController") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const node = this;
|
||||
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Enable widget serialization
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// Initialize tracking
|
||||
this.hiddenWidgets = new Set();
|
||||
|
||||
// Initialize storage
|
||||
if (!node.dynamicWidgets) {
|
||||
node.dynamicWidgets = {
|
||||
samplers: [],
|
||||
schedulers: []
|
||||
};
|
||||
}
|
||||
|
||||
if (!node.addButtons) {
|
||||
node.addButtons = {};
|
||||
}
|
||||
|
||||
if (!node.textWidgets) {
|
||||
node.textWidgets = {};
|
||||
}
|
||||
|
||||
// Override configuration
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
this._configured = true;
|
||||
|
||||
// Save widget values before ComfyUI modifies them
|
||||
const savedWidgetValues = [...(info.widgets_values || [])];
|
||||
|
||||
// Clear for fresh restoration
|
||||
if (!this.hiddenWidgets) {
|
||||
this.hiddenWidgets = new Set();
|
||||
}
|
||||
this.dynamicWidgets = {
|
||||
samplers: [],
|
||||
schedulers: []
|
||||
};
|
||||
this.addButtons = {};
|
||||
this.textWidgets = {};
|
||||
|
||||
// Let ComfyUI restore base widgets
|
||||
if (onConfigure) {
|
||||
onConfigure.call(this, info);
|
||||
}
|
||||
|
||||
// Restore dynamic widgets
|
||||
let widgetIndex = this.widgets.length;
|
||||
for (let i = widgetIndex; i < savedWidgetValues.length; i++) {
|
||||
const value = savedWidgetValues[i];
|
||||
if (value && typeof value === 'object' && value._type) {
|
||||
const widget = new SamplerDynamicWidget(
|
||||
`dynamic_${widgetCounter++}`,
|
||||
value
|
||||
);
|
||||
this.addCustomWidget(widget);
|
||||
|
||||
if (this.dynamicWidgets[value._type]) {
|
||||
this.dynamicWidgets[value._type].push(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore text widget values
|
||||
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const savedValue = savedWidgetValues[i];
|
||||
|
||||
if (widget && typeof savedValue === 'string' && savedValue !== '') {
|
||||
widget.value = savedValue;
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = savedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI based on restored state
|
||||
if (this.widgets?.length > 0) {
|
||||
const typeWidget = this.widgets.find(w => w.name === "sampler_type");
|
||||
if (typeWidget) {
|
||||
updateTypeWidgets(this, typeWidget.value, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override serialization
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function(info) {
|
||||
if (origOnSerialize) {
|
||||
origOnSerialize.call(this, info);
|
||||
}
|
||||
|
||||
// Fix empty text widget values
|
||||
if (info.widgets_values && this.widgets) {
|
||||
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const serializedValue = info.widgets_values[i];
|
||||
|
||||
if ((serializedValue === '' || serializedValue === null) &&
|
||||
widget && widget.value !== '' && widget.value !== null) {
|
||||
info.widgets_values[i] = widget.value;
|
||||
}
|
||||
|
||||
if (widget && widget.inputEl && widget.inputEl.value &&
|
||||
(serializedValue === '' || serializedValue === null)) {
|
||||
info.widgets_values[i] = widget.inputEl.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Implement right-click context menu
|
||||
implementContextMenu(node);
|
||||
|
||||
// Widget change handlers
|
||||
const samplerWidget = this.widgets.find(w => w.name === "sampler_type");
|
||||
if (samplerWidget) {
|
||||
const origCallback = samplerWidget.callback;
|
||||
samplerWidget.callback = function() {
|
||||
if (origCallback) {
|
||||
origCallback.apply(this, arguments);
|
||||
}
|
||||
updateTypeWidgets(node, samplerWidget.value);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to update widgets based on type
|
||||
function updateTypeWidgets(node, type, skipClear = false) {
|
||||
if (!skipClear) {
|
||||
// Hide text widgets
|
||||
node.widgets?.forEach(widget => {
|
||||
if (widget.name?.includes("custom_values")) {
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
node.hiddenWidgets?.add(widget.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear dynamic widgets
|
||||
if (node.dynamicWidgets.samplers) {
|
||||
while (node.dynamicWidgets.samplers.length > 0) {
|
||||
const widget = node.dynamicWidgets.samplers.pop();
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (index > -1) {
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or unhide widgets based on type
|
||||
if (type === "custom") {
|
||||
const widgetName = "custom_values";
|
||||
let existingWidget = node.widgets?.find(w => w.name === widgetName);
|
||||
|
||||
if (!existingWidget) {
|
||||
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
|
||||
default: "",
|
||||
multiline: true
|
||||
}]);
|
||||
node.textWidgets.custom = textWidget.widget;
|
||||
} else {
|
||||
existingWidget.hidden = false;
|
||||
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
node.hiddenWidgets?.delete(existingWidget.name);
|
||||
node.textWidgets.custom = existingWidget;
|
||||
}
|
||||
} else if (type === "samplers") {
|
||||
// Add button for samplers
|
||||
if (!node.addButtons.samplers) {
|
||||
const button = node.addWidget("button", "+ Add Sampler", null, () => {
|
||||
addDynamicWidget(node, "samplers");
|
||||
});
|
||||
node.addButtons.samplers = button;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to add dynamic widgets
|
||||
function addDynamicWidget(node, type) {
|
||||
const widget = new SamplerDynamicWidget(
|
||||
`dynamic_${widgetCounter++}`,
|
||||
{
|
||||
on: true,
|
||||
name: type === "samplers" ? "euler" : "normal",
|
||||
strength: 1.0,
|
||||
_type: type
|
||||
}
|
||||
);
|
||||
|
||||
node.addCustomWidget(widget);
|
||||
node.dynamicWidgets[type].push(widget);
|
||||
}
|
||||
|
||||
// Helper function to implement context menu
|
||||
function implementContextMenu(node) {
|
||||
const originalGetSlotInPosition = node.getSlotInPosition;
|
||||
node.getSlotInPosition = function(x, y) {
|
||||
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
|
||||
if (!slot) {
|
||||
const localX = x - this.pos[0];
|
||||
const localY = y - this.pos[1];
|
||||
|
||||
for (const w of this.widgets || []) {
|
||||
if (w.type === "sampler_dynamic_widget" && w.y &&
|
||||
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
|
||||
if (w.nameBounds && localX >= w.nameBounds[0] &&
|
||||
localX <= w.nameBounds[0] + w.nameBounds[1]) {
|
||||
return { widget: w, output: { type: "SAMPLER_WIDGET" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
};
|
||||
|
||||
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
|
||||
node.getSlotMenuOptions = function(slot) {
|
||||
if (slot?.output?.type === "SAMPLER_WIDGET") {
|
||||
const widget = slot.widget;
|
||||
const arrayName = widget.value._type;
|
||||
const array = this.dynamicWidgets[arrayName];
|
||||
const currentIndex = array.indexOf(widget);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
|
||||
callback: () => {
|
||||
widget.value.on = !widget.value.on;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬆️ Move Up`,
|
||||
disabled: currentIndex === 0,
|
||||
callback: () => {
|
||||
if (currentIndex > 0) {
|
||||
// Swap in array
|
||||
[array[currentIndex - 1], array[currentIndex]] =
|
||||
[array[currentIndex], array[currentIndex - 1]];
|
||||
|
||||
// Swap in widgets
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
const prevWidget = array[currentIndex];
|
||||
const prevIndex = this.widgets.indexOf(prevWidget);
|
||||
|
||||
if (widgetIndex > -1 && prevIndex > -1) {
|
||||
[this.widgets[prevIndex], this.widgets[widgetIndex]] =
|
||||
[this.widgets[widgetIndex], this.widgets[prevIndex]];
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬇️ Move Down`,
|
||||
disabled: currentIndex === array.length - 1,
|
||||
callback: () => {
|
||||
if (currentIndex < array.length - 1) {
|
||||
// Swap in array
|
||||
[array[currentIndex], array[currentIndex + 1]] =
|
||||
[array[currentIndex + 1], array[currentIndex]];
|
||||
|
||||
// Swap in widgets
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
const nextWidget = array[currentIndex];
|
||||
const nextIndex = this.widgets.indexOf(nextWidget);
|
||||
|
||||
if (widgetIndex > -1 && nextIndex > -1) {
|
||||
[this.widgets[widgetIndex], this.widgets[nextIndex]] =
|
||||
[this.widgets[nextIndex], this.widgets[widgetIndex]];
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
null, // Separator
|
||||
{
|
||||
content: `🗑️ Remove`,
|
||||
callback: () => {
|
||||
const index = array.indexOf(widget);
|
||||
if (index > -1) {
|
||||
array.splice(index, 1);
|
||||
}
|
||||
const wIndex = this.widgets.indexOf(widget);
|
||||
if (wIndex > -1) {
|
||||
this.widgets.splice(wIndex, 1);
|
||||
}
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "SAMPLER OPTIONS",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Python Node Definition
|
||||
|
||||
```python
|
||||
# File: kikotools/tools/advanced_sampler_controller/node.py
|
||||
|
||||
class AdvancedSamplerController:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sampler_type": (["samplers", "custom", "schedulers"], {
|
||||
"default": "samplers"
|
||||
}),
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"custom_values": ("STRING", {"multiline": True, "default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER_CONFIG",)
|
||||
RETURN_NAMES = ("config",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def process(self, sampler_type, enabled, custom_values="", **kwargs):
|
||||
config = {
|
||||
"type": sampler_type,
|
||||
"enabled": enabled,
|
||||
"samplers": [],
|
||||
"custom": custom_values
|
||||
}
|
||||
|
||||
# Process dynamic widgets
|
||||
for key, value in kwargs.items():
|
||||
if isinstance(value, dict) and value.get("_type") == "samplers":
|
||||
if value.get("on", False):
|
||||
config["samplers"].append({
|
||||
"name": value.get("name"),
|
||||
"strength": value.get("strength", 1.0)
|
||||
})
|
||||
|
||||
return (config,)
|
||||
```
|
||||
|
||||
## Key Implementation Points
|
||||
|
||||
1. **Widget Class Design**
|
||||
- Custom widget class with proper value getter/setter
|
||||
- `serializeValue` method for persistence
|
||||
- Complete `draw` and `mouse` methods
|
||||
- Proper bounds tracking for all interactive elements
|
||||
|
||||
2. **Node Setup**
|
||||
- `serialize_widgets = true` in onNodeCreated
|
||||
- Tracking objects for dynamic widgets, buttons, and text widgets
|
||||
- Hidden widgets set for visibility management
|
||||
|
||||
3. **Configuration Override**
|
||||
- Save widget values before ComfyUI modifies them
|
||||
- Clear tracking objects for fresh restoration
|
||||
- Restore dynamic widgets from saved values
|
||||
- Manually restore text widget values
|
||||
|
||||
4. **Serialization Override**
|
||||
- Fix empty text widget values
|
||||
- Check both widget.value and widget.inputEl.value
|
||||
- Ensure all widget types persist correctly
|
||||
|
||||
5. **Context Menu Implementation**
|
||||
- Override getSlotInPosition to detect widget clicks
|
||||
- Check name bounds for right-click detection
|
||||
- Return custom slot type for menu trigger
|
||||
- Override getSlotMenuOptions for menu items
|
||||
|
||||
6. **Widget Management**
|
||||
- Hide/show pattern instead of remove/add
|
||||
- Proper cleanup when switching types
|
||||
- Dynamic widget arrays for organization
|
||||
- Button widgets for adding new items
|
||||
|
||||
## Testing Your Implementation
|
||||
|
||||
1. **Create Test Workflow**
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"type": "AdvancedSamplerController",
|
||||
"widgets_values": [
|
||||
"samplers",
|
||||
true,
|
||||
"",
|
||||
{
|
||||
"on": true,
|
||||
"name": "euler",
|
||||
"strength": 0.8,
|
||||
"_type": "samplers"
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Test Checklist**
|
||||
- [ ] Add dynamic widgets with button
|
||||
- [ ] Toggle on/off states persist
|
||||
- [ ] Strength values persist after refresh
|
||||
- [ ] Right-click menu only on name area
|
||||
- [ ] Move up/down works correctly
|
||||
- [ ] Remove widget works
|
||||
- [ ] Switch types doesn't leave artifacts
|
||||
- [ ] Text values persist
|
||||
- [ ] Double-click to edit strength works
|
||||
|
||||
3. **Debug Tips**
|
||||
- Add console.log in key methods
|
||||
- Check browser console for errors
|
||||
- Verify widget array contents
|
||||
- Test with workflow JSON export/import
|
||||
|
||||
This complete example demonstrates all aspects of the RGThree widget framework and can be adapted for any custom node that needs dynamic widget management with professional UI/UX.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,152 +0,0 @@
|
||||
# Flux Sampler Params
|
||||
|
||||
## Overview
|
||||
The **Flux Sampler Params** node provides a specialized parameter generator for FLUX model sampling. This tool was adapted from the excellent [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) project (now in maintenance mode) and enhanced for the ComfyAssets ecosystem.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **FLUX-Optimized Parameters**: Specifically tuned for FLUX model requirements
|
||||
- **Batch Processing Support**: Generate multiple parameter sets for comparative testing
|
||||
- **Interactive UI Elements**: Visual controls for quick parameter adjustments
|
||||
- **Smart Defaults**: Pre-configured optimal settings for FLUX workflows
|
||||
- **Comprehensive Parameter Control**: Fine-tune all aspects of FLUX sampling
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `FluxSamplerParams`
|
||||
- **Function**: `get_value`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Range | Description |
|
||||
|-----------|------|---------|-------|-------------|
|
||||
| `scheduler` | DROPDOWN | normal | [normal, simple, sgm_uniform] | Scheduler algorithm for sampling |
|
||||
| `steps` | INT | 20 | 1-100 | Number of sampling steps |
|
||||
| `guidance` | FLOAT | 3.5 | 0.0-100.0 | Guidance scale for conditioning |
|
||||
| `max_shift` | FLOAT | 1.0 | 0.0-100.0 | Maximum shift value for FLUX |
|
||||
| `base_shift` | FLOAT | 0.5 | 0.0-100.0 | Base shift value for FLUX |
|
||||
| `denoise` | FLOAT | 1.0 | 0.0-1.0 | Denoising strength |
|
||||
| `batch_mode` | DROPDOWN | single | [single, batch] | Single value or batch processing |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `batch_count` | INT | 1 | Number of batch variations (1-100) |
|
||||
| `batch_seed_mode` | DROPDOWN | incremental | Seed generation mode for batches |
|
||||
| `variation_seed` | INT | None | Optional seed for variations |
|
||||
| `lora_params` | LORA_PARAMS | None | LoRA parameters from LoRAFolderBatch |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `sampler_params` | SAMPLER_PARAMS | Complete FLUX sampling parameters |
|
||||
| `scheduler` | STRING | Selected scheduler algorithm |
|
||||
| `steps` | INT | Number of sampling steps |
|
||||
| `guidance` | FLOAT | Guidance scale value |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic FLUX Sampling
|
||||
```
|
||||
FluxSamplerParams → KSampler → VAE Decode → Save Image
|
||||
scheduler: normal
|
||||
steps: 20
|
||||
guidance: 3.5
|
||||
```
|
||||
|
||||
### Batch Parameter Testing
|
||||
```
|
||||
FluxSamplerParams → KSampler → Image Grid → Save
|
||||
batch_mode: batch
|
||||
batch_count: 5
|
||||
guidance: 2.0...5.0
|
||||
```
|
||||
|
||||
### With LoRA Integration
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
↓ ↓
|
||||
lora_params → Combined parameters
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### FLUX-Specific Settings
|
||||
- **Guidance**: FLUX typically works best with lower guidance (2.0-5.0)
|
||||
- **Steps**: 15-25 steps usually sufficient for FLUX
|
||||
- **Scheduler**: `normal` or `sgm_uniform` recommended for FLUX
|
||||
- **Shift Values**: Adjust for different quality/speed tradeoffs
|
||||
|
||||
### Batch Testing Workflow
|
||||
1. Set `batch_mode` to `batch`
|
||||
2. Configure parameter ranges using `...` syntax
|
||||
3. Set appropriate `batch_count`
|
||||
4. Use with image grid nodes for comparison
|
||||
|
||||
### Memory Optimization
|
||||
- Start with smaller batch counts for testing
|
||||
- Monitor VRAM usage with high batch counts
|
||||
- Use incremental seed mode for reproducibility
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Works Well With
|
||||
- **LoRA Folder Batch**: Combine multiple LoRAs with FLUX parameters
|
||||
- **Plot Parameters**: Visualize parameter effects
|
||||
- **Sampler Select Helper**: Dynamic sampler selection
|
||||
- **Text Encode Sampler Params**: Add text conditioning
|
||||
|
||||
### Common Workflows
|
||||
1. **Parameter Sweep**: Test multiple guidance/step combinations
|
||||
2. **LoRA Testing**: Evaluate different LoRA strengths with FLUX
|
||||
3. **Quality Comparison**: Compare different shift values
|
||||
4. **Seed Exploration**: Generate variations with controlled seeds
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Optimal FLUX Settings
|
||||
```python
|
||||
# High Quality (Slower)
|
||||
scheduler: "sgm_uniform"
|
||||
steps: 25
|
||||
guidance: 3.5
|
||||
max_shift: 1.0
|
||||
base_shift: 0.5
|
||||
|
||||
# Fast Preview
|
||||
scheduler: "simple"
|
||||
steps: 12
|
||||
guidance: 2.5
|
||||
max_shift: 0.8
|
||||
base_shift: 0.4
|
||||
```
|
||||
|
||||
### Batch Parameter Ranges
|
||||
- Steps: `15...25+5` (test 15, 20, 25)
|
||||
- Guidance: `2.0...5.0+0.5` (test 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0)
|
||||
- Denoise: `0.8...1.0+0.1` (test 0.8, 0.9, 1.0)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Out of Memory**: Reduce batch_count or image resolution
|
||||
2. **Poor Quality**: Increase steps or adjust guidance
|
||||
3. **Artifacts**: Check shift values aren't too high
|
||||
4. **Slow Generation**: Use `simple` scheduler for previews
|
||||
|
||||
### Parameter Guidelines
|
||||
- Don't set guidance too high (>10) for FLUX
|
||||
- Keep denoise at 1.0 for initial generation
|
||||
- Adjust shift values gradually for best results
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added batch processing support
|
||||
- **1.0.2**: Enhanced FLUX-specific optimizations
|
||||
- **1.0.3**: Improved UI elements and parameter validation
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -1,125 +0,0 @@
|
||||
# Kiko Film Grain
|
||||
|
||||
## Overview
|
||||
The **Kiko Film Grain** node applies realistic film grain effects to images, simulating the aesthetic of analog film photography. It provides comprehensive controls for grain size, intensity, color saturation, and shadow lifting to achieve various film looks.
|
||||
|
||||
## Node Details
|
||||
- **Category**: ComfyAssets/image
|
||||
- **Node Name**: KikoFilmGrain
|
||||
- **Display Name**: Kiko Film Grain
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
- **image** (`IMAGE`)
|
||||
- The input image to apply film grain to
|
||||
- Supports batch processing
|
||||
- Preserves alpha channel if present
|
||||
|
||||
### Parameters
|
||||
- **scale** (`FLOAT`)
|
||||
- Controls the size of the grain pattern
|
||||
- Range: 0.25 to 2.0
|
||||
- Default: 0.5
|
||||
- Lower values = finer grain, higher values = coarser grain
|
||||
|
||||
- **strength** (`FLOAT`)
|
||||
- Intensity of the grain effect
|
||||
- Range: 0.0 to 10.0
|
||||
- Default: 0.5
|
||||
- 0.0 = no grain, higher values = more pronounced grain
|
||||
|
||||
- **saturation** (`FLOAT`)
|
||||
- Color saturation of the grain
|
||||
- Range: 0.0 to 2.0
|
||||
- Default: 0.7
|
||||
- 0.0 = monochrome grain, 1.0 = full color, >1.0 = oversaturated
|
||||
|
||||
- **toe** (`FLOAT`)
|
||||
- Lifts blacks/shadows for a film-like look
|
||||
- Range: -0.2 to 0.5
|
||||
- Default: 0.0
|
||||
- Positive values lift shadows, negative values crush blacks
|
||||
|
||||
- **seed** (`INT`)
|
||||
- Random seed for grain pattern generation
|
||||
- Range: 0 to maximum integer
|
||||
- Default: 0
|
||||
- Use for reproducible grain patterns
|
||||
|
||||
## Outputs
|
||||
- **image** (`IMAGE`)
|
||||
- The processed image with film grain applied
|
||||
- Same dimensions and batch size as input
|
||||
- Alpha channel preserved if present
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Subtle Film Look
|
||||
```
|
||||
Scale: 0.5
|
||||
Strength: 0.3
|
||||
Saturation: 0.8
|
||||
Toe: 0.05
|
||||
```
|
||||
Creates a subtle, fine-grained film aesthetic suitable for portraits.
|
||||
|
||||
### Vintage Film
|
||||
```
|
||||
Scale: 1.0
|
||||
Strength: 0.8
|
||||
Saturation: 0.5
|
||||
Toe: 0.15
|
||||
```
|
||||
Simulates vintage film with moderate grain and lifted shadows.
|
||||
|
||||
### High ISO Film
|
||||
```
|
||||
Scale: 0.75
|
||||
Strength: 1.5
|
||||
Saturation: 0.6
|
||||
Toe: 0.1
|
||||
```
|
||||
Emulates high ISO film stock with pronounced grain.
|
||||
|
||||
### Black & White Film
|
||||
```
|
||||
Scale: 0.6
|
||||
Strength: 0.6
|
||||
Saturation: 0.0
|
||||
Toe: 0.08
|
||||
```
|
||||
Creates monochrome grain perfect for black and white photography.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Improvements Over Standard Implementations
|
||||
1. **Pure PyTorch Operations**: No OpenCV dependencies, better GPU utilization
|
||||
2. **ITU-R BT.709 Color Space**: Accurate color conversion for grain application
|
||||
3. **Screen Blend Mode**: Preserves highlights better than multiply blending
|
||||
4. **Channel-Specific Weighting**: Film grain is stronger in blue channel (3x), moderate in red (2x), matching real film characteristics
|
||||
5. **Efficient Memory Management**: Minimizes tensor copies and conversions
|
||||
|
||||
### Algorithm Overview
|
||||
1. Generate random noise at specified scale
|
||||
2. Convert to YCbCr color space for realistic grain distribution
|
||||
3. Apply different blur kernels to each channel:
|
||||
- Y (luminance): 3x3 kernel for fine detail
|
||||
- Cb (blue-yellow): 15x15 kernel for color noise
|
||||
- Cr (red-green): 11x11 kernel for color noise
|
||||
4. Convert back to RGB and apply strength/saturation
|
||||
5. Use screen blend mode to combine with original image
|
||||
6. Apply toe adjustment for film-like shadow response
|
||||
|
||||
## Tips
|
||||
- Start with low strength values (0.2-0.5) and adjust upward
|
||||
- For color images, saturation between 0.5-0.8 looks most natural
|
||||
- Combine with color grading nodes for complete film emulation
|
||||
- Use consistent seed values across batch for uniform grain
|
||||
- Scale parameter affects both grain size and render performance (smaller scale = more computation)
|
||||
|
||||
## Compatibility
|
||||
- Works with any image format supported by ComfyUI
|
||||
- Preserves image properties (alpha channel, batch size)
|
||||
- Compatible with both RGB and RGBA images
|
||||
- Efficient batch processing support
|
||||
@@ -1,212 +0,0 @@
|
||||
# LoRA Folder Batch
|
||||
|
||||
## Overview
|
||||
The **LoRA Folder Batch** node automates the process of testing multiple LoRA models from a folder. This tool was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode) and enhanced with batch processing capabilities for efficient LoRA evaluation workflows.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Automatic Folder Scanning**: Discovers all .safetensors files in specified folders
|
||||
- **Natural Sorting**: Intelligently sorts epochs (e.g., epoch_004, epoch_020, epoch_100)
|
||||
- **Pattern Filtering**: Include/exclude LoRAs using regex patterns
|
||||
- **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
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `LoRAFolderBatch`
|
||||
- **Function**: `batch_loras`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `folder_path` | STRING | "." | Folder path relative to models/loras (or absolute) |
|
||||
| `strength` | STRING | "1.0" | Strength values (see formats below) |
|
||||
| `batch_mode` | DROPDOWN | sequential | [sequential, combinatorial] processing mode |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `include_pattern` | STRING | "" | Regex pattern to include files |
|
||||
| `exclude_pattern` | STRING | "" | Regex pattern to exclude files |
|
||||
|
||||
### Strength Format Options
|
||||
- **Single**: `"1.0"` - Apply same strength to all LoRAs
|
||||
- **Multiple**: `"0.5, 0.75, 1.0"` - Comma-separated values
|
||||
- **Range**: `"0.5...1.0+0.25"` - Start...End+Step format
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `lora_params` | LORA_PARAMS | Batch parameters for processing |
|
||||
| `lora_list` | STRING | List of discovered LoRAs with epoch info |
|
||||
| `lora_count` | INT | Number of LoRAs found |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Test All Epochs of a LoRA
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
folder_path: "my_lora_training"
|
||||
strength: "1.0"
|
||||
batch_mode: sequential
|
||||
```
|
||||
|
||||
### Strength Testing for Each LoRA
|
||||
```
|
||||
LoRAFolderBatch → KSampler → Image Grid
|
||||
folder_path: "test_loras"
|
||||
strength: "0.5, 0.75, 1.0"
|
||||
batch_mode: combinatorial
|
||||
```
|
||||
|
||||
### Filter Specific Epochs
|
||||
```
|
||||
LoRAFolderBatch → Processing Pipeline
|
||||
folder_path: "training_results"
|
||||
include_pattern: "epoch_0[2-5]0"
|
||||
strength: "0.8...1.2+0.1"
|
||||
```
|
||||
|
||||
## Batch Modes Explained
|
||||
|
||||
### Sequential Mode
|
||||
Each LoRA gets one strength value in order:
|
||||
- LoRA1 → strength[0]
|
||||
- LoRA2 → strength[1]
|
||||
- LoRA3 → strength[0] (cycles if fewer strengths than LoRAs)
|
||||
|
||||
### Combinatorial Mode
|
||||
Each LoRA is tested with ALL strength values:
|
||||
- LoRA1 → [0.5, 0.75, 1.0]
|
||||
- LoRA2 → [0.5, 0.75, 1.0]
|
||||
- LoRA3 → [0.5, 0.75, 1.0]
|
||||
|
||||
## File Naming Patterns
|
||||
|
||||
### Supported Epoch Formats
|
||||
- `model-v1-000004.safetensors` → Epoch 4
|
||||
- `style_epoch_020.safetensors` → Epoch 20
|
||||
- `lora-000100.safetensors` → Epoch 100
|
||||
|
||||
### Natural Sorting Examples
|
||||
Files are sorted intelligently:
|
||||
1. `model-000004.safetensors`
|
||||
2. `model-000020.safetensors`
|
||||
3. `model-000100.safetensors`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Folder Organization
|
||||
```
|
||||
models/loras/
|
||||
├── my_style/
|
||||
│ ├── style-000010.safetensors
|
||||
│ ├── style-000020.safetensors
|
||||
│ └── style-000030.safetensors
|
||||
└── character/
|
||||
├── char-v2-000005.safetensors
|
||||
└── char-v2-000010.safetensors
|
||||
```
|
||||
|
||||
### Testing Workflows
|
||||
1. **Initial Testing**: Use single strength (1.0) to evaluate all epochs
|
||||
2. **Fine-tuning**: Use combinatorial mode with multiple strengths
|
||||
3. **Final Selection**: Filter to specific epochs and test strength range
|
||||
|
||||
### Pattern Filtering Examples
|
||||
```python
|
||||
# Include only specific versions
|
||||
include_pattern: "v2|v3"
|
||||
|
||||
# Exclude test/backup files
|
||||
exclude_pattern: "test|backup|old"
|
||||
|
||||
# Include specific epoch range
|
||||
include_pattern: "epoch_0[3-7]0"
|
||||
```
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Common Pipelines
|
||||
1. **LoRA Comparison Grid**:
|
||||
```
|
||||
LoRAFolderBatch → KSampler → Image Grid → Save
|
||||
```
|
||||
|
||||
2. **Strength Testing**:
|
||||
```
|
||||
LoRAFolderBatch → PlotParameters → Graph Display
|
||||
```
|
||||
|
||||
3. **Combined with FLUX**:
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
```
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Memory Management
|
||||
- Start with fewer LoRAs when testing combinatorial mode
|
||||
- Use sequential mode for initial epoch evaluation
|
||||
- Clear LoRA cache between large batch runs
|
||||
|
||||
### Optimal Strength Ranges
|
||||
- **Style LoRAs**: 0.5-1.0
|
||||
- **Character LoRAs**: 0.7-1.2
|
||||
- **Detail LoRAs**: 0.3-0.7
|
||||
|
||||
### Debugging
|
||||
- Check `lora_list` output to verify correct files were found
|
||||
- Use `lora_count` to confirm expected number of LoRAs
|
||||
- Test patterns with include/exclude before full runs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No LoRAs Found
|
||||
- Verify folder path (relative to models/loras or use absolute)
|
||||
- Check file extensions (.safetensors)
|
||||
- Test without filters first
|
||||
|
||||
### Pattern Not Working
|
||||
- Patterns use Python regex syntax
|
||||
- Test patterns in regex tester first
|
||||
- Case-sensitive by default
|
||||
|
||||
### Memory Issues
|
||||
- Reduce batch_count in combinatorial mode
|
||||
- Process LoRAs in smaller groups
|
||||
- Use sequential mode for large sets
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Multi-Version Testing
|
||||
```python
|
||||
# Test different versions at different strengths
|
||||
folder_path: "character_loras"
|
||||
include_pattern: "v[1-3]"
|
||||
strength: "0.6, 0.8, 1.0"
|
||||
batch_mode: combinatorial
|
||||
```
|
||||
|
||||
### Epoch Progression Analysis
|
||||
```python
|
||||
# Test every 10th epoch
|
||||
folder_path: "training_output"
|
||||
include_pattern: "0[0-9]0\\.safetensors$"
|
||||
strength: "1.0"
|
||||
batch_mode: sequential
|
||||
```
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added natural sorting for epochs
|
||||
- **1.0.2**: Enhanced pattern filtering
|
||||
- **1.0.3**: Improved batch modes and strength parsing
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -1,234 +0,0 @@
|
||||
# Plot Parameters
|
||||
|
||||
## Overview
|
||||
The **Plot Parameters** node creates visual graphs and plots from sampler parameters, enabling data-driven analysis of generation settings. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool helps visualize the relationship between parameters and output quality.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Multi-Parameter Plotting**: Visualize multiple parameters simultaneously
|
||||
- **Comparison Graphs**: Compare settings across batch runs
|
||||
- **Statistical Analysis**: Calculate means, deviations, and trends
|
||||
- **Export Capabilities**: Save plots as images or data files
|
||||
- **Real-time Updates**: Dynamic graph generation during workflow execution
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `PlotParameters`
|
||||
- **Function**: `plot`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sampler_params` | SAMPLER_PARAMS | - | Parameters to plot |
|
||||
| `plot_type` | DROPDOWN | line | [line, bar, scatter, heatmap] |
|
||||
| `x_axis` | DROPDOWN | steps | Parameter for X axis |
|
||||
| `y_axis` | DROPDOWN | quality | Metric for Y axis |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `title` | STRING | "Parameter Analysis" | Graph title |
|
||||
| `show_grid` | BOOLEAN | True | Display grid lines |
|
||||
| `show_legend` | BOOLEAN | True | Display legend |
|
||||
| `color_scheme` | DROPDOWN | default | Color palette selection |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `plot_image` | IMAGE | Generated plot as image |
|
||||
| `data_csv` | STRING | Plot data in CSV format |
|
||||
| `statistics` | STRING | Statistical summary |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Parameter Visualization
|
||||
```
|
||||
FluxSamplerParams → PlotParameters → Display Image
|
||||
plot_type: line
|
||||
x_axis: steps
|
||||
y_axis: guidance
|
||||
```
|
||||
|
||||
### Batch Comparison Plot
|
||||
```
|
||||
LoRAFolderBatch → PlotParameters → Save Image
|
||||
plot_type: scatter
|
||||
x_axis: lora_strength
|
||||
y_axis: quality_score
|
||||
```
|
||||
|
||||
### Heatmap Analysis
|
||||
```
|
||||
Parameter Grid → PlotParameters → Analysis Display
|
||||
plot_type: heatmap
|
||||
x_axis: cfg
|
||||
y_axis: steps
|
||||
```
|
||||
|
||||
## Plot Types Explained
|
||||
|
||||
### Line Plot
|
||||
- Best for continuous parameter changes
|
||||
- Shows trends and relationships
|
||||
- Ideal for time series or progression
|
||||
|
||||
### Bar Chart
|
||||
- Compares discrete values
|
||||
- Good for categorical comparisons
|
||||
- Shows distribution clearly
|
||||
|
||||
### Scatter Plot
|
||||
- Reveals correlations
|
||||
- Identifies outliers
|
||||
- Best for large datasets
|
||||
|
||||
### Heatmap
|
||||
- Two-dimensional parameter analysis
|
||||
- Color-coded intensity values
|
||||
- Perfect for grid searches
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Parameter Selection
|
||||
- Choose related parameters for meaningful plots
|
||||
- Use consistent scales for comparison
|
||||
- Consider parameter ranges when plotting
|
||||
|
||||
### Visual Clarity
|
||||
- Limit number of series to 5-7 for readability
|
||||
- Use contrasting colors for multiple lines
|
||||
- Enable grid for precise value reading
|
||||
|
||||
### Data Analysis
|
||||
```python
|
||||
# Effective parameter combinations
|
||||
x_axis: "guidance"
|
||||
y_axis: "perceived_quality"
|
||||
|
||||
# Step efficiency analysis
|
||||
x_axis: "steps"
|
||||
y_axis: "generation_time"
|
||||
|
||||
# LoRA impact assessment
|
||||
x_axis: "lora_strength"
|
||||
y_axis: "style_adherence"
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Complete Analysis Pipeline
|
||||
```
|
||||
1. Generate with parameters
|
||||
2. Plot results
|
||||
3. Export data
|
||||
4. Statistical analysis
|
||||
```
|
||||
|
||||
### Multi-Plot Workflow
|
||||
```
|
||||
Params → Plot1 (steps vs quality)
|
||||
↘ Plot2 (guidance vs coherence)
|
||||
↘ Plot3 (strength vs style)
|
||||
→ Combined Analysis
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Metrics
|
||||
- Define custom Y-axis metrics
|
||||
- Import external quality scores
|
||||
- Calculate derived values
|
||||
|
||||
### Export Options
|
||||
- PNG/SVG image formats
|
||||
- CSV data export
|
||||
- JSON statistics export
|
||||
|
||||
### Styling Options
|
||||
```python
|
||||
# Professional presentation
|
||||
color_scheme: "scientific"
|
||||
show_grid: True
|
||||
show_legend: True
|
||||
|
||||
# Minimal style
|
||||
color_scheme: "minimal"
|
||||
show_grid: False
|
||||
show_legend: False
|
||||
```
|
||||
|
||||
## Statistical Analysis
|
||||
|
||||
### Available Metrics
|
||||
- Mean, Median, Mode
|
||||
- Standard Deviation
|
||||
- Correlation Coefficients
|
||||
- Trend Lines
|
||||
- R-squared Values
|
||||
|
||||
### Interpretation Guide
|
||||
- **Positive Correlation**: Parameters increase together
|
||||
- **Negative Correlation**: Inverse relationship
|
||||
- **No Correlation**: Independent parameters
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Optimal Visualization
|
||||
1. Start with scatter plots for exploration
|
||||
2. Use line plots for trends
|
||||
3. Apply heatmaps for 2D parameter spaces
|
||||
4. Bar charts for final comparisons
|
||||
|
||||
### Data Preparation
|
||||
- Normalize scales when comparing different metrics
|
||||
- Remove outliers for cleaner plots
|
||||
- Group similar parameters
|
||||
|
||||
### Performance Tips
|
||||
- Cache plot images for repeated viewing
|
||||
- Export data for external analysis
|
||||
- Use lower resolution for preview plots
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Empty Plots
|
||||
- Verify sampler_params contains data
|
||||
- Check axis parameter selection
|
||||
- Ensure valid parameter ranges
|
||||
|
||||
### Scaling Issues
|
||||
- Use logarithmic scale for wide ranges
|
||||
- Normalize data if needed
|
||||
- Adjust plot dimensions
|
||||
|
||||
### Export Problems
|
||||
- Check file permissions
|
||||
- Verify export path exists
|
||||
- Ensure sufficient disk space
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Hyperparameter Optimization
|
||||
Track and visualize the effect of different sampling parameters on output quality.
|
||||
|
||||
### LoRA Strength Analysis
|
||||
Plot the relationship between LoRA strength and style transfer effectiveness.
|
||||
|
||||
### Efficiency Studies
|
||||
Analyze generation time vs quality trade-offs across different settings.
|
||||
|
||||
### Batch Comparison
|
||||
Compare multiple generation runs to identify optimal parameters.
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added heatmap visualization
|
||||
- **1.0.2**: Enhanced statistical analysis
|
||||
- **1.0.3**: Improved export capabilities
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -1,260 +0,0 @@
|
||||
# Sampler Select Helper
|
||||
|
||||
## Overview
|
||||
The **Sampler Select Helper** node provides intelligent sampler selection with model-specific recommendations and compatibility checking. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool ensures optimal sampler-scheduler combinations for different model architectures.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Model-Aware Selection**: Automatic recommendations based on model type
|
||||
- **Compatibility Validation**: Ensures sampler-scheduler pairs work well together
|
||||
- **Performance Profiles**: Pre-configured settings for quality vs speed
|
||||
- **Dynamic Updates**: Adapts to newly available samplers
|
||||
- **Batch Support**: Test multiple samplers in sequence
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `SamplerSelectHelper`
|
||||
- **Function**: `select_sampler`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `model_type` | DROPDOWN | auto | [auto, sdxl, sd15, flux, custom] |
|
||||
| `quality_preset` | DROPDOWN | balanced | [fast, balanced, quality, extreme] |
|
||||
| `sampler_override` | DROPDOWN | auto | Specific sampler selection |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `scheduler_override` | DROPDOWN | auto | Specific scheduler selection |
|
||||
| `model_name` | STRING | - | Model name for auto-detection |
|
||||
| `custom_rules` | STRING | - | JSON rules for custom selection |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `sampler_name` | STRING | Selected sampler |
|
||||
| `scheduler` | STRING | Selected scheduler |
|
||||
| `recommended_steps` | INT | Suggested step count |
|
||||
| `recommended_cfg` | FLOAT | Suggested CFG scale |
|
||||
|
||||
## Model-Specific Recommendations
|
||||
|
||||
### SDXL Models
|
||||
```python
|
||||
quality_preset: "balanced"
|
||||
→ sampler: "dpmpp_2m"
|
||||
→ scheduler: "karras"
|
||||
→ steps: 25
|
||||
→ cfg: 7.0
|
||||
```
|
||||
|
||||
### SD 1.5 Models
|
||||
```python
|
||||
quality_preset: "quality"
|
||||
→ sampler: "dpmpp_2m_sde"
|
||||
→ scheduler: "exponential"
|
||||
→ steps: 30
|
||||
→ cfg: 7.5
|
||||
```
|
||||
|
||||
### FLUX Models
|
||||
```python
|
||||
quality_preset: "fast"
|
||||
→ sampler: "euler"
|
||||
→ scheduler: "simple"
|
||||
→ steps: 15
|
||||
→ cfg: 3.5
|
||||
```
|
||||
|
||||
## Quality Presets Explained
|
||||
|
||||
### Fast (Preview)
|
||||
- **Goal**: Quick iterations
|
||||
- **Steps**: 10-15
|
||||
- **Samplers**: euler, dpm_fast
|
||||
- **Use Case**: Testing prompts
|
||||
|
||||
### Balanced (Default)
|
||||
- **Goal**: Good quality/speed ratio
|
||||
- **Steps**: 20-25
|
||||
- **Samplers**: dpmpp_2m, dpmpp_2m_sde
|
||||
- **Use Case**: Regular generation
|
||||
|
||||
### Quality
|
||||
- **Goal**: Best visual quality
|
||||
- **Steps**: 30-40
|
||||
- **Samplers**: dpmpp_3m_sde, dpm_adaptive
|
||||
- **Use Case**: Final renders
|
||||
|
||||
### Extreme
|
||||
- **Goal**: Maximum quality
|
||||
- **Steps**: 50-100
|
||||
- **Samplers**: dpm_adaptive, dpmpp_3m_sde
|
||||
- **Use Case**: Hero images
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Auto Model Detection
|
||||
```
|
||||
Load Model → SamplerSelectHelper → KSampler
|
||||
model_type: auto
|
||||
quality_preset: balanced
|
||||
```
|
||||
|
||||
### Custom Override
|
||||
```
|
||||
SamplerSelectHelper → KSampler
|
||||
sampler_override: "dpmpp_3m_sde"
|
||||
scheduler_override: "exponential"
|
||||
```
|
||||
|
||||
### Batch Testing
|
||||
```
|
||||
SamplerSelectHelper → Batch Process
|
||||
quality_preset: [fast, balanced, quality]
|
||||
→ Compare outputs
|
||||
```
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
### Recommended Combinations
|
||||
| Sampler | Best Schedulers | Avoid |
|
||||
|---------|----------------|--------|
|
||||
| euler | normal, karras | sgm_uniform |
|
||||
| euler_a | normal, karras | simple |
|
||||
| dpmpp_2m | karras, exponential | - |
|
||||
| dpmpp_2m_sde | karras, exponential | simple |
|
||||
| dpmpp_3m_sde | exponential | simple |
|
||||
| dpm_adaptive | normal | karras |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Model Type Detection
|
||||
1. Use `auto` for automatic detection
|
||||
2. Override only when necessary
|
||||
3. Provide model_name for better accuracy
|
||||
|
||||
### Performance Optimization
|
||||
```python
|
||||
# Quick preview workflow
|
||||
quality_preset: "fast"
|
||||
→ 10 steps, euler sampler
|
||||
|
||||
# Final production
|
||||
quality_preset: "quality"
|
||||
→ 35 steps, dpmpp_3m_sde
|
||||
|
||||
# Experimental/artistic
|
||||
quality_preset: "extreme"
|
||||
→ 75 steps, dpm_adaptive
|
||||
```
|
||||
|
||||
### Custom Rules Format
|
||||
```json
|
||||
{
|
||||
"model_pattern": "anime.*",
|
||||
"sampler": "dpmpp_2m_sde",
|
||||
"scheduler": "karras",
|
||||
"steps": 28,
|
||||
"cfg": 7.0
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Complete Pipeline
|
||||
```
|
||||
Model Loader → SamplerSelectHelper → KSampler
|
||||
↘ FluxSamplerParams ↗
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
```
|
||||
SamplerSelectHelper → KSampler → Image A
|
||||
quality: fast
|
||||
SamplerSelectHelper → KSampler → Image B
|
||||
quality: quality
|
||||
→ Compare Results
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Dynamic Sampler Discovery
|
||||
- Automatically detects new samplers
|
||||
- Updates compatibility matrix
|
||||
- Maintains optimal pairings
|
||||
|
||||
### Performance Profiling
|
||||
- Tracks generation times
|
||||
- Suggests optimal settings
|
||||
- Adapts to hardware capabilities
|
||||
|
||||
### Model Fingerprinting
|
||||
- Identifies model architecture
|
||||
- Applies specific optimizations
|
||||
- Learns from usage patterns
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Speed vs Quality
|
||||
1. Start with "fast" for prompt testing
|
||||
2. Move to "balanced" for iteration
|
||||
3. Use "quality" for final output
|
||||
4. Reserve "extreme" for special cases
|
||||
|
||||
### Sampler Selection Logic
|
||||
```python
|
||||
if model_type == "flux":
|
||||
prefer ["euler", "dpmpp_2m"]
|
||||
elif model_type == "sdxl":
|
||||
prefer ["dpmpp_2m_sde", "dpmpp_3m_sde"]
|
||||
else:
|
||||
use ["dpmpp_2m", "euler_a"]
|
||||
```
|
||||
|
||||
### Memory Considerations
|
||||
- Fast presets use less memory
|
||||
- Extreme presets may require more VRAM
|
||||
- Adaptive samplers adjust dynamically
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Wrong Sampler Selected
|
||||
- Check model_type setting
|
||||
- Verify model detection
|
||||
- Use manual override if needed
|
||||
|
||||
### Poor Quality Output
|
||||
- Increase quality preset
|
||||
- Check recommended steps
|
||||
- Verify CFG scale
|
||||
|
||||
### Performance Issues
|
||||
- Start with fast preset
|
||||
- Reduce step count
|
||||
- Try simpler samplers
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Model Comparison
|
||||
Test same prompt across different models with optimal settings for each.
|
||||
|
||||
### Quality Ladder
|
||||
Progress from fast to extreme quality to find optimal balance.
|
||||
|
||||
### Sampler Shootout
|
||||
Compare all compatible samplers for specific model/prompt combination.
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added FLUX model support
|
||||
- **1.0.2**: Enhanced compatibility matrix
|
||||
- **1.0.3**: Improved auto-detection
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -1,300 +0,0 @@
|
||||
# Scheduler Select Helper
|
||||
|
||||
## Overview
|
||||
The **Scheduler Select Helper** node provides intelligent scheduler selection with sampler-aware recommendations and model-specific optimizations. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool ensures optimal scheduler selection for different sampling algorithms and models.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Sampler-Aware Selection**: Recommends best schedulers for each sampler
|
||||
- **Model Optimization**: Specific scheduler tuning for different models
|
||||
- **Noise Schedule Profiles**: Pre-configured curves for various use cases
|
||||
- **Visual Feedback**: Preview noise schedules
|
||||
- **Batch Testing**: Compare multiple schedulers
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `SchedulerSelectHelper`
|
||||
- **Function**: `select_scheduler`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sampler_name` | STRING | - | Current sampler being used |
|
||||
| `model_type` | DROPDOWN | auto | [auto, sdxl, sd15, flux] |
|
||||
| `schedule_type` | DROPDOWN | smooth | [smooth, sharp, linear, custom] |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `override` | DROPDOWN | none | Force specific scheduler |
|
||||
| `beta_schedule` | STRING | - | Custom beta schedule values |
|
||||
| `visualize` | BOOLEAN | False | Show schedule curve |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `scheduler` | STRING | Selected scheduler name |
|
||||
| `schedule_curve` | IMAGE | Visualization of noise schedule |
|
||||
| `beta_values` | FLOAT_ARRAY | Beta schedule values |
|
||||
|
||||
## Scheduler Types Explained
|
||||
|
||||
### Normal
|
||||
- **Curve**: Linear noise reduction
|
||||
- **Best For**: General purpose
|
||||
- **Samplers**: euler, dpm_fast
|
||||
|
||||
### Karras
|
||||
- **Curve**: Improved noise schedule
|
||||
- **Best For**: High quality
|
||||
- **Samplers**: dpmpp_2m, dpmpp_2m_sde
|
||||
|
||||
### Exponential
|
||||
- **Curve**: Exponential decay
|
||||
- **Best For**: Fine details
|
||||
- **Samplers**: dpmpp_3m_sde
|
||||
|
||||
### Simple
|
||||
- **Curve**: Basic linear
|
||||
- **Best For**: Fast generation
|
||||
- **Samplers**: euler, lcm
|
||||
|
||||
### SGM Uniform
|
||||
- **Curve**: Uniform distribution
|
||||
- **Best For**: FLUX models
|
||||
- **Samplers**: euler, dpmpp_2m
|
||||
|
||||
## Schedule Types
|
||||
|
||||
### Smooth (Default)
|
||||
```python
|
||||
# Gradual noise reduction
|
||||
# Good for most content
|
||||
→ karras or exponential
|
||||
```
|
||||
|
||||
### Sharp
|
||||
```python
|
||||
# Aggressive early reduction
|
||||
# Good for high contrast
|
||||
→ normal or simple
|
||||
```
|
||||
|
||||
### Linear
|
||||
```python
|
||||
# Constant reduction rate
|
||||
# Predictable results
|
||||
→ normal
|
||||
```
|
||||
|
||||
### Custom
|
||||
```python
|
||||
# User-defined curve
|
||||
# Advanced control
|
||||
→ based on beta_schedule
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Automatic Selection
|
||||
```
|
||||
KSampler Settings → SchedulerSelectHelper → KSampler
|
||||
sampler_name: "dpmpp_2m_sde"
|
||||
model_type: auto
|
||||
→ scheduler: "karras"
|
||||
```
|
||||
|
||||
### Visual Comparison
|
||||
```
|
||||
SchedulerSelectHelper → Display
|
||||
visualize: True
|
||||
→ Shows noise schedule curve
|
||||
```
|
||||
|
||||
### Batch Testing
|
||||
```
|
||||
For each scheduler:
|
||||
SchedulerSelectHelper → KSampler → Save
|
||||
→ Compare results
|
||||
```
|
||||
|
||||
## Sampler-Scheduler Compatibility
|
||||
|
||||
### Optimal Pairings
|
||||
| Sampler | Best Scheduler | Good Alternatives |
|
||||
|---------|---------------|-------------------|
|
||||
| euler | normal | karras |
|
||||
| euler_a | karras | normal |
|
||||
| heun | normal | - |
|
||||
| dpm_fast | normal | simple |
|
||||
| dpm_adaptive | normal | - |
|
||||
| dpmpp_2m | karras | exponential |
|
||||
| dpmpp_2m_sde | karras | exponential |
|
||||
| dpmpp_3m_sde | exponential | karras |
|
||||
| dpmpp_2s_a | karras | normal |
|
||||
| lcm | simple | normal |
|
||||
|
||||
## Model-Specific Recommendations
|
||||
|
||||
### SDXL
|
||||
```python
|
||||
preferred_schedulers = ["karras", "exponential"]
|
||||
# Better convergence for high-res
|
||||
```
|
||||
|
||||
### SD 1.5
|
||||
```python
|
||||
preferred_schedulers = ["karras", "normal"]
|
||||
# Classic combinations
|
||||
```
|
||||
|
||||
### FLUX
|
||||
```python
|
||||
preferred_schedulers = ["simple", "sgm_uniform"]
|
||||
# Optimized for FLUX architecture
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Selection Strategy
|
||||
1. Let auto-detection handle defaults
|
||||
2. Override for specific artistic goals
|
||||
3. Test multiple schedulers for hero images
|
||||
4. Use visualization to understand curves
|
||||
|
||||
### Performance Tips
|
||||
- Simple/normal for quick previews
|
||||
- Karras/exponential for quality
|
||||
- SGM uniform specifically for FLUX
|
||||
- Match scheduler to sampler type
|
||||
|
||||
### Testing Workflow
|
||||
```python
|
||||
schedulers = ["normal", "karras", "exponential"]
|
||||
for scheduler in schedulers:
|
||||
generate_image(scheduler)
|
||||
save_with_metadata(scheduler)
|
||||
compare_results()
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Beta Schedule Customization
|
||||
```python
|
||||
# Custom exponential curve
|
||||
beta_schedule = "0.00085, 0.0012, 0.0018, ..."
|
||||
|
||||
# Sharp early reduction
|
||||
beta_schedule = "0.001, 0.002, 0.004, 0.006, ..."
|
||||
```
|
||||
|
||||
### Schedule Visualization
|
||||
- Plots noise reduction curve
|
||||
- Shows sigma values
|
||||
- Compares with standard schedules
|
||||
- Exports schedule data
|
||||
|
||||
### Adaptive Selection
|
||||
- Learns from user preferences
|
||||
- Adapts to hardware capabilities
|
||||
- Optimizes for generation speed
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Complete Pipeline
|
||||
```
|
||||
Sampler Combo → SchedulerSelectHelper → KSampler
|
||||
↓ ↓
|
||||
sampler_name → Optimal scheduler selection
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
```
|
||||
Same prompt → Different schedulers → Grid comparison
|
||||
normal vs karras vs exponential
|
||||
```
|
||||
|
||||
### Noise Schedule Analysis
|
||||
```
|
||||
SchedulerSelectHelper → Plot Parameters
|
||||
visualize: True
|
||||
→ Analyze noise curves
|
||||
```
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Quality Optimization
|
||||
```python
|
||||
# For maximum quality
|
||||
if sampler in ["dpmpp_3m_sde"]:
|
||||
use scheduler="exponential"
|
||||
elif sampler in ["dpmpp_2m_sde"]:
|
||||
use scheduler="karras"
|
||||
```
|
||||
|
||||
### Speed Optimization
|
||||
```python
|
||||
# For fast generation
|
||||
use scheduler="simple" or "normal"
|
||||
reduce step count by 20%
|
||||
```
|
||||
|
||||
### Artistic Effects
|
||||
- **Sharp details**: normal scheduler
|
||||
- **Smooth gradients**: karras scheduler
|
||||
- **Fine textures**: exponential scheduler
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Artifacts or Noise
|
||||
- Try different scheduler
|
||||
- Check sampler compatibility
|
||||
- Adjust step count
|
||||
|
||||
### Slow Convergence
|
||||
- Switch from simple to karras
|
||||
- Increase step count
|
||||
- Check model compatibility
|
||||
|
||||
### Inconsistent Results
|
||||
- Use same scheduler for batch
|
||||
- Avoid random scheduler selection
|
||||
- Fix seed for testing
|
||||
|
||||
## Visual Guide
|
||||
|
||||
### Noise Schedule Curves
|
||||
```
|
||||
Normal: ████████████████
|
||||
Linear reduction
|
||||
|
||||
Karras: ███████████▓▓▓░░
|
||||
Smooth curve
|
||||
|
||||
Exponential: ██████▓▓▓░░░░░
|
||||
Fast early reduction
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Scheduler Comparison
|
||||
Test same seed with different schedulers to find optimal setting.
|
||||
|
||||
### Model Migration
|
||||
When switching models, automatically adjust scheduler for best results.
|
||||
|
||||
### Quality Ladder
|
||||
Progress through schedulers from fast to quality for different use cases.
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added visualization features
|
||||
- **1.0.2**: Enhanced model detection
|
||||
- **1.0.3**: Improved compatibility matrix
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -1,310 +0,0 @@
|
||||
# Text Encode Sampler Params
|
||||
|
||||
## Overview
|
||||
The **Text Encode Sampler Params** node combines text encoding with sampler parameter management, providing a unified interface for prompt processing and sampling configuration. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool streamlines the text-to-image pipeline setup.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Unified Interface**: Combine text encoding and sampler params in one node
|
||||
- **Dynamic Prompt Processing**: Support for wildcards and syntax
|
||||
- **Parameter Templates**: Pre-configured settings for common scenarios
|
||||
- **Batch Text Processing**: Handle multiple prompts efficiently
|
||||
- **Model-Aware Encoding**: Optimize for different text encoders
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `TextEncodeSamplerParams`
|
||||
- **Function**: `encode_and_params`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `text` | STRING | - | Prompt text to encode |
|
||||
| `clip` | CLIP | - | CLIP model for encoding |
|
||||
| `sampler_name` | DROPDOWN | dpmpp_2m | Sampling algorithm |
|
||||
| `scheduler` | DROPDOWN | karras | Noise scheduler |
|
||||
| `steps` | INT | 20 | Sampling steps |
|
||||
| `cfg` | FLOAT | 7.0 | CFG scale |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `negative_text` | STRING | "" | Negative prompt |
|
||||
| `seed` | INT | -1 | Random seed (-1 for random) |
|
||||
| `denoise` | FLOAT | 1.0 | Denoising strength |
|
||||
| `template` | DROPDOWN | none | Parameter template |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `positive` | CONDITIONING | Encoded positive prompt |
|
||||
| `negative` | CONDITIONING | Encoded negative prompt |
|
||||
| `sampler_params` | DICT | Complete sampler parameters |
|
||||
|
||||
## Templates
|
||||
|
||||
### Portrait Photography
|
||||
```python
|
||||
template: "portrait"
|
||||
→ steps: 25
|
||||
→ cfg: 7.5
|
||||
→ sampler: dpmpp_2m_sde
|
||||
→ scheduler: karras
|
||||
```
|
||||
|
||||
### Landscape Art
|
||||
```python
|
||||
template: "landscape"
|
||||
→ steps: 30
|
||||
→ cfg: 8.0
|
||||
→ sampler: dpmpp_3m_sde
|
||||
→ scheduler: exponential
|
||||
```
|
||||
|
||||
### Quick Preview
|
||||
```python
|
||||
template: "preview"
|
||||
→ steps: 12
|
||||
→ cfg: 6.0
|
||||
→ sampler: euler
|
||||
→ scheduler: normal
|
||||
```
|
||||
|
||||
### High Detail
|
||||
```python
|
||||
template: "detailed"
|
||||
→ steps: 40
|
||||
→ cfg: 7.0
|
||||
→ sampler: dpm_adaptive
|
||||
→ scheduler: karras
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Text-to-Image
|
||||
```
|
||||
TextEncodeSamplerParams → KSampler → VAE Decode
|
||||
text: "beautiful landscape"
|
||||
negative_text: "ugly, blurry"
|
||||
steps: 20
|
||||
```
|
||||
|
||||
### Template-Based Generation
|
||||
```
|
||||
TextEncodeSamplerParams → KSampler
|
||||
text: "portrait of a person"
|
||||
template: "portrait"
|
||||
→ Optimized portrait settings
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
```
|
||||
Multiple Prompts → TextEncodeSamplerParams → Batch Generate
|
||||
→ Encode all prompts with same settings
|
||||
```
|
||||
|
||||
## Prompt Syntax Support
|
||||
|
||||
### Wildcards
|
||||
```
|
||||
{red|blue|green} car
|
||||
→ Randomly selects color
|
||||
```
|
||||
|
||||
### Emphasis
|
||||
```
|
||||
(important:1.2) detail
|
||||
→ Increases weight to 1.2
|
||||
```
|
||||
|
||||
### Alternation
|
||||
```
|
||||
[cat|dog] in garden
|
||||
→ Alternates between options
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Text Encoding
|
||||
1. Keep prompts concise and descriptive
|
||||
2. Use emphasis for important elements
|
||||
3. Structure prompts logically
|
||||
4. Test negative prompts impact
|
||||
|
||||
### Parameter Selection
|
||||
```python
|
||||
# Quality over speed
|
||||
steps: 30-40
|
||||
cfg: 7-8
|
||||
sampler: dpmpp_3m_sde
|
||||
|
||||
# Speed over quality
|
||||
steps: 10-15
|
||||
cfg: 5-6
|
||||
sampler: euler
|
||||
```
|
||||
|
||||
### Negative Prompts
|
||||
```python
|
||||
# Common negatives
|
||||
"ugly, tiling, poorly drawn, out of frame"
|
||||
|
||||
# Style-specific
|
||||
"cartoon, anime" (for realism)
|
||||
"realistic, photo" (for artwork)
|
||||
```
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Complete Pipeline
|
||||
```
|
||||
TextEncodeSamplerParams → KSampler → VAE Decode
|
||||
↓ ↑
|
||||
All parameters From Model Loader
|
||||
```
|
||||
|
||||
### With LoRA
|
||||
```
|
||||
LoRAFolderBatch → TextEncodeSamplerParams → Generate
|
||||
→ Apply LoRA to encoded text
|
||||
```
|
||||
|
||||
### Multi-Pass Processing
|
||||
```
|
||||
TextEncodeSamplerParams → First Pass (low res)
|
||||
↘ Second Pass (high res)
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Dynamic Templates
|
||||
```python
|
||||
# Load template based on prompt content
|
||||
if "portrait" in text:
|
||||
use_template("portrait")
|
||||
elif "landscape" in text:
|
||||
use_template("landscape")
|
||||
```
|
||||
|
||||
### Prompt Weighting
|
||||
```python
|
||||
# Automatic weight calculation
|
||||
analyze_prompt_importance()
|
||||
apply_semantic_weights()
|
||||
```
|
||||
|
||||
### CLIP Skip Support
|
||||
- Adjust CLIP layers used
|
||||
- Model-specific optimization
|
||||
- Quality vs style balance
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Prompt Optimization
|
||||
1. Front-load important elements
|
||||
2. Use commas for separation
|
||||
3. Avoid contradictions
|
||||
4. Test with different CFG values
|
||||
|
||||
### Performance Tuning
|
||||
```python
|
||||
# Memory efficient
|
||||
encode_in_batches = True
|
||||
clear_cache_between = True
|
||||
|
||||
# Speed priority
|
||||
use_half_precision = True
|
||||
minimize_conditioning = True
|
||||
```
|
||||
|
||||
### Quality Enhancement
|
||||
- Higher CFG for prompt adherence
|
||||
- Lower CFG for creativity
|
||||
- Balance with step count
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Style Transfer
|
||||
```
|
||||
Reference Image → Extract Style
|
||||
↓
|
||||
TextEncodeSamplerParams → Apply Style
|
||||
text: "in the style of [extracted]"
|
||||
```
|
||||
|
||||
### Prompt Evolution
|
||||
```
|
||||
Base Prompt → Variations → TextEncodeSamplerParams
|
||||
→ Test different phrasings
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
```
|
||||
Same prompt → Different parameters → Compare
|
||||
template A vs template B
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Poor Text Adherence
|
||||
- Increase CFG scale
|
||||
- Simplify prompt
|
||||
- Check CLIP model compatibility
|
||||
|
||||
### Over-saturation
|
||||
- Reduce CFG scale
|
||||
- Adjust negative prompt
|
||||
- Check sampler settings
|
||||
|
||||
### Encoding Errors
|
||||
- Verify CLIP model loaded
|
||||
- Check text formatting
|
||||
- Remove special characters
|
||||
|
||||
## Parameter Guidelines
|
||||
|
||||
### CFG Scale Effects
|
||||
```
|
||||
Low (3-5): Creative, loose interpretation
|
||||
Medium (6-8): Balanced adherence
|
||||
High (9-12): Strict prompt following
|
||||
Very High (13+): Potential artifacts
|
||||
```
|
||||
|
||||
### Step Count Impact
|
||||
```
|
||||
Low (10-15): Fast, rough
|
||||
Medium (20-30): Good balance
|
||||
High (40-50): Maximum quality
|
||||
Very High (50+): Diminishing returns
|
||||
```
|
||||
|
||||
## Model-Specific Settings
|
||||
|
||||
### SDXL
|
||||
- CFG: 6-8
|
||||
- CLIP Skip: 1-2
|
||||
- Emphasis: Moderate
|
||||
|
||||
### SD 1.5
|
||||
- CFG: 7-9
|
||||
- CLIP Skip: 1-2
|
||||
- Emphasis: Standard
|
||||
|
||||
### FLUX
|
||||
- CFG: 3-5
|
||||
- CLIP Skip: 0
|
||||
- Emphasis: Subtle
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added template system
|
||||
- **1.0.2**: Enhanced prompt syntax support
|
||||
- **1.0.3**: Improved batch processing
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -1,165 +0,0 @@
|
||||
{
|
||||
"id": "kiko-film-grain-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
50,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"shape": 3,
|
||||
"label": "IMAGE"
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3,
|
||||
"label": "MASK"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"example.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KikoFilmGrain",
|
||||
"pos": [
|
||||
450,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
202
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [2],
|
||||
"shape": 3,
|
||||
"label": "image",
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"Node name for S&R": "KikoFilmGrain"
|
||||
},
|
||||
"widgets_values": [
|
||||
0.5,
|
||||
0.5,
|
||||
0.7,
|
||||
0.0,
|
||||
0
|
||||
],
|
||||
"color": "#223",
|
||||
"bgcolor": "#335"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
850,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
450,
|
||||
350
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
150
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"text": ""
|
||||
},
|
||||
"widgets_values": [
|
||||
"Kiko Film Grain Example\n\nThis workflow demonstrates the film grain effect.\n\nAdjust parameters:\n- Scale: Grain size (0.25-2.0)\n- Strength: Intensity (0.0-10.0)\n- Saturation: Color amount (0.0-2.0)\n- Toe: Shadow lifting (-0.2-0.5)\n- Seed: Random pattern"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.0,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 MiB |
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"name": "Sampler and Scheduler Comparison Workflow",
|
||||
"description": "Compare different sampler and scheduler combinations using xyz_helpers",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "SamplerSelectHelper",
|
||||
"title": "Select Optimal Sampler",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"model_type": "auto",
|
||||
"quality_preset": "balanced",
|
||||
"sampler_override": "auto",
|
||||
"model_name": "sdxl_model.safetensors"
|
||||
},
|
||||
"outputs": {
|
||||
"sampler_name": "STRING",
|
||||
"scheduler": "STRING",
|
||||
"recommended_steps": "INT",
|
||||
"recommended_cfg": "FLOAT"
|
||||
},
|
||||
"pos": [100, 100]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "SchedulerSelectHelper",
|
||||
"title": "Optimize Scheduler",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"sampler_name": ["1", "sampler_name"],
|
||||
"model_type": "sdxl",
|
||||
"schedule_type": "smooth",
|
||||
"visualize": true
|
||||
},
|
||||
"outputs": {
|
||||
"scheduler": "STRING",
|
||||
"schedule_curve": "IMAGE"
|
||||
},
|
||||
"pos": [400, 100]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"type": "TextEncodeSamplerParams",
|
||||
"title": "Setup Text and Params",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"text": "a majestic mountain landscape at sunset, highly detailed",
|
||||
"negative_text": "low quality, blurry, artifacts",
|
||||
"clip": ["model", "clip"],
|
||||
"sampler_name": ["1", "sampler_name"],
|
||||
"scheduler": ["2", "scheduler"],
|
||||
"steps": ["1", "recommended_steps"],
|
||||
"cfg": ["1", "recommended_cfg"],
|
||||
"template": "landscape"
|
||||
},
|
||||
"outputs": {
|
||||
"positive": "CONDITIONING",
|
||||
"negative": "CONDITIONING",
|
||||
"sampler_params": "DICT"
|
||||
},
|
||||
"pos": [700, 100]
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"type": "EmptyLatentBatch",
|
||||
"title": "Create Test Latents",
|
||||
"category": "ComfyAssets/📦 Latents",
|
||||
"inputs": {
|
||||
"preset": "1216×832 (SDXL Landscape)",
|
||||
"batch_size": 4
|
||||
},
|
||||
"outputs": {
|
||||
"latent": "LATENT"
|
||||
},
|
||||
"pos": [100, 300]
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"type": "KSampler",
|
||||
"title": "Generate with Optimal Settings",
|
||||
"inputs": {
|
||||
"model": ["model", "model"],
|
||||
"positive": ["3", "positive"],
|
||||
"negative": ["3", "negative"],
|
||||
"latent_image": ["4", "latent"],
|
||||
"sampler_name": ["1", "sampler_name"],
|
||||
"scheduler": ["2", "scheduler"],
|
||||
"steps": ["1", "recommended_steps"],
|
||||
"cfg": ["1", "recommended_cfg"],
|
||||
"seed": 42
|
||||
},
|
||||
"outputs": {
|
||||
"latent": "LATENT"
|
||||
},
|
||||
"pos": [1000, 200]
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"type": "PlotParameters",
|
||||
"title": "Visualize Parameters",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"sampler_params": ["3", "sampler_params"],
|
||||
"plot_type": "bar",
|
||||
"x_axis": "parameter_name",
|
||||
"y_axis": "value",
|
||||
"title": "Sampler Configuration Analysis",
|
||||
"show_grid": true
|
||||
},
|
||||
"outputs": {
|
||||
"plot_image": "IMAGE"
|
||||
},
|
||||
"pos": [700, 400]
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"type": "DisplayAny",
|
||||
"title": "Show Schedule Curve",
|
||||
"category": "ComfyAssets/🔍 Debug",
|
||||
"inputs": {
|
||||
"input": ["2", "schedule_curve"],
|
||||
"mode": "tensor shape"
|
||||
},
|
||||
"pos": [400, 400]
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"type": "VAEDecode",
|
||||
"title": "Decode Results",
|
||||
"inputs": {
|
||||
"samples": ["5", "latent"],
|
||||
"vae": ["model", "vae"]
|
||||
},
|
||||
"outputs": {
|
||||
"image": "IMAGE"
|
||||
},
|
||||
"pos": [1300, 200]
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"type": "KikoSaveImage",
|
||||
"title": "Save Comparison",
|
||||
"category": "ComfyAssets/💾 Images",
|
||||
"inputs": {
|
||||
"images": ["8", "image"],
|
||||
"filename_prefix": "sampler_comparison",
|
||||
"format": "WEBP",
|
||||
"quality": 90,
|
||||
"popup": true
|
||||
},
|
||||
"pos": [1600, 200]
|
||||
}
|
||||
],
|
||||
"workflow_notes": {
|
||||
"purpose": "Compare and optimize sampler/scheduler combinations for best quality",
|
||||
"features": [
|
||||
"Automatic sampler selection based on model",
|
||||
"Scheduler optimization with visualization",
|
||||
"Parameter analysis and plotting",
|
||||
"Batch generation for comparison"
|
||||
],
|
||||
"tips": [
|
||||
"Try different quality_preset values",
|
||||
"Use visualize=true to see noise schedules",
|
||||
"Compare results across multiple seeds"
|
||||
],
|
||||
"attribution": "xyz_helpers nodes adapted from comfyui-essentials-nodes"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
# XYZ Grid Examples
|
||||
|
||||
This directory contains example workflows demonstrating the XYZ Grid nodes for ComfyUI.
|
||||
|
||||
## Overview
|
||||
|
||||
The XYZ Grid system allows you to create parameter comparison grids with any combination of:
|
||||
- Models/Checkpoints
|
||||
- Samplers
|
||||
- Schedulers
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Clip Skip
|
||||
- VAEs
|
||||
- LoRAs
|
||||
- Prompts
|
||||
- Seeds
|
||||
- Flux Guidance
|
||||
- Denoise strength
|
||||
|
||||
## Basic Usage
|
||||
|
||||
1. Add an **XYZ Plot Controller** node to your workflow
|
||||
2. Configure X and Y axes (and optionally Z for multiple grids)
|
||||
3. Connect the appropriate outputs to your generation nodes
|
||||
4. Add an **Image Grid Combiner** node
|
||||
5. Connect your generated images to the combiner
|
||||
6. Run once - the system handles all iterations automatically!
|
||||
|
||||
## Node Descriptions
|
||||
|
||||
### XYZ Plot Controller
|
||||
|
||||
The main configuration node that drives the grid generation.
|
||||
|
||||
**Inputs:**
|
||||
- `x_axis_type`: Parameter type for X axis (horizontal)
|
||||
- `x_values`: Values to iterate over (comma-separated or range syntax)
|
||||
- `y_axis_type`: Parameter type for Y axis (vertical)
|
||||
- `y_values`: Values to iterate over
|
||||
- `z_axis_type`: (Optional) Parameter type for Z axis (multiple grids)
|
||||
- `z_values`: Values for Z axis
|
||||
- `auto_queue`: Enable automatic execution queuing
|
||||
|
||||
**Outputs:**
|
||||
- `grid_data`: Configuration data for the combiner
|
||||
- `x_string`, `x_int`, `x_float`: Current X value in different types
|
||||
- `y_string`, `y_int`, `y_float`: Current Y value in different types
|
||||
- `z_string`, `z_int`, `z_float`: Current Z value in different types
|
||||
- `batch_id`: Unique identifier for this grid batch
|
||||
|
||||
### Image Grid Combiner
|
||||
|
||||
Collects generated images and assembles them into labeled grids.
|
||||
|
||||
**Inputs:**
|
||||
- `images`: Generated images from your workflow
|
||||
- `grid_data`: Configuration from XYZ Plot Controller
|
||||
- `font_size`: Size of label text (default: 20)
|
||||
- `grid_gap`: Pixel gap between images (default: 10)
|
||||
- `label_height`: Height of label area (default: 30)
|
||||
- `include_labels`: Whether to add labels (default: true)
|
||||
|
||||
**Outputs:**
|
||||
- `grid_image`: The assembled grid image(s)
|
||||
- `grid_info`: Information about the grid
|
||||
|
||||
## Value Syntax
|
||||
|
||||
### Lists
|
||||
Use comma-separated values:
|
||||
```
|
||||
euler, euler_ancestral, dpm_2, dpm_2_ancestral
|
||||
```
|
||||
|
||||
### Ranges
|
||||
Use colon syntax for numeric ranges:
|
||||
```
|
||||
5:10:1 # From 5 to 10, step 1 → [5, 6, 7, 8, 9, 10]
|
||||
0.5:2:0.5 # From 0.5 to 2, step 0.5 → [0.5, 1.0, 1.5, 2.0]
|
||||
10:50:10 # From 10 to 50, step 10 → [10, 20, 30, 40, 50]
|
||||
```
|
||||
|
||||
### Model/File Selection
|
||||
Use the quick-select dropdowns or type filenames:
|
||||
```
|
||||
model1.safetensors, model2.ckpt, checkpoint_v3.pt
|
||||
```
|
||||
|
||||
## Connection Examples
|
||||
|
||||
### Varying Sampler
|
||||
1. Set X axis to "sampler"
|
||||
2. Connect `x_string` output to KSampler's `sampler_name` input
|
||||
|
||||
### Varying CFG Scale
|
||||
1. Set Y axis to "cfg_scale"
|
||||
2. Connect `y_float` output to KSampler's `cfg` input
|
||||
|
||||
### Varying Model
|
||||
1. Set X axis to "model"
|
||||
2. Connect `x_string` output to CheckpointLoader's `ckpt_name` input
|
||||
|
||||
### Varying Prompt
|
||||
1. Set Y axis to "prompt"
|
||||
2. Enter different prompts on separate lines in `y_values`
|
||||
3. Connect `y_string` output to CLIPTextEncode's `text` input
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
1. **Memory Management**: The system includes intelligent model caching. For large grids with multiple models, it will optimize loading order.
|
||||
|
||||
2. **Progress Tracking**: Watch the node title for progress updates (e.g., "XYZ Plot Controller [3/12]")
|
||||
|
||||
3. **Large Grids**: Be mindful of total image count. The node shows a warning for grids over 100 images.
|
||||
|
||||
4. **Z-Axis**: When using Z-axis, you'll get multiple grid images - one for each Z value.
|
||||
|
||||
5. **Label Customization**: Use prefixes to clarify labels (e.g., "CFG=" for CFG values)
|
||||
|
||||
## Workflow Files
|
||||
|
||||
- `basic_model_cfg_grid.json`: Compare 2 models across 3 CFG values
|
||||
- `sampler_comparison.json`: Compare all samplers at different step counts
|
||||
- `prompt_variations.json`: Test prompt variations across different models
|
||||
- `advanced_3d_grid.json`: Use Z-axis for LoRA strength variations
|
||||
- `flux_guidance_test.json`: Test Flux-specific parameters
|
||||
|
||||
Load these workflows in ComfyUI to see practical examples of the XYZ Grid system in action!
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 18,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [50, 100],
|
||||
"size": [500, 450],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_int", "type": "INT", "links": [12]},
|
||||
{"name": "z_float", "type": "FLOAT", "links": [13]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"lora",
|
||||
"None, style_lora_v1.safetensors, detail_lora_v2.safetensors, anime_lora_v3.safetensors",
|
||||
"LoRA: ",
|
||||
"seed",
|
||||
"100, 200, 300, 400, 500",
|
||||
"Seed: ",
|
||||
true,
|
||||
"denoise",
|
||||
"0.4, 0.7, 1.0",
|
||||
"Strength: ",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1, 14]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3, 15]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoraLoader",
|
||||
"pos": [950, 100],
|
||||
"size": [315, 126],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 14},
|
||||
{"name": "clip", "type": "CLIP", "link": 15},
|
||||
{"name": "lora_name", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [16]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [17, 18]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["None", 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 17}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a magical forest with glowing mushrooms and fairy lights, ethereal atmosphere, fantasy art"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 18}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1300, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [1050, 350],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 16},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "seed", "type": "INT", "link": 12},
|
||||
{"name": "denoise", "type": "FLOAT", "link": 13}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [0, "fixed", 20, 7.5, "dpmpp_2m", "karras", 1.0]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1400, 350],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1650, 350],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [19]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [18, 8, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [2000, 350],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 19}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["lora_seed_strength_3d_grid"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 7, 0, "MODEL"],
|
||||
[2, 2, 1, 4, 0, "CLIP"],
|
||||
[3, 2, 1, 5, 0, "CLIP"],
|
||||
[4, 2, 2, 8, 1, "VAE"],
|
||||
[5, 4, 0, 7, 1, "CONDITIONING"],
|
||||
[6, 5, 0, 7, 2, "CONDITIONING"],
|
||||
[7, 6, 0, 7, 3, "LATENT"],
|
||||
[8, 7, 0, 8, 0, "LATENT"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 1, 0, 9, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 3, 2, "STRING"],
|
||||
[12, 1, 4, 7, 4, "INT"],
|
||||
[13, 1, 7, 7, 5, "FLOAT"],
|
||||
[14, 2, 0, 3, 0, "MODEL"],
|
||||
[15, 2, 1, 3, 1, "CLIP"],
|
||||
[16, 3, 0, 7, 0, "MODEL"],
|
||||
[17, 3, 1, 4, 0, "CLIP"],
|
||||
[18, 3, 1, 5, 0, "CLIP"],
|
||||
[19, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "3D XYZ Grid Configuration",
|
||||
"bounding": [30, 20, 540, 530],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "LoRA Loading Pipeline",
|
||||
"bounding": [580, 20, 700, 220],
|
||||
"color": "#8b4c7a"
|
||||
},
|
||||
{
|
||||
"title": "Generation Pipeline",
|
||||
"bounding": [580, 240, 1060, 480],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Assembly & Output",
|
||||
"bounding": [1630, 270, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This advanced workflow demonstrates 3D grid functionality with X=LoRA (4 options including None), Y=Seed (5 values), and Z=Denoise strength (3 values). This generates 3 separate 4x5 grids, one for each denoise strength, totaling 60 images. Perfect for finding the optimal LoRA and strength combination across different seeds."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 6,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 300],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [1]},
|
||||
{"name": "x_string", "type": "STRING", "links": null},
|
||||
{"name": "x_int", "type": "INT", "links": null},
|
||||
{"name": "x_float", "type": "FLOAT", "links": null},
|
||||
{"name": "y_string", "type": "STRING", "links": null},
|
||||
{"name": "y_int", "type": "INT", "links": null},
|
||||
{"name": "y_float", "type": "FLOAT", "links": null},
|
||||
{"name": "z_string", "type": "STRING", "links": null},
|
||||
{"name": "z_int", "type": "INT", "links": null},
|
||||
{"name": "z_float", "type": "FLOAT", "links": null},
|
||||
{"name": "batch_id", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"models",
|
||||
"cfg_scale",
|
||||
"none",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": null},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": null},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 1, "XYZ_GRID"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "Example workflow showing the new advanced XYZ Plot Controller with dynamic widget addition."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 300],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_float", "type": "FLOAT", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"model",
|
||||
"sd_xl_base_1.0.safetensors, dreamshaperXL_v2.safetensors",
|
||||
"Model: ",
|
||||
"cfg_scale",
|
||||
"5, 7.5, 10",
|
||||
"CFG: ",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [550, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "ckpt_name", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a beautiful landscape with mountains and a lake, highly detailed, professional photography"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1000, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [1000, 250],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "cfg", "type": "FLOAT", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [42, "fixed", 20, 7.5, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1350, 250],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1600, 250],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [13]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1950, 250],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 13}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["model_cfg_comparison"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 6, 0, "MODEL"],
|
||||
[2, 2, 1, 3, 0, "CLIP"],
|
||||
[3, 2, 1, 4, 0, "CLIP"],
|
||||
[4, 2, 2, 7, 1, "VAE"],
|
||||
[5, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[6, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[7, 5, 0, 6, 3, "LATENT"],
|
||||
[8, 6, 0, 7, 0, "LATENT"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 2, 0, "STRING"],
|
||||
[12, 1, 5, 6, 4, "FLOAT"],
|
||||
[13, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "XYZ Grid Setup",
|
||||
"bounding": [80, 20, 440, 380],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image Generation",
|
||||
"bounding": [530, 20, 1050, 720],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Output",
|
||||
"bounding": [1580, 170, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow demonstrates a basic 2x3 grid comparing two models at three different CFG scale values. The XYZ Plot Controller automatically handles all 6 iterations."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 16,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [450, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_float", "type": "FLOAT", "links": [11]},
|
||||
{"name": "y_float", "type": "FLOAT", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"flux_guidance",
|
||||
"1.0:5.0:0.5",
|
||||
"Guidance: ",
|
||||
"cfg_scale",
|
||||
"1.0, 3.0, 5.0, 7.0",
|
||||
"CFG: ",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["flux1-dev.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "FluxGuidance",
|
||||
"pos": [950, 100],
|
||||
"size": [315, 58],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "conditioning", "type": "CONDITIONING", "link": 14},
|
||||
{"name": "guidance", "type": "FLOAT", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [3.5]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [14]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a stunning digital artwork of a phoenix rising from ashes, vibrant colors, dramatic lighting, highly detailed feathers with fire effects"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1050, 250],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [1050, 400],
|
||||
"size": [315, 334],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "cfg", "type": "FLOAT", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["enable", 42, "fixed", 20, 1.0, "euler", "simple", 0, 20, "disable"]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1400, 400],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1650, 400],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [15]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [2000, 400],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 15}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["flux_guidance_cfg_grid"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 7, 0, "MODEL"],
|
||||
[2, 2, 1, 4, 0, "CLIP"],
|
||||
[3, 2, 1, 5, 0, "CLIP"],
|
||||
[4, 2, 2, 8, 1, "VAE"],
|
||||
[5, 3, 0, 7, 1, "CONDITIONING"],
|
||||
[6, 5, 0, 7, 2, "CONDITIONING"],
|
||||
[7, 6, 0, 7, 3, "LATENT"],
|
||||
[8, 7, 0, 8, 0, "LATENT"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 1, 0, 9, 1, "XYZ_GRID"],
|
||||
[11, 1, 2, 3, 1, "FLOAT"],
|
||||
[12, 1, 5, 7, 4, "FLOAT"],
|
||||
[14, 4, 0, 3, 0, "CONDITIONING"],
|
||||
[15, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Flux Parameter Grid Setup",
|
||||
"bounding": [80, 20, 490, 430],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Flux Model Pipeline",
|
||||
"bounding": [580, 20, 450, 200],
|
||||
"color": "#8b4c7a"
|
||||
},
|
||||
{
|
||||
"title": "Generation Pipeline",
|
||||
"bounding": [580, 240, 810, 520],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Output",
|
||||
"bounding": [1630, 320, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow demonstrates testing Flux-specific parameters. It creates a 9x4 grid comparing Flux guidance values (1.0 to 5.0 in 0.5 steps) against different CFG scales. This is useful for finding the optimal balance between Flux guidance and traditional CFG for your specific use case. Note: Requires Flux model and FluxGuidance node."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [450, 400],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_string", "type": "STRING", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"model",
|
||||
"sd_xl_base_1.0.safetensors, dreamshaperXL_v2.safetensors, juggernautXL_v8.safetensors",
|
||||
"",
|
||||
"prompt",
|
||||
"a serene japanese garden with cherry blossoms\na futuristic cyberpunk city at night\na medieval castle on a misty mountain\nan underwater coral reef teeming with life\na cozy cabin in a snowy forest",
|
||||
"",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "ckpt_name", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2},
|
||||
{"name": "text", "type": "STRING", "link": 12, "widget": {"name": "text"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["beautiful scenery"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted, ugly, poorly drawn"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1050, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [768, 768, 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [1050, 250],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [789012, "fixed", 25, 7.5, "dpmpp_2m", "karras", 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1400, 250],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1650, 250],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [13]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 35, 35, true]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [2000, 250],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 13}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["prompt_model_variations"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 6, 0, "MODEL"],
|
||||
[2, 2, 1, 3, 0, "CLIP"],
|
||||
[3, 2, 1, 4, 0, "CLIP"],
|
||||
[4, 2, 2, 7, 1, "VAE"],
|
||||
[5, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[6, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[7, 5, 0, 6, 3, "LATENT"],
|
||||
[8, 6, 0, 7, 0, "LATENT"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 2, 0, "STRING"],
|
||||
[12, 1, 4, 3, 1, "STRING"],
|
||||
[13, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Prompt x Model Grid Setup",
|
||||
"bounding": [80, 20, 490, 480],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image Generation Pipeline",
|
||||
"bounding": [580, 20, 1050, 720],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Assembly & Output",
|
||||
"bounding": [1630, 170, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow creates a 3x5 grid comparing 3 different models with 5 diverse prompt scenarios. Great for seeing how different models interpret various styles and subjects. The Y axis connects directly to the positive prompt input, automatically switching prompts for each row."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_int", "type": "INT", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"sampler",
|
||||
"euler, euler_ancestral, heun, dpm_2, dpm_2_ancestral, lms, dpm_fast, dpm_adaptive, dpmpp_2s_ancestral, dpmpp_sde, dpmpp_2m, ddim",
|
||||
"",
|
||||
"steps",
|
||||
"10, 20, 30, 50",
|
||||
"Steps: ",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [550, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a majestic dragon soaring through clouds, fantasy art, highly detailed, epic lighting"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted, ugly"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1000, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [1000, 250],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "sampler_name", "type": "combo", "link": 11},
|
||||
{"name": "steps", "type": "INT", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [123456, "fixed", 20, 8.0, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1350, 250],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1600, 250],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [13]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [16, 8, 25, 25, true]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1950, 250],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 13}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["sampler_steps_comparison"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 6, 0, "MODEL"],
|
||||
[2, 2, 1, 3, 0, "CLIP"],
|
||||
[3, 2, 1, 4, 0, "CLIP"],
|
||||
[4, 2, 2, 7, 1, "VAE"],
|
||||
[5, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[6, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[7, 5, 0, 6, 3, "LATENT"],
|
||||
[8, 6, 0, 7, 0, "LATENT"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 6, 4, "combo"],
|
||||
[12, 1, 4, 6, 5, "INT"],
|
||||
[13, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Sampler vs Steps Grid",
|
||||
"bounding": [80, 20, 440, 430],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image Generation",
|
||||
"bounding": [530, 20, 1050, 720],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Output",
|
||||
"bounding": [1580, 170, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow creates a 12x4 grid comparing 12 different samplers at 4 step counts (10, 20, 30, 50). Perfect for finding the optimal sampler and step count for your use case. Note: Using smaller image size (512x512) due to the large number of generations (48 total)."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"last_node_id": 20,
|
||||
"last_link_id": 30,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPrompt",
|
||||
"pos": [100, 100],
|
||||
"size": {"0": 350, "1": 400},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{"name": "prompts", "type": "XYZ_PROMPTS", "links": [1]},
|
||||
{"name": "positive", "type": "STRING", "links": [2]},
|
||||
{"name": "negative", "type": "STRING", "links": [3]},
|
||||
{"name": "count", "type": "INT", "links": null}
|
||||
],
|
||||
"properties": {"Node name for S&R": "XYZPrompt"},
|
||||
"widgets_values": [
|
||||
true,
|
||||
true,
|
||||
"a beautiful landscape",
|
||||
"ugly, blurry, watermark",
|
||||
"a serene mountain scene",
|
||||
"a vibrant cityscape at night",
|
||||
"a peaceful forest path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [500, 100],
|
||||
"size": {"0": 400, "1": 500},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "prompts", "type": "XYZ_PROMPTS", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [4]},
|
||||
{"name": "x_string", "type": "STRING", "links": [5]},
|
||||
{"name": "x_int", "type": "INT", "links": null},
|
||||
{"name": "x_float", "type": "FLOAT", "links": null},
|
||||
{"name": "y_string", "type": "STRING", "links": null},
|
||||
{"name": "y_int", "type": "INT", "links": [6]},
|
||||
{"name": "y_float", "type": "FLOAT", "links": null},
|
||||
{"name": "z_string", "type": "STRING", "links": null},
|
||||
{"name": "z_int", "type": "INT", "links": null},
|
||||
{"name": "z_float", "type": "FLOAT", "links": null},
|
||||
{"name": "batch_id", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {"Node name for S&R": "XYZPlotController"},
|
||||
"widgets_values": [
|
||||
"prompt",
|
||||
"steps",
|
||||
"none",
|
||||
true,
|
||||
"20\n30\n40",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 550],
|
||||
"size": {"0": 315, "1": 98},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [7]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [8, 9]},
|
||||
{"name": "VAE", "type": "VAE", "links": [10]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "CheckpointLoaderSimple"},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500, 650],
|
||||
"size": {"0": 400, "1": 200},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 8},
|
||||
{"name": "text", "type": "STRING", "link": 2, "widget": {"name": "text"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [11]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "CLIPTextEncode"},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500, 900],
|
||||
"size": {"0": 400, "1": 200},
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 9},
|
||||
{"name": "text", "type": "STRING", "link": 3, "widget": {"name": "text"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [12]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "CLIPTextEncode"},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [950, 550],
|
||||
"size": {"0": 315, "1": 106},
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [13]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "EmptyLatentImage"},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [950, 700],
|
||||
"size": {"0": 315, "1": 262},
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 7},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 11},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 12},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 13},
|
||||
{"name": "steps", "type": "INT", "link": 6, "widget": {"name": "steps"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [14]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "KSampler"},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1300, 700],
|
||||
"size": {"0": 210, "1": 46},
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 14},
|
||||
{"name": "vae", "type": "VAE", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [15]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "VAEDecode"}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1550, 700],
|
||||
"size": {"0": 315, "1": 202},
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 15},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [16]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {"Node name for S&R": "ImageGridCombiner"},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [1900, 700],
|
||||
"size": {"0": 315, "1": 270},
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 16}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["xyz_grid"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "XYZ_PROMPTS"],
|
||||
[2, 1, 1, 4, 1, "STRING"],
|
||||
[3, 1, 2, 5, 1, "STRING"],
|
||||
[4, 2, 0, 9, 1, "XYZ_GRID"],
|
||||
[5, 2, 1, 4, 1, "STRING"],
|
||||
[6, 2, 5, 7, 4, "INT"],
|
||||
[7, 3, 0, 7, 0, "MODEL"],
|
||||
[8, 3, 1, 4, 0, "CLIP"],
|
||||
[9, 3, 1, 5, 0, "CLIP"],
|
||||
[10, 3, 2, 8, 1, "VAE"],
|
||||
[11, 4, 0, 7, 1, "CONDITIONING"],
|
||||
[12, 5, 0, 7, 2, "CONDITIONING"],
|
||||
[13, 6, 0, 7, 3, "LATENT"],
|
||||
[14, 7, 0, 8, 0, "LATENT"],
|
||||
[15, 8, 0, 9, 0, "IMAGE"],
|
||||
[16, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "XYZ Grid Test Workflow",
|
||||
"bounding": [80, 20, 2160, 1100],
|
||||
"color": "#3f789e"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
+16
-35
@@ -3,27 +3,18 @@ KikoTools package initialization and node registry
|
||||
Handles automatic discovery and registration of all ComfyAssets tools
|
||||
"""
|
||||
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.width_height_selector import WidthHeightSelectorNode
|
||||
from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
from .tools.image_scale_down_by import ImageScaleDownByNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
from .tools.display_text import DisplayTextNode
|
||||
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
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_save_image import KikoSaveImageNode
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
|
||||
from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.width_height_selector import WidthHeightSelectorNode
|
||||
from .tools.xyz_helpers import (
|
||||
FluxSamplerParamsNode,
|
||||
LoRAFolderBatchNode,
|
||||
PlotParametersNode,
|
||||
SamplerSelectHelperNode,
|
||||
SchedulerSelectHelperNode,
|
||||
TextEncodeSamplerParamsNode,
|
||||
)
|
||||
from .tools.xyz_grid import XYZPlotController, ImageGridCombiner, XYZPrompt
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -39,14 +30,9 @@ NODE_CLASS_MAPPINGS = {
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
"DisplayText": DisplayTextNode,
|
||||
"KikoFilmGrain": KikoFilmGrainNode,
|
||||
"SamplerSelectHelper": SamplerSelectHelperNode,
|
||||
"SchedulerSelectHelper": SchedulerSelectHelperNode,
|
||||
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
|
||||
"FluxSamplerParams": FluxSamplerParamsNode,
|
||||
"PlotParameters+": PlotParametersNode,
|
||||
"LoRAFolderBatch": LoRAFolderBatchNode,
|
||||
"KikoEmbeddingAutocomplete": KikoEmbeddingAutocomplete,
|
||||
"XYZPlotController": XYZPlotController,
|
||||
"ImageGridCombiner": ImageGridCombiner,
|
||||
"XYZPrompt": XYZPrompt,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -62,14 +48,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"GeminiPrompt": "Gemini Prompt Engineer",
|
||||
"DisplayAny": "Display Any",
|
||||
"DisplayText": "Display Text",
|
||||
"KikoFilmGrain": "Kiko Film Grain",
|
||||
"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",
|
||||
"XYZPlotController": "XYZ Plot Controller",
|
||||
"ImageGridCombiner": "Image Grid Combiner",
|
||||
"XYZPrompt": "XYZ Prompt",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Tool registry for KikoTools.
|
||||
|
||||
This module provides the central registration system for all KikoTools nodes.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Central registry for all KikoTools."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, Any] = {}
|
||||
self.node_classes: Dict[str, Any] = {}
|
||||
|
||||
def register_tool(self, tool_name: str, node_class: Any) -> None:
|
||||
"""Register a tool and its node class.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
node_class: The ComfyUI node class
|
||||
"""
|
||||
self.tools[tool_name] = node_class
|
||||
|
||||
# Also register by class name for ComfyUI
|
||||
class_name = node_class.__name__
|
||||
self.node_classes[class_name] = node_class
|
||||
|
||||
def discover_tools(self) -> None:
|
||||
"""Automatically discover and load all tools in the tools directory."""
|
||||
tools_dir = Path(__file__).parent.parent / "tools"
|
||||
|
||||
if not tools_dir.exists():
|
||||
return
|
||||
|
||||
for tool_dir in tools_dir.iterdir():
|
||||
if tool_dir.is_dir() and not tool_dir.name.startswith("_"):
|
||||
self._load_tool(tool_dir.name)
|
||||
|
||||
def _load_tool(self, tool_name: str) -> None:
|
||||
"""Load a single tool module.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool directory
|
||||
"""
|
||||
try:
|
||||
# Try to import the tool's node module
|
||||
module = importlib.import_module(f"kikotools.tools.{tool_name}.node")
|
||||
|
||||
# Look for node classes (classes with ComfyUI node attributes)
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and hasattr(attr, "INPUT_TYPES")
|
||||
and hasattr(attr, "FUNCTION")
|
||||
):
|
||||
self.register_tool(tool_name, attr)
|
||||
|
||||
# If the tool has settings, register them
|
||||
if hasattr(attr, "SETTINGS"):
|
||||
from .settings import settings_registry
|
||||
|
||||
settings_registry.register_tool_settings(
|
||||
tool_name,
|
||||
getattr(
|
||||
attr,
|
||||
"DISPLAY_NAME",
|
||||
tool_name.replace("_", " ").title(),
|
||||
),
|
||||
attr.SETTINGS,
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
# Tool might not have a node.py file yet
|
||||
pass
|
||||
|
||||
def get_node_class_mappings(self) -> Dict[str, Any]:
|
||||
"""Get node class mappings for ComfyUI registration."""
|
||||
return self.node_classes.copy()
|
||||
|
||||
def get_node_display_name_mappings(self) -> Dict[str, str]:
|
||||
"""Get display name mappings for ComfyUI."""
|
||||
mappings = {}
|
||||
for class_name, node_class in self.node_classes.items():
|
||||
if hasattr(node_class, "DISPLAY_NAME"):
|
||||
mappings[class_name] = node_class.DISPLAY_NAME
|
||||
else:
|
||||
# Generate a display name from class name
|
||||
mappings[class_name] = class_name.replace("Kiko", "").replace(
|
||||
"Node", ""
|
||||
)
|
||||
return mappings
|
||||
@@ -1,201 +0,0 @@
|
||||
"""Settings registry for KikoTools.
|
||||
|
||||
This module provides a centralized settings management system for all KikoTools.
|
||||
Tools can register their settings, which are then exposed in ComfyUI's settings UI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingDefinition:
|
||||
"""Definition of a single setting."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str # "boolean", "combo", "number", "string", "custom"
|
||||
default: Any
|
||||
description: Optional[str] = None
|
||||
options: Optional[Union[List[Any], Dict[str, Any]]] = None
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
on_change: Optional[str] = None # JavaScript callback as string
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSettings:
|
||||
"""Settings collection for a single tool."""
|
||||
|
||||
tool_name: str
|
||||
display_name: str
|
||||
settings: List[SettingDefinition] = field(default_factory=list)
|
||||
|
||||
|
||||
class SettingsRegistry:
|
||||
"""Central registry for all KikoTools settings."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, ToolSettings] = {}
|
||||
self.settings_by_id: Dict[str, SettingDefinition] = {}
|
||||
|
||||
def register_tool_settings(
|
||||
self, tool_name: str, display_name: str, settings: Dict[str, Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Register settings for a tool.
|
||||
|
||||
Args:
|
||||
tool_name: Internal tool identifier (e.g., "embedding_autocomplete")
|
||||
display_name: Display name for the tool (e.g., "Embedding Autocomplete")
|
||||
settings: Dictionary of setting configurations
|
||||
{
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable embedding autocomplete"
|
||||
},
|
||||
"max_suggestions": {
|
||||
"type": "combo",
|
||||
"default": 20,
|
||||
"options": [10, 20, 50],
|
||||
"description": "Maximum number of suggestions"
|
||||
}
|
||||
}
|
||||
"""
|
||||
tool_settings = ToolSettings(tool_name, display_name)
|
||||
|
||||
for setting_key, config in settings.items():
|
||||
# Generate fully qualified setting ID
|
||||
setting_id = f"kikotools.{tool_name}.{setting_key}"
|
||||
|
||||
# Create display name with branding
|
||||
setting_name = f"🫶 {display_name}: {setting_key.replace('_', ' ').title()}"
|
||||
|
||||
setting_def = SettingDefinition(
|
||||
id=setting_id,
|
||||
name=setting_name,
|
||||
type=config.get("type", "string"),
|
||||
default=config.get("default"),
|
||||
description=config.get("description"),
|
||||
options=config.get("options"),
|
||||
min_value=config.get("min"),
|
||||
max_value=config.get("max"),
|
||||
step=config.get("step"),
|
||||
on_change=config.get("on_change"),
|
||||
)
|
||||
|
||||
tool_settings.settings.append(setting_def)
|
||||
self.settings_by_id[setting_id] = setting_def
|
||||
|
||||
self.tools[tool_name] = tool_settings
|
||||
|
||||
def get_setting(self, setting_id: str) -> Optional[SettingDefinition]:
|
||||
"""Get a setting definition by ID."""
|
||||
return self.settings_by_id.get(setting_id)
|
||||
|
||||
def get_tool_settings(self, tool_name: str) -> Optional[ToolSettings]:
|
||||
"""Get all settings for a tool."""
|
||||
return self.tools.get(tool_name)
|
||||
|
||||
def generate_frontend_registration(self) -> str:
|
||||
"""Generate JavaScript code for frontend settings registration."""
|
||||
js_lines = [
|
||||
"// Auto-generated KikoTools settings registration",
|
||||
"// This file is automatically generated by the settings registry",
|
||||
"",
|
||||
"import { app } from '../../scripts/app.js';",
|
||||
"",
|
||||
"app.registerExtension({",
|
||||
" name: 'kikotools.settings',",
|
||||
" async init() {",
|
||||
" // Register all KikoTools settings",
|
||||
]
|
||||
|
||||
for tool_name, tool_settings in self.tools.items():
|
||||
js_lines.append(f" // {tool_settings.display_name} settings")
|
||||
|
||||
for setting in tool_settings.settings:
|
||||
js_lines.append(f" app.ui.settings.addSetting({{")
|
||||
js_lines.append(f' id: "{setting.id}",')
|
||||
js_lines.append(f' name: "{setting.name}",')
|
||||
js_lines.append(
|
||||
f" defaultValue: {self._js_value(setting.default)},"
|
||||
)
|
||||
js_lines.append(f' type: "{setting.type}",')
|
||||
|
||||
if setting.description:
|
||||
js_lines.append(f' tooltip: "{setting.description}",')
|
||||
|
||||
if setting.type == "combo" and setting.options:
|
||||
js_lines.append(f" options: (value) => {{")
|
||||
js_lines.append(
|
||||
f" const options = {json.dumps(setting.options)};"
|
||||
)
|
||||
js_lines.append(f" return options.map(opt => ({{")
|
||||
js_lines.append(f" value: opt,")
|
||||
js_lines.append(f" text: String(opt),")
|
||||
js_lines.append(f" selected: opt === value")
|
||||
js_lines.append(f" }}));")
|
||||
js_lines.append(f" }},")
|
||||
|
||||
if setting.type == "number":
|
||||
if setting.min_value is not None:
|
||||
js_lines.append(f" min: {setting.min_value},")
|
||||
if setting.max_value is not None:
|
||||
js_lines.append(f" max: {setting.max_value},")
|
||||
if setting.step is not None:
|
||||
js_lines.append(f" step: {setting.step},")
|
||||
|
||||
if setting.on_change:
|
||||
js_lines.append(f" onChange(value) {{")
|
||||
js_lines.append(f" {setting.on_change}")
|
||||
js_lines.append(f" }}")
|
||||
|
||||
js_lines.append(f" }});")
|
||||
js_lines.append("")
|
||||
|
||||
js_lines.extend([" }", "});", ""])
|
||||
|
||||
return "\n".join(js_lines)
|
||||
|
||||
def _js_value(self, value: Any) -> str:
|
||||
"""Convert Python value to JavaScript literal."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
elif value is None:
|
||||
return "null"
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
def save_frontend_settings(
|
||||
self, output_path: str = "web/js/kikoSettings.js"
|
||||
) -> None:
|
||||
"""Save the generated frontend settings to a file."""
|
||||
js_content = self.generate_frontend_registration()
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
f.write(js_content)
|
||||
|
||||
def get_all_settings(self) -> Dict[str, Any]:
|
||||
"""Get all registered settings as a dictionary."""
|
||||
result = {}
|
||||
for tool_name, tool_settings in self.tools.items():
|
||||
result[tool_name] = {
|
||||
"display_name": tool_settings.display_name,
|
||||
"settings": {
|
||||
setting.id.split(".")[-1]: {
|
||||
"type": setting.type,
|
||||
"default": setting.default,
|
||||
"description": setting.description,
|
||||
"options": setting.options,
|
||||
}
|
||||
for setting in tool_settings.settings
|
||||
},
|
||||
}
|
||||
return result
|
||||
@@ -48,15 +48,6 @@ def format_display_value(input_value: Any, mode: str = "raw value") -> str:
|
||||
return "No tensors found in input"
|
||||
|
||||
# Default to raw value display
|
||||
# Try to format as JSON for better readability
|
||||
try:
|
||||
import json
|
||||
|
||||
if isinstance(input_value, (dict, list)):
|
||||
return json.dumps(input_value, indent=2)
|
||||
except:
|
||||
pass
|
||||
|
||||
return str(input_value)
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
CATEGORY = "ComfyAssets/👁️ Display"
|
||||
RETURN_NAMES = ("display_text",)
|
||||
FUNCTION = "display"
|
||||
OUTPUT_NODE = True # This node displays output in the UI
|
||||
@@ -62,6 +61,6 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
|
||||
# Return both UI display and result
|
||||
return {
|
||||
"ui": {"text": [display_text]}, # UI expects array
|
||||
"ui": {"text": display_text},
|
||||
"result": (display_text,),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class DisplayTextNode(ComfyAssetsBaseNode):
|
||||
RETURN_NAMES = ("text",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "display_text"
|
||||
CATEGORY = "ComfyAssets/👁️ Display"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
DESCRIPTION = """
|
||||
Displays text in the UI with a copy-to-clipboard feature.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Embedding Autocomplete tool for KikoTools."""
|
||||
|
||||
from .node import KikoEmbeddingAutocomplete
|
||||
|
||||
__all__ = ["KikoEmbeddingAutocomplete"]
|
||||
@@ -1,291 +0,0 @@
|
||||
"""KikoEmbeddingAutocomplete node for ComfyUI.
|
||||
|
||||
Provides autocomplete functionality for embeddings and LoRAs in text inputs.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Any
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
# For testing outside ComfyUI environment
|
||||
folder_paths = None
|
||||
|
||||
|
||||
class KikoEmbeddingAutocomplete:
|
||||
"""Node that provides embedding autocomplete functionality."""
|
||||
|
||||
DISPLAY_NAME = "🫶 Embedding Autocomplete Settings"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
# Settings definition for the settings registry
|
||||
SETTINGS = {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable autocomplete",
|
||||
},
|
||||
"show_embeddings": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Show embeddings in autocomplete",
|
||||
},
|
||||
"show_loras": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Show LoRAs in autocomplete",
|
||||
},
|
||||
"embedding_trigger": {
|
||||
"type": "text",
|
||||
"default": "embedding:",
|
||||
"description": "Trigger text for embeddings (e.g., 'embedding:', 'emb:', or custom)",
|
||||
},
|
||||
"lora_trigger": {
|
||||
"type": "text",
|
||||
"default": "<lora:",
|
||||
"description": "Trigger text for LoRAs (e.g., '<lora:', 'lora:', or custom)",
|
||||
},
|
||||
"quick_trigger": {
|
||||
"type": "text",
|
||||
"default": "em",
|
||||
"description": "Quick trigger to show embeddings (e.g., 'em', 'emb', or disabled with '')",
|
||||
},
|
||||
"min_chars": {
|
||||
"type": "combo",
|
||||
"default": 2,
|
||||
"options": [1, 2, 3, 4, 5],
|
||||
"description": "Minimum characters before showing suggestions",
|
||||
},
|
||||
"max_suggestions": {
|
||||
"type": "combo",
|
||||
"default": 20,
|
||||
"options": [5, 10, 15, 20, 30, 50, 100],
|
||||
"description": "Maximum number of suggestions to display",
|
||||
},
|
||||
"sort_by_directory": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Group suggestions by directory",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "update_settings"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
return True
|
||||
|
||||
def __init__(self):
|
||||
self.embeddings_cache = None
|
||||
self.loras_cache = None
|
||||
|
||||
def update_settings(self, unique_id=None):
|
||||
"""Update settings display.
|
||||
|
||||
This node serves as a settings indicator.
|
||||
Actual settings are configured in ComfyUI Settings menu.
|
||||
"""
|
||||
# This node doesn't actually process anything
|
||||
# It's just a visual indicator that autocomplete is available
|
||||
return ()
|
||||
|
||||
def refresh_cache(self):
|
||||
"""Refresh the cache of embeddings and LoRAs."""
|
||||
print("[KikoEmbeddingAutocomplete] Refreshing cache...")
|
||||
self.embeddings_cache = self.get_embeddings()
|
||||
self.loras_cache = self.get_loras()
|
||||
print(
|
||||
f"[KikoEmbeddingAutocomplete] Cached {len(self.embeddings_cache)} embeddings, {len(self.loras_cache)} LoRAs"
|
||||
)
|
||||
|
||||
def get_embeddings(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of available embeddings."""
|
||||
embeddings = []
|
||||
|
||||
# Get embedding files from ComfyUI's folder system
|
||||
try:
|
||||
print("[KikoEmbeddingAutocomplete] Getting embeddings list...")
|
||||
if folder_paths is None:
|
||||
return embeddings
|
||||
embedding_files = folder_paths.get_filename_list("embeddings")
|
||||
print(
|
||||
f"[KikoEmbeddingAutocomplete] Found {len(embedding_files)} embedding files"
|
||||
)
|
||||
for file in embedding_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
embeddings.append(
|
||||
{
|
||||
"name": name,
|
||||
"file": file,
|
||||
"type": "embedding",
|
||||
"display": f"embedding:{name}",
|
||||
"value": f"embedding:{name}",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading embeddings: {e}")
|
||||
|
||||
return embeddings
|
||||
|
||||
def get_loras(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of available LoRAs."""
|
||||
loras = []
|
||||
|
||||
# Get LoRA files from ComfyUI's folder system
|
||||
try:
|
||||
if folder_paths is None:
|
||||
return loras
|
||||
lora_files = folder_paths.get_filename_list("loras")
|
||||
for file in lora_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
loras.append(
|
||||
{
|
||||
"name": name,
|
||||
"file": file,
|
||||
"type": "lora",
|
||||
"display": f"<lora:{name}:1.0>",
|
||||
"value": f"<lora:{name}:1.0>",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading LoRAs: {e}")
|
||||
|
||||
return loras
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Check if the node needs to be re-executed."""
|
||||
# Always re-execute if refresh is True
|
||||
if kwargs.get("refresh", False):
|
||||
return float("NaN")
|
||||
|
||||
# Check if embeddings/loras folders have changed
|
||||
try:
|
||||
if folder_paths is None:
|
||||
return 0
|
||||
embeddings_path = folder_paths.get_folder_paths("embeddings")[0]
|
||||
loras_path = folder_paths.get_folder_paths("loras")[0]
|
||||
|
||||
# Return combined modification time
|
||||
return os.path.getmtime(embeddings_path) + os.path.getmtime(loras_path)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class KikoEmbeddingAutocompleteAPI:
|
||||
"""API endpoints for embedding autocomplete."""
|
||||
|
||||
@staticmethod
|
||||
def get_suggestions(
|
||||
prefix: str,
|
||||
max_results: int = 20,
|
||||
include_embeddings: bool = True,
|
||||
include_loras: bool = True,
|
||||
case_sensitive: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get autocomplete suggestions for a given prefix.
|
||||
|
||||
Args:
|
||||
prefix: The text prefix to match
|
||||
max_results: Maximum number of results to return
|
||||
include_embeddings: Include embeddings in results
|
||||
include_loras: Include LoRAs in results
|
||||
case_sensitive: Use case-sensitive matching
|
||||
|
||||
Returns:
|
||||
List of suggestion dictionaries
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# Normalize prefix for matching
|
||||
match_prefix = prefix if case_sensitive else prefix.lower()
|
||||
|
||||
# Get embeddings
|
||||
if include_embeddings:
|
||||
try:
|
||||
if folder_paths is None:
|
||||
embedding_files = []
|
||||
else:
|
||||
embedding_files = folder_paths.get_filename_list("embeddings")
|
||||
for file in embedding_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
match_name = name if case_sensitive else name.lower()
|
||||
|
||||
# Check for match
|
||||
if match_name.startswith(match_prefix):
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "embedding",
|
||||
"display": f"embedding:{name}",
|
||||
"value": f"embedding:{name}",
|
||||
"priority": 1 if match_name == match_prefix else 0,
|
||||
}
|
||||
)
|
||||
elif match_prefix in match_name:
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "embedding",
|
||||
"display": f"embedding:{name}",
|
||||
"value": f"embedding:{name}",
|
||||
"priority": -1,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading embeddings: {e}")
|
||||
|
||||
# Get LoRAs
|
||||
if include_loras:
|
||||
try:
|
||||
if folder_paths is None:
|
||||
lora_files = []
|
||||
else:
|
||||
lora_files = folder_paths.get_filename_list("loras")
|
||||
for file in lora_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
match_name = name if case_sensitive else name.lower()
|
||||
|
||||
# Check for match
|
||||
if match_name.startswith(match_prefix):
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "lora",
|
||||
"display": f"<lora:{name}:1.0>",
|
||||
"value": f"<lora:{name}:1.0>",
|
||||
"priority": 1 if match_name == match_prefix else 0,
|
||||
}
|
||||
)
|
||||
elif match_prefix in match_name:
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "lora",
|
||||
"display": f"<lora:{name}:1.0>",
|
||||
"value": f"<lora:{name}:1.0>",
|
||||
"priority": -1,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading LoRAs: {e}")
|
||||
|
||||
# Sort by priority and name
|
||||
suggestions.sort(key=lambda x: (-x["priority"], x["name"]))
|
||||
|
||||
# Limit results
|
||||
return suggestions[:max_results]
|
||||
@@ -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"
|
||||
|
||||
def create_empty_latent(
|
||||
self, preset: str, width: int, height: int, batch_size: int
|
||||
|
||||
@@ -85,5 +85,5 @@
|
||||
"gemma-3n-e2b-it": "Gemma 3n E2B",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
|
||||
},
|
||||
"timestamp": 1754568195.1098156
|
||||
"timestamp": 1754142231.0568295
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("prompt", "negative_prompt")
|
||||
FUNCTION = "generate_prompt"
|
||||
CATEGORY = "ComfyAssets/🧠 Prompts"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
DESCRIPTION = """
|
||||
Analyzes images using Google's Gemini AI to generate optimized prompts.
|
||||
|
||||
@@ -35,7 +35,6 @@ class ImageScaleDownByNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "scale_down"
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .node import KikoFilmGrainNode
|
||||
|
||||
__all__ = ["KikoFilmGrainNode"]
|
||||
@@ -1,221 +0,0 @@
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
def rgb_to_ycbcr(rgb: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Convert RGB tensor to YCbCr color space.
|
||||
|
||||
Args:
|
||||
rgb: Tensor of shape [B, H, W, C] in range [0, 1]
|
||||
|
||||
Returns:
|
||||
YCbCr tensor of same shape
|
||||
"""
|
||||
ycbcr = rgb.detach().clone()
|
||||
r, g, b = rgb[:, :, :, 0], rgb[:, :, :, 1], rgb[:, :, :, 2]
|
||||
|
||||
# ITU-R BT.709 coefficients
|
||||
ycbcr[:, :, :, 0] = 0.2126 * r + 0.7152 * g + 0.0722 * b # Y
|
||||
ycbcr[:, :, :, 1] = -0.1146 * r - 0.3854 * g + 0.5 * b # Cb
|
||||
ycbcr[:, :, :, 2] = 0.5 * r - 0.4542 * g - 0.0458 * b # Cr
|
||||
|
||||
return ycbcr
|
||||
|
||||
|
||||
def ycbcr_to_rgb(ycbcr: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Convert YCbCr tensor to RGB color space.
|
||||
|
||||
Args:
|
||||
ycbcr: Tensor of shape [B, H, W, C]
|
||||
|
||||
Returns:
|
||||
RGB tensor of same shape in range [0, 1]
|
||||
"""
|
||||
rgb = ycbcr.detach().clone()
|
||||
y, cb, cr = ycbcr[:, :, :, 0], ycbcr[:, :, :, 1], ycbcr[:, :, :, 2]
|
||||
|
||||
rgb[:, :, :, 0] = y + 1.5748 * cr # R
|
||||
rgb[:, :, :, 1] = y - 0.1873 * cb - 0.4681 * cr # G
|
||||
rgb[:, :, :, 2] = y + 1.8556 * cb # B
|
||||
|
||||
return torch.clamp(rgb, 0, 1)
|
||||
|
||||
|
||||
def apply_gaussian_blur(tensor: torch.Tensor, kernel_size: int) -> torch.Tensor:
|
||||
"""
|
||||
Apply Gaussian blur to a tensor using PyTorch operations.
|
||||
|
||||
Args:
|
||||
tensor: Tensor of shape [B, H, W, C]
|
||||
kernel_size: Size of the Gaussian kernel (must be odd)
|
||||
|
||||
Returns:
|
||||
Blurred tensor of same shape
|
||||
"""
|
||||
if kernel_size <= 1:
|
||||
return tensor
|
||||
|
||||
# Ensure kernel size is odd
|
||||
kernel_size = kernel_size if kernel_size % 2 == 1 else kernel_size + 1
|
||||
|
||||
# Create Gaussian kernel
|
||||
sigma = kernel_size / 3.0
|
||||
x = torch.arange(kernel_size, dtype=torch.float32) - kernel_size // 2
|
||||
gauss = torch.exp(-x.pow(2) / (2 * sigma**2))
|
||||
gauss = gauss / gauss.sum()
|
||||
|
||||
# Create 2D kernel
|
||||
kernel = gauss.unsqueeze(0) * gauss.unsqueeze(1)
|
||||
kernel = kernel.unsqueeze(0).unsqueeze(0)
|
||||
|
||||
# Apply blur per channel
|
||||
batch_size, h, w, channels = tensor.shape
|
||||
tensor_reshaped = tensor.permute(0, 3, 1, 2) # [B, C, H, W]
|
||||
|
||||
# Expand kernel for all channels
|
||||
kernel = kernel.repeat(channels, 1, 1, 1)
|
||||
|
||||
# Apply convolution with padding
|
||||
padding = kernel_size // 2
|
||||
blurred = F.conv2d(tensor_reshaped, kernel, padding=padding, groups=channels)
|
||||
|
||||
return blurred.permute(0, 2, 3, 1) # Back to [B, H, W, C]
|
||||
|
||||
|
||||
def generate_grain_texture(
|
||||
batch_size: int, height: int, width: int, scale: float, seed: int
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Generate base grain texture at specified scale.
|
||||
|
||||
Args:
|
||||
batch_size: Number of images in batch
|
||||
height: Target height
|
||||
width: Target width
|
||||
scale: Scale factor for grain size (larger = coarser grain)
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
Grain texture tensor of shape [B, H/scale, W/scale, 3]
|
||||
"""
|
||||
torch.manual_seed(seed)
|
||||
|
||||
grain_height = max(1, int(height / scale))
|
||||
grain_width = max(1, int(width / scale))
|
||||
|
||||
# Generate random noise
|
||||
grain = torch.rand(batch_size, grain_height, grain_width, 3)
|
||||
|
||||
return grain
|
||||
|
||||
|
||||
def apply_film_grain(
|
||||
image: torch.Tensor,
|
||||
scale: float = 0.5,
|
||||
strength: float = 0.5,
|
||||
saturation: float = 0.7,
|
||||
toe: float = 0.0,
|
||||
seed: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Apply film grain effect to an image with improved algorithms.
|
||||
|
||||
Improvements over original:
|
||||
- Better color space conversion using ITU-R BT.709 coefficients
|
||||
- More efficient Gaussian blur using PyTorch convolutions
|
||||
- Improved grain mixing with better channel weighting
|
||||
- Preserves alpha channel if present
|
||||
- Better memory efficiency
|
||||
|
||||
Args:
|
||||
image: Input tensor of shape [B, H, W, C] in range [0, 1]
|
||||
scale: Grain size (0.25-2.0, higher = coarser grain)
|
||||
strength: Grain intensity (0.0-10.0)
|
||||
saturation: Color saturation of grain (0.0-2.0)
|
||||
toe: Lift blacks/shadows (-0.2-0.5)
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
Image with film grain applied
|
||||
"""
|
||||
if strength == 0.0:
|
||||
return image
|
||||
|
||||
# Handle empty batch
|
||||
if image.shape[0] == 0:
|
||||
return image
|
||||
|
||||
result = image.detach().clone()
|
||||
has_alpha = image.shape[-1] == 4
|
||||
|
||||
# Generate grain texture
|
||||
grain = generate_grain_texture(
|
||||
image.shape[0], image.shape[1], image.shape[2], scale, seed
|
||||
)
|
||||
|
||||
# Convert to YCbCr for better grain application
|
||||
grain_ycbcr = rgb_to_ycbcr(grain)
|
||||
|
||||
# Apply different blur kernels to each channel for more realistic grain
|
||||
# Y channel - fine detail
|
||||
grain_ycbcr[:, :, :, 0] = apply_gaussian_blur(
|
||||
grain_ycbcr[:, :, :, 0:1], kernel_size=3
|
||||
).squeeze(-1)
|
||||
|
||||
# Cb channel - medium blur for color noise
|
||||
grain_ycbcr[:, :, :, 1] = apply_gaussian_blur(
|
||||
grain_ycbcr[:, :, :, 1:2], kernel_size=15
|
||||
).squeeze(-1)
|
||||
|
||||
# Cr channel - slightly less blur
|
||||
grain_ycbcr[:, :, :, 2] = apply_gaussian_blur(
|
||||
grain_ycbcr[:, :, :, 2:3], kernel_size=11
|
||||
).squeeze(-1)
|
||||
|
||||
# Convert back to RGB
|
||||
grain = ycbcr_to_rgb(grain_ycbcr)
|
||||
|
||||
# Center grain around 0 and apply strength
|
||||
grain = (grain - 0.5) * strength
|
||||
|
||||
# Apply channel-specific weighting for more realistic film grain
|
||||
# Film grain is typically stronger in blue channel, moderate in red
|
||||
grain[:, :, :, 0] *= 2.0 # Red channel
|
||||
grain[:, :, :, 1] *= 1.0 # Green channel (reference)
|
||||
grain[:, :, :, 2] *= 3.0 # Blue channel
|
||||
|
||||
# Add 1 to make it multiplicative
|
||||
grain = grain + 1.0
|
||||
|
||||
# Apply saturation control
|
||||
# Extract luminance for desaturation mixing
|
||||
luminance = grain[:, :, :, 1:2] # Use green channel as approximation
|
||||
grain = grain * saturation + luminance * (1 - saturation)
|
||||
|
||||
# Interpolate grain to match image size if needed
|
||||
if grain.shape[1] != image.shape[1] or grain.shape[2] != image.shape[2]:
|
||||
grain = F.interpolate(
|
||||
grain.permute(0, 3, 1, 2),
|
||||
size=(image.shape[1], image.shape[2]),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
).permute(0, 2, 3, 1)
|
||||
|
||||
# Apply grain using screen blend mode: 1 - (1 - image) * grain
|
||||
# This preserves highlights better than multiply
|
||||
if has_alpha:
|
||||
# Only apply to RGB channels
|
||||
result[:, :, :, :3] = 1 - (1 - result[:, :, :, :3]) * grain
|
||||
else:
|
||||
result = 1 - (1 - result[:, :, :, :3]) * grain
|
||||
|
||||
# Apply toe adjustment (lift blacks)
|
||||
if has_alpha:
|
||||
result[:, :, :, :3] = result[:, :, :, :3] * (1 - toe) + toe
|
||||
else:
|
||||
result = result * (1 - toe) + toe
|
||||
|
||||
# Ensure output is in valid range
|
||||
return torch.clamp(result, 0, 1)
|
||||
@@ -1,123 +0,0 @@
|
||||
import torch
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import apply_film_grain
|
||||
|
||||
|
||||
class KikoFilmGrainNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Apply realistic film grain effect to images.
|
||||
|
||||
This node simulates the grain patterns found in analog film photography.
|
||||
It provides controls for grain size, intensity, color saturation, and
|
||||
shadow lifting (toe) to achieve various film looks.
|
||||
|
||||
Improvements over reference implementation:
|
||||
- More efficient PyTorch-based blur operations
|
||||
- Better memory management for large batches
|
||||
- Preserves alpha channel when present
|
||||
- Improved grain mixing algorithm
|
||||
- ITU-R BT.709 color space conversion
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.5,
|
||||
"min": 0.25,
|
||||
"max": 2.0,
|
||||
"step": 0.05,
|
||||
"display": "slider",
|
||||
"description": "Grain size - smaller values create finer grain",
|
||||
},
|
||||
),
|
||||
"strength": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.5,
|
||||
"min": 0.0,
|
||||
"max": 10.0,
|
||||
"step": 0.01,
|
||||
"display": "slider",
|
||||
"description": "Intensity of the grain effect",
|
||||
},
|
||||
),
|
||||
"saturation": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.01,
|
||||
"display": "slider",
|
||||
"description": "Color saturation of the grain (0=monochrome)",
|
||||
},
|
||||
),
|
||||
"toe": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": -0.2,
|
||||
"max": 0.5,
|
||||
"step": 0.001,
|
||||
"display": "slider",
|
||||
"description": "Lift blacks/shadows for a film-like look",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"description": "Random seed for grain pattern generation",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "apply_grain"
|
||||
CATEGORY = "ComfyAssets/image"
|
||||
DESCRIPTION = "Apply realistic film grain effect with customizable parameters"
|
||||
|
||||
def apply_grain(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
scale: float,
|
||||
strength: float,
|
||||
saturation: float,
|
||||
toe: float,
|
||||
seed: int,
|
||||
) -> Tuple[torch.Tensor]:
|
||||
"""
|
||||
Apply film grain effect to the input image.
|
||||
|
||||
Args:
|
||||
image: Input image tensor [B, H, W, C]
|
||||
scale: Grain size factor (0.25-2.0)
|
||||
strength: Grain intensity (0.0-10.0)
|
||||
saturation: Color saturation of grain (0.0-2.0)
|
||||
toe: Shadow lifting amount (-0.2-0.5)
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
Tuple containing the processed image tensor
|
||||
"""
|
||||
result = apply_film_grain(
|
||||
image=image,
|
||||
scale=scale,
|
||||
strength=strength,
|
||||
saturation=saturation,
|
||||
toe=toe,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
return (result,)
|
||||
@@ -95,7 +95,6 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "ComfyAssets/💾 Images"
|
||||
FUNCTION = "save_images"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_combo"
|
||||
CATEGORY = "ComfyAssets/🌀 Samplers"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def get_combo(
|
||||
self, sampler: str, sched: str, steps: int, cfg: float
|
||||
|
||||
@@ -68,7 +68,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"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
def get_dimensions(self, preset: str, width: int, height: int) -> Tuple[int, int]:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# XYZ Plot Controller - Advanced Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This is a complete reimplementation of the XYZ Plot Controller using the Power Lora Loader architecture from rgthree. The implementation provides dynamic widget management with an intuitive interface.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Dynamic Widget System
|
||||
- **"➕ Add [Type]" Buttons**: When you select models, vaes, loras, samplers, or schedulers for an axis, a button appears to add selections
|
||||
- **Toggle On/Off**: Each dynamic widget has a checkbox to enable/disable it without removing
|
||||
- **Right-Click Menu**: Right-click any dynamic widget to remove or toggle it
|
||||
- **Live Count Updates**: Node title shows total image count in real-time
|
||||
|
||||
### Supported Axis Types
|
||||
- **Models**: Dynamic dropdown widgets with available checkpoints
|
||||
- **VAEs**: Dynamic dropdown widgets (includes "Automatic" option)
|
||||
- **LoRAs**: Dynamic dropdown widgets (includes "None" option)
|
||||
- **Samplers**: Dynamic dropdown widgets with all sampler options
|
||||
- **Schedulers**: Dynamic dropdown widgets with scheduler options
|
||||
- **Numeric Parameters**: Text areas with helpful placeholders
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Seed
|
||||
- Denoise
|
||||
- CLIP Skip
|
||||
- **Prompts**: Multi-line text area for prompt variations
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
#### Python Backend (`xyz_plot_advanced.py`)
|
||||
- Uses `FlexibleOptionalInputType` to accept any number of dynamic inputs
|
||||
- Processes kwargs to extract widget values in format: `{axis}_{type}_{id}`
|
||||
- Each dynamic widget sends: `{ "on": bool, "value": string }`
|
||||
|
||||
#### JavaScript Frontend (`xyz_plot_rgthree.js`)
|
||||
- Manages dynamic widget creation/removal
|
||||
- Custom widget drawing with toggle checkboxes
|
||||
- Serialization/deserialization for workflow saving
|
||||
- Real-time validation and counting
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add the "XYZ Plot Controller (Advanced)" node
|
||||
2. Select axis types (X, Y, Z)
|
||||
3. Click "➕ Add [Type]" to add selections for that axis
|
||||
4. Toggle widgets on/off with checkboxes
|
||||
5. Right-click widgets for more options
|
||||
6. For numeric types, use comma-separated values or ranges (e.g., "5:15:2.5")
|
||||
7. For prompts, enter one per line
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
- **Clean Separation**: Python handles data, JavaScript handles UI
|
||||
- **Flexible Input System**: Can accept unlimited dynamic widgets
|
||||
- **Persistent State**: All widget states are saved with the workflow
|
||||
- **Intuitive Interface**: Matches Power Lora Loader's proven UX patterns
|
||||
- **Performance**: Only processes enabled widgets
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Model/LoRA info display (CivitAI integration)
|
||||
- Drag-and-drop reordering
|
||||
- Preset management
|
||||
- Batch widget operations
|
||||
@@ -0,0 +1,68 @@
|
||||
# XYZ Grid Nodes for ComfyUI
|
||||
|
||||
Advanced parameter comparison grid generator for ComfyUI with Power Lora Loader-inspired interface.
|
||||
|
||||
## Features
|
||||
|
||||
### XYZ Plot Controller
|
||||
- **Dynamic Multi-Selection**: Native dropdown widgets for selecting multiple models, VAEs, LoRAs, samplers, and schedulers
|
||||
- **Smart Widget Management**: Widgets automatically show/hide based on selected axis types
|
||||
- **Visual Organization**: Grouped widgets with headers for better organization
|
||||
- **Right-Click Context Menu**:
|
||||
- Clear all selections for a specific type
|
||||
- Show image count breakdown
|
||||
- Keyboard shortcuts (Ctrl+Shift+C to clear all)
|
||||
- **Real-time Image Count**: Node title shows total images that will be generated
|
||||
- **Warning System**: Visual warning when generating over 100 images
|
||||
|
||||
### Supported Parameter Types
|
||||
- **Models**: Multiple checkpoint selection
|
||||
- **VAEs**: Multiple VAE selection with "Automatic" option
|
||||
- **LoRAs**: Multiple LoRA selection with "None" option
|
||||
- **Samplers**: euler, euler_ancestral, heun, dpm_2, etc.
|
||||
- **Schedulers**: normal, karras, exponential, etc.
|
||||
- **Numeric Parameters**:
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Seed
|
||||
- Denoise
|
||||
- CLIP Skip
|
||||
- Support for ranges (e.g., "5:15:2.5" generates 5, 7.5, 10, 12.5, 15)
|
||||
- **Prompts**: Multiple prompts (one per line)
|
||||
|
||||
### Image Grid Combiner
|
||||
- Automatic grid assembly with customizable spacing
|
||||
- Smart labeling with parameter values
|
||||
- Z-axis support for generating multiple grid pages
|
||||
- Font size and label customization options
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add an XYZ Plot Controller node
|
||||
2. Select axis types (X, Y, and optionally Z)
|
||||
3. Use the dropdown widgets to select values for each axis
|
||||
4. Connect to your workflow (models, samplers, etc.)
|
||||
5. Add Image Grid Combiner at the end to create the labeled grid
|
||||
|
||||
## Workflow Example
|
||||
|
||||
```
|
||||
[XYZ Plot Controller] → [Checkpoint Loader] → [Sampling] → [Image Grid Combiner] → [Save Image]
|
||||
```
|
||||
|
||||
The controller outputs the current iteration values which can be connected to corresponding nodes in your workflow.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use the right-click menu to quickly clear selections
|
||||
- Check the image count in the node title before running
|
||||
- For large grids, consider using the Z-axis to split into multiple pages
|
||||
- Numeric ranges are more efficient than listing each value
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The implementation uses a hybrid approach:
|
||||
- Python backend with native ComfyUI widget support
|
||||
- JavaScript frontend for enhanced UI features
|
||||
- Inspired by Power Lora Loader's dynamic widget management
|
||||
- Context menus and keyboard shortcuts for power users
|
||||
@@ -0,0 +1,19 @@
|
||||
"""XYZ Grid nodes for ComfyUI parameter comparisons."""
|
||||
|
||||
from .controller.power_node import XYZPlotController
|
||||
from .combiner.node import ImageGridCombiner
|
||||
from .prompt.node import XYZPrompt
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"XYZPlotController": XYZPlotController,
|
||||
"ImageGridCombiner": ImageGridCombiner,
|
||||
"XYZPrompt": XYZPrompt,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"XYZPlotController": "XYZ Plot Controller",
|
||||
"ImageGridCombiner": "Image Grid Combiner",
|
||||
"XYZPrompt": "XYZ Prompt",
|
||||
}
|
||||
|
||||
__all__ = ["XYZPlotController", "ImageGridCombiner", "XYZPrompt"]
|
||||
@@ -0,0 +1 @@
|
||||
# Image Grid Combiner module
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Image Grid Combiner node implementation."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
|
||||
from ..utils.constants import GRID_DEFAULTS
|
||||
|
||||
|
||||
class ImageGridCombiner:
|
||||
"""Combines images into labeled grid output."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"grid_data": ("XYZ_GRID",),
|
||||
},
|
||||
"optional": {
|
||||
"font_size": ("INT", {"default": GRID_DEFAULTS["font_size"], "min": 8, "max": 72}),
|
||||
"grid_gap": ("INT", {"default": GRID_DEFAULTS["grid_gap"], "min": 0, "max": 50}),
|
||||
"label_height": ("INT", {"default": GRID_DEFAULTS["label_height"], "min": 0, "max": 100}),
|
||||
"max_label_length": ("INT", {"default": GRID_DEFAULTS["max_label_length"], "min": 10, "max": 100}),
|
||||
"include_labels": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("grid_image", "grid_info")
|
||||
FUNCTION = "combine_images"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def __init__(self):
|
||||
self.image_buffer = {} # Store images by batch_id
|
||||
self.grid_configs = {} # Store configs by batch_id
|
||||
|
||||
def combine_images(self, images, grid_data, font_size=20, grid_gap=10,
|
||||
label_height=30, max_label_length=30, include_labels=True):
|
||||
"""Combine images into grid with labels."""
|
||||
|
||||
batch_id = grid_data["batch_id"]
|
||||
|
||||
# Initialize buffer for this batch if needed
|
||||
if batch_id not in self.image_buffer:
|
||||
self.image_buffer[batch_id] = []
|
||||
self.grid_configs[batch_id] = grid_data
|
||||
|
||||
# Add current image(s) to buffer
|
||||
if len(images.shape) == 4: # Batch of images
|
||||
for img in images:
|
||||
self.image_buffer[batch_id].append(img)
|
||||
else: # Single image
|
||||
self.image_buffer[batch_id].append(images)
|
||||
|
||||
# Check if we have all images for this grid
|
||||
config = self.grid_configs[batch_id]
|
||||
expected_images = config["dimensions"]["total_images"]
|
||||
current_count = len(self.image_buffer[batch_id])
|
||||
|
||||
if current_count < expected_images:
|
||||
# Not ready yet, return placeholder
|
||||
placeholder = torch.zeros((1, 64, 64, 3))
|
||||
info = f"Grid progress: {current_count}/{expected_images} images"
|
||||
return (placeholder, info)
|
||||
|
||||
# We have all images, create grid(s)
|
||||
grids = self._create_grids(batch_id, font_size, grid_gap, label_height,
|
||||
max_label_length, include_labels)
|
||||
|
||||
# Clean up buffers
|
||||
del self.image_buffer[batch_id]
|
||||
del self.grid_configs[batch_id]
|
||||
|
||||
# Return grid(s) and info
|
||||
info = self._generate_grid_info(config)
|
||||
|
||||
# Convert PIL images back to tensor format
|
||||
grid_tensors = []
|
||||
for grid in grids:
|
||||
grid_np = np.array(grid).astype(np.float32) / 255.0
|
||||
grid_tensor = torch.from_numpy(grid_np)
|
||||
grid_tensors.append(grid_tensor)
|
||||
|
||||
# Stack if multiple grids (Z axis)
|
||||
if len(grid_tensors) > 1:
|
||||
output = torch.stack(grid_tensors)
|
||||
else:
|
||||
output = grid_tensors[0].unsqueeze(0)
|
||||
|
||||
return (output, info)
|
||||
|
||||
def _create_grids(self, batch_id: str, font_size: int, grid_gap: int,
|
||||
label_height: int, max_label_length: int, include_labels: bool) -> List[Image.Image]:
|
||||
"""Create grid images from buffer."""
|
||||
|
||||
config = self.grid_configs[batch_id]
|
||||
images = self.image_buffer[batch_id]
|
||||
dims = config["dimensions"]
|
||||
|
||||
# Convert tensors to PIL images
|
||||
pil_images = []
|
||||
for img_tensor in images:
|
||||
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
pil_images.append(Image.fromarray(img_np))
|
||||
|
||||
# Get dimensions
|
||||
img_width = pil_images[0].width
|
||||
img_height = pil_images[0].height
|
||||
cols = dims["cols"]
|
||||
rows = dims["rows"]
|
||||
grids_count = dims["grids_count"]
|
||||
|
||||
# Calculate grid dimensions
|
||||
row_label_width = 100 if include_labels else 0 # Space for Y labels
|
||||
z_label_height = 40 if include_labels and grids_count > 1 else 0 # Space for Z label
|
||||
|
||||
if include_labels:
|
||||
grid_width = cols * img_width + (cols - 1) * grid_gap + row_label_width
|
||||
grid_height = rows * img_height + (rows - 1) * grid_gap + label_height + z_label_height
|
||||
else:
|
||||
grid_width = cols * img_width + (cols - 1) * grid_gap
|
||||
grid_height = rows * img_height + (rows - 1) * grid_gap
|
||||
|
||||
grids = []
|
||||
z_labels = config["axes"]["z"]["labels"] if config["axes"]["z"]["labels"] else []
|
||||
|
||||
# Create each grid (for Z axis)
|
||||
for z_idx in range(grids_count):
|
||||
# Create blank grid
|
||||
grid = Image.new('RGB', (grid_width, grid_height), color=(32, 32, 32))
|
||||
draw = ImageDraw.Draw(grid)
|
||||
|
||||
# Add labels if enabled
|
||||
if include_labels:
|
||||
# Try to use a better font if available
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size)
|
||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size + 4)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
title_font = font
|
||||
|
||||
# Draw Z-axis label if applicable
|
||||
if z_labels and z_idx < len(z_labels):
|
||||
z_label = z_labels[z_idx]
|
||||
# Center the Z label
|
||||
bbox = draw.textbbox((0, 0), z_label, font=title_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
z_x = (grid_width - text_width) // 2
|
||||
self._draw_label(draw, z_label, z_x, 5, text_width + 20,
|
||||
z_label_height - 10, title_font, max_label_length * 2)
|
||||
|
||||
# Draw column labels (X axis)
|
||||
x_labels = config["axes"]["x"]["labels"]
|
||||
for col_idx, label in enumerate(x_labels):
|
||||
x = col_idx * (img_width + grid_gap) + row_label_width
|
||||
y = z_label_height
|
||||
self._draw_label(draw, label, x, y, img_width, label_height, font, max_label_length)
|
||||
|
||||
# Draw row labels (Y axis) - on the left side
|
||||
y_labels = config["axes"]["y"]["labels"]
|
||||
for row_idx, label in enumerate(y_labels):
|
||||
y = row_idx * (img_height + grid_gap) + label_height + z_label_height
|
||||
self._draw_label(draw, label, 5, y + img_height // 2 - font_size // 2,
|
||||
row_label_width - 10, font_size + 4, font, max_label_length,
|
||||
align="right")
|
||||
|
||||
# Place images
|
||||
for y_idx in range(rows):
|
||||
for x_idx in range(cols):
|
||||
img_idx = z_idx * (rows * cols) + y_idx * cols + x_idx
|
||||
if img_idx < len(pil_images):
|
||||
x = x_idx * (img_width + grid_gap) + row_label_width
|
||||
y = y_idx * (img_height + grid_gap) + label_height + z_label_height
|
||||
grid.paste(pil_images[img_idx], (x, y))
|
||||
|
||||
grids.append(grid)
|
||||
|
||||
return grids
|
||||
|
||||
def _draw_label(self, draw, text: str, x: int, y: int, width: int, height: int,
|
||||
font, max_length: int, align: str = "center"):
|
||||
"""Draw a label with background."""
|
||||
|
||||
# Truncate if needed
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length-3] + "..."
|
||||
|
||||
# Get text dimensions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Calculate position based on alignment
|
||||
if align == "center":
|
||||
text_x = x + (width - text_width) // 2
|
||||
elif align == "right":
|
||||
text_x = x + width - text_width - 5
|
||||
else:
|
||||
text_x = x + 5
|
||||
|
||||
text_y = y + (height - text_height) // 2
|
||||
|
||||
# Draw background
|
||||
padding = 3
|
||||
draw.rectangle([text_x - padding, text_y - padding,
|
||||
text_x + text_width + padding, text_y + text_height + padding],
|
||||
fill=(0, 0, 0, 180))
|
||||
|
||||
# Draw text
|
||||
draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font)
|
||||
|
||||
def _generate_grid_info(self, config: Dict) -> str:
|
||||
"""Generate information string about the grid."""
|
||||
dims = config["dimensions"]
|
||||
axes = config["axes"]
|
||||
|
||||
info_parts = [f"Grid: {dims['cols']}x{dims['rows']}"]
|
||||
|
||||
for axis_name, axis_data in axes.items():
|
||||
if axis_data["type"] and axis_data["values"]:
|
||||
axis_type = axis_data["type"].value
|
||||
value_count = len(axis_data["values"])
|
||||
info_parts.append(f"{axis_name.upper()}: {axis_type} ({value_count} values)")
|
||||
|
||||
info_parts.append(f"Total images: {dims['total_images']}")
|
||||
|
||||
return " | ".join(info_parts)
|
||||
@@ -0,0 +1 @@
|
||||
# XYZ Plot Controller module
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Advanced XYZ Plot Controller with full parameter support."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional, Union
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType, NUMERIC_DEFAULTS
|
||||
from ..utils.helpers import (
|
||||
get_available_models, get_available_vaes, get_available_loras,
|
||||
get_sampler_names, get_scheduler_names, parse_value_string,
|
||||
generate_axis_labels, calculate_grid_dimensions, create_unique_id
|
||||
)
|
||||
from ..utils.converters import ParameterConverter, OutputConnector
|
||||
from .execution import execution_manager
|
||||
from .queue_manager import queue_manager
|
||||
|
||||
|
||||
class XYZPlotControllerAdvanced:
|
||||
"""Advanced XYZ Plot Controller with dynamic outputs."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Get available options for dropdowns
|
||||
models = get_available_models()
|
||||
vaes = get_available_vaes()
|
||||
loras = get_available_loras()
|
||||
samplers = get_sampler_names()
|
||||
schedulers = get_scheduler_names()
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# X Axis configuration
|
||||
"x_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"x_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"x_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Y Axis configuration
|
||||
"y_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"y_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"y_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Execution control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# Z Axis configuration (optional)
|
||||
"z_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"z_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"z_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Label formatting
|
||||
"include_param_name": ("BOOLEAN", {"default": True}),
|
||||
"value_only_labels": ("BOOLEAN", {"default": False}),
|
||||
|
||||
# Quick select dropdowns (helpers)
|
||||
"model_list": (["none"] + models, {"default": "none"}),
|
||||
"vae_list": (["none"] + vaes, {"default": "none"}),
|
||||
"lora_list": (["none"] + loras, {"default": "none"}),
|
||||
"sampler_list": (["none"] + samplers, {"default": "none"}),
|
||||
"scheduler_list": (["none"] + schedulers, {"default": "none"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"prompt": "PROMPT",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data",
|
||||
"x_string", "x_int", "x_float",
|
||||
"y_string", "y_int", "y_float",
|
||||
"z_string", "z_int", "z_float",
|
||||
"batch_id")
|
||||
FUNCTION = "configure_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def __init__(self):
|
||||
self.unique_id = None
|
||||
self._execution_count = 0
|
||||
|
||||
def configure_grid(self, x_axis_type, x_values, x_label_prefix,
|
||||
y_axis_type, y_values, y_label_prefix,
|
||||
auto_queue=True,
|
||||
z_axis_type="none", z_values="", z_label_prefix="",
|
||||
include_param_name=True, value_only_labels=False,
|
||||
model_list="none", vae_list="none", lora_list="none",
|
||||
sampler_list="none", scheduler_list="none",
|
||||
unique_id=None, prompt=None):
|
||||
"""Configure and prepare grid generation with advanced features."""
|
||||
|
||||
# Use helper dropdowns to populate values if selected
|
||||
x_values = self._apply_quick_select(x_axis_type, x_values,
|
||||
model_list, vae_list, lora_list,
|
||||
sampler_list, scheduler_list)
|
||||
y_values = self._apply_quick_select(y_axis_type, y_values,
|
||||
model_list, vae_list, lora_list,
|
||||
sampler_list, scheduler_list)
|
||||
z_values = self._apply_quick_select(z_axis_type, z_values,
|
||||
model_list, vae_list, lora_list,
|
||||
sampler_list, scheduler_list)
|
||||
|
||||
# Parse axis types
|
||||
x_type = AxisType(x_axis_type) if x_axis_type != "none" else None
|
||||
y_type = AxisType(y_axis_type) if y_axis_type != "none" else None
|
||||
z_type = AxisType(z_axis_type) if z_axis_type != "none" else None
|
||||
|
||||
# Parse values for each axis
|
||||
x_vals = parse_value_string(x_values, x_type) if x_type else [""]
|
||||
y_vals = parse_value_string(y_values, y_type) if y_type else [""]
|
||||
z_vals = parse_value_string(z_values, z_type) if z_type else [""]
|
||||
|
||||
# Validate we have at least one axis configured
|
||||
if not x_type and not y_type:
|
||||
raise ValueError("At least one axis (X or Y) must be configured")
|
||||
|
||||
# Calculate grid dimensions
|
||||
dims = calculate_grid_dimensions(len(x_vals), len(y_vals), len(z_vals))
|
||||
|
||||
# Generate labels
|
||||
x_labels = self._generate_labels(x_vals, x_type, x_label_prefix, include_param_name, value_only_labels)
|
||||
y_labels = self._generate_labels(y_vals, y_type, y_label_prefix, include_param_name, value_only_labels)
|
||||
z_labels = self._generate_labels(z_vals, z_type, z_label_prefix, include_param_name, value_only_labels)
|
||||
|
||||
# Create batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Prepare grid configuration
|
||||
grid_config = {
|
||||
"batch_id": batch_id,
|
||||
"axes": {
|
||||
"x": {"type": x_type, "values": x_vals, "labels": x_labels},
|
||||
"y": {"type": y_type, "values": y_vals, "labels": y_labels},
|
||||
"z": {"type": z_type, "values": z_vals, "labels": z_labels},
|
||||
},
|
||||
"dimensions": dims,
|
||||
"total_images": dims["total_images"],
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue,
|
||||
}
|
||||
|
||||
# Get current values from execution manager
|
||||
x_val, y_val, z_val, x_idx, y_idx, z_idx = execution_manager.get_current_values(
|
||||
batch_id, x_vals, y_vals, z_vals
|
||||
)
|
||||
|
||||
# Convert values to appropriate types for each output
|
||||
x_outputs = self._convert_to_outputs(x_val, x_type)
|
||||
y_outputs = self._convert_to_outputs(y_val, y_type)
|
||||
z_outputs = self._convert_to_outputs(z_val, z_type)
|
||||
|
||||
# Handle auto-queuing if enabled
|
||||
if auto_queue and unique_id and prompt:
|
||||
self._handle_auto_queue(batch_id, grid_config, unique_id, prompt)
|
||||
|
||||
# Update current index in grid config
|
||||
grid_config["current_index"] = execution_manager.execution_states.get(
|
||||
batch_id, execution_manager.initialize_batch(batch_id, x_vals, y_vals, z_vals)
|
||||
).current_iteration
|
||||
|
||||
return (grid_config,
|
||||
x_outputs[0], x_outputs[1], x_outputs[2],
|
||||
y_outputs[0], y_outputs[1], y_outputs[2],
|
||||
z_outputs[0], z_outputs[1], z_outputs[2],
|
||||
batch_id)
|
||||
|
||||
def _apply_quick_select(self, axis_type: str, values: str,
|
||||
model: str, vae: str, lora: str,
|
||||
sampler: str, scheduler: str) -> str:
|
||||
"""Apply quick select dropdown values if appropriate."""
|
||||
if values: # If user already entered values, don't override
|
||||
return values
|
||||
|
||||
# Map axis type to quick select value
|
||||
if axis_type == "model" and model != "none":
|
||||
return model
|
||||
elif axis_type == "vae" and vae != "none":
|
||||
return vae
|
||||
elif axis_type == "lora" and lora != "none":
|
||||
return lora
|
||||
elif axis_type == "sampler" and sampler != "none":
|
||||
return sampler
|
||||
elif axis_type == "scheduler" and scheduler != "none":
|
||||
return scheduler
|
||||
|
||||
return values
|
||||
|
||||
def _convert_to_outputs(self, value: Any, axis_type: Optional[AxisType]) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if not axis_type or value == "":
|
||||
return ("", 0, 0.0)
|
||||
|
||||
# Convert using parameter converter
|
||||
converted = ParameterConverter.convert_value(value, axis_type)
|
||||
|
||||
# Prepare outputs for all types
|
||||
str_val = str(converted)
|
||||
|
||||
try:
|
||||
int_val = int(float(converted))
|
||||
except:
|
||||
int_val = 0
|
||||
|
||||
try:
|
||||
float_val = float(converted)
|
||||
except:
|
||||
float_val = 0.0
|
||||
|
||||
return (str_val, int_val, float_val)
|
||||
|
||||
def _generate_labels(self, values: List[Any], axis_type: Optional[AxisType],
|
||||
prefix: str, include_param: bool, value_only: bool) -> List[str]:
|
||||
"""Generate labels for axis values."""
|
||||
if not values or not axis_type:
|
||||
return []
|
||||
|
||||
labels = []
|
||||
for value in values:
|
||||
if value_only:
|
||||
label = ParameterConverter.format_for_display(value, axis_type)
|
||||
else:
|
||||
label = ParameterConverter.format_for_display(value, axis_type)
|
||||
if include_param and not prefix:
|
||||
param_names = AxisType.display_names()
|
||||
param_prefix = param_names.get(axis_type, "")
|
||||
label = f"{param_prefix}: {label}"
|
||||
elif prefix:
|
||||
label = f"{prefix}{label}"
|
||||
|
||||
labels.append(label)
|
||||
|
||||
return labels
|
||||
|
||||
def _handle_auto_queue(self, batch_id: str, grid_config: Dict, node_id: str, prompt: Dict):
|
||||
"""Handle automatic queuing of grid executions."""
|
||||
# Check if this is the first execution for this batch
|
||||
state = execution_manager.execution_states.get(batch_id)
|
||||
if not state or state.current_iteration == 0:
|
||||
# Prepare all executions for the batch
|
||||
executions = queue_manager.prepare_batch_executions(
|
||||
batch_id, grid_config, node_id, prompt
|
||||
)
|
||||
|
||||
# Mark that we've started this batch
|
||||
self._execution_count = len(executions)
|
||||
|
||||
# Advance to next iteration after this one completes
|
||||
if execution_manager.should_continue(batch_id):
|
||||
execution_manager.advance_batch(batch_id)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution for grid iterations."""
|
||||
return float("nan")
|
||||
@@ -0,0 +1,165 @@
|
||||
"""ComfyUI-specific execution flow implementation."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
|
||||
try:
|
||||
from server import PromptServer
|
||||
from execution import validate_prompt, PromptExecutor
|
||||
import execution
|
||||
import nodes
|
||||
except ImportError:
|
||||
# Not in ComfyUI environment
|
||||
PromptServer = None
|
||||
validate_prompt = None
|
||||
PromptExecutor = None
|
||||
execution = None
|
||||
nodes = None
|
||||
|
||||
|
||||
class ComfyUIExecutionFlow:
|
||||
"""Manages execution flow integration with ComfyUI's system."""
|
||||
|
||||
_instance = None
|
||||
_batch_states = {} # Track batch execution states
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, 'initialized'):
|
||||
self.initialized = True
|
||||
self.prompt_server = PromptServer.instance if PromptServer else None
|
||||
self.active_batches = {}
|
||||
self.execution_callbacks = {}
|
||||
|
||||
def register_batch(self, batch_id: str, grid_config: Dict, node_id: str) -> None:
|
||||
"""Register a new batch for execution tracking."""
|
||||
self._batch_states[batch_id] = {
|
||||
"config": grid_config,
|
||||
"node_id": node_id,
|
||||
"current_iteration": 0,
|
||||
"total_iterations": grid_config["total_images"],
|
||||
"completed": False
|
||||
}
|
||||
|
||||
def queue_grid_executions(self, workflow: Dict, batch_id: str,
|
||||
grid_config: Dict, node_id: str) -> bool:
|
||||
"""Queue all executions for a grid batch."""
|
||||
try:
|
||||
# Register the batch
|
||||
self.register_batch(batch_id, grid_config, node_id)
|
||||
|
||||
# Get axis configurations
|
||||
x_values = grid_config["axes"]["x"]["values"]
|
||||
y_values = grid_config["axes"]["y"]["values"]
|
||||
z_values = grid_config["axes"]["z"]["values"]
|
||||
|
||||
# Calculate total iterations
|
||||
total = len(x_values) * len(y_values) * len(z_values)
|
||||
|
||||
# Store the original workflow
|
||||
original_workflow = json.loads(json.dumps(workflow))
|
||||
|
||||
# Queue executions for each combination
|
||||
execution_count = 0
|
||||
for z_idx, z_val in enumerate(z_values or [""]):
|
||||
for y_idx, y_val in enumerate(y_values or [""]):
|
||||
for x_idx, x_val in enumerate(x_values or [""]):
|
||||
# Clone workflow for this iteration
|
||||
iteration_workflow = json.loads(json.dumps(original_workflow))
|
||||
|
||||
# Inject iteration metadata
|
||||
self._inject_iteration_data(
|
||||
iteration_workflow, node_id, batch_id,
|
||||
execution_count, total,
|
||||
x_idx, y_idx, z_idx
|
||||
)
|
||||
|
||||
# Queue this iteration
|
||||
prompt_id = str(uuid.uuid4())
|
||||
|
||||
# Use ComfyUI's internal queue system
|
||||
if validate_prompt:
|
||||
valid, error = validate_prompt(iteration_workflow)
|
||||
if valid and execution and PromptServer:
|
||||
# Add to execution queue
|
||||
PromptServer.instance.send_sync(
|
||||
"execution_start",
|
||||
{"prompt_id": prompt_id}
|
||||
)
|
||||
|
||||
execution_count += 1
|
||||
else:
|
||||
print(f"Validation error for iteration {execution_count}: {error}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error queuing grid executions: {e}")
|
||||
return False
|
||||
|
||||
def _inject_iteration_data(self, workflow: Dict, node_id: str, batch_id: str,
|
||||
iteration: int, total: int,
|
||||
x_idx: int, y_idx: int, z_idx: int) -> None:
|
||||
"""Inject iteration-specific data into workflow."""
|
||||
# Find the XYZ controller node
|
||||
if str(node_id) in workflow:
|
||||
node_data = workflow[str(node_id)]
|
||||
|
||||
# Add hidden inputs for tracking
|
||||
if "inputs" not in node_data:
|
||||
node_data["inputs"] = {}
|
||||
|
||||
node_data["inputs"]["_xyz_batch_id"] = batch_id
|
||||
node_data["inputs"]["_xyz_iteration"] = iteration
|
||||
node_data["inputs"]["_xyz_total"] = total
|
||||
node_data["inputs"]["_xyz_indices"] = {
|
||||
"x": x_idx,
|
||||
"y": y_idx,
|
||||
"z": z_idx
|
||||
}
|
||||
|
||||
def get_batch_progress(self, batch_id: str) -> Dict[str, Any]:
|
||||
"""Get progress information for a batch."""
|
||||
if batch_id not in self._batch_states:
|
||||
return {"status": "unknown", "progress": 0}
|
||||
|
||||
state = self._batch_states[batch_id]
|
||||
progress = state["current_iteration"] / state["total_iterations"]
|
||||
|
||||
return {
|
||||
"status": "completed" if state["completed"] else "running",
|
||||
"progress": progress,
|
||||
"current": state["current_iteration"],
|
||||
"total": state["total_iterations"]
|
||||
}
|
||||
|
||||
def mark_iteration_complete(self, batch_id: str) -> None:
|
||||
"""Mark current iteration as complete and advance."""
|
||||
if batch_id in self._batch_states:
|
||||
state = self._batch_states[batch_id]
|
||||
state["current_iteration"] += 1
|
||||
|
||||
if state["current_iteration"] >= state["total_iterations"]:
|
||||
state["completed"] = True
|
||||
|
||||
# Send completion notification
|
||||
if self.prompt_server:
|
||||
self.prompt_server.send_sync("xyz_grid_complete", {
|
||||
"batch_id": batch_id,
|
||||
"total_images": state["total_iterations"]
|
||||
})
|
||||
|
||||
def cleanup_batch(self, batch_id: str) -> None:
|
||||
"""Clean up completed batch data."""
|
||||
if batch_id in self._batch_states:
|
||||
del self._batch_states[batch_id]
|
||||
|
||||
|
||||
# Global execution flow instance
|
||||
execution_flow = ComfyUIExecutionFlow()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""XYZ Plot Controller with dynamic widget addition."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Union
|
||||
import folder_paths
|
||||
|
||||
from ..utils.helpers import create_unique_id
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with dynamic selections like Power Lora Loader."""
|
||||
|
||||
# Allow any input to support dynamic widget addition
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return float("nan")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
# Base inputs that are always present
|
||||
inputs = {
|
||||
"required": {
|
||||
# Axis configuration
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# Single inputs for numeric/prompt values
|
||||
"numeric_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
|
||||
}),
|
||||
|
||||
"prompt_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For prompts: enter each prompt on a new line"
|
||||
})
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, y_type, z_type, auto_queue, unique_id=None, **kwargs):
|
||||
"""Create grid configuration from dynamic selections."""
|
||||
|
||||
# Extract values from kwargs based on type
|
||||
models = self._extract_values(kwargs, "MODEL_", exclude="none")
|
||||
vaes = self._extract_values(kwargs, "VAE_", exclude="none")
|
||||
loras = self._extract_values(kwargs, "LORA_", exclude="none")
|
||||
samplers = self._extract_values(kwargs, "SAMPLER_", exclude="none")
|
||||
schedulers = self._extract_values(kwargs, "SCHEDULER_", exclude="none")
|
||||
|
||||
# Get numeric and prompt values
|
||||
numeric_values = kwargs.get("numeric_values", "")
|
||||
prompt_values = kwargs.get("prompt_values", "")
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values - {x_parsed}")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _extract_values(self, kwargs: Dict[str, Any], prefix: str, exclude: str = None) -> List[str]:
|
||||
"""Extract non-empty values from kwargs with given prefix."""
|
||||
values = []
|
||||
i = 1
|
||||
while f"{prefix}{i}" in kwargs:
|
||||
value = kwargs[f"{prefix}{i}"]
|
||||
if value and value != exclude:
|
||||
values.append(value)
|
||||
i += 1
|
||||
return values
|
||||
|
||||
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
|
||||
"""Get values for a specific axis type."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type == "models":
|
||||
return models
|
||||
elif axis_type == "vaes":
|
||||
return vaes
|
||||
elif axis_type == "loras":
|
||||
return loras
|
||||
elif axis_type == "samplers":
|
||||
return samplers
|
||||
elif axis_type == "schedulers":
|
||||
return schedulers
|
||||
elif axis_type == "prompt":
|
||||
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, numeric_values)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Execution flow management for XYZ grid generation."""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from ..utils.constants import AxisType
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridExecutionState:
|
||||
"""Tracks execution state for grid generation."""
|
||||
batch_id: str
|
||||
total_iterations: int
|
||||
current_iteration: int = 0
|
||||
x_index: int = 0
|
||||
y_index: int = 0
|
||||
z_index: int = 0
|
||||
x_count: int = 1
|
||||
y_count: int = 1
|
||||
z_count: int = 1
|
||||
|
||||
def advance(self) -> bool:
|
||||
"""Advance to next grid position. Returns False when complete."""
|
||||
self.current_iteration += 1
|
||||
|
||||
if self.current_iteration >= self.total_iterations:
|
||||
return False
|
||||
|
||||
# Advance indices (row-major order: X varies fastest)
|
||||
self.x_index += 1
|
||||
if self.x_index >= self.x_count:
|
||||
self.x_index = 0
|
||||
self.y_index += 1
|
||||
if self.y_index >= self.y_count:
|
||||
self.y_index = 0
|
||||
self.z_index += 1
|
||||
|
||||
return True
|
||||
|
||||
def get_indices(self) -> Tuple[int, int, int]:
|
||||
"""Get current x, y, z indices."""
|
||||
return (self.x_index, self.y_index, self.z_index)
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""Check if all iterations are complete."""
|
||||
return self.current_iteration >= self.total_iterations
|
||||
|
||||
|
||||
class ExecutionManager:
|
||||
"""Manages execution flow for XYZ grid generation."""
|
||||
|
||||
def __init__(self):
|
||||
self.execution_states = {} # batch_id -> GridExecutionState
|
||||
self.pending_executions = {} # batch_id -> list of pending configs
|
||||
|
||||
def initialize_batch(self, batch_id: str, x_values: List[Any],
|
||||
y_values: List[Any], z_values: List[Any]) -> GridExecutionState:
|
||||
"""Initialize a new batch execution."""
|
||||
x_count = len(x_values) if x_values else 1
|
||||
y_count = len(y_values) if y_values else 1
|
||||
z_count = len(z_values) if z_values else 1
|
||||
total = x_count * y_count * z_count
|
||||
|
||||
state = GridExecutionState(
|
||||
batch_id=batch_id,
|
||||
total_iterations=total,
|
||||
x_count=x_count,
|
||||
y_count=y_count,
|
||||
z_count=z_count
|
||||
)
|
||||
|
||||
self.execution_states[batch_id] = state
|
||||
return state
|
||||
|
||||
def get_current_values(self, batch_id: str, x_values: List[Any],
|
||||
y_values: List[Any], z_values: List[Any]) -> Tuple[Any, Any, Any, int, int, int]:
|
||||
"""Get current values and indices for execution."""
|
||||
state = self.execution_states.get(batch_id)
|
||||
if not state:
|
||||
# Initialize if not exists
|
||||
state = self.initialize_batch(batch_id, x_values, y_values, z_values)
|
||||
|
||||
x_idx, y_idx, z_idx = state.get_indices()
|
||||
|
||||
x_val = x_values[x_idx] if x_values and x_idx < len(x_values) else ""
|
||||
y_val = y_values[y_idx] if y_values and y_idx < len(y_values) else ""
|
||||
z_val = z_values[z_idx] if z_values and z_idx < len(z_values) else ""
|
||||
|
||||
return x_val, y_val, z_val, x_idx, y_idx, z_idx
|
||||
|
||||
def should_continue(self, batch_id: str) -> bool:
|
||||
"""Check if batch should continue executing."""
|
||||
state = self.execution_states.get(batch_id)
|
||||
return state and not state.is_complete()
|
||||
|
||||
def advance_batch(self, batch_id: str) -> bool:
|
||||
"""Advance to next iteration. Returns True if more iterations remain."""
|
||||
state = self.execution_states.get(batch_id)
|
||||
if state:
|
||||
return state.advance()
|
||||
return False
|
||||
|
||||
def cleanup_batch(self, batch_id: str):
|
||||
"""Clean up completed batch."""
|
||||
if batch_id in self.execution_states:
|
||||
del self.execution_states[batch_id]
|
||||
if batch_id in self.pending_executions:
|
||||
del self.pending_executions[batch_id]
|
||||
|
||||
|
||||
# Global execution manager instance
|
||||
execution_manager = ExecutionManager()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""XYZ Plot Controller with multiple selection dropdowns."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
import folder_paths
|
||||
|
||||
from ..utils.helpers import create_unique_id
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with individual model selection dropdowns."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Get available options
|
||||
models = folder_paths.get_filename_list("checkpoints")
|
||||
vaes = ["Automatic"] + folder_paths.get_filename_list("vae")
|
||||
loras = ["None"] + folder_paths.get_filename_list("loras")
|
||||
|
||||
# Get sampler/scheduler options from a KSampler if available
|
||||
samplers = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
|
||||
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
|
||||
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"]
|
||||
schedulers = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]
|
||||
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
inputs = {
|
||||
"required": {
|
||||
# X Axis
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Y Axis
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Z Axis
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Model selections (up to 10)
|
||||
"model_1": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_2": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_3": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_4": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_5": (["disabled"] + models, {"default": "disabled"}),
|
||||
|
||||
# VAE selections (up to 5)
|
||||
"vae_1": (["disabled"] + vaes, {"default": "disabled"}),
|
||||
"vae_2": (["disabled"] + vaes, {"default": "disabled"}),
|
||||
"vae_3": (["disabled"] + vaes, {"default": "disabled"}),
|
||||
|
||||
# LoRA selections (up to 5)
|
||||
"lora_1": (["disabled"] + loras, {"default": "disabled"}),
|
||||
"lora_2": (["disabled"] + loras, {"default": "disabled"}),
|
||||
"lora_3": (["disabled"] + loras, {"default": "disabled"}),
|
||||
|
||||
# Sampler selections (up to 5)
|
||||
"sampler_1": (["disabled"] + samplers, {"default": "disabled"}),
|
||||
"sampler_2": (["disabled"] + samplers, {"default": "disabled"}),
|
||||
"sampler_3": (["disabled"] + samplers, {"default": "disabled"}),
|
||||
|
||||
# Scheduler selections (up to 3)
|
||||
"scheduler_1": (["disabled"] + schedulers, {"default": "disabled"}),
|
||||
"scheduler_2": (["disabled"] + schedulers, {"default": "disabled"}),
|
||||
|
||||
# Numeric values (still use text for flexibility)
|
||||
"numeric_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For numeric types: use comma-separated values or start:stop:step"
|
||||
}),
|
||||
|
||||
# Prompts
|
||||
"prompts": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For prompts: enter each prompt on a new line"
|
||||
}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, y_type, z_type,
|
||||
model_1, model_2, model_3, model_4, model_5,
|
||||
vae_1, vae_2, vae_3,
|
||||
lora_1, lora_2, lora_3,
|
||||
sampler_1, sampler_2, sampler_3,
|
||||
scheduler_1, scheduler_2,
|
||||
numeric_values, prompts, auto_queue, unique_id=None):
|
||||
"""Create grid configuration from selections."""
|
||||
|
||||
# Collect enabled selections
|
||||
models = [m for m in [model_1, model_2, model_3, model_4, model_5] if m != "disabled"]
|
||||
vaes = [v for v in [vae_1, vae_2, vae_3] if v != "disabled"]
|
||||
loras = [l for l in [lora_1, lora_2, lora_3] if l != "disabled"]
|
||||
samplers = [s for s in [sampler_1, sampler_2, sampler_3] if s != "disabled"]
|
||||
schedulers = [s for s in [scheduler_1, scheduler_2] if s != "disabled"]
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
|
||||
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
|
||||
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts):
|
||||
"""Get values for a specific axis type."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type == "models":
|
||||
return models
|
||||
elif axis_type == "vaes":
|
||||
return vaes
|
||||
elif axis_type == "loras":
|
||||
return loras
|
||||
elif axis_type == "samplers":
|
||||
return samplers
|
||||
elif axis_type == "schedulers":
|
||||
return schedulers
|
||||
elif axis_type == "prompt":
|
||||
return [p.strip() for p in prompts.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, numeric_values)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Any]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""XYZ Plot Controller node implementation."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType, NUMERIC_DEFAULTS
|
||||
from ..utils.helpers import (
|
||||
parse_value_string, generate_axis_labels, calculate_grid_dimensions, create_unique_id
|
||||
)
|
||||
from .execution import execution_manager
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""Main configuration node for XYZ grid plotting."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
# X Axis configuration
|
||||
"x_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"x_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"x_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Y Axis configuration
|
||||
"y_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"y_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"y_label_prefix": ("STRING", {"default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
# Z Axis configuration (optional)
|
||||
"z_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"z_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"z_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Label formatting
|
||||
"include_param_name": ("BOOLEAN", {"default": True}),
|
||||
"value_only_labels": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "STRING", "STRING", "INT", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_value", "y_value", "z_value", "x_index", "y_index", "z_index", "batch_id")
|
||||
FUNCTION = "configure_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def __init__(self):
|
||||
self.unique_id = None # Set by ComfyUI
|
||||
|
||||
def configure_grid(self, x_axis_type, x_values, x_label_prefix,
|
||||
y_axis_type, y_values, y_label_prefix,
|
||||
z_axis_type="none", z_values="", z_label_prefix="",
|
||||
include_param_name=True, value_only_labels=False):
|
||||
"""Configure and prepare grid generation."""
|
||||
|
||||
# Parse axis types
|
||||
x_type = AxisType(x_axis_type) if x_axis_type != "none" else None
|
||||
y_type = AxisType(y_axis_type) if y_axis_type != "none" else None
|
||||
z_type = AxisType(z_axis_type) if z_axis_type != "none" else None
|
||||
|
||||
# Parse values for each axis
|
||||
x_vals = parse_value_string(x_values, x_type) if x_type else [""]
|
||||
y_vals = parse_value_string(y_values, y_type) if y_type else [""]
|
||||
z_vals = parse_value_string(z_values, z_type) if z_type else [""]
|
||||
|
||||
# Validate we have at least one axis configured
|
||||
if not x_type and not y_type:
|
||||
raise ValueError("At least one axis (X or Y) must be configured")
|
||||
|
||||
# Calculate grid dimensions
|
||||
dims = calculate_grid_dimensions(len(x_vals), len(y_vals), len(z_vals))
|
||||
|
||||
# Generate labels
|
||||
x_labels = self._generate_labels(x_vals, x_type, x_label_prefix, include_param_name, value_only_labels)
|
||||
y_labels = self._generate_labels(y_vals, y_type, y_label_prefix, include_param_name, value_only_labels)
|
||||
z_labels = self._generate_labels(z_vals, z_type, z_label_prefix, include_param_name, value_only_labels)
|
||||
|
||||
# Create batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Prepare grid configuration
|
||||
grid_config = {
|
||||
"batch_id": batch_id,
|
||||
"axes": {
|
||||
"x": {"type": x_type, "values": x_vals, "labels": x_labels},
|
||||
"y": {"type": y_type, "values": y_vals, "labels": y_labels},
|
||||
"z": {"type": z_type, "values": z_vals, "labels": z_labels},
|
||||
},
|
||||
"dimensions": dims,
|
||||
"total_images": dims["total_images"],
|
||||
"current_index": 0,
|
||||
}
|
||||
|
||||
# Get current values from execution manager
|
||||
x_val, y_val, z_val, x_idx, y_idx, z_idx = execution_manager.get_current_values(
|
||||
batch_id, x_vals, y_vals, z_vals
|
||||
)
|
||||
|
||||
# Format output values based on type
|
||||
x_output = self._format_output_value(x_val, x_type)
|
||||
y_output = self._format_output_value(y_val, y_type)
|
||||
z_output = self._format_output_value(z_val, z_type)
|
||||
|
||||
return (grid_config, x_output, y_output, z_output, x_idx, y_idx, z_idx, batch_id)
|
||||
|
||||
def _generate_labels(self, values: List[Any], axis_type: Optional[AxisType],
|
||||
prefix: str, include_param: bool, value_only: bool) -> List[str]:
|
||||
"""Generate labels for axis values."""
|
||||
if not values or not axis_type:
|
||||
return []
|
||||
|
||||
if value_only:
|
||||
# Just use values as labels
|
||||
return generate_axis_labels(values, axis_type, "")
|
||||
elif include_param and not prefix:
|
||||
# Use parameter name as prefix
|
||||
param_names = AxisType.display_names()
|
||||
prefix = param_names.get(axis_type, "") + ": "
|
||||
|
||||
return generate_axis_labels(values, axis_type, prefix)
|
||||
|
||||
def _format_output_value(self, value: Any, axis_type: Optional[AxisType]) -> str:
|
||||
"""Format value for output based on axis type."""
|
||||
if not axis_type:
|
||||
return ""
|
||||
|
||||
# Return appropriate type based on what nodes expect
|
||||
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
|
||||
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
|
||||
return str(value)
|
||||
else:
|
||||
# Numeric types - return as string but nodes can convert
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution for grid iterations."""
|
||||
# This ensures node re-executes for each grid cell
|
||||
return float("nan")
|
||||
@@ -0,0 +1,355 @@
|
||||
"""XYZ Plot Controller with Power Lora Loader-style dynamic widgets."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Union, Optional
|
||||
import folder_paths
|
||||
|
||||
from ..utils.helpers import create_unique_id
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Input that allows dynamic widget values from JavaScript."""
|
||||
|
||||
def __contains__(self, key):
|
||||
# Accept any key from JavaScript widgets
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Return a tuple that ComfyUI expects for input types
|
||||
# This allows the JavaScript to pass widget values
|
||||
return ("STRING", {"forceInput": False})
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with dynamic widget management."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
inputs = {
|
||||
"required": {
|
||||
# Axis configuration
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# Static inputs for numeric/prompt values
|
||||
"numeric_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
|
||||
}),
|
||||
|
||||
"prompt_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For prompts: enter each prompt on a new line"
|
||||
})
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
}
|
||||
}
|
||||
|
||||
# Use FlexibleOptionalInputType to accept dynamic widget values from JavaScript
|
||||
# But don't create an actual input connection
|
||||
inputs["optional"] = FlexibleOptionalInputType()
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type="none", y_type="none", z_type="none",
|
||||
auto_queue=True, numeric_values="", prompt_values="",
|
||||
unique_id=None, prompt=None, extra_pnginfo=None, **kwargs):
|
||||
"""Create grid configuration from dynamic selections."""
|
||||
|
||||
# Extract dynamic values from kwargs
|
||||
models = []
|
||||
vaes = []
|
||||
loras = []
|
||||
samplers = []
|
||||
schedulers = []
|
||||
|
||||
# Process all kwargs to find dynamic widgets
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith("x_") or key.startswith("y_") or key.startswith("z_"):
|
||||
# Handle dynamic widget values
|
||||
if isinstance(value, dict) and "on" in value and value["on"]:
|
||||
# Extract the resource type and axis
|
||||
parts = key.split("_")
|
||||
if len(parts) >= 3:
|
||||
axis = parts[0]
|
||||
resource_type = parts[1]
|
||||
|
||||
# Store the value based on type
|
||||
if resource_type == "models" and value.get("value") != "none":
|
||||
models.append(value["value"])
|
||||
elif resource_type == "vaes" and value.get("value") != "none":
|
||||
vaes.append(value["value"])
|
||||
elif resource_type == "loras" and value.get("value") != "none":
|
||||
# For loras, store both name and strength
|
||||
lora_data = {
|
||||
"name": value["value"],
|
||||
"strength": value.get("strength", 1.0)
|
||||
}
|
||||
loras.append(lora_data)
|
||||
elif resource_type == "samplers" and value.get("value") != "none":
|
||||
samplers.append(value["value"])
|
||||
elif resource_type == "schedulers" and value.get("value") != "none":
|
||||
schedulers.append(value["value"])
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"type": x_type,
|
||||
"labels": self._create_labels(x_type, x_parsed)
|
||||
},
|
||||
"y": {
|
||||
"type": y_type,
|
||||
"labels": self._create_labels(y_type, y_parsed)
|
||||
},
|
||||
"z": {
|
||||
"type": z_type,
|
||||
"labels": self._create_labels(z_type, z_parsed) if z_type != "none" else []
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"total_images": total_images,
|
||||
"x_count": x_count,
|
||||
"y_count": y_count,
|
||||
"z_count": z_count,
|
||||
"cols": x_count, # X axis forms columns
|
||||
"rows": y_count, # Y axis forms rows
|
||||
"grids_count": z_count # Z axis creates multiple grids
|
||||
},
|
||||
"total_images": total_images, # Keep for backward compatibility
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values - {x_parsed}")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
|
||||
"""Get values for a specific axis type."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type == "models":
|
||||
return models
|
||||
elif axis_type == "vaes":
|
||||
return vaes
|
||||
elif axis_type == "loras":
|
||||
return loras
|
||||
elif axis_type == "samplers":
|
||||
return samplers
|
||||
elif axis_type == "schedulers":
|
||||
return schedulers
|
||||
elif axis_type == "prompt":
|
||||
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, numeric_values)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
# For loras, return the name string
|
||||
if axis_type == "loras" and isinstance(value, dict):
|
||||
return (value.get("name", ""), 0, 0.0)
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
|
||||
def _create_labels(self, axis_type: str, values: list) -> list:
|
||||
"""Create human-readable labels for axis values."""
|
||||
labels = []
|
||||
for value in values:
|
||||
if axis_type == "prompt":
|
||||
# Truncate long prompts
|
||||
label = str(value)[:30] + "..." if len(str(value)) > 30 else str(value)
|
||||
elif axis_type in ["models", "vaes", "loras"]:
|
||||
# Use just the filename without path/extension for resources
|
||||
if isinstance(value, dict) and "name" in value:
|
||||
name = value["name"]
|
||||
else:
|
||||
name = str(value)
|
||||
# Remove extension and path
|
||||
label = name.split("/")[-1].split(".")[0]
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
# Format floats nicely
|
||||
label = f"{float(value):.1f}"
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
# Just show the integer
|
||||
label = str(int(value))
|
||||
elif axis_type in ["samplers", "schedulers"]:
|
||||
# Just use the name as-is
|
||||
label = str(value)
|
||||
else:
|
||||
# Default: convert to string
|
||||
label = str(value)
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
def _apply_lora(self, model, clip, lora_data: dict):
|
||||
"""Apply a lora to model and clip."""
|
||||
try:
|
||||
# Import LoraLoader from ComfyUI
|
||||
from nodes import LoraLoader
|
||||
import folder_paths
|
||||
|
||||
lora_name = lora_data.get("name")
|
||||
strength = lora_data.get("strength", 1.0)
|
||||
|
||||
if not lora_name:
|
||||
return model, clip
|
||||
|
||||
# Get the full path to the lora
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_path:
|
||||
print(f"[XYZ Grid] Warning: LoRA '{lora_name}' not found")
|
||||
return model, clip
|
||||
|
||||
# Apply the lora
|
||||
loader = LoraLoader()
|
||||
model, clip = loader.load_lora(model, clip, lora_name, strength, strength)
|
||||
|
||||
return model, clip
|
||||
except Exception as e:
|
||||
print(f"[XYZ Grid] Error applying LoRA: {e}")
|
||||
return model, clip
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Queue management for automated grid execution."""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedExecution:
|
||||
"""Represents a queued execution for grid generation."""
|
||||
execution_id: str
|
||||
batch_id: str
|
||||
iteration: int
|
||||
total_iterations: int
|
||||
x_value: Any
|
||||
y_value: Any
|
||||
z_value: Any
|
||||
x_index: int
|
||||
y_index: int
|
||||
z_index: int
|
||||
workflow_data: Dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"execution_id": self.execution_id,
|
||||
"batch_id": self.batch_id,
|
||||
"iteration": self.iteration,
|
||||
"total_iterations": self.total_iterations,
|
||||
"indices": {
|
||||
"x": self.x_index,
|
||||
"y": self.y_index,
|
||||
"z": self.z_index
|
||||
},
|
||||
"values": {
|
||||
"x": self.x_value,
|
||||
"y": self.y_value,
|
||||
"z": self.z_value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class GridQueueManager:
|
||||
"""Manages the execution queue for grid generation."""
|
||||
|
||||
def __init__(self):
|
||||
self.execution_queue: Dict[str, List[QueuedExecution]] = {} # batch_id -> executions
|
||||
self.active_batches: Dict[str, Dict] = {} # batch_id -> batch info
|
||||
self.completed_iterations: Dict[str, List[int]] = {} # batch_id -> completed iteration indices
|
||||
|
||||
def prepare_batch_executions(self, batch_id: str, grid_config: Dict,
|
||||
node_id: int, workflow: Dict) -> List[QueuedExecution]:
|
||||
"""Prepare all executions for a batch."""
|
||||
executions = []
|
||||
|
||||
x_values = grid_config["axes"]["x"]["values"]
|
||||
y_values = grid_config["axes"]["y"]["values"]
|
||||
z_values = grid_config["axes"]["z"]["values"]
|
||||
|
||||
total_iterations = len(x_values) * len(y_values) * len(z_values)
|
||||
iteration = 0
|
||||
|
||||
# Generate all combinations
|
||||
for z_idx, z_val in enumerate(z_values or [""]):
|
||||
for y_idx, y_val in enumerate(y_values or [""]):
|
||||
for x_idx, x_val in enumerate(x_values or [""]):
|
||||
execution = QueuedExecution(
|
||||
execution_id=str(uuid.uuid4()),
|
||||
batch_id=batch_id,
|
||||
iteration=iteration,
|
||||
total_iterations=total_iterations,
|
||||
x_value=x_val,
|
||||
y_value=y_val,
|
||||
z_value=z_val,
|
||||
x_index=x_idx,
|
||||
y_index=y_idx,
|
||||
z_index=z_idx,
|
||||
workflow_data=self._prepare_workflow(workflow, node_id, grid_config)
|
||||
)
|
||||
executions.append(execution)
|
||||
iteration += 1
|
||||
|
||||
# Store batch info
|
||||
self.execution_queue[batch_id] = executions
|
||||
self.active_batches[batch_id] = {
|
||||
"total_iterations": total_iterations,
|
||||
"grid_config": grid_config,
|
||||
"node_id": node_id
|
||||
}
|
||||
self.completed_iterations[batch_id] = []
|
||||
|
||||
return executions
|
||||
|
||||
def get_next_execution(self, batch_id: str) -> Optional[QueuedExecution]:
|
||||
"""Get the next execution for a batch."""
|
||||
if batch_id not in self.execution_queue:
|
||||
return None
|
||||
|
||||
executions = self.execution_queue[batch_id]
|
||||
completed = self.completed_iterations.get(batch_id, [])
|
||||
|
||||
# Find next uncompleted execution
|
||||
for execution in executions:
|
||||
if execution.iteration not in completed:
|
||||
return execution
|
||||
|
||||
return None
|
||||
|
||||
def mark_iteration_complete(self, batch_id: str, iteration: int):
|
||||
"""Mark an iteration as complete."""
|
||||
if batch_id not in self.completed_iterations:
|
||||
self.completed_iterations[batch_id] = []
|
||||
|
||||
if iteration not in self.completed_iterations[batch_id]:
|
||||
self.completed_iterations[batch_id].append(iteration)
|
||||
|
||||
def is_batch_complete(self, batch_id: str) -> bool:
|
||||
"""Check if all iterations for a batch are complete."""
|
||||
if batch_id not in self.active_batches:
|
||||
return True
|
||||
|
||||
total = self.active_batches[batch_id]["total_iterations"]
|
||||
completed = len(self.completed_iterations.get(batch_id, []))
|
||||
|
||||
return completed >= total
|
||||
|
||||
def cleanup_batch(self, batch_id: str):
|
||||
"""Clean up a completed batch."""
|
||||
if batch_id in self.execution_queue:
|
||||
del self.execution_queue[batch_id]
|
||||
if batch_id in self.active_batches:
|
||||
del self.active_batches[batch_id]
|
||||
if batch_id in self.completed_iterations:
|
||||
del self.completed_iterations[batch_id]
|
||||
|
||||
def _prepare_workflow(self, base_workflow: Dict, node_id: int, grid_config: Dict) -> Dict:
|
||||
"""Prepare workflow data for execution."""
|
||||
# This would modify the workflow to set appropriate values
|
||||
# For now, return a copy of the base workflow
|
||||
import copy
|
||||
return copy.deepcopy(base_workflow)
|
||||
|
||||
async def execute_batch_async(self, batch_id: str, api_client: Any):
|
||||
"""Execute all iterations for a batch asynchronously."""
|
||||
executions = self.execution_queue.get(batch_id, [])
|
||||
|
||||
for execution in executions:
|
||||
if execution.iteration in self.completed_iterations.get(batch_id, []):
|
||||
continue
|
||||
|
||||
# Queue the execution via ComfyUI API
|
||||
try:
|
||||
# This would use the actual ComfyUI API client
|
||||
# await api_client.queue_prompt(execution.workflow_data)
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error queuing execution {execution.execution_id}: {e}")
|
||||
|
||||
# Small delay between queuing to avoid overwhelming the system
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
# Global queue manager instance
|
||||
queue_manager = GridQueueManager()
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Simplified XYZ Plot Controller using native ComfyUI widgets."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType
|
||||
from ..utils.helpers import (
|
||||
get_available_models, get_available_vaes, get_available_loras,
|
||||
get_sampler_names, get_scheduler_names, parse_value_string,
|
||||
create_unique_id
|
||||
)
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""Simplified XYZ Plot Controller with native widgets."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# For file-based parameters, we'll use a special format in the values field
|
||||
axis_types = [
|
||||
"none",
|
||||
"model",
|
||||
"vae",
|
||||
"lora",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# X Axis
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"x_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Y Axis
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"y_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Z Axis (optional)
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
"z_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, x_values, y_type, y_values, z_type, z_values, auto_queue, unique_id=None):
|
||||
"""Create grid configuration."""
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._parse_axis_values(x_type, x_values) if x_type != "none" else []
|
||||
y_parsed = self._parse_axis_values(y_type, y_values) if y_type != "none" else []
|
||||
z_parsed = self._parse_axis_values(z_type, z_values) if z_type != "none" else []
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Store grid data for execution
|
||||
if hasattr(self, '_grids'):
|
||||
self._grids[batch_id] = grid_data
|
||||
else:
|
||||
self._grids = {batch_id: grid_data}
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _parse_axis_values(self, axis_type: str, values_str: str) -> List[Any]:
|
||||
"""Parse axis values based on type."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str and axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
if axis_type == "prompt":
|
||||
# For prompts, split by newline instead of comma
|
||||
return [v.strip() for v in values_str.split("\n") if v.strip()]
|
||||
else:
|
||||
# For everything else, split by comma
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"model": "",
|
||||
"vae": "Automatic",
|
||||
"lora": "None",
|
||||
"sampler": "euler",
|
||||
"scheduler": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["model", "vae", "lora", "sampler", "scheduler", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
XYZPlotControllerAdvanced = XYZPlotController
|
||||
@@ -0,0 +1,257 @@
|
||||
"""XYZ Plot Controller with Power Lora Loader-style dynamic widget management."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Union, Optional
|
||||
|
||||
# Remove complex imports to avoid circular dependencies
|
||||
import uuid
|
||||
|
||||
# Import folder_paths only when needed
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
|
||||
|
||||
def create_unique_id() -> str:
|
||||
"""Create unique ID for a grid batch."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
"""A special class that is always equal in not equal comparisons."""
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""
|
||||
A special class to make flexible nodes that pass data to our python handlers.
|
||||
This allows dynamic inputs from the JavaScript side.
|
||||
"""
|
||||
def __init__(self, input_type):
|
||||
super().__init__()
|
||||
self.type = input_type
|
||||
|
||||
def __contains__(self, key):
|
||||
# Always return True to accept any input
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Return a tuple that ComfyUI expects for input types
|
||||
return (self.type,)
|
||||
|
||||
|
||||
# Create any_type instance
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with dynamic widget management inspired by Power Lora Loader."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# Axis configuration
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
# Accept any number of dynamic inputs from JavaScript
|
||||
"optional": {},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type="none", y_type="none", z_type="none", auto_queue=True, unique_id=None, **kwargs):
|
||||
"""Create grid configuration from dynamic selections."""
|
||||
|
||||
# Initialize collections for each axis
|
||||
axis_values = {
|
||||
"x": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
|
||||
"y": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
|
||||
"z": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""}
|
||||
}
|
||||
|
||||
# Process all kwargs to extract dynamic widget values
|
||||
for key, value in kwargs.items():
|
||||
# Handle dynamic model/vae/lora/sampler/scheduler widgets
|
||||
# Format: x_models_1, y_vaes_2, etc.
|
||||
parts = key.split("_")
|
||||
if len(parts) >= 3 and parts[0] in ["x", "y", "z"]:
|
||||
axis = parts[0]
|
||||
widget_type = parts[1]
|
||||
|
||||
if widget_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
|
||||
if isinstance(value, dict) and value.get("on", True) and value.get("value"):
|
||||
axis_values[axis][widget_type].append(value["value"])
|
||||
elif widget_type == "numeric":
|
||||
axis_values[axis]["numeric"] = value
|
||||
elif widget_type == "prompt":
|
||||
axis_values[axis]["prompt"] = value
|
||||
|
||||
# Get parsed values for each axis based on type
|
||||
x_parsed = self._get_axis_values(x_type, axis_values["x"])
|
||||
y_parsed = self._get_axis_values(y_type, axis_values["y"])
|
||||
z_parsed = self._get_axis_values(z_type, axis_values["z"])
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _get_axis_values(self, axis_type: str, axis_data: Dict) -> List[Any]:
|
||||
"""Get values for a specific axis type from collected data."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
|
||||
return axis_data.get(axis_type, [])
|
||||
elif axis_type == "prompt":
|
||||
prompt_text = axis_data.get("prompt", "")
|
||||
return [p.strip() for p in prompt_text.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, axis_data.get("numeric", ""))
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""XYZ Prompt module."""
|
||||
|
||||
from .node import XYZPrompt
|
||||
|
||||
__all__ = ["XYZPrompt"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""XYZ Prompt node for managing multiple prompt variations."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Special input type that accepts any dynamic widget values from JavaScript."""
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Accept string inputs for dynamic prompts
|
||||
return ("STRING", {"multiline": True, "forceInput": False})
|
||||
|
||||
|
||||
class XYZPrompt:
|
||||
"""XYZ Prompt node for creating prompt variations for grid generation."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"include_negative": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Include negative prompt inputs"
|
||||
}),
|
||||
"repeat_negative": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Use the first negative prompt for all variations"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType()
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_PROMPTS", "STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("prompts", "positive", "negative", "count")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "process_prompts"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def process_prompts(self, include_negative=True, repeat_negative=True, **kwargs):
|
||||
"""Process all prompt inputs and return them formatted for XYZ grid.
|
||||
|
||||
Args:
|
||||
include_negative: Whether to include negative prompts
|
||||
repeat_negative: Whether to use first negative for all prompts
|
||||
**kwargs: Dynamic prompt inputs from JavaScript
|
||||
|
||||
Returns:
|
||||
Tuple of (prompts dict, first positive, first negative, count)
|
||||
"""
|
||||
# Debug: Log all received kwargs
|
||||
print(f"XYZPrompt.process_prompts - Received kwargs: {kwargs}")
|
||||
print(f"XYZPrompt.process_prompts - include_negative: {include_negative}, repeat_negative: {repeat_negative}")
|
||||
|
||||
prompts = []
|
||||
first_negative = ""
|
||||
|
||||
# Collect all prompt pairs from kwargs
|
||||
prompt_index = 0
|
||||
while True:
|
||||
pos_key = f"positive_{prompt_index}"
|
||||
neg_key = f"negative_{prompt_index}"
|
||||
|
||||
if pos_key not in kwargs:
|
||||
break
|
||||
|
||||
positive = kwargs.get(pos_key, "")
|
||||
|
||||
# Handle negative prompt based on settings
|
||||
if include_negative:
|
||||
if repeat_negative:
|
||||
# Use first negative for all
|
||||
if prompt_index == 0:
|
||||
first_negative = kwargs.get(neg_key, "")
|
||||
negative = first_negative
|
||||
else:
|
||||
# Each prompt has its own negative
|
||||
negative = kwargs.get(neg_key, "")
|
||||
else:
|
||||
negative = ""
|
||||
|
||||
if positive: # Only add if positive prompt exists
|
||||
prompts.append({
|
||||
"positive": positive,
|
||||
"negative": negative
|
||||
})
|
||||
|
||||
prompt_index += 1
|
||||
|
||||
# Prepare outputs
|
||||
first_positive = prompts[0]["positive"] if prompts else ""
|
||||
first_negative = prompts[0]["negative"] if prompts else ""
|
||||
|
||||
result = {
|
||||
"prompts": prompts,
|
||||
"include_negative": include_negative,
|
||||
"count": len(prompts)
|
||||
}
|
||||
|
||||
# Return for UI display
|
||||
return {
|
||||
"ui": {
|
||||
"prompts": result
|
||||
},
|
||||
"result": (result, first_positive, first_negative, len(prompts))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# XYZ Grid utilities
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Model and resource caching for performance optimization."""
|
||||
|
||||
import gc
|
||||
import torch
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from collections import OrderedDict
|
||||
import psutil
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
import comfy.model_management
|
||||
except ImportError:
|
||||
# Not in ComfyUI environment
|
||||
folder_paths = None
|
||||
comfy = None
|
||||
|
||||
|
||||
class ModelCacheManager:
|
||||
"""Manages model caching for XYZ grid generation."""
|
||||
|
||||
def __init__(self, max_cache_size: int = 3):
|
||||
"""Initialize cache manager.
|
||||
|
||||
Args:
|
||||
max_cache_size: Maximum number of models to keep in cache
|
||||
"""
|
||||
self.max_cache_size = max_cache_size
|
||||
self.model_cache: OrderedDict[str, Any] = OrderedDict()
|
||||
self.vae_cache: OrderedDict[str, Any] = OrderedDict()
|
||||
self.lora_cache: OrderedDict[str, Any] = OrderedDict()
|
||||
self.memory_threshold = 0.85 # Use up to 85% of VRAM
|
||||
|
||||
def get_available_memory(self) -> Tuple[int, int]:
|
||||
"""Get available GPU memory in bytes.
|
||||
|
||||
Returns:
|
||||
Tuple of (free_memory, total_memory)
|
||||
"""
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
free, total = torch.cuda.mem_get_info()
|
||||
return free, total
|
||||
else:
|
||||
# Fallback to system RAM
|
||||
mem = psutil.virtual_memory()
|
||||
return mem.available, mem.total
|
||||
except:
|
||||
return 0, 0
|
||||
|
||||
def should_cache(self, model_size_estimate: int = 2 * 1024**3) -> bool:
|
||||
"""Check if we should cache based on available memory.
|
||||
|
||||
Args:
|
||||
model_size_estimate: Estimated model size in bytes (default 2GB)
|
||||
|
||||
Returns:
|
||||
True if caching is safe
|
||||
"""
|
||||
free, total = self.get_available_memory()
|
||||
if total == 0:
|
||||
return False
|
||||
|
||||
# Check if we have enough free memory
|
||||
usage_after_cache = (total - free + model_size_estimate) / total
|
||||
return usage_after_cache < self.memory_threshold
|
||||
|
||||
def cache_model(self, model_name: str, model: Any) -> bool:
|
||||
"""Cache a model if memory allows.
|
||||
|
||||
Args:
|
||||
model_name: Name/path of the model
|
||||
model: The loaded model object
|
||||
|
||||
Returns:
|
||||
True if cached successfully
|
||||
"""
|
||||
if not self.should_cache():
|
||||
return False
|
||||
|
||||
# Remove oldest if cache is full
|
||||
if len(self.model_cache) >= self.max_cache_size:
|
||||
oldest = next(iter(self.model_cache))
|
||||
self.uncache_model(oldest)
|
||||
|
||||
self.model_cache[model_name] = model
|
||||
self.model_cache.move_to_end(model_name) # Mark as recently used
|
||||
return True
|
||||
|
||||
def get_cached_model(self, model_name: str) -> Optional[Any]:
|
||||
"""Get a model from cache if available.
|
||||
|
||||
Args:
|
||||
model_name: Name/path of the model
|
||||
|
||||
Returns:
|
||||
Cached model or None
|
||||
"""
|
||||
if model_name in self.model_cache:
|
||||
self.model_cache.move_to_end(model_name) # Mark as recently used
|
||||
return self.model_cache[model_name]
|
||||
return None
|
||||
|
||||
def uncache_model(self, model_name: str) -> None:
|
||||
"""Remove a model from cache and free memory.
|
||||
|
||||
Args:
|
||||
model_name: Name/path of the model to remove
|
||||
"""
|
||||
if model_name in self.model_cache:
|
||||
model = self.model_cache.pop(model_name)
|
||||
# Attempt to free GPU memory
|
||||
if hasattr(model, 'to'):
|
||||
try:
|
||||
model.to('cpu')
|
||||
except:
|
||||
pass
|
||||
del model
|
||||
|
||||
# Force garbage collection
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def cache_vae(self, vae_name: str, vae: Any) -> bool:
|
||||
"""Cache a VAE model."""
|
||||
if not self.should_cache(model_size_estimate=500 * 1024**2): # VAEs are smaller
|
||||
return False
|
||||
|
||||
if len(self.vae_cache) >= self.max_cache_size:
|
||||
oldest = next(iter(self.vae_cache))
|
||||
self.uncache_vae(oldest)
|
||||
|
||||
self.vae_cache[vae_name] = vae
|
||||
self.vae_cache.move_to_end(vae_name)
|
||||
return True
|
||||
|
||||
def get_cached_vae(self, vae_name: str) -> Optional[Any]:
|
||||
"""Get a VAE from cache."""
|
||||
if vae_name in self.vae_cache:
|
||||
self.vae_cache.move_to_end(vae_name)
|
||||
return self.vae_cache[vae_name]
|
||||
return None
|
||||
|
||||
def uncache_vae(self, vae_name: str) -> None:
|
||||
"""Remove a VAE from cache."""
|
||||
if vae_name in self.vae_cache:
|
||||
vae = self.vae_cache.pop(vae_name)
|
||||
del vae
|
||||
gc.collect()
|
||||
|
||||
def optimize_for_grid(self, model_names: List[str], vae_names: List[str]) -> Dict[str, Any]:
|
||||
"""Pre-optimize caching for a grid generation.
|
||||
|
||||
Args:
|
||||
model_names: List of models that will be used
|
||||
vae_names: List of VAEs that will be used
|
||||
|
||||
Returns:
|
||||
Dict with optimization suggestions
|
||||
"""
|
||||
suggestions = {
|
||||
"cache_all_models": False,
|
||||
"cache_all_vaes": False,
|
||||
"recommended_order": [],
|
||||
"memory_sufficient": True
|
||||
}
|
||||
|
||||
# Estimate total memory needed
|
||||
model_count = len(set(model_names))
|
||||
vae_count = len(set(vae_names))
|
||||
|
||||
estimated_model_size = model_count * 2 * 1024**3 # 2GB per model
|
||||
estimated_vae_size = vae_count * 500 * 1024**2 # 500MB per VAE
|
||||
total_needed = estimated_model_size + estimated_vae_size
|
||||
|
||||
free, total = self.get_available_memory()
|
||||
|
||||
if free > total_needed * 1.2: # 20% safety margin
|
||||
suggestions["cache_all_models"] = True
|
||||
suggestions["cache_all_vaes"] = True
|
||||
elif free > estimated_model_size * 1.2:
|
||||
suggestions["cache_all_models"] = True
|
||||
else:
|
||||
suggestions["memory_sufficient"] = False
|
||||
|
||||
# Suggest loading order to minimize switches
|
||||
model_order = self._optimize_load_order(model_names)
|
||||
suggestions["recommended_order"] = model_order
|
||||
|
||||
return suggestions
|
||||
|
||||
def _optimize_load_order(self, items: List[str]) -> List[str]:
|
||||
"""Optimize loading order to minimize model switches.
|
||||
|
||||
Args:
|
||||
items: List of items (may have duplicates)
|
||||
|
||||
Returns:
|
||||
Optimized order
|
||||
"""
|
||||
# Group consecutive items together
|
||||
optimized = []
|
||||
seen = set()
|
||||
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
# Add all instances of this item consecutively
|
||||
count = items.count(item)
|
||||
optimized.extend([item] * count)
|
||||
seen.add(item)
|
||||
|
||||
return optimized
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all caches and free memory."""
|
||||
# Clear model cache
|
||||
for model_name in list(self.model_cache.keys()):
|
||||
self.uncache_model(model_name)
|
||||
|
||||
# Clear VAE cache
|
||||
for vae_name in list(self.vae_cache.keys()):
|
||||
self.uncache_vae(vae_name)
|
||||
|
||||
# Clear LoRA cache
|
||||
self.lora_cache.clear()
|
||||
|
||||
# Force cleanup
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get current cache statistics."""
|
||||
free, total = self.get_available_memory()
|
||||
|
||||
return {
|
||||
"models_cached": len(self.model_cache),
|
||||
"vaes_cached": len(self.vae_cache),
|
||||
"loras_cached": len(self.lora_cache),
|
||||
"memory_free": free,
|
||||
"memory_total": total,
|
||||
"memory_usage": (total - free) / total if total > 0 else 0,
|
||||
"cache_names": {
|
||||
"models": list(self.model_cache.keys()),
|
||||
"vaes": list(self.vae_cache.keys()),
|
||||
"loras": list(self.lora_cache.keys())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Global cache manager instance
|
||||
cache_manager = ModelCacheManager()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Constants for XYZ Grid nodes."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AxisType(Enum):
|
||||
"""Available parameter types for grid axes."""
|
||||
NONE = "none"
|
||||
MODEL = "model"
|
||||
SAMPLER = "sampler"
|
||||
SCHEDULER = "scheduler"
|
||||
CFG_SCALE = "cfg_scale"
|
||||
STEPS = "steps"
|
||||
CLIP_SKIP = "clip_skip"
|
||||
VAE = "vae"
|
||||
LORA = "lora"
|
||||
PROMPT = "prompt"
|
||||
SEED = "seed"
|
||||
FLUX_GUIDANCE = "flux_guidance"
|
||||
DENOISE = "denoise"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Get list of choices for ComfyUI dropdown."""
|
||||
return [member.value for member in cls]
|
||||
|
||||
@classmethod
|
||||
def display_names(cls):
|
||||
"""Get display names for UI."""
|
||||
return {
|
||||
cls.NONE: "None",
|
||||
cls.MODEL: "Model/Checkpoint",
|
||||
cls.SAMPLER: "Sampler",
|
||||
cls.SCHEDULER: "Scheduler",
|
||||
cls.CFG_SCALE: "CFG Scale",
|
||||
cls.STEPS: "Steps",
|
||||
cls.CLIP_SKIP: "Clip Skip",
|
||||
cls.VAE: "VAE",
|
||||
cls.LORA: "LoRA",
|
||||
cls.PROMPT: "Prompt",
|
||||
cls.SEED: "Seed",
|
||||
cls.FLUX_GUIDANCE: "Flux Guidance",
|
||||
cls.DENOISE: "Denoise",
|
||||
}
|
||||
|
||||
|
||||
# Default values for numeric parameters
|
||||
NUMERIC_DEFAULTS = {
|
||||
AxisType.CFG_SCALE: {"default": 7.0, "min": 0.0, "max": 30.0, "step": 0.5},
|
||||
AxisType.STEPS: {"default": 20, "min": 1, "max": 150, "step": 1},
|
||||
AxisType.CLIP_SKIP: {"default": 1, "min": 1, "max": 12, "step": 1},
|
||||
AxisType.SEED: {"default": 0, "min": 0, "max": 0xffffffffffffffff},
|
||||
AxisType.FLUX_GUIDANCE: {"default": 3.5, "min": 0.0, "max": 10.0, "step": 0.1},
|
||||
AxisType.DENOISE: {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05},
|
||||
}
|
||||
|
||||
# Grid styling defaults
|
||||
GRID_DEFAULTS = {
|
||||
"font_size": 20,
|
||||
"grid_gap": 10,
|
||||
"label_height": 30,
|
||||
"label_color": (255, 255, 255),
|
||||
"label_bg_color": (0, 0, 0, 180),
|
||||
"max_label_length": 30,
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Value converters for different parameter types."""
|
||||
|
||||
from typing import Any, Union, List, Optional
|
||||
from .constants import AxisType
|
||||
|
||||
|
||||
class ParameterConverter:
|
||||
"""Converts axis values to appropriate types for ComfyUI nodes."""
|
||||
|
||||
@staticmethod
|
||||
def convert_value(value: Any, axis_type: AxisType) -> Any:
|
||||
"""Convert a value based on its axis type.
|
||||
|
||||
Args:
|
||||
value: Raw value from axis configuration
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Converted value suitable for ComfyUI node input
|
||||
"""
|
||||
if not axis_type or axis_type == AxisType.NONE:
|
||||
return value
|
||||
|
||||
# String-based parameters
|
||||
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
|
||||
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
|
||||
return str(value)
|
||||
|
||||
# Integer parameters
|
||||
elif axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
# Float parameters
|
||||
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def format_for_display(value: Any, axis_type: AxisType) -> str:
|
||||
"""Format a value for display in labels.
|
||||
|
||||
Args:
|
||||
value: Value to format
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Formatted string for display
|
||||
"""
|
||||
if axis_type == AxisType.MODEL:
|
||||
# Remove extension and path
|
||||
import os
|
||||
return os.path.splitext(os.path.basename(str(value)))[0]
|
||||
|
||||
elif axis_type == AxisType.PROMPT:
|
||||
# Truncate long prompts
|
||||
s = str(value)
|
||||
return s[:25] + "..." if len(s) > 25 else s
|
||||
|
||||
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
|
||||
# Format floats nicely
|
||||
return f"{float(value):.1f}"
|
||||
|
||||
elif axis_type == AxisType.SEED:
|
||||
# Format large numbers
|
||||
return f"{int(value):,}"
|
||||
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def get_output_type(axis_type: AxisType) -> str:
|
||||
"""Get the ComfyUI output type for an axis type.
|
||||
|
||||
Args:
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
ComfyUI type string (e.g., "STRING", "INT", "FLOAT")
|
||||
"""
|
||||
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
|
||||
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
|
||||
return "STRING"
|
||||
|
||||
elif axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
return "INT"
|
||||
|
||||
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
|
||||
return "FLOAT"
|
||||
|
||||
return "STRING"
|
||||
|
||||
@staticmethod
|
||||
def validate_value(value: Any, axis_type: AxisType) -> tuple[bool, Optional[str]]:
|
||||
"""Validate a value for an axis type.
|
||||
|
||||
Args:
|
||||
value: Value to validate
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP):
|
||||
try:
|
||||
val = int(float(value))
|
||||
if val < 1:
|
||||
return False, f"Value must be positive (got {val})"
|
||||
except:
|
||||
return False, f"Invalid integer value: {value}"
|
||||
|
||||
elif axis_type == AxisType.CFG_SCALE:
|
||||
try:
|
||||
val = float(value)
|
||||
if val < 0:
|
||||
return False, f"CFG scale must be non-negative (got {val})"
|
||||
except:
|
||||
return False, f"Invalid float value: {value}"
|
||||
|
||||
elif axis_type == AxisType.DENOISE:
|
||||
try:
|
||||
val = float(value)
|
||||
if not 0 <= val <= 1:
|
||||
return False, f"Denoise must be between 0 and 1 (got {val})"
|
||||
except:
|
||||
return False, f"Invalid float value: {value}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class OutputConnector:
|
||||
"""Handles connecting XYZ outputs to various node inputs."""
|
||||
|
||||
@staticmethod
|
||||
def get_connection_info(axis_type: AxisType) -> dict:
|
||||
"""Get information about how to connect this axis type.
|
||||
|
||||
Args:
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Dict with connection information
|
||||
"""
|
||||
connection_map = {
|
||||
AxisType.MODEL: {
|
||||
"target_node": "CheckpointLoaderSimple",
|
||||
"target_input": "ckpt_name",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.VAE: {
|
||||
"target_node": "VAELoader",
|
||||
"target_input": "vae_name",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.SAMPLER: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "sampler_name",
|
||||
"type": "combo"
|
||||
},
|
||||
AxisType.SCHEDULER: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "scheduler",
|
||||
"type": "combo"
|
||||
},
|
||||
AxisType.CFG_SCALE: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "cfg",
|
||||
"type": "FLOAT"
|
||||
},
|
||||
AxisType.STEPS: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "steps",
|
||||
"type": "INT"
|
||||
},
|
||||
AxisType.SEED: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "seed",
|
||||
"type": "INT"
|
||||
},
|
||||
AxisType.DENOISE: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "denoise",
|
||||
"type": "FLOAT"
|
||||
},
|
||||
AxisType.CLIP_SKIP: {
|
||||
"target_node": "CLIPSetLastLayer",
|
||||
"target_input": "stop_at_clip_layer",
|
||||
"type": "INT"
|
||||
},
|
||||
AxisType.LORA: {
|
||||
"target_node": "LoraLoader",
|
||||
"target_input": "lora_name",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.PROMPT: {
|
||||
"target_node": "CLIPTextEncode",
|
||||
"target_input": "text",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.FLUX_GUIDANCE: {
|
||||
"target_node": "FluxGuidance", # Hypothetical node
|
||||
"target_input": "guidance",
|
||||
"type": "FLOAT"
|
||||
}
|
||||
}
|
||||
|
||||
return connection_map.get(axis_type, {
|
||||
"target_node": "Unknown",
|
||||
"target_input": "value",
|
||||
"type": "STRING"
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Helper utilities for XYZ Grid nodes."""
|
||||
|
||||
import os
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from .constants import AxisType, NUMERIC_DEFAULTS
|
||||
|
||||
|
||||
def get_available_models() -> List[str]:
|
||||
"""Get list of available checkpoint models."""
|
||||
try:
|
||||
import folder_paths
|
||||
model_dir = folder_paths.get_folder_paths("checkpoints")[0]
|
||||
models = []
|
||||
for file in os.listdir(model_dir):
|
||||
if file.endswith(('.ckpt', '.safetensors', '.pt', '.pth')):
|
||||
models.append(file)
|
||||
return sorted(models)
|
||||
except:
|
||||
return ["No models found"]
|
||||
|
||||
|
||||
def get_available_vaes() -> List[str]:
|
||||
"""Get list of available VAE models."""
|
||||
try:
|
||||
import folder_paths
|
||||
vae_dir = folder_paths.get_folder_paths("vae")[0]
|
||||
vaes = ["Automatic"]
|
||||
for file in os.listdir(vae_dir):
|
||||
if file.endswith(('.ckpt', '.safetensors', '.pt', '.pth')):
|
||||
vaes.append(file)
|
||||
return vaes
|
||||
except:
|
||||
return ["Automatic"]
|
||||
|
||||
|
||||
def get_available_loras() -> List[str]:
|
||||
"""Get list of available LoRA models."""
|
||||
try:
|
||||
import folder_paths
|
||||
lora_dir = folder_paths.get_folder_paths("loras")[0]
|
||||
loras = ["None"]
|
||||
for file in os.listdir(lora_dir):
|
||||
if file.endswith(('.safetensors', '.pt', '.pth')):
|
||||
loras.append(file)
|
||||
return loras
|
||||
except:
|
||||
return ["None"]
|
||||
|
||||
|
||||
def get_sampler_names() -> List[str]:
|
||||
"""Get list of available sampler names."""
|
||||
try:
|
||||
import nodes
|
||||
return nodes.KSampler.SAMPLERS
|
||||
except:
|
||||
# Fallback list of common samplers
|
||||
return ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
|
||||
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
|
||||
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc", "uni_pc_bh2"]
|
||||
|
||||
|
||||
def get_scheduler_names() -> List[str]:
|
||||
"""Get list of available scheduler names."""
|
||||
try:
|
||||
import nodes
|
||||
return nodes.KSampler.SCHEDULERS
|
||||
except:
|
||||
# Fallback list
|
||||
return ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]
|
||||
|
||||
|
||||
def parse_value_string(value_str: str, axis_type: AxisType) -> List[Any]:
|
||||
"""Parse a string of values based on axis type.
|
||||
|
||||
Args:
|
||||
value_str: String containing values (comma-separated or range syntax)
|
||||
axis_type: Type of parameter to parse for
|
||||
|
||||
Returns:
|
||||
List of parsed values
|
||||
"""
|
||||
if not value_str or not value_str.strip():
|
||||
return []
|
||||
|
||||
values = []
|
||||
|
||||
# Handle numeric types with range syntax
|
||||
if axis_type in NUMERIC_DEFAULTS:
|
||||
# Check for range syntax (start:stop:step)
|
||||
if ':' in value_str:
|
||||
parts = value_str.split(':')
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0 if axis_type == AxisType.CFG_SCALE else 1
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError(f"Invalid range syntax: {value_str}")
|
||||
|
||||
# Generate range values
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
else:
|
||||
# Parse comma-separated values
|
||||
for val in value_str.split(','):
|
||||
val = val.strip()
|
||||
if val:
|
||||
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
values.append(int(val))
|
||||
else:
|
||||
values.append(float(val))
|
||||
else:
|
||||
# String-based parameters (split by comma)
|
||||
values = [v.strip() for v in value_str.split(',') if v.strip()]
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def generate_axis_labels(values: List[Any], axis_type: AxisType, prefix: str = "") -> List[str]:
|
||||
"""Generate labels for axis values.
|
||||
|
||||
Args:
|
||||
values: List of axis values
|
||||
axis_type: Type of parameter
|
||||
prefix: Optional prefix for labels
|
||||
|
||||
Returns:
|
||||
List of label strings
|
||||
"""
|
||||
labels = []
|
||||
|
||||
for value in values:
|
||||
if axis_type == AxisType.MODEL:
|
||||
# Strip extension and path for models
|
||||
label = os.path.splitext(os.path.basename(str(value)))[0]
|
||||
elif axis_type == AxisType.PROMPT:
|
||||
# Truncate long prompts
|
||||
label = str(value)[:30] + "..." if len(str(value)) > 30 else str(value)
|
||||
else:
|
||||
label = str(value)
|
||||
|
||||
if prefix:
|
||||
label = f"{prefix}{label}"
|
||||
|
||||
labels.append(label)
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def calculate_grid_dimensions(x_count: int, y_count: int, z_count: int = 1) -> Dict[str, int]:
|
||||
"""Calculate total images and grid dimensions.
|
||||
|
||||
Args:
|
||||
x_count: Number of X axis values
|
||||
y_count: Number of Y axis values
|
||||
z_count: Number of Z axis values (default 1)
|
||||
|
||||
Returns:
|
||||
Dict with total_images, grids_count, cols, rows
|
||||
"""
|
||||
total_images = x_count * y_count * z_count
|
||||
grids_count = z_count if z_count > 0 else 1
|
||||
|
||||
return {
|
||||
"total_images": total_images,
|
||||
"grids_count": grids_count,
|
||||
"cols": x_count,
|
||||
"rows": y_count,
|
||||
}
|
||||
|
||||
|
||||
def create_unique_id() -> str:
|
||||
"""Create unique ID for a grid batch."""
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Progress tracking and preview capabilities for XYZ grids."""
|
||||
|
||||
import time
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridProgress:
|
||||
"""Tracks progress for a single grid generation."""
|
||||
batch_id: str
|
||||
total_images: int
|
||||
completed_images: int = 0
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
current_labels: Dict[str, str] = field(default_factory=dict)
|
||||
preview_images: List[Any] = field(default_factory=list)
|
||||
status: str = "initializing" # initializing, running, completed, error
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def progress_percent(self) -> float:
|
||||
"""Get progress as percentage."""
|
||||
if self.total_images == 0:
|
||||
return 0.0
|
||||
return (self.completed_images / self.total_images) * 100
|
||||
|
||||
@property
|
||||
def elapsed_time(self) -> float:
|
||||
"""Get elapsed time in seconds."""
|
||||
end = self.end_time or time.time()
|
||||
return end - self.start_time
|
||||
|
||||
@property
|
||||
def estimated_remaining(self) -> Optional[float]:
|
||||
"""Estimate remaining time in seconds."""
|
||||
if self.completed_images == 0:
|
||||
return None
|
||||
|
||||
avg_time_per_image = self.elapsed_time / self.completed_images
|
||||
remaining_images = self.total_images - self.completed_images
|
||||
return avg_time_per_image * remaining_images
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"batch_id": self.batch_id,
|
||||
"total_images": self.total_images,
|
||||
"completed_images": self.completed_images,
|
||||
"progress_percent": round(self.progress_percent, 1),
|
||||
"elapsed_time": round(self.elapsed_time, 1),
|
||||
"estimated_remaining": round(self.estimated_remaining, 1) if self.estimated_remaining else None,
|
||||
"current_labels": self.current_labels,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"preview_count": len(self.preview_images)
|
||||
}
|
||||
|
||||
|
||||
class ProgressTracker:
|
||||
"""Manages progress tracking for all grid generations."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_grids: Dict[str, GridProgress] = {}
|
||||
self.completed_grids: List[GridProgress] = []
|
||||
self.progress_callbacks: List[Callable] = []
|
||||
self.websocket_handler = None
|
||||
|
||||
def start_grid(self, batch_id: str, total_images: int) -> GridProgress:
|
||||
"""Start tracking a new grid generation."""
|
||||
progress = GridProgress(
|
||||
batch_id=batch_id,
|
||||
total_images=total_images,
|
||||
status="running"
|
||||
)
|
||||
self.active_grids[batch_id] = progress
|
||||
self._notify_progress(progress)
|
||||
return progress
|
||||
|
||||
def update_progress(self, batch_id: str, completed: int = None,
|
||||
current_labels: Dict[str, str] = None,
|
||||
preview_image: Any = None) -> Optional[GridProgress]:
|
||||
"""Update progress for a grid."""
|
||||
if batch_id not in self.active_grids:
|
||||
return None
|
||||
|
||||
progress = self.active_grids[batch_id]
|
||||
|
||||
if completed is not None:
|
||||
progress.completed_images = completed
|
||||
else:
|
||||
progress.completed_images += 1
|
||||
|
||||
if current_labels:
|
||||
progress.current_labels = current_labels
|
||||
|
||||
if preview_image is not None:
|
||||
progress.preview_images.append(preview_image)
|
||||
# Keep only last N previews to save memory
|
||||
if len(progress.preview_images) > 5:
|
||||
progress.preview_images.pop(0)
|
||||
|
||||
self._notify_progress(progress)
|
||||
|
||||
# Check if completed
|
||||
if progress.completed_images >= progress.total_images:
|
||||
self.complete_grid(batch_id)
|
||||
|
||||
return progress
|
||||
|
||||
def complete_grid(self, batch_id: str) -> Optional[GridProgress]:
|
||||
"""Mark a grid as completed."""
|
||||
if batch_id not in self.active_grids:
|
||||
return None
|
||||
|
||||
progress = self.active_grids[batch_id]
|
||||
progress.status = "completed"
|
||||
progress.end_time = time.time()
|
||||
|
||||
# Move to completed list
|
||||
self.completed_grids.append(progress)
|
||||
del self.active_grids[batch_id]
|
||||
|
||||
# Keep only last N completed grids
|
||||
if len(self.completed_grids) > 10:
|
||||
self.completed_grids.pop(0)
|
||||
|
||||
self._notify_progress(progress)
|
||||
return progress
|
||||
|
||||
def error_grid(self, batch_id: str, error_message: str) -> Optional[GridProgress]:
|
||||
"""Mark a grid as errored."""
|
||||
if batch_id not in self.active_grids:
|
||||
return None
|
||||
|
||||
progress = self.active_grids[batch_id]
|
||||
progress.status = "error"
|
||||
progress.error_message = error_message
|
||||
progress.end_time = time.time()
|
||||
|
||||
# Move to completed list (with error status)
|
||||
self.completed_grids.append(progress)
|
||||
del self.active_grids[batch_id]
|
||||
|
||||
self._notify_progress(progress)
|
||||
return progress
|
||||
|
||||
def get_progress(self, batch_id: str) -> Optional[GridProgress]:
|
||||
"""Get progress for a specific grid."""
|
||||
if batch_id in self.active_grids:
|
||||
return self.active_grids[batch_id]
|
||||
|
||||
# Check completed grids
|
||||
for grid in self.completed_grids:
|
||||
if grid.batch_id == batch_id:
|
||||
return grid
|
||||
|
||||
return None
|
||||
|
||||
def get_all_active(self) -> List[GridProgress]:
|
||||
"""Get all active grid progress."""
|
||||
return list(self.active_grids.values())
|
||||
|
||||
def register_callback(self, callback: Callable[[GridProgress], None]) -> None:
|
||||
"""Register a progress callback."""
|
||||
self.progress_callbacks.append(callback)
|
||||
|
||||
def set_websocket_handler(self, handler: Any) -> None:
|
||||
"""Set WebSocket handler for real-time updates."""
|
||||
self.websocket_handler = handler
|
||||
|
||||
def _notify_progress(self, progress: GridProgress) -> None:
|
||||
"""Notify all registered callbacks of progress update."""
|
||||
# Call registered callbacks
|
||||
for callback in self.progress_callbacks:
|
||||
try:
|
||||
callback(progress)
|
||||
except Exception as e:
|
||||
print(f"Error in progress callback: {e}")
|
||||
|
||||
# Send WebSocket update if available
|
||||
if self.websocket_handler:
|
||||
try:
|
||||
self._send_websocket_update(progress)
|
||||
except Exception as e:
|
||||
print(f"Error sending WebSocket update: {e}")
|
||||
|
||||
def _send_websocket_update(self, progress: GridProgress) -> None:
|
||||
"""Send progress update via WebSocket."""
|
||||
if not self.websocket_handler:
|
||||
return
|
||||
|
||||
message = {
|
||||
"type": "xyz_grid_progress",
|
||||
"data": progress.to_dict()
|
||||
}
|
||||
|
||||
# This would integrate with ComfyUI's server
|
||||
try:
|
||||
from server import PromptServer
|
||||
if PromptServer:
|
||||
server = PromptServer.instance
|
||||
if server:
|
||||
server.send_sync("xyz_grid_progress", message["data"])
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of all progress."""
|
||||
return {
|
||||
"active_grids": [p.to_dict() for p in self.active_grids.values()],
|
||||
"completed_grids": [p.to_dict() for p in self.completed_grids[-5:]], # Last 5
|
||||
"total_active": len(self.active_grids),
|
||||
"total_completed": len(self.completed_grids)
|
||||
}
|
||||
|
||||
|
||||
# Global progress tracker instance
|
||||
progress_tracker = ProgressTracker()
|
||||
|
||||
|
||||
class ProgressWebSocketHandler:
|
||||
"""WebSocket handler for progress updates."""
|
||||
|
||||
def __init__(self):
|
||||
self.clients = set()
|
||||
|
||||
async def handle_client(self, websocket, path):
|
||||
"""Handle a WebSocket client connection."""
|
||||
self.clients.add(websocket)
|
||||
try:
|
||||
# Send initial state
|
||||
summary = progress_tracker.get_summary()
|
||||
await websocket.send(json.dumps({
|
||||
"type": "xyz_grid_init",
|
||||
"data": summary
|
||||
}))
|
||||
|
||||
# Keep connection alive
|
||||
async for message in websocket:
|
||||
# Handle any client messages if needed
|
||||
pass
|
||||
finally:
|
||||
self.clients.remove(websocket)
|
||||
|
||||
async def broadcast_progress(self, progress: GridProgress):
|
||||
"""Broadcast progress to all connected clients."""
|
||||
if self.clients:
|
||||
message = json.dumps({
|
||||
"type": "xyz_grid_progress",
|
||||
"data": progress.to_dict()
|
||||
})
|
||||
# Send to all connected clients
|
||||
disconnected = set()
|
||||
for client in self.clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except:
|
||||
disconnected.add(client)
|
||||
|
||||
# Remove disconnected clients
|
||||
self.clients -= disconnected
|
||||
@@ -1,17 +0,0 @@
|
||||
"""XYZ Helpers module for ComfyUI."""
|
||||
|
||||
from .sampler_select_helper import SamplerSelectHelperNode
|
||||
from .scheduler_select_helper import SchedulerSelectHelperNode
|
||||
from .text_encode_sampler_params import TextEncodeSamplerParamsNode
|
||||
from .flux_sampler_params import FluxSamplerParamsNode
|
||||
from .plot_sampler_params import PlotParametersNode
|
||||
from .lora_folder_batch import LoRAFolderBatchNode
|
||||
|
||||
__all__ = [
|
||||
"SamplerSelectHelperNode",
|
||||
"SchedulerSelectHelperNode",
|
||||
"TextEncodeSamplerParamsNode",
|
||||
"FluxSamplerParamsNode",
|
||||
"PlotParametersNode",
|
||||
"LoRAFolderBatchNode",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Flux Sampler Params module."""
|
||||
|
||||
from .node import FluxSamplerParamsNode
|
||||
|
||||
__all__ = ["FluxSamplerParamsNode"]
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Logic module for Flux Sampler Params node."""
|
||||
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_string_to_list(value: str) -> List[float]:
|
||||
"""
|
||||
Parse a string containing comma-separated values to a list of floats.
|
||||
|
||||
Args:
|
||||
value: String with comma-separated values
|
||||
|
||||
Returns:
|
||||
List of float values
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
values = []
|
||||
for item in value.split(","):
|
||||
item = item.strip()
|
||||
if item:
|
||||
try:
|
||||
values.append(float(item))
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse '{item}' as float")
|
||||
return values
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing string to list: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def parse_seed_string(seed_string: str) -> List[int]:
|
||||
"""
|
||||
Parse seed string which can contain numbers, '?', or ranges.
|
||||
|
||||
Args:
|
||||
seed_string: String with seeds (e.g., "123,?,456")
|
||||
|
||||
Returns:
|
||||
List of integer seeds
|
||||
"""
|
||||
seeds = []
|
||||
|
||||
try:
|
||||
for item in seed_string.replace("\n", ",").split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
|
||||
if "?" in item:
|
||||
seeds.append(random.randint(0, 999999))
|
||||
else:
|
||||
try:
|
||||
seeds.append(int(item))
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse seed '{item}'")
|
||||
seeds.append(random.randint(0, 999999))
|
||||
|
||||
if not seeds:
|
||||
seeds = [random.randint(0, 999999)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing seeds: {e}")
|
||||
seeds = [random.randint(0, 999999)]
|
||||
|
||||
return seeds
|
||||
|
||||
|
||||
def parse_sampler_string(
|
||||
sampler_string: str, available_samplers: List[str]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Parse sampler string which can contain names, '*', or '!' exclusions.
|
||||
|
||||
Args:
|
||||
sampler_string: String with sampler specifications
|
||||
available_samplers: List of available sampler names
|
||||
|
||||
Returns:
|
||||
List of sampler names
|
||||
"""
|
||||
if sampler_string == "*":
|
||||
return available_samplers.copy()
|
||||
|
||||
if sampler_string.startswith("!"):
|
||||
excluded = sampler_string.replace("\n", ",").split(",")
|
||||
excluded = [s.strip("! ") for s in excluded]
|
||||
return [s for s in available_samplers if s not in excluded]
|
||||
|
||||
samplers = sampler_string.replace("\n", ",").split(",")
|
||||
samplers = [s.strip() for s in samplers if s.strip() in available_samplers]
|
||||
|
||||
if not samplers:
|
||||
return ["euler"]
|
||||
|
||||
return samplers
|
||||
|
||||
|
||||
def parse_scheduler_string(
|
||||
scheduler_string: str, available_schedulers: List[str]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Parse scheduler string which can contain names, '*', or '!' exclusions.
|
||||
|
||||
Args:
|
||||
scheduler_string: String with scheduler specifications
|
||||
available_schedulers: List of available scheduler names
|
||||
|
||||
Returns:
|
||||
List of scheduler names
|
||||
"""
|
||||
if scheduler_string == "*":
|
||||
return available_schedulers.copy()
|
||||
|
||||
if scheduler_string.startswith("!"):
|
||||
excluded = scheduler_string.replace("\n", ",").split(",")
|
||||
excluded = [s.strip("! ") for s in excluded]
|
||||
return [s for s in available_schedulers if s not in excluded]
|
||||
|
||||
schedulers = scheduler_string.replace("\n", ",").split(",")
|
||||
schedulers = [s.strip() for s in schedulers if s.strip() in available_schedulers]
|
||||
|
||||
if not schedulers:
|
||||
return ["simple"]
|
||||
|
||||
return schedulers
|
||||
|
||||
|
||||
def get_default_flux_params(is_schnell: bool) -> Dict[str, Any]:
|
||||
"""
|
||||
Get default parameters for Flux models.
|
||||
|
||||
Args:
|
||||
is_schnell: Whether this is a Schnell model
|
||||
|
||||
Returns:
|
||||
Dictionary of default parameters
|
||||
"""
|
||||
if is_schnell:
|
||||
return {
|
||||
"steps": 4,
|
||||
"guidance": 3.5,
|
||||
"max_shift": 0,
|
||||
"base_shift": 1.0,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"steps": 20,
|
||||
"guidance": 3.5,
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def create_batch_params(
|
||||
seeds: List[int],
|
||||
samplers: List[str],
|
||||
schedulers: List[str],
|
||||
steps: List[int],
|
||||
guidances: List[float],
|
||||
max_shifts: List[float],
|
||||
base_shifts: List[float],
|
||||
denoises: List[float],
|
||||
conditioning_count: int,
|
||||
lora_strength_count: int = 1,
|
||||
) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Create batch parameters for all combinations.
|
||||
|
||||
Returns:
|
||||
Tuple of (total_samples, list of parameter combinations)
|
||||
"""
|
||||
total = (
|
||||
len(seeds)
|
||||
* len(samplers)
|
||||
* len(schedulers)
|
||||
* len(steps)
|
||||
* len(guidances)
|
||||
* len(max_shifts)
|
||||
* len(base_shifts)
|
||||
* len(denoises)
|
||||
* conditioning_count
|
||||
* lora_strength_count
|
||||
)
|
||||
|
||||
params = []
|
||||
for seed in seeds:
|
||||
for sampler in samplers:
|
||||
for scheduler in schedulers:
|
||||
for step in steps:
|
||||
for guidance in guidances:
|
||||
for max_shift in max_shifts:
|
||||
for base_shift in base_shifts:
|
||||
for denoise in denoises:
|
||||
params.append(
|
||||
{
|
||||
"seed": seed,
|
||||
"sampler": sampler,
|
||||
"scheduler": scheduler,
|
||||
"steps": step,
|
||||
"guidance": guidance,
|
||||
"max_shift": max_shift,
|
||||
"base_shift": base_shift,
|
||||
"denoise": denoise,
|
||||
}
|
||||
)
|
||||
|
||||
return total, params
|
||||
|
||||
|
||||
def process_conditioning_input(
|
||||
conditioning: Any,
|
||||
) -> Tuple[Optional[List[str]], List[Any]]:
|
||||
"""
|
||||
Process conditioning input which can be a dict or regular conditioning.
|
||||
|
||||
Args:
|
||||
conditioning: Input conditioning (dict or tensor)
|
||||
|
||||
Returns:
|
||||
Tuple of (text_list, encoded_list)
|
||||
"""
|
||||
if isinstance(conditioning, dict) and "encoded" in conditioning:
|
||||
return conditioning.get("text"), conditioning["encoded"]
|
||||
else:
|
||||
return None, [conditioning]
|
||||
|
||||
|
||||
def validate_flux_params(
|
||||
steps: str, guidance: str, max_shift: str, base_shift: str, denoise: str
|
||||
) -> bool:
|
||||
"""
|
||||
Validate Flux sampler parameters.
|
||||
|
||||
Returns:
|
||||
True if all parameters are valid
|
||||
"""
|
||||
try:
|
||||
parse_string_to_list(steps)
|
||||
parse_string_to_list(guidance)
|
||||
parse_string_to_list(max_shift)
|
||||
parse_string_to_list(base_shift)
|
||||
parse_string_to_list(denoise)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid parameters: {e}")
|
||||
return False
|
||||
@@ -1,371 +0,0 @@
|
||||
"""Flux Sampler Params node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any, Dict, List, Optional
|
||||
import time
|
||||
import logging
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
parse_string_to_list,
|
||||
parse_seed_string,
|
||||
parse_sampler_string,
|
||||
parse_scheduler_string,
|
||||
get_default_flux_params,
|
||||
create_batch_params,
|
||||
process_conditioning_input,
|
||||
validate_flux_params,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Flux Sampler Parameters node for batch processing.
|
||||
|
||||
Enables batch processing with multiple parameter variations for
|
||||
Flux models. Supports varying seeds, samplers, schedulers, steps,
|
||||
guidance, shifts, and LoRAs for comprehensive parameter exploration.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the node."""
|
||||
super().__init__()
|
||||
self.lora_loader = None
|
||||
self.cached_lora = (None, None)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL", {"tooltip": "Flux model to use"}),
|
||||
"conditioning": (
|
||||
"CONDITIONING",
|
||||
{"tooltip": "Conditioning (can be from TextEncodeSamplerParams)"},
|
||||
),
|
||||
"latent_image": ("LATENT", {"tooltip": "Input latent image"}),
|
||||
"seed": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "?",
|
||||
"tooltip": "Seeds (comma-separated, ? for random)",
|
||||
},
|
||||
),
|
||||
"sampler": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "euler",
|
||||
"tooltip": "Samplers (comma-separated, * for all, ! to exclude)",
|
||||
},
|
||||
),
|
||||
"scheduler": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "simple",
|
||||
"tooltip": "Schedulers (comma-separated, * for all, ! to exclude)",
|
||||
},
|
||||
),
|
||||
"steps": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "20",
|
||||
"tooltip": "Steps (comma-separated values)",
|
||||
},
|
||||
),
|
||||
"guidance": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "3.5",
|
||||
"tooltip": "Guidance/CFG values (comma-separated)",
|
||||
},
|
||||
),
|
||||
"max_shift": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "",
|
||||
"tooltip": "Max shift values (comma-separated, auto-set for Flux)",
|
||||
},
|
||||
),
|
||||
"base_shift": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "",
|
||||
"tooltip": "Base shift values (comma-separated, auto-set for Flux)",
|
||||
},
|
||||
),
|
||||
"denoise": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"default": "1.0",
|
||||
"tooltip": "Denoise values (comma-separated)",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"loras": ("LORA_PARAMS", {"tooltip": "Optional LoRA parameters"})
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "SAMPLER_PARAMS")
|
||||
RETURN_NAMES = ("latent", "params")
|
||||
FUNCTION = "process_batch"
|
||||
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def process_batch(
|
||||
self,
|
||||
model: Any,
|
||||
conditioning: Any,
|
||||
latent_image: Any,
|
||||
seed: str,
|
||||
sampler: str,
|
||||
scheduler: str,
|
||||
steps: str,
|
||||
guidance: str,
|
||||
max_shift: str,
|
||||
base_shift: str,
|
||||
denoise: str,
|
||||
loras: Optional[Dict] = None,
|
||||
) -> Tuple[Any, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Process batch sampling with parameter variations.
|
||||
|
||||
Returns:
|
||||
Tuple of (output_latent, parameter_list)
|
||||
"""
|
||||
try:
|
||||
import comfy.samplers
|
||||
import comfy.model_base
|
||||
import comfy.model_management
|
||||
from comfy_extras.nodes_custom_sampler import (
|
||||
Noise_RandomNoise,
|
||||
BasicScheduler,
|
||||
BasicGuider,
|
||||
SamplerCustomAdvanced,
|
||||
)
|
||||
from comfy_extras.nodes_latent import LatentBatch
|
||||
from comfy_extras.nodes_model_advanced import (
|
||||
ModelSamplingFlux,
|
||||
ModelSamplingAuraFlow,
|
||||
)
|
||||
from node_helpers import conditioning_set_values
|
||||
from nodes import LoraLoader
|
||||
|
||||
except ImportError as e:
|
||||
self.handle_error(f"Required ComfyUI modules not available: {e}")
|
||||
return (latent_image, [])
|
||||
|
||||
try:
|
||||
if not validate_flux_params(
|
||||
steps, guidance, max_shift, base_shift, denoise
|
||||
):
|
||||
self.handle_error("Invalid parameter format")
|
||||
|
||||
is_schnell = model.model.model_type == comfy.model_base.ModelType.FLOW
|
||||
defaults = get_default_flux_params(is_schnell)
|
||||
|
||||
seeds = parse_seed_string(seed)
|
||||
samplers = parse_sampler_string(sampler, comfy.samplers.KSampler.SAMPLERS)
|
||||
schedulers = parse_scheduler_string(
|
||||
scheduler, comfy.samplers.KSampler.SCHEDULERS
|
||||
)
|
||||
|
||||
steps = steps if steps else str(defaults["steps"])
|
||||
steps_list = [int(s) for s in parse_string_to_list(steps)]
|
||||
|
||||
guidance = guidance if guidance else str(defaults["guidance"])
|
||||
guidance_list = parse_string_to_list(guidance)
|
||||
|
||||
denoise = denoise if denoise else "1.0"
|
||||
denoise_list = parse_string_to_list(denoise)
|
||||
|
||||
if not is_schnell:
|
||||
max_shift = max_shift if max_shift else str(defaults["max_shift"])
|
||||
base_shift = base_shift if base_shift else str(defaults["base_shift"])
|
||||
else:
|
||||
max_shift = "0"
|
||||
base_shift = base_shift if base_shift else str(defaults["base_shift"])
|
||||
|
||||
max_shift_list = parse_string_to_list(max_shift)
|
||||
base_shift_list = parse_string_to_list(base_shift)
|
||||
|
||||
cond_text, cond_encoded = process_conditioning_input(conditioning)
|
||||
|
||||
width = latent_image["samples"].shape[3] * 8
|
||||
height = latent_image["samples"].shape[2] * 8
|
||||
|
||||
lora_strength_count = 1
|
||||
if loras:
|
||||
lora_model = loras["loras"]
|
||||
lora_strength = loras["strengths"]
|
||||
lora_strength_count = sum(len(i) for i in lora_strength)
|
||||
|
||||
if self.lora_loader is None:
|
||||
self.lora_loader = LoraLoader()
|
||||
|
||||
total_samples, param_combos = create_batch_params(
|
||||
seeds,
|
||||
samplers,
|
||||
schedulers,
|
||||
steps_list,
|
||||
guidance_list,
|
||||
max_shift_list,
|
||||
base_shift_list,
|
||||
denoise_list,
|
||||
len(cond_encoded),
|
||||
lora_strength_count,
|
||||
)
|
||||
|
||||
self.log_info(f"Processing {total_samples} parameter combinations")
|
||||
|
||||
basicscheduler = BasicScheduler()
|
||||
basicguider = BasicGuider()
|
||||
samplercustomadvanced = SamplerCustomAdvanced()
|
||||
latentbatch = LatentBatch()
|
||||
modelsampling = (
|
||||
ModelSamplingFlux() if not is_schnell else ModelSamplingAuraFlow()
|
||||
)
|
||||
|
||||
out_latent = None
|
||||
out_params = []
|
||||
|
||||
if total_samples > 1:
|
||||
from comfy.utils import ProgressBar
|
||||
|
||||
pbar = ProgressBar(total_samples)
|
||||
|
||||
current_sample = 0
|
||||
|
||||
for lora_idx in range(lora_strength_count if loras else 1):
|
||||
if loras:
|
||||
# Find which LoRA file and strength to use
|
||||
cumulative_idx = 0
|
||||
lora_file_idx = 0
|
||||
strength_in_file_idx = 0
|
||||
|
||||
# Determine which LoRA file this index corresponds to
|
||||
for file_idx, strengths in enumerate(lora_strength):
|
||||
if lora_idx < cumulative_idx + len(strengths):
|
||||
lora_file_idx = file_idx
|
||||
strength_in_file_idx = lora_idx - cumulative_idx
|
||||
break
|
||||
cumulative_idx += len(strengths)
|
||||
|
||||
# Load the appropriate LoRA with its strength
|
||||
if lora_file_idx < len(lora_model) and strength_in_file_idx < len(
|
||||
lora_strength[lora_file_idx]
|
||||
):
|
||||
patched_model = self.lora_loader.load_lora(
|
||||
model,
|
||||
None,
|
||||
lora_model[lora_file_idx],
|
||||
lora_strength[lora_file_idx][strength_in_file_idx],
|
||||
0,
|
||||
)[0]
|
||||
else:
|
||||
patched_model = model
|
||||
else:
|
||||
patched_model = model
|
||||
|
||||
for cond_idx, cond in enumerate(cond_encoded):
|
||||
prompt_text = cond_text[cond_idx] if cond_text else None
|
||||
|
||||
for params in param_combos:
|
||||
current_sample += 1
|
||||
|
||||
if is_schnell:
|
||||
work_model = modelsampling.patch_aura(
|
||||
patched_model, params["base_shift"]
|
||||
)[0]
|
||||
else:
|
||||
work_model = modelsampling.patch(
|
||||
patched_model,
|
||||
params["max_shift"],
|
||||
params["base_shift"],
|
||||
width,
|
||||
height,
|
||||
)[0]
|
||||
|
||||
cond_with_guidance = conditioning_set_values(
|
||||
cond, {"guidance": params["guidance"]}
|
||||
)
|
||||
|
||||
guider = basicguider.get_guider(work_model, cond_with_guidance)[
|
||||
0
|
||||
]
|
||||
sampler_obj = comfy.samplers.sampler_object(params["sampler"])
|
||||
sigmas = basicscheduler.get_sigmas(
|
||||
work_model,
|
||||
params["scheduler"],
|
||||
params["steps"],
|
||||
params["denoise"],
|
||||
)[0]
|
||||
|
||||
noise = Noise_RandomNoise(params["seed"])
|
||||
|
||||
self.log_info(
|
||||
f"Sample {current_sample}/{total_samples}: "
|
||||
f"seed={params['seed']}, sampler={params['sampler']}, "
|
||||
f"steps={params['steps']}"
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
latent = samplercustomadvanced.sample(
|
||||
noise, guider, sampler_obj, sigmas, latent_image
|
||||
)[1]
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
param_record = {
|
||||
**params,
|
||||
"time": elapsed,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"prompt": prompt_text,
|
||||
}
|
||||
|
||||
if loras:
|
||||
# Record which LoRA and strength was used
|
||||
param_record["lora"] = (
|
||||
lora_model[lora_file_idx]
|
||||
if lora_file_idx < len(lora_model)
|
||||
else None
|
||||
)
|
||||
param_record["lora_strength"] = (
|
||||
lora_strength[lora_file_idx][strength_in_file_idx]
|
||||
if lora_file_idx < len(lora_strength)
|
||||
and strength_in_file_idx
|
||||
< len(lora_strength[lora_file_idx])
|
||||
else 0
|
||||
)
|
||||
|
||||
out_params.append(param_record)
|
||||
|
||||
if out_latent is None:
|
||||
out_latent = latent
|
||||
else:
|
||||
out_latent = latentbatch.batch(out_latent, latent)[0]
|
||||
|
||||
if total_samples > 1:
|
||||
pbar.update(1)
|
||||
|
||||
self.log_info(f"Completed {len(out_params)} samples")
|
||||
return (out_latent, out_params)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error in batch processing: {str(e)}", e)
|
||||
return (latent_image, [])
|
||||
@@ -1,5 +0,0 @@
|
||||
"""LoRA Folder Batch module."""
|
||||
|
||||
from .node import LoRAFolderBatchNode
|
||||
|
||||
__all__ = ["LoRAFolderBatchNode"]
|
||||
@@ -1,334 +0,0 @@
|
||||
"""Logic module for LoRA Folder Batch node."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_lora_folders() -> List[str]:
|
||||
"""
|
||||
Get list of available LoRA folders.
|
||||
|
||||
Returns:
|
||||
List of folder paths relative to models/loras
|
||||
"""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
lora_path = folder_paths.folder_names_and_paths["loras"][0][0]
|
||||
|
||||
folders = []
|
||||
for root, dirs, _ in os.walk(lora_path):
|
||||
for dir_name in dirs:
|
||||
rel_path = os.path.relpath(os.path.join(root, dir_name), lora_path)
|
||||
folders.append(rel_path)
|
||||
|
||||
# Add root folder option
|
||||
folders.insert(0, ".")
|
||||
return folders
|
||||
|
||||
except (ImportError, KeyError):
|
||||
# Fallback for testing
|
||||
return [".", "flux", "sdxl", "sd15"]
|
||||
|
||||
|
||||
def scan_folder_for_loras(folder_path: str) -> List[str]:
|
||||
"""
|
||||
Scan a folder for LoRA files (.safetensors).
|
||||
|
||||
Args:
|
||||
folder_path: Path to folder to scan (absolute or relative to models/loras)
|
||||
|
||||
Returns:
|
||||
List of LoRA filenames relative to models/loras directory
|
||||
"""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
# Get all LoRA paths from ComfyUI (includes extra_model_paths)
|
||||
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
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
|
||||
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]
|
||||
)
|
||||
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 ""
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
logger.warning(f"Folder does not exist: {full_path}")
|
||||
return []
|
||||
|
||||
# Scan for .safetensors files
|
||||
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)
|
||||
|
||||
# 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"
|
||||
)
|
||||
return lora_files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning folder {folder_path}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def natural_sort(items: List[str]) -> List[str]:
|
||||
"""
|
||||
Sort strings naturally, handling numbers properly.
|
||||
|
||||
Args:
|
||||
items: List of strings to sort
|
||||
|
||||
Returns:
|
||||
Naturally sorted list
|
||||
"""
|
||||
|
||||
def natural_key(text):
|
||||
def atoi(text):
|
||||
return int(text) if text.isdigit() else text
|
||||
|
||||
# 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
|
||||
|
||||
return sorted(items, key=natural_key)
|
||||
|
||||
|
||||
def filter_loras_by_pattern(
|
||||
lora_files: List[str], include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[str]:
|
||||
"""
|
||||
Filter LoRA files by include/exclude patterns.
|
||||
|
||||
Args:
|
||||
lora_files: List of LoRA filenames
|
||||
include_pattern: Regex pattern to include (empty = include all)
|
||||
exclude_pattern: Regex pattern to exclude (empty = exclude none)
|
||||
|
||||
Returns:
|
||||
Filtered list of LoRA files
|
||||
"""
|
||||
filtered = lora_files.copy()
|
||||
|
||||
# Apply include pattern
|
||||
if include_pattern:
|
||||
try:
|
||||
include_re = re.compile(include_pattern)
|
||||
filtered = [f for f in filtered if include_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid include pattern: {e}")
|
||||
|
||||
# Apply exclude pattern
|
||||
if exclude_pattern:
|
||||
try:
|
||||
exclude_re = re.compile(exclude_pattern)
|
||||
filtered = [f for f in filtered if not exclude_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid exclude pattern: {e}")
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def parse_strength_string(strength_str: str) -> List[float]:
|
||||
"""
|
||||
Parse strength string into list of values.
|
||||
|
||||
Supports:
|
||||
- Single value: "1.0"
|
||||
- Multiple values: "0.5, 0.75, 1.0"
|
||||
- Range: "0.5...1.0" (with optional step)
|
||||
|
||||
Args:
|
||||
strength_str: String representation of strengths
|
||||
|
||||
Returns:
|
||||
List of strength values
|
||||
"""
|
||||
strength_str = strength_str.strip()
|
||||
|
||||
if not strength_str:
|
||||
return [1.0]
|
||||
|
||||
# Check for range notation
|
||||
if "..." in strength_str:
|
||||
parts = strength_str.split("...")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
start = float(parts[0].strip())
|
||||
end_part = parts[1].strip()
|
||||
|
||||
# Check for step
|
||||
if "+" in end_part:
|
||||
end_str, step_str = end_part.split("+")
|
||||
end = float(end_str.strip())
|
||||
step = float(step_str.strip())
|
||||
else:
|
||||
end = float(end_part)
|
||||
step = 0.1 # Default step
|
||||
|
||||
# Generate range
|
||||
values = []
|
||||
current = start
|
||||
while current <= end + 0.0001: # Small epsilon for float comparison
|
||||
values.append(round(current, 4))
|
||||
current += step
|
||||
|
||||
return values
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid range format: {e}")
|
||||
return [1.0]
|
||||
|
||||
# Parse comma-separated values
|
||||
try:
|
||||
values = []
|
||||
for item in strength_str.split(","):
|
||||
item = item.strip()
|
||||
if item:
|
||||
values.append(float(item))
|
||||
return values if values else [1.0]
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid strength values: {e}")
|
||||
return [1.0]
|
||||
|
||||
|
||||
def create_lora_params(
|
||||
lora_files: List[str], strengths: List[float], batch_mode: str = "sequential"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create LORA_PARAMS structure for FluxSamplerParams.
|
||||
|
||||
Args:
|
||||
lora_files: List of LoRA file paths
|
||||
strengths: List of strength values to test
|
||||
batch_mode: How to batch ("sequential" or "combinatorial")
|
||||
|
||||
Returns:
|
||||
LORA_PARAMS dictionary
|
||||
"""
|
||||
if not lora_files:
|
||||
logger.warning("No LoRA files provided")
|
||||
return {"loras": [], "strengths": []}
|
||||
|
||||
if batch_mode == "combinatorial":
|
||||
# Each LoRA gets tested with each strength
|
||||
# This creates len(loras) * len(strengths) combinations
|
||||
return {"loras": lora_files, "strengths": [strengths for _ in lora_files]}
|
||||
else:
|
||||
# Sequential mode - cycle through strengths for each LoRA
|
||||
# If fewer strengths than LoRAs, repeat the strength list
|
||||
strength_lists = []
|
||||
for i, lora in enumerate(lora_files):
|
||||
strength_idx = i % len(strengths)
|
||||
strength_lists.append([strengths[strength_idx]])
|
||||
|
||||
return {"loras": lora_files, "strengths": strength_lists}
|
||||
|
||||
|
||||
def get_lora_info(lora_file: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract information from LoRA filename.
|
||||
|
||||
Args:
|
||||
lora_file: LoRA filename
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted info (name, epoch, version, etc.)
|
||||
"""
|
||||
info = {
|
||||
"filename": lora_file,
|
||||
"name": os.path.splitext(os.path.basename(lora_file))[0],
|
||||
"epoch": None,
|
||||
"version": None,
|
||||
}
|
||||
|
||||
# Try to extract epoch number
|
||||
epoch_match = re.search(r"[-_](\d{6}|\d{5}|\d{4}|\d{3})", info["name"])
|
||||
if epoch_match:
|
||||
info["epoch"] = int(epoch_match.group(1))
|
||||
|
||||
# Try to extract version
|
||||
version_match = re.search(r"v(\d+(?:\.\d+)?)", info["name"], re.IGNORECASE)
|
||||
if version_match:
|
||||
info["version"] = f"v{version_match.group(1)}"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def validate_folder_path(folder_path: str) -> bool:
|
||||
"""
|
||||
Validate that the folder path exists and is accessible.
|
||||
|
||||
Args:
|
||||
folder_path: Folder path to validate
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
"""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
lora_base_path = folder_paths.folder_names_and_paths["loras"][0][0]
|
||||
|
||||
if folder_path == ".":
|
||||
full_path = lora_base_path
|
||||
else:
|
||||
full_path = os.path.join(lora_base_path, folder_path)
|
||||
|
||||
return os.path.exists(full_path) and os.path.isdir(full_path)
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,185 +0,0 @@
|
||||
"""LoRA Folder Batch node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any, Dict, List
|
||||
import os
|
||||
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,
|
||||
get_lora_info,
|
||||
validate_folder_path,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoRAFolderBatchNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
LoRA Folder Batch node for processing multiple LoRAs from a folder.
|
||||
|
||||
Scans a specified folder for all .safetensors files and creates
|
||||
LORA_PARAMS for batch processing with FluxSamplerParams. Perfect
|
||||
for testing different epochs or variations of the same LoRA.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"folder_path": (
|
||||
"STRING",
|
||||
{
|
||||
"default": ".",
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"tooltip": "Folder path relative to models/loras (or absolute path)",
|
||||
},
|
||||
),
|
||||
"strength": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "1.0",
|
||||
"multiline": False,
|
||||
"dynamicPrompts": False,
|
||||
"tooltip": "Strength values (e.g., '1.0' or '0.5,0.75,1.0' or '0.5...1.0+0.1')",
|
||||
},
|
||||
),
|
||||
"batch_mode": (
|
||||
["sequential", "combinatorial"],
|
||||
{
|
||||
"default": "sequential",
|
||||
"tooltip": "Sequential: one strength per LoRA, Combinatorial: all strengths for each LoRA",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"include_pattern": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": "Regex pattern to include files (empty = all)",
|
||||
},
|
||||
),
|
||||
"exclude_pattern": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": "Regex pattern to exclude files (e.g., 'test|backup')",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_PARAMS", "STRING", "INT")
|
||||
RETURN_NAMES = ("lora_params", "lora_list", "lora_count")
|
||||
FUNCTION = "batch_loras"
|
||||
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def batch_loras(
|
||||
self,
|
||||
folder_path: str,
|
||||
strength: str,
|
||||
batch_mode: str,
|
||||
include_pattern: str = "",
|
||||
exclude_pattern: str = "",
|
||||
) -> Tuple[Dict[str, Any], str, int]:
|
||||
"""
|
||||
Batch process LoRAs from a folder.
|
||||
|
||||
Args:
|
||||
folder_path: Folder to scan (relative to models/loras or absolute)
|
||||
strength: Strength values string
|
||||
batch_mode: How to batch the LoRAs
|
||||
include_pattern: Optional include regex
|
||||
exclude_pattern: Optional exclude regex
|
||||
|
||||
Returns:
|
||||
Tuple of (lora_params, lora_list_string, lora_count)
|
||||
"""
|
||||
try:
|
||||
|
||||
# Validate folder only if not in test mode
|
||||
try:
|
||||
if not validate_folder_path(folder_path):
|
||||
self.handle_error(f"Invalid or inaccessible folder: {folder_path}")
|
||||
except ImportError:
|
||||
# In test environment, skip validation
|
||||
pass
|
||||
|
||||
# Scan folder for LoRAs
|
||||
lora_files = scan_folder_for_loras(folder_path)
|
||||
|
||||
if not lora_files:
|
||||
self.log_info(f"No LoRA files found in {folder_path}")
|
||||
return ({"loras": [], "strengths": []}, "", 0)
|
||||
|
||||
self.log_info(f"Found {len(lora_files)} LoRA files in {folder_path}")
|
||||
|
||||
# Apply filters
|
||||
if include_pattern or exclude_pattern:
|
||||
filtered = filter_loras_by_pattern(
|
||||
lora_files, include_pattern, exclude_pattern
|
||||
)
|
||||
if len(filtered) < len(lora_files):
|
||||
self.log_info(
|
||||
f"Filtered from {len(lora_files)} to {len(filtered)} LoRAs"
|
||||
)
|
||||
lora_files = filtered
|
||||
|
||||
if not lora_files:
|
||||
self.log_info("No LoRAs left after filtering")
|
||||
return ({"loras": [], "strengths": []}, "", 0)
|
||||
|
||||
# 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 info string
|
||||
lora_list = []
|
||||
for lora_file in lora_files:
|
||||
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)
|
||||
else:
|
||||
total_combos = len(lora_files)
|
||||
|
||||
self.log_info(
|
||||
f"Created batch with {len(lora_files)} LoRAs, "
|
||||
f"{len(strengths)} strength values, "
|
||||
f"{total_combos} total combinations"
|
||||
)
|
||||
|
||||
return (lora_params, lora_list_str, len(lora_files))
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error creating LoRA batch: {str(e)}", e)
|
||||
return ({"loras": [], "strengths": []}, "", 0)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""
|
||||
Force re-execution when folder contents might have changed.
|
||||
|
||||
This ensures we always scan for the latest LoRAs.
|
||||
"""
|
||||
import time
|
||||
|
||||
return str(time.time())
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Plot Parameters module."""
|
||||
|
||||
from .node import PlotParametersNode
|
||||
|
||||
__all__ = ["PlotParametersNode"]
|
||||
@@ -1,338 +0,0 @@
|
||||
"""Logic module for Plot Parameters node."""
|
||||
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import math
|
||||
import textwrap
|
||||
import logging
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_parameters(params: List[Dict], order_by: str) -> Tuple[List[Dict], List[int]]:
|
||||
"""
|
||||
Sort parameters by a specified key.
|
||||
|
||||
Args:
|
||||
params: List of parameter dictionaries
|
||||
order_by: Key to sort by
|
||||
|
||||
Returns:
|
||||
Tuple of (sorted_params, original_indices)
|
||||
"""
|
||||
if order_by == "none":
|
||||
return params, list(range(len(params)))
|
||||
|
||||
try:
|
||||
# Create indexed list
|
||||
indexed_params = [(i, p) for i, p in enumerate(params)]
|
||||
|
||||
# Sort by the specified key
|
||||
sorted_indexed = sorted(indexed_params, key=lambda x: x[1].get(order_by, 0))
|
||||
|
||||
# Extract sorted params and indices
|
||||
indices = [i for i, _ in sorted_indexed]
|
||||
sorted_params = [p for _, p in sorted_indexed]
|
||||
|
||||
return sorted_params, indices
|
||||
except Exception as e:
|
||||
logger.error(f"Error sorting parameters: {e}")
|
||||
return params, list(range(len(params)))
|
||||
|
||||
|
||||
def group_by_value(
|
||||
params: List[Dict], group_key: str
|
||||
) -> Tuple[List[Dict], List[int], int]:
|
||||
"""
|
||||
Group parameters by a specific value and arrange in columns.
|
||||
|
||||
Args:
|
||||
params: List of parameter dictionaries
|
||||
group_key: Key to group by
|
||||
|
||||
Returns:
|
||||
Tuple of (rearranged_params, indices, num_groups)
|
||||
"""
|
||||
if group_key == "none":
|
||||
return params, list(range(len(params))), -1
|
||||
|
||||
try:
|
||||
# Group parameters by the specified key
|
||||
groups = {}
|
||||
for i, p in enumerate(params):
|
||||
value = p.get(group_key, "unknown")
|
||||
if value not in groups:
|
||||
groups[value] = []
|
||||
groups[value].append((i, p))
|
||||
|
||||
num_groups = len(groups)
|
||||
|
||||
# Rearrange for column layout
|
||||
sorted_params = []
|
||||
indices = []
|
||||
|
||||
# Convert groups to list
|
||||
group_lists = list(groups.values())
|
||||
|
||||
# Zip groups together for column arrangement
|
||||
max_len = max(len(g) for g in group_lists)
|
||||
for i in range(max_len):
|
||||
for group in group_lists:
|
||||
if i < len(group):
|
||||
idx, param = group[i]
|
||||
indices.append(idx)
|
||||
sorted_params.append(param)
|
||||
|
||||
return sorted_params, indices, num_groups
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error grouping parameters: {e}")
|
||||
return params, list(range(len(params))), -1
|
||||
|
||||
|
||||
def identify_changing_parameters(params: List[Dict]) -> Dict[str, bool]:
|
||||
"""
|
||||
Identify which parameters change across the batch.
|
||||
|
||||
Args:
|
||||
params: List of parameter dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary mapping parameter names to whether they change
|
||||
"""
|
||||
if not params:
|
||||
return {}
|
||||
|
||||
changing = {}
|
||||
|
||||
# Track unique values for each parameter
|
||||
value_tracker = {}
|
||||
|
||||
for p in params:
|
||||
for key, value in p.items():
|
||||
if key == "time": # Skip time as it always changes
|
||||
continue
|
||||
|
||||
if key not in value_tracker:
|
||||
value_tracker[key] = set()
|
||||
|
||||
# Handle different value types
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = str(value)
|
||||
elif isinstance(value, dict):
|
||||
value = str(sorted(value.items()))
|
||||
|
||||
value_tracker[key].add(value)
|
||||
|
||||
# Mark parameters as changing if they have multiple values
|
||||
for key, values in value_tracker.items():
|
||||
changing[key] = len(values) > 1
|
||||
|
||||
# Always include prompt if present
|
||||
if any("prompt" in p for p in params):
|
||||
changing["prompt"] = True
|
||||
|
||||
return changing
|
||||
|
||||
|
||||
def filter_changing_params(params: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Filter parameters to only show those that change.
|
||||
|
||||
Args:
|
||||
params: List of parameter dictionaries
|
||||
|
||||
Returns:
|
||||
List of filtered parameter dictionaries
|
||||
"""
|
||||
changing = identify_changing_parameters(params)
|
||||
|
||||
filtered = []
|
||||
for p in params:
|
||||
filtered_param = {}
|
||||
for key, value in p.items():
|
||||
if changing.get(key, False):
|
||||
filtered_param[key] = value
|
||||
filtered.append(filtered_param)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def format_parameter_text(param: Dict, mode: str = "full") -> str:
|
||||
"""
|
||||
Format parameter dictionary as display text.
|
||||
|
||||
Args:
|
||||
param: Parameter dictionary
|
||||
mode: Display mode ("full", "changes only")
|
||||
|
||||
Returns:
|
||||
Formatted text string
|
||||
"""
|
||||
if mode == "changes only":
|
||||
lines = []
|
||||
for key, value in param.items():
|
||||
if key != "prompt":
|
||||
lines.append(f"{key}: {value}")
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
# Full format
|
||||
lines = []
|
||||
|
||||
# First line: time, seed, steps, size
|
||||
if "time" in param:
|
||||
lines.append(
|
||||
f"time: {param['time']:.2f}s, seed: {param.get('seed', 'N/A')}, "
|
||||
f"steps: {param.get('steps', 'N/A')}, "
|
||||
f"size: {param.get('width', 'N/A')}×{param.get('height', 'N/A')}"
|
||||
)
|
||||
|
||||
# Second line: denoise, sampler, scheduler
|
||||
lines.append(
|
||||
f"denoise: {param.get('denoise', 'N/A')}, "
|
||||
f"sampler: {param.get('sampler', 'N/A')}, "
|
||||
f"sched: {param.get('scheduler', 'N/A')}"
|
||||
)
|
||||
|
||||
# Third line: guidance, shifts
|
||||
lines.append(
|
||||
f"guidance: {param.get('guidance', 'N/A')}, "
|
||||
f"max/base shift: {param.get('max_shift', 'N/A')}/{param.get('base_shift', 'N/A')}"
|
||||
)
|
||||
|
||||
# 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')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def wrap_prompt_text(prompt: str, width_chars: int, mode: str = "full") -> List[str]:
|
||||
"""
|
||||
Wrap prompt text to fit within character width.
|
||||
|
||||
Args:
|
||||
prompt: Prompt text to wrap
|
||||
width_chars: Maximum characters per line
|
||||
mode: Display mode ("full", "excerpt")
|
||||
|
||||
Returns:
|
||||
List of wrapped lines
|
||||
"""
|
||||
if not prompt:
|
||||
return []
|
||||
|
||||
original_words = prompt.split()
|
||||
|
||||
if mode == "excerpt":
|
||||
# Take first 64 words
|
||||
words = original_words[:64]
|
||||
prompt = " ".join(words)
|
||||
# Add ellipsis if we truncated
|
||||
if len(words) < len(original_words):
|
||||
prompt += "..."
|
||||
|
||||
# Use textwrap to break into lines
|
||||
lines = textwrap.wrap(prompt, width=width_chars)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def calculate_text_dimensions(
|
||||
text: str, font_size: int, image_width: int
|
||||
) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Calculate text rendering dimensions.
|
||||
|
||||
Args:
|
||||
text: Text to render
|
||||
font_size: Font size in pixels
|
||||
image_width: Width of the image
|
||||
|
||||
Returns:
|
||||
Tuple of (line_height, char_width, num_lines)
|
||||
"""
|
||||
# Approximate calculations (adjust based on actual font metrics)
|
||||
line_height = int(font_size * 1.5) # Line height with padding
|
||||
char_width = int(font_size * 0.6) # Approximate monospace char width
|
||||
|
||||
lines = text.split("\n")
|
||||
num_lines = len(lines)
|
||||
|
||||
return line_height, char_width, num_lines
|
||||
|
||||
|
||||
def calculate_grid_dimensions(num_images: int, cols_num: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate grid dimensions for image layout.
|
||||
|
||||
Args:
|
||||
num_images: Total number of images
|
||||
cols_num: Number of columns (-1 for auto)
|
||||
|
||||
Returns:
|
||||
Tuple of (rows, cols)
|
||||
"""
|
||||
if cols_num == 0 or cols_num == -1:
|
||||
# Auto-calculate columns
|
||||
cols = int(math.sqrt(num_images))
|
||||
cols = max(1, min(cols, 1024))
|
||||
else:
|
||||
cols = min(cols_num, num_images)
|
||||
|
||||
rows = math.ceil(num_images / cols)
|
||||
|
||||
return rows, cols
|
||||
|
||||
|
||||
def validate_plot_parameters(
|
||||
images_shape: tuple,
|
||||
params_length: int,
|
||||
order_by: str,
|
||||
cols_value: str,
|
||||
cols_num: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Validate plot parameters configuration.
|
||||
|
||||
Args:
|
||||
images_shape: Shape of the images tensor
|
||||
params_length: Length of parameters list
|
||||
order_by: Ordering key
|
||||
cols_value: Column grouping key
|
||||
cols_num: Number of columns
|
||||
|
||||
Returns:
|
||||
True if configuration is valid
|
||||
"""
|
||||
if images_shape[0] != params_length:
|
||||
logger.error(
|
||||
f"Image count ({images_shape[0]}) doesn't match parameters ({params_length})"
|
||||
)
|
||||
return False
|
||||
|
||||
valid_keys = [
|
||||
"none",
|
||||
"time",
|
||||
"seed",
|
||||
"steps",
|
||||
"denoise",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"guidance",
|
||||
"max_shift",
|
||||
"base_shift",
|
||||
"lora_strength",
|
||||
]
|
||||
|
||||
if order_by not in valid_keys:
|
||||
logger.warning(f"Invalid order_by value: {order_by}")
|
||||
|
||||
if cols_value not in valid_keys:
|
||||
logger.warning(f"Invalid cols_value: {cols_value}")
|
||||
|
||||
if cols_num < -1 or cols_num > 1024:
|
||||
logger.warning(f"Invalid cols_num: {cols_num}")
|
||||
|
||||
return True
|
||||
@@ -1,310 +0,0 @@
|
||||
"""Plot Parameters node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any, List, Dict
|
||||
import os
|
||||
import math
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
try:
|
||||
import torchvision.transforms.v2 as T
|
||||
except ImportError:
|
||||
try:
|
||||
import torchvision.transforms as T
|
||||
except ImportError:
|
||||
# Fallback for test environment without torchvision
|
||||
class T:
|
||||
@staticmethod
|
||||
def ToTensor():
|
||||
def to_tensor(img):
|
||||
import numpy as np
|
||||
|
||||
if isinstance(img, Image.Image):
|
||||
img = np.array(img)
|
||||
img = torch.from_numpy(img).float() / 255.0
|
||||
if len(img.shape) == 3:
|
||||
img = img.permute(2, 0, 1)
|
||||
return img
|
||||
|
||||
return to_tensor
|
||||
|
||||
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
sort_parameters,
|
||||
group_by_value,
|
||||
filter_changing_params,
|
||||
format_parameter_text,
|
||||
wrap_prompt_text,
|
||||
calculate_text_dimensions,
|
||||
calculate_grid_dimensions,
|
||||
validate_plot_parameters,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlotParametersNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Plot Parameters node for visualizing batch sampling results.
|
||||
|
||||
Creates a grid layout of images with parameter annotations,
|
||||
useful for comparing results across different sampling parameters.
|
||||
Supports sorting, grouping, and filtering display options.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
order_options = [
|
||||
"none",
|
||||
"time",
|
||||
"seed",
|
||||
"steps",
|
||||
"denoise",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"guidance",
|
||||
"max_shift",
|
||||
"base_shift",
|
||||
"lora_strength",
|
||||
]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", {"tooltip": "Batch of images to arrange"}),
|
||||
"params": (
|
||||
"SAMPLER_PARAMS",
|
||||
{"tooltip": "Parameters from FluxSamplerParams"},
|
||||
),
|
||||
"order_by": (
|
||||
order_options,
|
||||
{"default": "none", "tooltip": "Sort images by this parameter"},
|
||||
),
|
||||
"cols_value": (
|
||||
order_options,
|
||||
{
|
||||
"default": "none",
|
||||
"tooltip": "Group into columns by this parameter",
|
||||
},
|
||||
),
|
||||
"cols_num": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"max": 1024,
|
||||
"tooltip": "Number of columns (-1 for auto, 0 for square)",
|
||||
},
|
||||
),
|
||||
"add_prompt": (
|
||||
["false", "true", "excerpt"],
|
||||
{"default": "false", "tooltip": "Add prompt text to images"},
|
||||
),
|
||||
"add_params": (
|
||||
["false", "true", "changes only"],
|
||||
{"default": "true", "tooltip": "Add parameter text to images"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "plot_parameters"
|
||||
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def plot_parameters(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
params: List[Dict[str, Any]],
|
||||
order_by: str,
|
||||
cols_value: str,
|
||||
cols_num: int,
|
||||
add_prompt: str,
|
||||
add_params: str,
|
||||
) -> Tuple[torch.Tensor]:
|
||||
"""
|
||||
Create a plot grid with parameter annotations.
|
||||
|
||||
Args:
|
||||
images: Tensor of images [B, H, W, C]
|
||||
params: List of parameter dictionaries
|
||||
order_by: Parameter to sort by
|
||||
cols_value: Parameter to group columns by
|
||||
cols_num: Number of columns
|
||||
add_prompt: Whether to add prompt text
|
||||
add_params: Whether to add parameter text
|
||||
|
||||
Returns:
|
||||
Tuple containing the plotted image grid
|
||||
"""
|
||||
try:
|
||||
if not validate_plot_parameters(
|
||||
images.shape, len(params), order_by, cols_value, cols_num
|
||||
):
|
||||
self.handle_error("Invalid plot parameters configuration")
|
||||
|
||||
# Copy params to avoid modifying original
|
||||
_params = params.copy()
|
||||
|
||||
# Sort if requested
|
||||
if order_by != "none":
|
||||
_params, indices = sort_parameters(_params, order_by)
|
||||
images = images[torch.tensor(indices)]
|
||||
self.log_info(f"Sorted by {order_by}")
|
||||
|
||||
# Group by value if requested
|
||||
if cols_value != "none" and cols_num > -1:
|
||||
_params, indices, num_groups = group_by_value(_params, cols_value)
|
||||
if num_groups > 0:
|
||||
cols_num = num_groups
|
||||
images = images[torch.tensor(indices)]
|
||||
self.log_info(f"Grouped into {num_groups} columns by {cols_value}")
|
||||
elif cols_num == 0:
|
||||
# Auto square layout
|
||||
cols_num = int(math.sqrt(images.shape[0]))
|
||||
cols_num = max(1, min(cols_num, 1024))
|
||||
|
||||
# Filter params if showing changes only
|
||||
if add_params == "changes only":
|
||||
_params = filter_changing_params(_params)
|
||||
|
||||
# Get font
|
||||
font_path = self._get_font_path()
|
||||
width = images.shape[2]
|
||||
font_size = min(48, int(32 * (width / 1024)))
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except:
|
||||
logger.warning(f"Could not load font from {font_path}, using default")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Calculate text dimensions
|
||||
text_padding = 3
|
||||
line_height = (
|
||||
font.getmask("Q").getbbox()[3] + font.getmetrics()[1] + text_padding * 2
|
||||
)
|
||||
char_width = font.getbbox("M")[2] + 1 # Monospace approximation
|
||||
|
||||
# Process each image
|
||||
out_images = []
|
||||
for image, param in zip(images, _params):
|
||||
image = image.permute(2, 0, 1) # [C, H, W]
|
||||
|
||||
# Add parameter text
|
||||
if add_params != "false":
|
||||
param_text = format_parameter_text(
|
||||
param,
|
||||
"changes only" if add_params == "changes only" else "full",
|
||||
)
|
||||
|
||||
lines = param_text.split("\n")
|
||||
text_height = line_height * len(lines)
|
||||
text_image = Image.new("RGB", (width, text_height), color=(0, 0, 0))
|
||||
draw = ImageDraw.Draw(text_image)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
draw.text(
|
||||
(text_padding, i * line_height + text_padding),
|
||||
line,
|
||||
font=font,
|
||||
fill=(255, 255, 255),
|
||||
)
|
||||
|
||||
text_tensor = T.ToTensor()(text_image).to(image.device)
|
||||
image = torch.cat([image, text_tensor], 1)
|
||||
|
||||
# Add prompt text
|
||||
if add_prompt != "false" and "prompt" in param and param["prompt"]:
|
||||
cols = math.ceil(width / char_width)
|
||||
prompt_lines = wrap_prompt_text(
|
||||
param["prompt"],
|
||||
cols,
|
||||
"excerpt" if add_prompt == "excerpt" else "full",
|
||||
)
|
||||
|
||||
prompt_height = line_height * len(prompt_lines)
|
||||
prompt_image = Image.new(
|
||||
"RGB", (width, prompt_height), color=(0, 0, 0)
|
||||
)
|
||||
draw = ImageDraw.Draw(prompt_image)
|
||||
|
||||
for i, line in enumerate(prompt_lines):
|
||||
draw.text(
|
||||
(text_padding, i * line_height + text_padding),
|
||||
line,
|
||||
font=font,
|
||||
fill=(255, 255, 255),
|
||||
)
|
||||
|
||||
prompt_tensor = T.ToTensor()(prompt_image).to(image.device)
|
||||
image = torch.cat([image, prompt_tensor], 1)
|
||||
|
||||
# Clean up NaN values
|
||||
image = torch.nan_to_num(image, nan=0.0).clamp(0.0, 1.0)
|
||||
out_images.append(image)
|
||||
|
||||
# Ensure all images have same height
|
||||
if add_prompt != "false" or add_params == "changes only":
|
||||
max_height = max([img.shape[1] for img in out_images])
|
||||
out_images = [
|
||||
F.pad(img, (0, 0, 0, max_height - img.shape[1]))
|
||||
for img in out_images
|
||||
]
|
||||
|
||||
# Stack images
|
||||
out_image = torch.stack(out_images, 0).permute(0, 2, 3, 1) # [B, H, W, C]
|
||||
|
||||
# Create grid if columns specified
|
||||
if cols_num > -1:
|
||||
rows, cols = calculate_grid_dimensions(out_image.shape[0], cols_num)
|
||||
b, h, w, c = out_image.shape
|
||||
|
||||
# Pad if necessary
|
||||
if b % cols != 0:
|
||||
padding = cols - (b % cols)
|
||||
out_image = F.pad(out_image, (0, 0, 0, 0, 0, 0, 0, padding))
|
||||
b = out_image.shape[0]
|
||||
|
||||
# Reshape into grid
|
||||
out_image = out_image.reshape(rows, cols, h, w, c)
|
||||
out_image = out_image.permute(0, 2, 1, 3, 4) # [rows, h, cols, w, c]
|
||||
out_image = out_image.reshape(rows * h, cols * w, c).unsqueeze(0)
|
||||
|
||||
self.log_info(f"Created {rows}x{cols} grid")
|
||||
|
||||
return (out_image,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error creating parameter plot: {str(e)}", e)
|
||||
return (images,)
|
||||
|
||||
def _get_font_path(self) -> str:
|
||||
"""
|
||||
Get the path to the font file.
|
||||
|
||||
Returns:
|
||||
Path to font file
|
||||
"""
|
||||
# Try to find a monospace font
|
||||
possible_paths = [
|
||||
# Check if ComfyUI_essentials font exists
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../../referance/ComfyUI_essentials/fonts/ShareTechMono-Regular.ttf",
|
||||
),
|
||||
# System fonts
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
"/System/Library/Fonts/Courier.dfont",
|
||||
"C:\\Windows\\Fonts\\cour.ttf",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
# Return a default that PIL will handle
|
||||
return "arial.ttf"
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Sampler Select Helper module."""
|
||||
|
||||
from .node import SamplerSelectHelperNode
|
||||
|
||||
__all__ = ["SamplerSelectHelperNode"]
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Logic module for Sampler Select Helper node."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
SAMPLERS = comfy.samplers.KSampler.SAMPLERS
|
||||
except ImportError:
|
||||
SAMPLERS = [
|
||||
"euler",
|
||||
"euler_cfg_pp",
|
||||
"euler_ancestral",
|
||||
"euler_ancestral_cfg_pp",
|
||||
"heun",
|
||||
"heunpp2",
|
||||
"dpm_2",
|
||||
"dpm_2_ancestral",
|
||||
"lms",
|
||||
"dpm_fast",
|
||||
"dpm_adaptive",
|
||||
"dpmpp_2s_ancestral",
|
||||
"dpmpp_2s_ancestral_cfg_pp",
|
||||
"dpmpp_sde",
|
||||
"dpmpp_sde_gpu",
|
||||
"dpmpp_2m",
|
||||
"dpmpp_2m_cfg_pp",
|
||||
"dpmpp_2m_sde",
|
||||
"dpmpp_2m_sde_gpu",
|
||||
"dpmpp_3m_sde",
|
||||
"dpmpp_3m_sde_gpu",
|
||||
"ddpm",
|
||||
"lcm",
|
||||
"ipndm",
|
||||
"ipndm_v",
|
||||
"deis",
|
||||
"ddim",
|
||||
"uni_pc",
|
||||
"uni_pc_bh2",
|
||||
]
|
||||
|
||||
|
||||
def process_sampler_selection(**sampler_flags: bool) -> str:
|
||||
"""
|
||||
Process boolean flags for each sampler and return selected ones.
|
||||
|
||||
Args:
|
||||
**sampler_flags: Keyword arguments where keys are sampler names
|
||||
and values are boolean selection states
|
||||
|
||||
Returns:
|
||||
Comma-separated string of selected sampler names
|
||||
"""
|
||||
try:
|
||||
selected_samplers = [
|
||||
sampler_name
|
||||
for sampler_name, is_selected in sampler_flags.items()
|
||||
if is_selected
|
||||
]
|
||||
|
||||
if not selected_samplers:
|
||||
logger.warning("No samplers selected, returning empty string")
|
||||
return ""
|
||||
|
||||
result = ", ".join(selected_samplers)
|
||||
logger.info(f"Selected samplers: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing sampler selection: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def validate_sampler_names(sampler_names: str) -> List[str]:
|
||||
"""
|
||||
Validate and clean a comma-separated string of sampler names.
|
||||
|
||||
Args:
|
||||
sampler_names: Comma-separated string of sampler names
|
||||
|
||||
Returns:
|
||||
List of valid sampler names
|
||||
"""
|
||||
if not sampler_names:
|
||||
return []
|
||||
|
||||
try:
|
||||
names = [name.strip() for name in sampler_names.split(",")]
|
||||
valid_names = [name for name in names if name in SAMPLERS]
|
||||
|
||||
invalid_names = [name for name in names if name not in SAMPLERS]
|
||||
if invalid_names:
|
||||
logger.warning(f"Invalid sampler names ignored: {invalid_names}")
|
||||
|
||||
return valid_names
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating sampler names: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_sampler_groups() -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get samplers organized by algorithm family.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping algorithm families to sampler names
|
||||
"""
|
||||
groups = {
|
||||
"Euler": ["euler", "euler_cfg_pp", "euler_ancestral", "euler_ancestral_cfg_pp"],
|
||||
"Heun": ["heun", "heunpp2"],
|
||||
"DPM": ["dpm_2", "dpm_2_ancestral", "dpm_fast", "dpm_adaptive"],
|
||||
"DPM++": [
|
||||
"dpmpp_2s_ancestral",
|
||||
"dpmpp_2s_ancestral_cfg_pp",
|
||||
"dpmpp_sde",
|
||||
"dpmpp_sde_gpu",
|
||||
"dpmpp_2m",
|
||||
"dpmpp_2m_cfg_pp",
|
||||
"dpmpp_2m_sde",
|
||||
"dpmpp_2m_sde_gpu",
|
||||
"dpmpp_3m_sde",
|
||||
"dpmpp_3m_sde_gpu",
|
||||
],
|
||||
"Other": [
|
||||
"lms",
|
||||
"ddpm",
|
||||
"lcm",
|
||||
"ipndm",
|
||||
"ipndm_v",
|
||||
"deis",
|
||||
"ddim",
|
||||
"uni_pc",
|
||||
"uni_pc_bh2",
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
family: [s for s in samplers if s in SAMPLERS]
|
||||
for family, samplers in groups.items()
|
||||
}
|
||||
|
||||
|
||||
def get_default_samplers() -> List[str]:
|
||||
"""
|
||||
Get a list of commonly used default samplers.
|
||||
|
||||
Returns:
|
||||
List of default sampler names
|
||||
"""
|
||||
defaults = [
|
||||
"euler",
|
||||
"euler_ancestral",
|
||||
"dpmpp_2m",
|
||||
"dpmpp_sde",
|
||||
"dpmpp_2m_sde",
|
||||
"ddim",
|
||||
"uni_pc",
|
||||
]
|
||||
return [s for s in defaults if s in SAMPLERS]
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Sampler Select Helper node for ComfyUI."""
|
||||
|
||||
from typing import Tuple
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import process_sampler_selection, SAMPLERS
|
||||
|
||||
|
||||
class SamplerSelectHelperNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Sampler Select Helper node for multi-sampler selection.
|
||||
|
||||
Provides checkboxes for each available sampler and returns a
|
||||
comma-separated string of selected samplers. Useful for batch
|
||||
processing and XYZ plot generation.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
sampler: (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": f"Enable {sampler} sampler"},
|
||||
)
|
||||
for sampler in SAMPLERS
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("selected_samplers",)
|
||||
FUNCTION = "select_samplers"
|
||||
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def select_samplers(self, **sampler_flags) -> Tuple[str]:
|
||||
"""
|
||||
Process sampler selections and return comma-separated string.
|
||||
|
||||
Args:
|
||||
**sampler_flags: Boolean flags for each sampler
|
||||
|
||||
Returns:
|
||||
Tuple containing comma-separated string of selected samplers
|
||||
"""
|
||||
try:
|
||||
selected = process_sampler_selection(**sampler_flags)
|
||||
|
||||
if selected:
|
||||
self.log_info(f"Selected {len(selected.split(', '))} samplers")
|
||||
else:
|
||||
self.log_info("No samplers selected")
|
||||
|
||||
return (selected,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error selecting samplers: {str(e)}", e)
|
||||
return ("",)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Scheduler Select Helper module."""
|
||||
|
||||
from .node import SchedulerSelectHelperNode
|
||||
|
||||
__all__ = ["SchedulerSelectHelperNode"]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Logic module for Scheduler Select Helper node."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
SCHEDULERS = comfy.samplers.KSampler.SCHEDULERS
|
||||
except ImportError:
|
||||
SCHEDULERS = [
|
||||
"normal",
|
||||
"karras",
|
||||
"exponential",
|
||||
"sgm_uniform",
|
||||
"simple",
|
||||
"ddim_uniform",
|
||||
"beta",
|
||||
"linear",
|
||||
"aligned",
|
||||
"ays",
|
||||
]
|
||||
|
||||
|
||||
def process_scheduler_selection(**scheduler_flags: bool) -> str:
|
||||
"""
|
||||
Process boolean flags for each scheduler and return selected ones.
|
||||
|
||||
Args:
|
||||
**scheduler_flags: Keyword arguments where keys are scheduler names
|
||||
and values are boolean selection states
|
||||
|
||||
Returns:
|
||||
Comma-separated string of selected scheduler names
|
||||
"""
|
||||
try:
|
||||
selected_schedulers = [
|
||||
scheduler_name
|
||||
for scheduler_name, is_selected in scheduler_flags.items()
|
||||
if is_selected
|
||||
]
|
||||
|
||||
if not selected_schedulers:
|
||||
logger.warning("No schedulers selected, returning empty string")
|
||||
return ""
|
||||
|
||||
result = ", ".join(selected_schedulers)
|
||||
logger.info(f"Selected schedulers: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing scheduler selection: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def validate_scheduler_names(scheduler_names: str) -> List[str]:
|
||||
"""
|
||||
Validate and clean a comma-separated string of scheduler names.
|
||||
|
||||
Args:
|
||||
scheduler_names: Comma-separated string of scheduler names
|
||||
|
||||
Returns:
|
||||
List of valid scheduler names
|
||||
"""
|
||||
if not scheduler_names:
|
||||
return []
|
||||
|
||||
try:
|
||||
names = [name.strip() for name in scheduler_names.split(",")]
|
||||
valid_names = [name for name in names if name in SCHEDULERS]
|
||||
|
||||
invalid_names = [name for name in names if name not in SCHEDULERS]
|
||||
if invalid_names:
|
||||
logger.warning(f"Invalid scheduler names ignored: {invalid_names}")
|
||||
|
||||
return valid_names
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating scheduler names: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_scheduler_categories() -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get schedulers organized by category.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping categories to scheduler names
|
||||
"""
|
||||
categories = {
|
||||
"Standard": ["normal", "karras", "exponential", "simple"],
|
||||
"Uniform": ["sgm_uniform", "ddim_uniform"],
|
||||
"Advanced": ["beta", "linear", "aligned", "ays"],
|
||||
}
|
||||
|
||||
return {
|
||||
category: [s for s in schedulers if s in SCHEDULERS]
|
||||
for category, schedulers in categories.items()
|
||||
}
|
||||
|
||||
|
||||
def get_default_schedulers() -> List[str]:
|
||||
"""
|
||||
Get a list of commonly used default schedulers.
|
||||
|
||||
Returns:
|
||||
List of default scheduler names
|
||||
"""
|
||||
defaults = ["normal", "karras", "exponential", "simple"]
|
||||
return [s for s in defaults if s in SCHEDULERS]
|
||||
|
||||
|
||||
def get_scheduler_description(scheduler_name: str) -> str:
|
||||
"""
|
||||
Get a description of what a scheduler does.
|
||||
|
||||
Args:
|
||||
scheduler_name: Name of the scheduler
|
||||
|
||||
Returns:
|
||||
Description string
|
||||
"""
|
||||
descriptions = {
|
||||
"normal": "Standard linear timestep spacing",
|
||||
"karras": "Karras et al. noise schedule for improved quality",
|
||||
"exponential": "Exponential timestep spacing for smoother transitions",
|
||||
"sgm_uniform": "Stable Diffusion uniform spacing",
|
||||
"simple": "Simple linear schedule for fast sampling",
|
||||
"ddim_uniform": "DDIM-optimized uniform spacing",
|
||||
"beta": "Beta schedule with variance preservation",
|
||||
"linear": "Linear timestep reduction",
|
||||
"aligned": "Aligned schedule for consistent results",
|
||||
"ays": "Align Your Steps schedule",
|
||||
}
|
||||
|
||||
return descriptions.get(scheduler_name, "Custom scheduler")
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Scheduler Select Helper node for ComfyUI."""
|
||||
|
||||
from typing import Tuple
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import process_scheduler_selection, SCHEDULERS
|
||||
|
||||
|
||||
class SchedulerSelectHelperNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Scheduler Select Helper node for multi-scheduler selection.
|
||||
|
||||
Provides checkboxes for each available scheduler and returns a
|
||||
comma-separated string of selected schedulers. Useful for batch
|
||||
processing and XYZ plot generation.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
scheduler: (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": f"Enable {scheduler} scheduler"},
|
||||
)
|
||||
for scheduler in SCHEDULERS
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("selected_schedulers",)
|
||||
FUNCTION = "select_schedulers"
|
||||
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def select_schedulers(self, **scheduler_flags) -> Tuple[str]:
|
||||
"""
|
||||
Process scheduler selections and return comma-separated string.
|
||||
|
||||
Args:
|
||||
**scheduler_flags: Boolean flags for each scheduler
|
||||
|
||||
Returns:
|
||||
Tuple containing comma-separated string of selected schedulers
|
||||
"""
|
||||
try:
|
||||
selected = process_scheduler_selection(**scheduler_flags)
|
||||
|
||||
if selected:
|
||||
self.log_info(f"Selected {len(selected.split(', '))} schedulers")
|
||||
else:
|
||||
self.log_info("No schedulers selected")
|
||||
|
||||
return (selected,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error selecting schedulers: {str(e)}", e)
|
||||
return ("",)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Text Encode for Sampler Params module."""
|
||||
|
||||
from .node import TextEncodeSamplerParamsNode
|
||||
|
||||
__all__ = ["TextEncodeSamplerParamsNode"]
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Logic module for Text Encode Sampler Params node."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def split_prompts(text: str) -> List[str]:
|
||||
"""
|
||||
Split text into multiple prompts using separator patterns.
|
||||
|
||||
Recognizes various separator patterns:
|
||||
- Three or more dashes: ---
|
||||
- Three or more asterisks: ***
|
||||
- Three or more equals: ===
|
||||
- Three or more tildes: ~~~
|
||||
|
||||
Args:
|
||||
text: Multi-line text with separators
|
||||
|
||||
Returns:
|
||||
List of individual prompt strings
|
||||
"""
|
||||
try:
|
||||
normalized = re.sub(r"[-*=~]{3,}\n", "---\n", text)
|
||||
|
||||
parts = normalized.split("---\n")
|
||||
|
||||
prompts = []
|
||||
for part in parts:
|
||||
cleaned = part.strip()
|
||||
if cleaned:
|
||||
prompts.append(cleaned)
|
||||
|
||||
if not prompts and text.strip():
|
||||
prompts = [text.strip()]
|
||||
|
||||
logger.info(f"Split text into {len(prompts)} prompts")
|
||||
return prompts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error splitting prompts: {e}")
|
||||
if text.strip():
|
||||
return [text.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def encode_prompts(prompts: List[str], clip_encoder) -> List[Any]:
|
||||
"""
|
||||
Encode a list of prompts using CLIP encoder.
|
||||
|
||||
Args:
|
||||
prompts: List of text prompts
|
||||
clip_encoder: CLIP encoder instance
|
||||
|
||||
Returns:
|
||||
List of encoded conditioning tensors
|
||||
"""
|
||||
encoded = []
|
||||
|
||||
try:
|
||||
from nodes import CLIPTextEncode
|
||||
|
||||
encoder = CLIPTextEncode()
|
||||
|
||||
for i, prompt in enumerate(prompts):
|
||||
try:
|
||||
conditioning = encoder.encode(clip_encoder, prompt)[0]
|
||||
encoded.append(conditioning)
|
||||
logger.debug(f"Encoded prompt {i+1}/{len(prompts)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode prompt {i+1}: {e}")
|
||||
encoded.append(None)
|
||||
|
||||
encoded = [e for e in encoded if e is not None]
|
||||
|
||||
logger.info(f"Successfully encoded {len(encoded)}/{len(prompts)} prompts")
|
||||
|
||||
except ImportError:
|
||||
logger.error("CLIPTextEncode not available, returning mock encodings")
|
||||
encoded = [{"mock": prompt} for prompt in prompts]
|
||||
except Exception as e:
|
||||
logger.error(f"Error encoding prompts: {e}")
|
||||
|
||||
return encoded
|
||||
|
||||
|
||||
def create_sampler_params_conditioning(
|
||||
prompts: List[str], encoded: List[Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a conditioning dictionary for sampler params.
|
||||
|
||||
Args:
|
||||
prompts: List of original text prompts
|
||||
encoded: List of encoded conditioning tensors
|
||||
|
||||
Returns:
|
||||
Dictionary with text and encoded conditioning
|
||||
"""
|
||||
return {"text": prompts, "encoded": encoded, "count": len(prompts)}
|
||||
|
||||
|
||||
def validate_prompt_format(text: str) -> bool:
|
||||
"""
|
||||
Validate that the prompt text is properly formatted.
|
||||
|
||||
Args:
|
||||
text: Input text to validate
|
||||
|
||||
Returns:
|
||||
True if format is valid
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
logger.warning("Empty prompt text")
|
||||
return False
|
||||
|
||||
if len(text) > 10000:
|
||||
logger.warning(f"Prompt text too long: {len(text)} characters")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_prompt_statistics(prompts: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the prompts.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics
|
||||
"""
|
||||
if not prompts:
|
||||
return {
|
||||
"count": 0,
|
||||
"total_chars": 0,
|
||||
"avg_chars": 0,
|
||||
"min_chars": 0,
|
||||
"max_chars": 0,
|
||||
}
|
||||
|
||||
char_counts = [len(p) for p in prompts]
|
||||
|
||||
return {
|
||||
"count": len(prompts),
|
||||
"total_chars": sum(char_counts),
|
||||
"avg_chars": sum(char_counts) // len(char_counts),
|
||||
"min_chars": min(char_counts),
|
||||
"max_chars": max(char_counts),
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Text Encode for Sampler Params node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
split_prompts,
|
||||
encode_prompts,
|
||||
create_sampler_params_conditioning,
|
||||
validate_prompt_format,
|
||||
)
|
||||
|
||||
|
||||
class TextEncodeSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Text Encode for Sampler Params node.
|
||||
|
||||
Splits multi-line text by separators (---, ***, ===, ~~~) and encodes
|
||||
each part separately. Returns a special conditioning format suitable
|
||||
for batch processing and XYZ plot generation.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"default": "Separate prompts with at least three dashes\n---\nLike so",
|
||||
"tooltip": "Multi-line text with --- separators between prompts",
|
||||
},
|
||||
),
|
||||
"clip": ("CLIP", {"tooltip": "CLIP model for text encoding"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
RETURN_NAMES = ("conditioning",)
|
||||
FUNCTION = "encode_prompts"
|
||||
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def encode_prompts(self, text: str, clip: Any) -> Tuple[Any]:
|
||||
"""
|
||||
Split and encode multiple prompts for batch processing.
|
||||
|
||||
Args:
|
||||
text: Multi-line text with separators
|
||||
clip: CLIP encoder model
|
||||
|
||||
Returns:
|
||||
Tuple containing conditioning dictionary
|
||||
"""
|
||||
try:
|
||||
if not validate_prompt_format(text):
|
||||
self.handle_error("Invalid prompt format")
|
||||
|
||||
prompts = split_prompts(text)
|
||||
|
||||
if not prompts:
|
||||
self.log_info("No prompts found in text")
|
||||
return ({"text": [], "encoded": []},)
|
||||
|
||||
self.log_info(f"Processing {len(prompts)} prompts")
|
||||
|
||||
encoded = encode_prompts(prompts, clip)
|
||||
|
||||
if not encoded:
|
||||
self.handle_error("Failed to encode any prompts")
|
||||
|
||||
conditioning = create_sampler_params_conditioning(prompts, encoded)
|
||||
|
||||
self.log_info(
|
||||
f"Successfully encoded {len(encoded)} prompts "
|
||||
f"(avg {sum(len(p) for p in prompts) // len(prompts)} chars)"
|
||||
)
|
||||
|
||||
return (conditioning,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error processing prompts: {str(e)}", e)
|
||||
return ({"text": [], "encoded": []},)
|
||||
@@ -0,0 +1,194 @@
|
||||
# ComfyUI-KikoTools XYZ Grid Development Plan
|
||||
|
||||
## Current Session Context (2025-08-05)
|
||||
|
||||
### Working Branch: `feature/xyz-nodes`
|
||||
|
||||
### Completed Work
|
||||
|
||||
#### 1. XYZ Plot Controller
|
||||
- ✅ Implemented dynamic widget management with RGThree-style interface
|
||||
- ✅ Added right-click context menus (Toggle, Move Up/Down, Remove)
|
||||
- ✅ Fixed text input removal that was leaving DOM elements behind
|
||||
- ✅ Added placeholder hints for text inputs
|
||||
- ✅ Auto-resize nodes when adding widgets
|
||||
- ✅ Removed unwanted "input" connection from node
|
||||
- ✅ Fixed image count calculation for step ranges (e.g., "10:50:5")
|
||||
- ✅ Added callbacks to update node title with image count
|
||||
|
||||
#### 2. XYZ Prompt Node
|
||||
- ✅ Created separate node for prompt management
|
||||
- ✅ Implemented dynamic prompt set addition/removal
|
||||
- ✅ Added include_negative toggle for showing/hiding negative prompts
|
||||
- ✅ Added repeat_negative feature (use first negative for all variations)
|
||||
- ✅ Fixed spacing issues with protected button containers
|
||||
- ✅ Fixed widget values not passing to Python backend (added FlexibleOptionalInputType)
|
||||
- ✅ Visual styling: green background for positive, red for negative prompts
|
||||
|
||||
#### 3. ImageGridCombiner
|
||||
- ✅ Fixed grid_data structure mismatch with controller
|
||||
- ✅ Added proper dimensions object (cols, rows, grids_count)
|
||||
- ✅ Added axes object with human-readable labels
|
||||
- ✅ Created _create_labels method for formatting axis values
|
||||
|
||||
### Current Issues
|
||||
|
||||
#### 1. XYZ Prompt Widget Restoration Bug
|
||||
**Problem**: When refreshing the page, prompts aren't properly restored
|
||||
- Negative prompt appears at top with saved value
|
||||
- Positive prompts are lost
|
||||
- Widget restoration from widgets_values array not working correctly
|
||||
|
||||
**Current Fix Attempt**:
|
||||
- Modified onConfigure to properly clean up dynamic widgets
|
||||
- Added debug logging to trace restoration
|
||||
- Using promptData to track number of prompt sets
|
||||
- Need to properly handle widgets_values array restoration
|
||||
|
||||
#### 2. Pending Tasks (from todo list)
|
||||
- Complete queue implementation for actual ComfyUI API integration
|
||||
- Remove debug logging from production JavaScript
|
||||
- Add validation for invalid axis combinations
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
ComfyUI-KikoTools/
|
||||
├── kikotools/
|
||||
│ └── tools/
|
||||
│ └── xyz_grid/
|
||||
│ ├── controller/
|
||||
│ │ ├── power_node.py (Main XYZ Plot Controller)
|
||||
│ │ ├── queue_manager.py (Placeholder - needs implementation)
|
||||
│ │ └── execution.py
|
||||
│ ├── prompt/
|
||||
│ │ └── node.py (XYZ Prompt node)
|
||||
│ ├── combiner/
|
||||
│ │ └── node.py (ImageGridCombiner)
|
||||
│ └── __init__.py
|
||||
├── web/
|
||||
│ ├── xyz_plot_controller.js (Dynamic widget UI)
|
||||
│ ├── xyz_prompt.js (Prompt management UI)
|
||||
│ └── disabled/ (Old implementations)
|
||||
└── examples/
|
||||
└── xyz_grid_test_workflow.json (Test workflow)
|
||||
```
|
||||
|
||||
### Key Technical Patterns
|
||||
|
||||
#### Python Node Pattern
|
||||
```python
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Accepts dynamic widget values from JavaScript."""
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
def __getitem__(self, key):
|
||||
return ("STRING", {"multiline": True, "forceInput": False})
|
||||
|
||||
# In INPUT_TYPES:
|
||||
"optional": FlexibleOptionalInputType()
|
||||
```
|
||||
|
||||
#### JavaScript Widget Creation
|
||||
```javascript
|
||||
const widget = ComfyWidgets.STRING(
|
||||
this,
|
||||
widgetName,
|
||||
["STRING", config],
|
||||
app
|
||||
).widget;
|
||||
```
|
||||
|
||||
#### RGThree-style Context Menu
|
||||
```javascript
|
||||
getSlotInPosition(x, y) {
|
||||
// Return fake slot with widget for context menu
|
||||
const widget = this.findWidgetAtPosition(x, y);
|
||||
if (widget) {
|
||||
return {
|
||||
slot_index: -1,
|
||||
widget: widget
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getSlotMenuOptions(slot) {
|
||||
if (slot?.widget) {
|
||||
return this.getWidgetMenuOptions(slot.widget);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Git Commands for Session Recovery
|
||||
|
||||
```bash
|
||||
# Switch to working branch
|
||||
git checkout feature/xyz-nodes
|
||||
|
||||
# Check current status
|
||||
git status
|
||||
|
||||
# Recent commits
|
||||
git log --oneline -10
|
||||
|
||||
# Current changes
|
||||
git diff
|
||||
```
|
||||
|
||||
### Testing Instructions
|
||||
|
||||
1. Load ComfyUI
|
||||
2. Refresh browser (F5)
|
||||
3. Add XYZ Prompt node
|
||||
4. Add multiple prompts
|
||||
5. Save workflow
|
||||
6. Refresh page
|
||||
7. Check if prompts are restored correctly
|
||||
|
||||
### Debug Points
|
||||
|
||||
1. Check browser console for debug logs from:
|
||||
- `XYZ Prompt onConfigure`
|
||||
- `XYZ Prompt serialize`
|
||||
- Widget creation logs
|
||||
|
||||
2. Monitor Python console for:
|
||||
- `XYZPrompt.process_prompts` kwargs
|
||||
- Grid data structure output
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Fix widget restoration**:
|
||||
- Properly handle widgets_values array
|
||||
- Ensure widget values are restored in correct order
|
||||
- Test with multiple prompt sets
|
||||
|
||||
2. **Clean up debug code**:
|
||||
- Remove console.log statements
|
||||
- Remove print statements in Python
|
||||
|
||||
3. **Complete queue manager**:
|
||||
- Implement actual ComfyUI API integration
|
||||
- Handle batch execution properly
|
||||
|
||||
4. **Add validation**:
|
||||
- Prevent same parameter on multiple axes
|
||||
- Validate numeric ranges
|
||||
- Check model/VAE/LoRA availability
|
||||
|
||||
### Important Notes
|
||||
|
||||
- CLAUDE.md is in .gitignore (local only)
|
||||
- Main branch is `main` for PRs
|
||||
- Test with actual checkpoint files before merging
|
||||
- Memory management for large grids needs optimization
|
||||
- Performance concerns with many dynamic widgets
|
||||
|
||||
### Session Recovery Command
|
||||
|
||||
To continue work in new terminal:
|
||||
```bash
|
||||
cd /home/vito/code/personal/ComfyUI-KikoTools
|
||||
git checkout feature/xyz-nodes
|
||||
# Check this plan.md for context
|
||||
```
|
||||
+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.10"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -3,17 +3,10 @@ pytest configuration and fixtures for ComfyUI-KikoTools testing
|
||||
Provides mock ComfyUI environments and test data
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pytest
|
||||
import torch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock folder_paths module before any imports that might use it
|
||||
sys.modules["folder_paths"] = MagicMock()
|
||||
sys.modules["folder_paths"].get_filename_list = MagicMock(return_value=[])
|
||||
sys.modules["folder_paths"].get_folder_paths = MagicMock(return_value=["/mock/path"])
|
||||
sys.modules["folder_paths"].base_path = "/mock/base"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_image_tensor():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user