Compare commits

..
Author SHA1 Message Date
Vito Sansevero 11931a2e51 docs: add comprehensive plan.md for session recovery
- Document all completed XYZ Grid node work
- List current issues and pending fixes
- Include technical patterns and code examples
- Add testing instructions and debug points
- Provide git commands for session recovery
2025-08-05 16:15:03 -07:00
Vito Sansevero 9da65b7e38 chore: remove CLAUDE.md from repository and add to .gitignore
- Remove CLAUDE.md from version control
- Add CLAUDE.md to .gitignore to keep it local-only
- Project instructions should remain private to each developer
2025-08-05 16:05:59 -07:00
Vito Sansevero 0868b32318 fix: resolve grid_data structure mismatch between controller and combiner
- Add dimensions object with cols, rows, and grids_count for ImageGridCombiner
- Add axes object with human-readable labels for each axis
- Create _create_labels method to format axis values appropriately
- Keep backward compatibility with root-level total_images
- Fix ImageGridCombiner crash when processing grid data
2025-08-05 16:04:49 -07:00
Vito Sansevero de49b8abec removed 2025-08-05 14:52:34 -07:00
Vito Sansevero 64f2b0530d fix: XYZ Prompt widget values now properly pass to Python backend
- Add FlexibleOptionalInputType to accept dynamic widget values from JavaScript
- Create protected button container to prevent text overflow onto remove buttons
- Add visual separator and background for button area
- Add debug logging for troubleshooting widget value serialization
- Fix widget value collection to match XYZ Plot Controller pattern
2025-08-05 14:50:35 -07:00
Vito Sansevero 1308d0055e feat: add XYZ Prompt node with dynamic prompt management
- Create separate XYZ Prompt node for cleaner architecture
- Add include_negative toggle to show/hide negative prompts
- Add repeat_negative option to use first negative for all variations
- Implement dynamic prompt widget management with add/remove functionality
- Style positive prompts with green background, negative with red
- Fix widget spacing issues with proper margins and spacers
- Track non-empty prompts in node title counter
2025-08-05 14:33:36 -07:00
Vito Sansevero f484482e2c feat: complete XYZ Plot Controller widget functionality
- Remove unwanted input connection from node
- Fix FlexibleOptionalInputType to not create input slot
- Add callbacks to update image count when any input changes
- Support text widget changes for ranges (e.g., 10:50:5)
- Update count when dropdown selections change
- Update count when widgets are toggled on/off
- Properly calculate total images from all axis combinations
- Verified outputs with Display Any nodes showing correct grid data
2025-08-05 13:59:52 -07:00
Vito Sansevero 8c44b99d37 feat: improve text input widgets with placeholders and auto-resize
- Add helpful placeholder hints for all text input fields
- Make all text inputs multiline for better hint display
- Use monospace font for value entry
- Set minimum height for non-prompt fields
- Auto-resize node when adding widgets to prevent overflow
- Add proper padding to prevent last widget from clipping
2025-08-05 13:04:22 -07:00
Vito Sansevero c035313e99 feat: implement right-click context menu for widgets
- Override getSlotInPosition to detect widget clicks
- Return fake slot with widget attached for menu handling
- Add context menu with Toggle, Move Up/Down, and Remove options
- Follow RGThree's pattern for widget context menus
2025-08-05 12:19:06 -07:00
Vito Sansevero 0d9d6d1872 fix: properly remove text input DOM elements when switching axis types
- Add DOM element cleanup when removing widgets
- Call onRemoved callbacks for proper widget cleanup
- Handle both 'text' and 'customtext' widget types
- Fixes issue where text inputs remained visible after switching from prompt to none
2025-08-05 11:09:32 -07:00
Vito Sansevero 40bfccc952 feat: implement XYZ Plot Controller with RGThree-style widget framework
- Created XYZ Plot Controller node with dynamic widget management
- Implemented full widget persistence across page refreshes
- Added RGThree-style UI with toggles and strength controls
- Fixed text widget serialization issues
- Implemented hide/show pattern for widget management
- Added comprehensive right-click context menus
- Created detailed documentation of the widget framework
- Removed all debug console.log statements for production
2025-08-04 19:17:54 -07:00
Vito Sansevero 7b87b01535 feat: implement XYZ Plot Controller with native dropdown selections
- Use individual dropdown widgets for each model/vae/lora selection
- Similar UI to checkpoint loader - select from dropdown, disable to remove
- Support up to 5 models, 3 VAEs, 3 LoRAs, 3 samplers, 2 schedulers
- Keep text fields for numeric values and prompts
- Update JavaScript to count selections and show total images
- Use native ComfyUI file selection dropdowns
2025-08-04 14:54:12 -07:00
Vito Sansevero cb61b60c27 fix: update imports to use new simplified XYZPlotController
- Replace XYZPlotControllerAdvanced with XYZPlotController
- Update all import statements to match new class name
- Fix __all__ exports in xyz_grid module
2025-08-04 14:40:02 -07:00
Vito Sansevero c45c9c0b91 feat: redesign XYZ Plot Controller using native ComfyUI widgets
- Remove complex HTML/JS custom interface
- Create simplified node using standard ComfyUI inputs
- Add helpful tooltips and placeholder text via minimal JS
- Support range notation (start:stop:step) for numeric values
- Show total image count in node title
- Use multiline text inputs for value entry
- Work with ComfyUI's native widget system
2025-08-04 14:26:52 -07:00
Vito Sansevero b3c510b90d fix: keep original widgets in array to prevent execution errors
- Keep widgets in the array but hide them visually
- Add custom widget to the array instead of replacing it
- Ensure backend can still access widget values
- Remove duplicate size setting
2025-08-04 14:20:05 -07:00
Vito Sansevero 417e99b172 fix: use fixed dimensions to prevent massive overflow
- Set fixed width (360px) and height (620px) for container
- Remove percentage-based sizing that was causing overflow
- Use explicit pixel dimensions for widget element
- Ensure consistent sizing throughout
2025-08-04 14:14:32 -07:00
Vito Sansevero 59fe64d04f fix: constrain widget height to prevent overflow
- Use max-height instead of fixed height for container
- Set overflow hidden on widget element
- Update resize handler to use maxHeight instead of height
- Ensure widget respects node boundaries
2025-08-04 14:11:49 -07:00
Vito Sansevero 19666de804 fix: simplify widget creation and remove old conflicting file
- Remove xyz_plot_controller_old.js that was interfering
- Create widget immediately without delay
- Use fixed height container instead of absolute positioning
- Add computeSize function to widget for proper sizing
- Schedule resize with setTimeout(0) for next tick
2025-08-04 14:09:17 -07:00
Vito Sansevero 60a3104a38 fix: improve initial widget rendering with delayed creation
- Delay widget creation by 50ms to ensure node is fully initialized
- Set node size before creating widget
- Use absolute positioning for container to fill available space
- Force multiple canvas redraws to ensure proper display
- Explicitly set widget dimensions in pixels
2025-08-04 14:05:38 -07:00
Vito Sansevero 01c14ea363 fix: resolve initial rendering issue in XYZ Plot Controller
- Add computeSize callback to DOM widget for proper initial sizing
- Force widget size update after creation
- Add onResize handler to properly adjust widget when node is resized
- Set container minimum height and overflow properties
- Force canvas redraw after widget creation
2025-08-04 14:02:37 -07:00
Vito Sansevero 63c9d81ebd fix: adjust XYZ Plot Controller sizing to show all elements properly
- Increase initial node height to 680px to show all 3 axis groups
- Reduce padding and margins in axis groups for more compact layout
- Adjust textarea min/max heights for better space utilization
- Ensure Total Images counter is properly positioned at bottom
- Fix element overlap issues by providing adequate vertical space
2025-08-04 13:58:12 -07:00
Vito Sansevero 94200003ee fix: set proper initial size for XYZ Plot Controller node
- Set initial size to 350x550 immediately in onNodeCreated
- Remove computeSize override for simpler implementation
- Ensure node displays correctly when first added to canvas
- Match behavior of Display Text node for consistent UX
2025-08-04 13:50:24 -07:00
Vito Sansevero f1e73d1e94 fix: optimize XYZ Plot Controller layout and sizing
- Reduce padding and margins throughout for more compact display
- Decrease font sizes appropriately (11px for inputs, 10px for info)
- Set fixed node dimensions (350x520) for consistent appearance
- Limit textarea heights to prevent excessive vertical space
- Adjust button and info box styling for tighter layout
- Override computeSize to maintain proper dimensions
2025-08-04 13:38:09 -07:00
Vito Sansevero 429f3f067a fix: properly hide original widgets in XYZ Plot Controller
- Store original widgets in separate array for access
- Remove all widgets from display array to prevent them showing
- Hide widget parent elements to remove spacing
- Update all widget references to use originalWidgets array
- Ensure custom UI is the only visible widget
2025-08-04 13:29:20 -07:00
Vito Sansevero 84138cd46e feat: complete rewrite of XYZ Plot Controller UI using custom HTML interface
- Replace problematic widget-based UI with full HTML interface
- Fix overlapping buttons and spacing issues
- Add proper multi-select dialogs with search functionality
- Implement Select All/Clear All buttons in dialogs
- Hide original widgets to prevent conflicts
- Add visual grouping for X/Y/Z axes
- Improve responsive layout and styling
- Maintain sync with underlying widget values
2025-08-04 13:21:49 -07:00
Vito Sansevero 978580924a fix: improve XYZ Plot Controller UI with generic parameter detection and proper widget updates
- Add generic findOptionsForParameter function that searches all nodes
- Fix button not updating when axis type changes
- Improve spacing to prevent widget overlap
- Add proper pluralization for button labels (VAEs, LoRAs, etc.)
- Add hover effects and better visual styling
- Ensure widget callbacks properly trigger updates
- Add node resizing when content changes
2025-08-04 13:09:58 -07:00
Vito Sansevero be560ad2f5 fix: rewrite XYZ Plot Controller JS using proper ComfyUI patterns
- Use correct import path (../../scripts/app.js)
- Implement using addDOMWidget for custom UI elements
- Use onNodeCreated and onWidgetChange hooks properly
- Add info display and total image count in DOM widget
- Fix widget element access patterns
- Simplify implementation for better compatibility
2025-08-04 12:44:24 -07:00
Vito Sansevero 0e7747d248 fix: consolidate and fix XYZ Plot Controller JavaScript
- Move JS files to correct web directory location
- Fix import paths for ComfyUI compatibility
- Consolidate all UI functionality into single xyz_plot_controller.js
- Add proper widget enhancement with select buttons
- Fix API calls to use ComfyUI's api object
- Add working multi-select dialogs and validation
2025-08-04 11:56:35 -07:00
Vito Sansevero 012c0e698c feat: add intelligent UI for XYZ Plot Controller
- Dynamic value selection widgets for models, VAEs, LoRAs, samplers
- Multi-select dialogs with search functionality
- Context-sensitive examples and usage hints for each parameter type
- Real-time validation for numeric inputs with range syntax support
- Visual feedback with proper styling and animations
- Connection hints showing where to connect outputs
- Parameter-specific placeholders and tooltips
2025-08-04 11:50:12 -07:00
Vito Sansevero b18f7cad38 feat: add complete set of example workflows for XYZ grid
- sampler_comparison.json: 12 samplers x 4 step counts grid
- prompt_variations.json: 3 models x 5 diverse prompts
- advanced_3d_grid.json: LoRA x Seed x Denoise strength (3D)
- flux_guidance_test.json: Flux guidance x CFG scale comparison
- All workflows include proper node connections and documentation
2025-08-04 11:43:45 -07:00
Vito Sansevero 4bab6eb73e feat: complete XYZ grid implementation with all features
- Add execution flow with batch management and queue system
- Implement Z-axis support for multiple grid pages with labels
- Add model caching manager with intelligent memory management
- Create progress tracking system with WebSocket support
- Write comprehensive test suite (59 tests, 100% passing)
- Add example workflows and detailed documentation
- Optimize grid assembly with better label positioning
- Support for all parameter types including Flux guidance
2025-08-04 11:36:37 -07:00
Vito Sansevero b14b86fc85 feat: implement XYZ Plot Controller and Image Grid Combiner nodes
- Add comprehensive XYZ grid generation system for parameter comparisons
- Support for X, Y, and Z axes with any parameter type (models, samplers, CFG, etc.)
- Automatic grid assembly with professional labeling and annotations
- Dynamic UI with real-time image count preview
- Execution flow management for automated batch processing
- Full test coverage for helpers and converters
- Extensible architecture for future parameter types
2025-08-04 11:24:51 -07:00
169 changed files with 12590 additions and 16776 deletions
+6 -6
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.10'
@@ -133,10 +133,10 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.10'
@@ -164,10 +164,10 @@ jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.10'
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository_owner == 'ComfyAssets' }}
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
submodules: true
- name: Publish Custom Node
+2 -2
View File
@@ -15,10 +15,10 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.10'
+13 -17
View File
@@ -17,10 +17,10 @@ jobs:
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@@ -53,7 +53,7 @@ jobs:
print('✓ All imports successful')
# Test base node
assert 'ComfyAssets' in ComfyAssetsBaseNode.CATEGORY
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 'ComfyAssets/' in res_class.CATEGORY
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 'ComfyAssets/' in wh_class.CATEGORY
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 'ComfyAssets/' in sampler_class.CATEGORY
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 'ComfyAssets/' in seed_class.CATEGORY
assert seed_class.CATEGORY == 'ComfyAssets'
print('✓ Seed History ComfyUI integration passed')
print('🎉 All tools ComfyUI integration readiness tests passed!')
@@ -402,10 +398,10 @@ jobs:
test-package-structure:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.10"
@@ -458,7 +454,7 @@ jobs:
test-documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Test documentation completeness
run: |
+1 -1
View File
@@ -162,4 +162,4 @@ experiments/
# Gemini model cache
.gemini_models_cache.json
referance/
CLAUDE.md
-288
View File
@@ -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
+14 -306
View File
@@ -8,14 +8,7 @@
> A modular collection of essential custom ComfyUI nodes missing from the standard release.
ComfyUI-KikoTools provides carefully crafted, production-ready nodes under the "ComfyAssets" category.
Each tool is built with clean interfaces, thorough testing, and optimized performance for SDXL and FLUX workflows.
This project started out of frustration with keeping ComfyUI up to date and waiting for dozens of custom nodes to update—most of which I didn’t even use. After taking a hard look at my workflow, I realized I only needed one or two features from these nodes, many of which were abandoned or stuck in maintenance mode.
I tried forking, patching, and submitting merge requests, but eventually decided to create my own curated collection of tools—fully supported and maintained by me. That’s how Kiko’s Tools was born.
I’m sharing them here with the community, and I hope you find them as useful as I do.
ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped under the **"ComfyAssets"** category. Each tool is designed with clean interfaces, comprehensive testing, and optimized performance for SDXL and FLUX workflows.
## 🚀 Features
@@ -23,34 +16,16 @@ I’m sharing them here with the community, and I hope you find them as useful a
| 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 |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
| [🎬 Film Grain](#-film-grain) | Add realistic film grain effects to images | 💾 Images |
| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | 🔧 Utils |
| [🧹 Kiko Purge VRAM](#-kiko-purge-vram) | Intelligent VRAM management with detailed reporting | 🛠️ Utils |
| [📂 Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | 💾 Images |
### 🧰 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.
@@ -230,233 +205,6 @@ Adjusts image dimensions to be multiples of a specified value for model compatib
![Image to Multiple Of Example](examples/workflows/image_to_multiple_of_example.png)
#### 📉 Image Scale Down By
Efficiently scale images down by a specified factor with quality preservation.
- **Proportional Scaling**: Reduces both width and height by the same factor
- **Quality Preservation**: Uses bilinear interpolation with antialiasing
- **Batch Support**: Process multiple images simultaneously
- **Memory Efficient**: Optimized for large image batches
- **Flexible Factor**: Scale from 0.01x to 1.0x with 0.01 precision
**Use Cases:**
- Create thumbnails or preview images
- Reduce memory usage for large workflows
- Generate image pyramids for multi-scale processing
- Quick downsampling for performance optimization
- Prepare images for web display or transmission
#### 🎬 Film Grain
Add realistic analog film grain effects to generated images.
- **Realistic Grain Simulation**: Mimics actual film photography characteristics
- **Grain Size Control**: Fine to coarse grain patterns (0.25x to 2.0x)
- **Intensity Adjustment**: Variable strength from subtle to pronounced (0-10)
- **Color Saturation**: Monochrome to full color grain (0-2)
- **Shadow Lifting (Toe)**: Film-like shadow response curves
- **Red Multiplier**: Adjust red channel independently for vintage looks
- **Alpha Preservation**: Maintains transparency when present
- **ITU-R BT.709 Color Space**: Professional color handling
**Use Cases:**
- Add vintage film aesthetic to AI-generated images
- Create cinematic looks with authentic grain patterns
- Simulate different film stocks (35mm, 16mm, etc.)
- Add texture to overly smooth AI renders
- Match grain from reference photography
#### 🎛️ Flux Sampler Params
FLUX-optimized parameter generator with intelligent batch processing capabilities.
- **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
![LoRA Folder Batch Example](examples/workflows/xyz_helpers_lora_testing.png)
#### 📊 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
#### 📂 Local Image Loader
Visual gallery browser for loading local images, videos, and audio files directly into ComfyUI workflows.
- **Visual Gallery Interface**: Browse files with thumbnail previews in a masonry layout
- **Multi-Media Support**: Load images (JPG, PNG, GIF, WebP), videos (MP4, WebM, MOV), and audio files (MP3, WAV, OGG, FLAC)
- **Quick Navigation**: Navigate folders with breadcrumb path and parent directory button
- **Responsive Layout**: Automatically adjusts thumbnail grid to available space
- **Metadata Extraction**: Reads embedded prompt and workflow data from generated images
- **Saved Paths**: Remember frequently used directories for quick access
- **Double-Click Preview**: Open full-size media in new browser tab
- **Smart Sorting**: Sort by name, date, or file size in ascending or descending order
- **Pagination Support**: Efficiently browse large directories with page controls
**Use Cases:**
- Load reference images from local folders for img2img workflows
- Browse and select from collections of generated images
- Quickly access frequently used asset directories
- Extract prompts and settings from previously generated images
- Preview media files before loading into workflow
### 🔤 Embedding Autocomplete
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
<div align="center">
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-emb.png?raw=true" width="30%" alt="Embedding Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-lora.png?raw=true" width="30%" alt="LoRA Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-tag.png?raw=true" width="30%" alt="Tag Autocomplete" />
</div>
This feature is an enhanced fork of the autocomplete functionality from [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss. We've modernized the codebase, fixed existing bugs, and added robust security features.
**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 Purge VRAM
**Intelligent GPU memory management with threshold-based triggering and detailed reporting.**
**Key Features:**
- **4 Purge Modes**:
- `soft`: Basic garbage collection and cache clearing
- `aggressive`: Multiple GC passes with full CUDA cache clearing
- `models_only`: Unload all models and clear model cache
- `cache_only`: Clear CUDA cache without garbage collection
- **Smart Thresholds**: Only purge when memory usage exceeds specified MB limit
- **Detailed Reporting**: Shows before/after memory usage, freed MB, and timing
- **Passthrough Design**: Acts as workflow checkpoint without disrupting data flow
- **CPU Fallback**: Gracefully handles non-CUDA environments
**Use Cases:**
- Free memory between heavy processing stages
- Prevent OOM errors in complex workflows
- Debug memory usage patterns
- Optimize multi-model workflows
- Clean up after batch processing
**Parameters:**
- **anything**: Any input (passed through unchanged)
- **mode**: Purge strategy selection
- **report_memory**: Generate detailed memory statistics
- **memory_threshold_mb**: Only purge if usage exceeds (0 = always purge)
**Example Output:**
```
Memory usage (5000.0 MB) exceeds threshold (4000 MB)
Memory Purge Report
-------------------
Mode: soft
Memory Freed: 2500.0 MB
Before: 5000.0 MB used (62.5%)
After: 2500.0 MB used (31.3%)
Time: 150.0ms
```
### 💾 Kiko Save Image Features
**Use Cases:**
@@ -660,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
@@ -691,14 +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) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) |
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
| **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 |
@@ -992,33 +717,16 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 19 (13 core tools + 6 xyz-helpers)
- **Features**: Embedding Autocomplete (settings-based, not a node)
- **Categories**: 9 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
View File
@@ -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():
+148
View File
@@ -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.
+394
View File
@@ -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.
+684
View File
@@ -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

-106
View File
@@ -1,106 +0,0 @@
# Batch Prompts Node
The **Batch Prompts** node loads and processes prompts from text files for batch generation workflows. It automatically cycles through prompts with each execution, making it perfect for testing multiple prompts in queue batches.
## Features
- **File-based prompt loading** - Load prompts from text files with `---` separators
- **Auto-increment mode** - Automatically advance to the next prompt with each execution
- **Positive/Negative splitting** - Automatically splits prompts at "Negative:" markers
- **Persistent state** - Maintains position across ComfyUI restarts
- **Wrap-around support** - Loop back to the first prompt after the last one
- **Progress tracking** - Shows current position and total prompts
## Input Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt_file` | STRING | "prompts.txt" | Path to text file containing prompts |
| `index` | INT | 0 | Manual prompt index (when auto_increment is off) |
| `auto_increment` | BOOLEAN | True | Automatically advance to next prompt |
| `wrap_around` | BOOLEAN | True | Loop back to start after last prompt |
| `split_negative` | BOOLEAN | True | Split prompts at "Negative:" marker |
| `reload_file` | BOOLEAN | False | Force reload file from disk |
| `show_preview` | BOOLEAN | True | Show prompt preview in console |
## Output Values
| Output | Type | Description |
|--------|------|-------------|
| `positive` | STRING | The positive prompt text |
| `negative` | STRING | The negative prompt text (if split) |
| `full_prompt` | STRING | Complete prompt including negative |
| `next_prompt` | STRING | Preview of the next prompt |
| `current_index` | INT | Current prompt index (0-based) |
| `total_prompts` | INT | Total number of prompts |
| `batch_info` | STRING | Progress information string |
## Prompt File Format
Create a text file with prompts separated by `---` on its own line:
```
A beautiful sunset over the ocean
Negative: blurry, dark, low quality
---
Mountain landscape with snow peaks
Negative: foggy, unclear
---
Futuristic city at night
Negative: old, vintage, sepia
```
## Usage Examples
### Basic Queue Batch Processing
1. Create a prompt file in your ComfyUI `input` folder
2. Add the Batch Prompts node to your workflow
3. Set `prompt_file` to your file name
4. Enable `auto_increment` and `wrap_around`
5. Connect `positive` to your text encoder
6. Connect `negative` to your negative text encoder
7. Set Queue Batch to desired number (e.g., 10)
8. Run the queue - prompts will cycle automatically
### Manual Index Control
For manual control over which prompt to use:
1. Set `auto_increment` to False
2. Control the `index` parameter manually
3. Use with other nodes that provide index values
### Monitoring Progress
The node provides several ways to track progress:
- `batch_info` output shows "Prompt X of Y (Z% complete)"
- Console logging shows current prompt preview (when `show_preview` is True)
- `current_index` and `total_prompts` for custom progress displays
## Tips
- Place prompt files in the ComfyUI `input` folder for easy access
- Use relative paths like "prompts.txt" for files in the input folder
- Use absolute paths for files elsewhere on your system
- The node maintains state across ComfyUI restarts
- Set `reload_file` to True to force re-reading after editing the file
- Empty sections (between `---` markers) are automatically skipped
## Troubleshooting
### Prompts not changing in queue batch
- Ensure `auto_increment` is set to True
- Check console for "[BatchPrompts] Auto-increment" messages
- Restart ComfyUI after installing/updating the node
### File not found errors
- Check that the file exists in the ComfyUI `input` folder
- Try using an absolute path to test
- Ensure file has read permissions
### State persistence
- State is stored in your system's temp directory
- Clear `/tmp/comfyui_batch_prompts/` to reset all counters
- Use `reload_file` to reset counter for a specific file
@@ -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.
-125
View File
@@ -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,158 +0,0 @@
# Local Image Loader
## Overview
The Local Image Loader node provides a visual gallery interface for browsing and selecting images, videos, and audio files from your local filesystem directly within ComfyUI. This streamlined version focuses on essential functionality without the complexity of tagging or metadata management.
## Features
- **Visual Gallery Browser**: Browse local directories with thumbnail previews
- **Multi-Media Support**: Load images, videos, and audio files
- **Directory Navigation**: Navigate through folders with ease
- **Sorting Options**: Sort by name, date, or file size
- **Saved Paths**: Save frequently used directory paths for quick access
- **Pagination**: Handle large directories with paginated display
- **Lightbox Preview**: Full-size preview with zoom and pan capabilities
## Node Inputs
### Required Inputs
None - The node uses a visual interface for file selection
### Hidden Inputs
- `unique_id`: Automatically assigned node identifier
## Node Outputs
| Output | Type | Description |
|--------|------|-------------|
| `image` | IMAGE | The selected image as a tensor |
| `video_path` | STRING | Path to the selected video file |
| `audio_path` | STRING | Path to the selected audio file |
| `info` | STRING | JSON metadata about the selected image |
## Usage
### Basic Workflow
1. **Add the Node**: Search for "Local Image Loader" in the node menu
2. **Browse Directory**: Enter a directory path or use saved paths
3. **Select Media**: Click on thumbnails to select files
4. **Connect Outputs**: Use the outputs in your workflow
### Interface Controls
#### Path Management
- **Directory Input**: Enter or paste a directory path
- **Saved Paths Dropdown**: Quick access to saved directories
- **Save Path Button** (💾): Save current directory to favorites
- **Browse Button** (📁): Load the entered directory
#### View Options
- **Videos Checkbox**: Show/hide video files
- **Audio Checkbox**: Show/hide audio files
- **Sort By**: Choose between Name, Date, or Size
- **Sort Order**: Ascending (↑) or Descending (↓)
- **Refresh Button** (🔄): Reload current directory
#### Gallery Display
- **Thumbnail Grid**: Visual preview of files
- **Blue Border**: Selected items are highlighted
- **Folder Icons**: Navigate into subdirectories
- **Video Overlay**: Visual indicator for video files
- **Pagination**: Navigate through pages of results
## File Support
### Supported Image Formats
- `.jpg`, `.jpeg`
- `.png`
- `.bmp`
- `.gif`
- `.webp`
### Supported Video Formats
- `.mp4`
- `.webm`
- `.mov`
- `.mkv`
- `.avi`
### Supported Audio Formats
- `.mp3`
- `.wav`
- `.ogg`
- `.flac`
## Image Metadata
When an image is selected, the node extracts and returns metadata including:
- **Basic Info**: Filename, width, height, format, mode
- **Embedded Parameters**: Generation parameters if present
- **Workflow Data**: Embedded ComfyUI workflow if present
- **Prompt Data**: Embedded prompt information if present
## Examples
### Loading an Image for Processing
```
Local Image Loader → Load Image → Image Processing Node
↓
[info] → Display Text (to show metadata)
```
### Setting Up a Multi-Media Workflow
```
Local Image Loader → [image] → Image Preview
↓
[video_path] → Video Player Node
↓
[audio_path] → Audio Player Node
```
## Tips and Best Practices
1. **Save Frequently Used Paths**: Use the save button to bookmark directories you use often
2. **Use Sorting**: Sort by date to find recent files quickly
3. **Keyboard Navigation**: Press Enter in the path field to load a directory
4. **Performance**: For directories with thousands of files, use pagination to navigate efficiently
5. **Thumbnail Generation**: Thumbnails are generated on-demand and cached for performance
## Differences from Original
This version simplifies the original ComfyUI_Local_Image_Gallery by removing:
- Tag filtering and management
- Rating system
- Global tag search
- Metadata editing capabilities
These features were removed to focus on the core functionality of browsing and selecting files, making the tool simpler and more straightforward to use.
## Troubleshooting
### Common Issues
**Directory Not Loading**
- Verify the path exists and you have read permissions
- Check for special characters in the path
- Try using absolute paths instead of relative ones
**Thumbnails Not Showing**
- Ensure the files are in supported formats
- Check if the images are corrupted
- Try refreshing the gallery
**Large Directories Slow to Load**
- Use sorting and pagination to manage large folders
- Consider organizing files into subdirectories
- Enable only the media types you need (images, videos, audio)
## Technical Details
The node creates a visual widget that runs in the ComfyUI interface and communicates with the backend through API endpoints to:
- List directory contents
- Generate thumbnails
- Save user preferences
- Handle file selection
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
-264
View File
@@ -1,264 +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
- **Auto-Batching**: Automatically splits large LoRA collections into manageable chunks to prevent UI disconnection
## 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 |
| `max_loras` | INT | 50 | Maximum LoRAs to process (when auto_batch disabled) |
| `sort_order` | DROPDOWN | natural | Sorting method [natural, alphabetical, newest, oldest] |
| `auto_batch` | DROPDOWN | disabled | Enable auto-batching for large collections [disabled, enabled] |
| `batch_size` | INT | 25 | Number of LoRAs per batch when auto-batching |
| `batch_index` | INT | 0 | Which batch to output (0-based) when auto-batching |
### Strength Format Options
- **Single**: `"1.0"` - Apply same strength to all LoRAs
- **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"
```
### Auto-Batch Large Collections
```
LoRAFolderBatch → FluxSamplerParams → KSampler
folder_path: "massive_lora_collection" # 100+ files
strength: "1.0"
auto_batch: enabled
batch_size: 25
batch_index: 0 # Change to 1, 2, 3... for subsequent batches
```
## Batch Modes Explained
### Sequential Mode
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]
## Auto-Batching for Large Collections
### Overview
When testing large numbers of LoRAs (e.g., 75+ files), ComfyUI can experience UI disconnections or memory issues. Auto-batching solves this by automatically splitting your LoRA collection into smaller, manageable chunks.
### How It Works
1. **Enable Auto-Batching**: Set `auto_batch` to "enabled"
2. **Set Batch Size**: Configure `batch_size` (default: 25, range: 5-100)
3. **Select Batch**: Use `batch_index` to choose which batch to process
### Example: Testing 75 LoRAs
With 75 LoRAs and batch_size=25, the system creates 3 batches:
- **Batch 0**: LoRAs 1-25 (set batch_index=0)
- **Batch 1**: LoRAs 26-50 (set batch_index=1)
- **Batch 2**: LoRAs 51-75 (set batch_index=2)
Run your workflow 3 times, changing only the `batch_index` each time.
### Visual Feedback
When auto-batching is enabled, the `lora_list` output includes batch information:
```
=== Batch 1/3 (LoRAs 1-25) ===
style-epoch-001
style-epoch-002
...
```
### Best Practices for Auto-Batching
1. **Start with Default**: Use batch_size=25 for most scenarios
2. **Adjust for Memory**: Decrease batch_size if you still experience issues
3. **Combinatorial Mode**: Be extra careful - 25 LoRAs × 3 strengths = 75 combinations
4. **Save Between Batches**: Save your results after each batch to avoid data loss
5. **Use Plot Parameters**: The batch info appears in plot visualizations for easy tracking
## File Naming Patterns
### Supported Epoch Formats
- `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
- **1.0.4**: Added auto-batching for large LoRA collections
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
-234
View File
@@ -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

-14
View File
@@ -1,14 +0,0 @@
A beautiful sunset over the ocean, golden hour lighting, professional photography, vibrant colors, high detail
Negative: blurry, dark, low quality, distorted, oversaturated
---
Majestic mountain landscape with snow-capped peaks, dramatic clouds, alpine scenery, crystal clear air, epic composition
Negative: foggy, flat lighting, boring composition, low contrast
---
Futuristic cityscape at night, neon lights, cyberpunk aesthetic, rain-slicked streets, atmospheric, blade runner style
Negative: daylight, rural, old fashioned, low tech, empty streets
---
Enchanted forest with magical glowing mushrooms, fairy lights, mystical atmosphere, ancient trees, fantasy art style
Negative: desert, urban, modern, realistic, mundane
---
Space station orbiting Earth, detailed mechanical structures, astronauts performing spacewalk, realistic sci-fi, NASA photography
Negative: fantasy, medieval, underwater, cartoon style
@@ -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"
}
}
+129
View File
@@ -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!
+244
View File
@@ -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
}
+213
View File
@@ -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
}
+235
View File
@@ -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
}
+213
View File
@@ -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
}
+212
View File
@@ -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
}
+238
View File
@@ -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 -45
View File
@@ -3,34 +3,21 @@ KikoTools package initialization and node registry
Handles automatic discovery and registration of all ComfyAssets tools
"""
from .tools.batch_prompts import BatchPromptsNode
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_purge_vram import KikoPurgeVRAM
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.local_image_loader import LocalImageLoaderNode
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 = {
"BatchPrompts": BatchPromptsNode,
"ResolutionCalculator": ResolutionCalculatorNode,
"WidthHeightSelector": WidthHeightSelectorNode,
"SeedHistory": SeedHistoryNode,
@@ -43,21 +30,12 @@ NODE_CLASS_MAPPINGS = {
"GeminiPrompt": GeminiPromptNode,
"DisplayAny": DisplayAnyNode,
"DisplayText": DisplayTextNode,
"KikoFilmGrain": KikoFilmGrainNode,
"KikoPurgeVRAM": KikoPurgeVRAM,
"KikoLocalImageLoader": LocalImageLoaderNode,
"SamplerSelectHelper": SamplerSelectHelperNode,
"SchedulerSelectHelper": SchedulerSelectHelperNode,
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
# Note: KikoEmbeddingAutocomplete is not registered as a node
# It's a settings-only feature accessed through ComfyUI settings menu
"XYZPlotController": XYZPlotController,
"ImageGridCombiner": ImageGridCombiner,
"XYZPrompt": XYZPrompt,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BatchPrompts": "Batch Prompts",
"ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector",
"SeedHistory": "Seed History",
@@ -70,16 +48,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GeminiPrompt": "Gemini Prompt Engineer",
"DisplayAny": "Display Any",
"DisplayText": "Display Text",
"KikoFilmGrain": "Film Grain",
"KikoPurgeVRAM": "Kiko Purge VRAM",
"KikoLocalImageLoader": "Local Image Loader",
"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 removed - settings only, not a node
"XYZPlotController": "XYZ Plot Controller",
"ImageGridCombiner": "Image Grid Combiner",
"XYZPrompt": "XYZ Prompt",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
-8
View File
@@ -1,8 +0,0 @@
"""AnyType for wildcard input matching in ComfyUI nodes."""
class AnyType(str):
"""A special type that matches any input type in ComfyUI."""
def __ne__(self, other):
return False
+1 -1
View File
@@ -20,7 +20,7 @@ class ComfyAssetsBaseNode:
- Consistent return type handling
"""
CATEGORY = "🫶 ComfyAssets"
CATEGORY = "ComfyAssets"
def validate_inputs(self, **kwargs) -> None:
"""
View File
-95
View File
@@ -1,95 +0,0 @@
"""Tool registry for KikoTools.
This module provides the central registration system for all KikoTools nodes.
"""
import importlib
from typing import Dict, Any
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:
# 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
-201
View File
@@ -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(" 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(" options: (value) => {")
js_lines.append(
f" const options = {json.dumps(setting.options)};"
)
js_lines.append(" return options.map(opt => ({")
js_lines.append(" value: opt,")
js_lines.append(" text: String(opt),")
js_lines.append(" selected: opt === value")
js_lines.append(" }));")
js_lines.append(" }},")
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(" onChange(value) {")
js_lines.append(f" {setting.on_change}")
js_lines.append(" }")
js_lines.append(" }});")
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
@@ -1,5 +0,0 @@
"""Batch Prompts node for loading and processing prompts from text files."""
from .node import BatchPromptsNode
__all__ = ["BatchPromptsNode"]
-278
View File
@@ -1,278 +0,0 @@
"""Logic module for Batch Prompts node."""
import os
from typing import List, Tuple, Dict, Any
import logging
logger = logging.getLogger(__name__)
def load_prompts_from_file(file_path: str) -> List[str]:
"""
Load prompts from a text file where prompts are separated by '---'.
Args:
file_path: Path to the text file containing prompts
Returns:
List of prompts (each prompt may be multi-line)
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Split by --- separator
prompts = content.split("---")
# Clean up prompts - remove leading/trailing whitespace but preserve internal formatting
cleaned_prompts = []
for prompt in prompts:
prompt = prompt.strip()
if prompt: # Only add non-empty prompts
cleaned_prompts.append(prompt)
logger.info(f"Loaded {len(cleaned_prompts)} prompts from {file_path}")
return cleaned_prompts
except Exception as e:
logger.error(f"Error loading prompts from {file_path}: {e}")
return []
def get_prompt_at_index(
prompts: List[str], index: int, wrap: bool = True
) -> Tuple[str, int]:
"""
Get prompt at specified index with optional wrapping.
Args:
prompts: List of prompts
index: Index to retrieve
wrap: Whether to wrap around to beginning when index exceeds list length
Returns:
Tuple of (prompt text, actual index used)
"""
if not prompts:
return ("", 0)
if wrap:
actual_index = index % len(prompts)
else:
actual_index = min(index, len(prompts) - 1)
return (prompts[actual_index], actual_index)
def get_next_prompt(
prompts: List[str], current_index: int, wrap: bool = True
) -> Tuple[str, int]:
"""
Get the next prompt in sequence.
Args:
prompts: List of prompts
current_index: Current prompt index
wrap: Whether to wrap around to beginning
Returns:
Tuple of (next prompt text, next index)
"""
if not prompts:
return ("", 0)
next_index = current_index + 1
if wrap:
next_index = next_index % len(prompts)
else:
next_index = min(next_index, len(prompts) - 1)
return (prompts[next_index], next_index)
def get_prompt_preview(prompt: str, max_length: int = 100) -> str:
"""
Get a preview of a prompt, truncated if necessary.
Args:
prompt: Full prompt text
max_length: Maximum length for preview
Returns:
Preview string
"""
if len(prompt) <= max_length:
return prompt
return prompt[:max_length] + "..."
def parse_prompt_file_list(file_list_str: str) -> List[str]:
"""
Parse a comma-separated list of prompt file paths.
Args:
file_list_str: Comma-separated file paths
Returns:
List of file paths
"""
if not file_list_str:
return []
files = []
for file_path in file_list_str.split(","):
file_path = file_path.strip()
if file_path:
files.append(file_path)
return files
def merge_prompts_from_multiple_files(file_paths: List[str]) -> List[str]:
"""
Load and merge prompts from multiple files.
Args:
file_paths: List of file paths
Returns:
Combined list of all prompts
"""
all_prompts = []
for file_path in file_paths:
prompts = load_prompts_from_file(file_path)
all_prompts.extend(prompts)
logger.info(f"Merged {len(all_prompts)} prompts from {len(file_paths)} files")
return all_prompts
def get_batch_info(prompts: List[str], current_index: int) -> Dict[str, Any]:
"""
Get information about current batch processing state.
Args:
prompts: List of prompts
current_index: Current prompt index
Returns:
Dictionary with batch information
"""
total = len(prompts)
return {
"current_index": current_index,
"total_prompts": total,
"progress": f"{current_index + 1}/{total}" if total > 0 else "0/0",
"percentage": (current_index / total * 100) if total > 0 else 0,
"remaining": total - current_index - 1 if total > 0 else 0,
"is_complete": current_index >= total - 1 if total > 0 else True,
}
def validate_prompt_file(file_path: str) -> Tuple[bool, str]:
"""
Validate that a prompt file exists and is readable.
Args:
file_path: Path to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not file_path:
return (False, "No file path provided")
if not os.path.exists(file_path):
return (False, f"File not found: {file_path}")
if not os.path.isfile(file_path):
return (False, f"Path is not a file: {file_path}")
try:
with open(file_path, "r", encoding="utf-8") as f:
f.read(1) # Try to read one character
return (True, "")
except Exception as e:
return (False, f"Cannot read file: {str(e)}")
def format_prompt_for_display(prompt: str, index: int, total: int) -> str:
"""
Format a prompt for display with index information.
Args:
prompt: Prompt text
index: Current index
total: Total number of prompts
Returns:
Formatted display string
"""
header = f"[Prompt {index + 1}/{total}]"
separator = "-" * len(header)
return f"{header}\n{separator}\n{prompt}"
def split_prompt_into_positive_negative(
prompt: str, negative_prefix: str = "Negative:"
) -> Tuple[str, str]:
"""
Split a prompt into positive and negative parts.
Args:
prompt: Full prompt text
negative_prefix: Prefix that marks the negative prompt section
Returns:
Tuple of (positive_prompt, negative_prompt)
"""
# Look for negative prompt marker
negative_lower = negative_prefix.lower()
prompt_lower = prompt.lower()
if negative_lower in prompt_lower:
# Find the actual position (case-insensitive search)
idx = prompt_lower.index(negative_lower)
positive = prompt[:idx].strip()
negative = prompt[idx + len(negative_prefix) :].strip()
return (positive, negative)
# No negative prompt found
return (prompt, "")
def create_batch_queue(
prompts: List[str], batch_size: int = 1, randomize: bool = False
) -> List[List[int]]:
"""
Create a queue of prompt indices for batch processing.
Args:
prompts: List of prompts
batch_size: Number of prompts per batch
randomize: Whether to randomize the order
Returns:
List of batches, where each batch is a list of prompt indices
"""
if not prompts:
return []
indices = list(range(len(prompts)))
if randomize:
import random
random.shuffle(indices)
batches = []
for i in range(0, len(indices), batch_size):
batch = indices[i : i + batch_size]
batches.append(batch)
return batches
-237
View File
@@ -1,237 +0,0 @@
"""Batch Prompts node for ComfyUI."""
import os
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
load_prompts_from_file,
get_prompt_at_index,
get_next_prompt,
get_prompt_preview,
get_batch_info,
validate_prompt_file,
split_prompt_into_positive_negative,
)
from .state_manager import STATE_MANAGER
class BatchPromptsNode(ComfyAssetsBaseNode):
"""
Batch Prompts node for loading and iterating through prompts from text files.
Loads prompts from a text file where prompts are separated by '---' markers,
provides iteration control, and outputs both current and next prompts with
optional positive/negative splitting.
"""
# Class variable to cache loaded prompts
_prompt_cache = {}
@classmethod
def INPUT_TYPES(cls):
"""Define the input types for the ComfyUI node."""
# Try to get input folder path
try:
import folder_paths
folder_paths.get_input_directory()
except Exception:
pass
return {
"required": {
"prompt_file": (
"STRING",
{
"default": "prompts.txt",
"multiline": False,
"tooltip": "Path to text file containing prompts separated by '---'",
},
),
"index": (
"INT",
{
"default": 0,
"min": 0,
"max": 9999,
"step": 1,
"tooltip": "Current prompt index (0-based)",
},
),
"auto_increment": (
"BOOLEAN",
{
"default": True,
"tooltip": "Automatically increment index after each execution",
},
),
"wrap_around": (
"BOOLEAN",
{
"default": True,
"tooltip": "Wrap to first prompt after reaching the end",
},
),
"split_negative": (
"BOOLEAN",
{
"default": True,
"tooltip": "Split prompts into positive/negative at 'Negative:' marker",
},
),
},
"optional": {
"reload_file": (
"BOOLEAN",
{"default": False, "tooltip": "Force reload file from disk"},
),
"show_preview": (
"BOOLEAN",
{"default": True, "tooltip": "Show prompt preview in console"},
),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "INT", "INT", "STRING")
RETURN_NAMES = (
"positive",
"negative",
"full_prompt",
"next_prompt",
"current_index",
"total_prompts",
"batch_info",
)
FUNCTION = "process_batch_prompts"
CATEGORY = "🫶 ComfyAssets/📝 Text"
def process_batch_prompts(
self,
prompt_file: str,
index: int,
auto_increment: bool,
wrap_around: bool,
split_negative: bool,
reload_file: bool = False,
show_preview: bool = True,
) -> Tuple[str, str, str, str, int, int, str]:
"""
Process batch prompts from file.
Args:
prompt_file: Path to prompt file
index: Current prompt index
auto_increment: Whether to auto-increment index
wrap_around: Whether to wrap around at end
split_negative: Whether to split positive/negative prompts
reload_file: Force reload from disk
show_preview: Show prompt preview in console
Returns:
Tuple of (positive, negative, full_prompt, next_prompt, current_index, total_prompts, batch_info)
"""
try:
# Handle file path first to get a consistent key
if not os.path.isabs(prompt_file):
# Try to resolve relative to ComfyUI input directory
try:
import folder_paths
input_dir = folder_paths.get_input_directory()
full_path = os.path.join(input_dir, prompt_file)
except Exception:
# Fallback to current directory
full_path = os.path.abspath(prompt_file)
else:
full_path = prompt_file
# Use persistent state manager for tracking execution
if auto_increment:
# Use file-based persistent state
actual_index = STATE_MANAGER.increment_execution_count(full_path)
print(
f"[BatchPrompts] Auto-increment: using index {actual_index} for {os.path.basename(prompt_file)}"
)
else:
actual_index = index
print(f"[BatchPrompts] Manual mode: using index {actual_index}")
# Validate file
is_valid, error_msg = validate_prompt_file(full_path)
if not is_valid:
self.handle_error(f"Invalid prompt file: {error_msg}")
# Load prompts (with caching)
cache_key = full_path
if reload_file or cache_key not in self._prompt_cache:
prompts = load_prompts_from_file(full_path)
if not prompts:
self.handle_error(f"No prompts found in file: {prompt_file}")
self._prompt_cache[cache_key] = prompts
# Reset execution count when reloading file
if reload_file:
STATE_MANAGER.reset_execution_count(full_path)
self.log_info(f"Loaded {len(prompts)} prompts from {prompt_file}")
else:
prompts = self._prompt_cache[cache_key]
# Get current prompt using the determined index
current_prompt, used_index = get_prompt_at_index(
prompts, actual_index, wrap_around
)
# Get next prompt
next_prompt_text, next_index = get_next_prompt(
prompts, used_index, wrap_around
)
# Split positive/negative if requested
if split_negative:
positive, negative = split_prompt_into_positive_negative(current_prompt)
else:
positive = current_prompt
negative = ""
# Get batch info
batch_info_dict = get_batch_info(prompts, used_index)
batch_info_str = (
f"Prompt {batch_info_dict['current_index'] + 1} of {batch_info_dict['total_prompts']} "
f"({batch_info_dict['percentage']:.1f}% complete)"
)
# Show preview if requested
if show_preview:
preview = get_prompt_preview(positive, 80)
self.log_info(
f"Current prompt [{used_index + 1}/{len(prompts)}]: {preview}"
)
# No need to manually reset - the modulo operation in get_prompt_at_index handles wrapping
return (
positive,
negative,
current_prompt,
next_prompt_text,
used_index,
len(prompts),
batch_info_str,
)
except Exception as e:
self.handle_error(f"Error processing batch prompts: {str(e)}")
# Return empty values on error
return ("", "", "", "", 0, 0, "Error")
@classmethod
def IS_CHANGED(cls, **kwargs):
"""
Check if node inputs have changed.
This ensures the node re-executes when needed.
"""
# Import time to ensure unique value each check
import time
# Return current timestamp to guarantee the node is seen as changed
# This forces re-execution on every workflow run
return str(time.time())
@@ -1,72 +0,0 @@
"""Simple Batch Prompts node for ComfyUI - debugging version."""
import os
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
load_prompts_from_file,
get_prompt_at_index,
split_prompt_into_positive_negative,
)
# Global counter that persists across all executions
GLOBAL_COUNTER = {"count": 0}
class SimpleBatchPromptsNode(ComfyAssetsBaseNode):
"""
Simplified Batch Prompts node for debugging.
Uses a global counter to ensure prompts change.
"""
@classmethod
def INPUT_TYPES(cls):
"""Define the input types for the ComfyUI node."""
return {
"required": {
"prompt_file": ("STRING", {"default": "prompts.txt"}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("positive", "negative", "index")
FUNCTION = "get_next_prompt"
CATEGORY = "🫶 ComfyAssets/📝 Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Force re-execution every time."""
GLOBAL_COUNTER["count"] += 1
return GLOBAL_COUNTER["count"]
def get_next_prompt(self, prompt_file: str) -> Tuple[str, str, int]:
"""Get the next prompt in sequence."""
# Resolve file path
if not os.path.isabs(prompt_file):
try:
import folder_paths
input_dir = folder_paths.get_input_directory()
full_path = os.path.join(input_dir, prompt_file)
except ImportError:
full_path = os.path.abspath(prompt_file)
else:
full_path = prompt_file
# Load prompts
prompts = load_prompts_from_file(full_path)
if not prompts:
return ("No prompts found", "", 0)
# Get current prompt based on global counter
index = GLOBAL_COUNTER["count"] % len(prompts)
current_prompt, _ = get_prompt_at_index(prompts, index, wrap=True)
# Split positive/negative
positive, negative = split_prompt_into_positive_negative(current_prompt)
print(
f"[SimpleBatchPrompts] Counter={GLOBAL_COUNTER['count']}, Index={index}, Prompt={positive[:30]}..."
)
return (positive, negative, index)
@@ -1,62 +0,0 @@
"""State management for batch prompts using file persistence."""
import json
import tempfile
from pathlib import Path
from typing import Dict, Any
class StateManager:
"""Manages persistent state for batch prompt execution."""
def __init__(self):
# Use temp directory for state files
self.state_dir = Path(tempfile.gettempdir()) / "comfyui_batch_prompts"
self.state_dir.mkdir(exist_ok=True)
self.state_file = self.state_dir / "execution_state.json"
def get_state(self) -> Dict[str, Any]:
"""Load state from file."""
if self.state_file.exists():
try:
with open(self.state_file, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {}
def save_state(self, state: Dict[str, Any]):
"""Save state to file."""
try:
with open(self.state_file, "w") as f:
json.dump(state, f)
except Exception as e:
print(f"[BatchPrompts] Failed to save state: {e}")
def get_execution_count(self, file_path: str) -> int:
"""Get execution count for a specific file."""
state = self.get_state()
counts = state.get("execution_counts", {})
return counts.get(file_path, 0)
def increment_execution_count(self, file_path: str) -> int:
"""Increment and return execution count for a file."""
state = self.get_state()
counts = state.get("execution_counts", {})
current = counts.get(file_path, 0)
counts[file_path] = current + 1
state["execution_counts"] = counts
self.save_state(state)
return current
def reset_execution_count(self, file_path: str):
"""Reset execution count for a file."""
state = self.get_state()
counts = state.get("execution_counts", {})
counts[file_path] = 0
state["execution_counts"] = counts
self.save_state(state)
# Global state manager instance
STATE_MANAGER = StateManager()
+1 -10
View File
@@ -1,6 +1,6 @@
"""Logic for DisplayAny node - displays any input value or tensor shape."""
from typing import Any, List
from typing import Any, List, Union
def get_tensor_shapes(input_value: Any) -> List[List[int]]:
@@ -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 (TypeError, ValueError):
pass
return str(input_value)
+2 -3
View File
@@ -1,6 +1,6 @@
"""DisplayAny node for ComfyUI - displays any input value or tensor information."""
from typing import Any, Dict
from typing import Any, Dict, Tuple
from ...base import ComfyAssetsBaseNode
from .logic import format_display_value, validate_display_mode
@@ -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,),
}
+1 -1
View File
@@ -19,7 +19,7 @@ class DisplayTextNode(ComfyAssetsBaseNode):
RETURN_NAMES = ("text",)
OUTPUT_NODE = True
FUNCTION = "display_text"
CATEGORY = "🫶 ComfyAssets/👁️ Display"
CATEGORY = "ComfyAssets"
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]
+1 -1
View File
@@ -96,7 +96,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
FUNCTION = "create_empty_latent"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
CATEGORY = "ComfyAssets"
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
}
+1 -1
View File
@@ -51,7 +51,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "negative_prompt")
FUNCTION = "generate_prompt"
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
CATEGORY = "ComfyAssets"
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"]
-221
View File
@@ -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)
-123
View File
@@ -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/💾 Images"
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,)
@@ -1,3 +0,0 @@
from .node import KikoPurgeVRAM
__all__ = ["KikoPurgeVRAM"]
-130
View File
@@ -1,130 +0,0 @@
import gc
from typing import Dict, Tuple
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
try:
import comfy.model_management as mm
COMFY_AVAILABLE = True
except ImportError:
COMFY_AVAILABLE = False
def get_memory_stats() -> Dict[str, float]:
stats = {
"cuda_available": False,
"free_mb": 0,
"total_mb": 0,
"used_mb": 0,
"used_percent": 0,
}
if TORCH_AVAILABLE and torch.cuda.is_available():
stats["cuda_available"] = True
free, total = torch.cuda.mem_get_info()
free_mb = free / (1024 * 1024)
total_mb = total / (1024 * 1024)
used_mb = total_mb - free_mb
stats["free_mb"] = free_mb
stats["total_mb"] = total_mb
stats["used_mb"] = used_mb
stats["used_percent"] = (used_mb / total_mb) * 100 if total_mb > 0 else 0
return stats
def purge_memory(mode: str = "soft", unload_models: bool = False) -> float:
before_stats = get_memory_stats()
if mode == "soft":
# Basic garbage collection and cache clearing
gc.collect()
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.empty_cache()
elif mode == "aggressive":
# Multiple passes of garbage collection with full cache clearing
gc.collect()
gc.collect()
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
elif mode == "models_only":
# Only unload models
if COMFY_AVAILABLE:
mm.unload_all_models()
mm.soft_empty_cache()
gc.collect()
elif mode == "cache_only":
# Only clear cache without garbage collection
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.empty_cache()
# Handle model unloading for non-model-specific modes
if unload_models and mode not in ["models_only"]:
if COMFY_AVAILABLE:
mm.unload_all_models()
mm.soft_empty_cache()
after_stats = get_memory_stats()
freed_mb = before_stats["used_mb"] - after_stats["used_mb"]
return max(0, freed_mb)
def format_memory_report(
before: Dict[str, float], after: Dict[str, float], mode: str, elapsed_ms: float
) -> str:
if not before.get("cuda_available", True):
return (
"Memory Purge Report\n"
"-------------------\n"
"CUDA not available - CPU memory management only\n"
f"Mode: {mode}\n"
f"Time: {elapsed_ms:.1f}ms"
)
freed_mb = before["used_mb"] - after["used_mb"]
report = [
"Memory Purge Report",
"-------------------",
f"Mode: {mode}",
f"Memory Freed: {freed_mb:.1f} MB",
f"Before: {before['used_mb']:.1f} MB used ({before['used_percent']:.1f}%)",
f"After: {after['used_mb']:.1f} MB used ({after['used_percent']:.1f}%)",
f"Time: {elapsed_ms:.1f}ms",
]
return "\n".join(report)
def should_purge(threshold_mb: int) -> Tuple[bool, str]:
if threshold_mb <= 0:
return True, ""
stats = get_memory_stats()
if not stats["cuda_available"]:
return True, "CUDA not available, proceeding with CPU memory management"
if stats["used_mb"] >= threshold_mb:
return (
True,
f"Memory usage ({stats['used_mb']:.1f} MB) exceeds threshold ({threshold_mb} MB)",
)
else:
return (
False,
f"Memory usage ({stats['used_mb']:.1f} MB) below threshold ({threshold_mb} MB)",
)
-102
View File
@@ -1,102 +0,0 @@
import time
from typing import Any, Dict, Tuple
try:
from ...base.base_node import ComfyAssetsBaseNode as BaseNode
from ...base.any_type import AnyType
except ImportError:
# Fallback for testing environment
from kikotools.base.base_node import ComfyAssetsBaseNode as BaseNode
from kikotools.base.any_type import AnyType
from .logic import get_memory_stats, purge_memory, format_memory_report, should_purge
any_type = AnyType("*")
class KikoPurgeVRAM(BaseNode):
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"anything": (any_type, {}),
"mode": (
["soft", "aggressive", "models_only", "cache_only"],
{
"default": "soft",
"tooltip": "Purge mode: soft (basic), aggressive (thorough), models_only (unload models), cache_only (clear cache)",
},
),
"report_memory": (
"BOOLEAN",
{
"default": True,
"tooltip": "Generate detailed memory usage report",
},
),
},
"optional": {
"memory_threshold_mb": (
"INT",
{
"default": 0,
"min": 0,
"max": 48000,
"step": 100,
"tooltip": "Only purge if memory usage exceeds this threshold (0 = always purge)",
},
),
},
}
RETURN_TYPES = (any_type, "STRING")
RETURN_NAMES = ("passthrough", "memory_report")
FUNCTION = "purge_vram"
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
OUTPUT_NODE = True
DESCRIPTION = "Purge VRAM to free up GPU memory during workflow execution. Passes through any input unchanged."
def purge_vram(
self,
anything: Any,
mode: str,
report_memory: bool,
memory_threshold_mb: int = 0,
) -> Tuple[Any, str]:
# Check if we should purge based on threshold
should_run, threshold_msg = should_purge(memory_threshold_mb)
if not should_run:
if report_memory:
return anything, f"Memory purge skipped: {threshold_msg}"
else:
return anything, ""
# Get before stats
before_stats = get_memory_stats() if report_memory else None
start_time = time.time()
# Determine if we should unload models
unload_models = mode in ["models_only", "aggressive"]
# Perform memory purge
purge_memory(mode=mode, unload_models=unload_models)
# Calculate elapsed time
elapsed_ms = (time.time() - start_time) * 1000
# Generate report if requested
if report_memory:
after_stats = get_memory_stats()
report = format_memory_report(before_stats, after_stats, mode, elapsed_ms)
if threshold_msg and memory_threshold_mb > 0:
report = f"{threshold_msg}\n\n{report}"
else:
report = ""
# Pass through the input unchanged
return anything, report
NODE_CLASS_MAPPINGS = {"KikoPurgeVRAM": KikoPurgeVRAM}
NODE_DISPLAY_NAME_MAPPINGS = {"KikoPurgeVRAM": "Kiko Purge VRAM"}
-1
View File
@@ -95,7 +95,6 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ()
CATEGORY = "🫶 ComfyAssets/💾 Images"
FUNCTION = "save_images"
OUTPUT_NODE = True
@@ -1,13 +0,0 @@
"""Local Image Loader tool for KikoTools."""
from .node import LocalImageLoaderNode
NODE_CLASS_MAPPINGS = {
"KikoLocalImageLoader": LocalImageLoaderNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KikoLocalImageLoader": "Local Image Loader",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
@@ -1,7 +0,0 @@
{
"last_path": "/home/vito/ai-apps/ComfyUI-3.12/output",
"saved_paths": [
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
"/home/vito/ai-apps/ComfyUI-3.12/output/"
]
}
-144
View File
@@ -1,144 +0,0 @@
"""Core logic for Local Image Loader."""
import os
import json
import torch
import numpy as np
from PIL import Image
from typing import Tuple, Dict, Any, List
def get_supported_extensions() -> Dict[str, List[str]]:
"""Get supported file extensions by type."""
return {
"image": [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"],
"video": [".mp4", ".webm", ".mov", ".mkv", ".avi"],
"audio": [".mp3", ".wav", ".ogg", ".flac"],
}
def load_image_from_path(path: str) -> Tuple[torch.Tensor, Dict[str, Any]]:
"""
Load an image from the given path and convert it to a tensor.
Args:
path: Path to the image file
Returns:
Tuple of (image tensor, metadata dict)
"""
if not os.path.exists(path):
raise FileNotFoundError(f"File not found: {path}")
with Image.open(path) as img:
# Convert to appropriate format
if "A" in img.getbands():
img_out = img.convert("RGBA")
else:
img_out = img.convert("RGB")
# Convert to tensor
img_array = np.array(img_out).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(img_array)[None,]
# Collect metadata
metadata = {
"filename": os.path.basename(path),
"width": img.width,
"height": img.height,
"mode": img.mode,
"format": img.format,
}
# Check for embedded metadata
if "parameters" in img.info:
metadata["parameters"] = img.info["parameters"]
if "prompt" in img.info:
try:
metadata["prompt"] = json.loads(img.info["prompt"])
except (json.JSONDecodeError, TypeError):
metadata["prompt"] = img.info["prompt"]
if "workflow" in img.info:
try:
metadata["workflow"] = json.loads(img.info["workflow"])
except (json.JSONDecodeError, TypeError):
metadata["workflow"] = img.info["workflow"]
return image_tensor, metadata
def scan_directory(
directory: str,
show_videos: bool = False,
show_audio: bool = False,
sort_by: str = "name",
sort_order: str = "asc",
) -> List[Dict[str, Any]]:
"""
Scan a directory for supported media files.
Args:
directory: Directory path to scan
show_videos: Include video files
show_audio: Include audio files
sort_by: Sort criteria ('name', 'date', 'size')
sort_order: Sort order ('asc', 'desc')
Returns:
List of file information dictionaries
"""
if not os.path.isdir(directory):
raise NotADirectoryError(f"Not a directory: {directory}")
extensions = get_supported_extensions()
items = []
for item in os.listdir(directory):
full_path = os.path.join(directory, item)
try:
stats = os.stat(full_path)
item_data = {
"path": full_path,
"name": item,
"mtime": stats.st_mtime,
"size": stats.st_size,
}
if os.path.isdir(full_path):
items.append({**item_data, "type": "dir"})
else:
ext = os.path.splitext(item)[1].lower()
item_type = None
if ext in extensions["image"]:
item_type = "image"
elif show_videos and ext in extensions["video"]:
item_type = "video"
elif show_audio and ext in extensions["audio"]:
item_type = "audio"
if item_type:
items.append({**item_data, "type": item_type})
except (PermissionError, FileNotFoundError):
continue
# Sort items
reverse = sort_order == "desc"
if sort_by == "date":
items.sort(key=lambda x: x["mtime"], reverse=reverse)
elif sort_by == "size":
items.sort(key=lambda x: x.get("size", 0), reverse=reverse)
else: # name
items.sort(key=lambda x: x["name"].lower(), reverse=reverse)
# Directories first
items.sort(key=lambda x: x["type"] != "dir")
return items
def create_empty_tensor() -> torch.Tensor:
"""Create an empty tensor for when no image is selected."""
return torch.zeros(1, 1, 1, 4)
-291
View File
@@ -1,291 +0,0 @@
"""Local Image Loader node for ComfyUI."""
import os
import json
import torch
from typing import Dict, Any, Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import load_image_from_path, create_empty_tensor
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
SELECTIONS_FILE = os.path.join(NODE_DIR, "selections.json")
CONFIG_FILE = os.path.join(NODE_DIR, "config.json")
def load_selections() -> Dict[str, Any]:
"""Load node selections from file."""
if not os.path.exists(SELECTIONS_FILE):
return {}
try:
with open(SELECTIONS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def save_selections(data: Dict[str, Any]) -> None:
"""Save node selections to file."""
try:
with open(SELECTIONS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"KikoLocalImageLoader: Error saving selections: {e}")
def load_config() -> Dict[str, Any]:
"""Load configuration from file."""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {}
def save_config(data: Dict[str, Any]) -> None:
"""Save configuration to file."""
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
except Exception as e:
print(f"KikoLocalImageLoader: Error saving config: {e}")
class LocalImageLoaderNode(ComfyAssetsBaseNode):
"""Node for loading images from local filesystem with a visual gallery interface."""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""Define input types for the node."""
return {
"required": {},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = (
"IMAGE",
"STRING",
"STRING",
"STRING",
)
RETURN_NAMES = (
"image",
"video_path",
"audio_path",
"info",
)
FUNCTION = "load_media"
CATEGORY = "🫶 ComfyAssets/💾 Images"
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Check if node state has changed."""
if os.path.exists(SELECTIONS_FILE):
return os.path.getmtime(SELECTIONS_FILE)
return float("inf")
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str, str, str]:
"""
Load selected media based on node's unique ID.
Args:
unique_id: Unique identifier for this node instance
Returns:
Tuple of (image tensor, video path, audio path, info string)
"""
image_tensor = create_empty_tensor()
video_path = ""
audio_path = ""
info_string = ""
selections = load_selections()
node_selections = selections.get(str(unique_id), {})
# Load image if selected
image_selection = node_selections.get("image")
if image_selection and image_selection.get("path"):
image_path = image_selection["path"]
if os.path.exists(image_path):
try:
image_tensor, metadata = load_image_from_path(image_path)
info_string = json.dumps(metadata, indent=4, ensure_ascii=False)
except Exception as e:
print(f"KikoLocalImageLoader: Error loading image: {e}")
# Get video path if selected
video_selection = node_selections.get("video")
if video_selection and video_selection.get("path"):
if os.path.exists(video_selection["path"]):
video_path = video_selection["path"]
# Get audio path if selected
audio_selection = node_selections.get("audio")
if audio_selection and audio_selection.get("path"):
if os.path.exists(audio_selection["path"]):
audio_path = audio_selection["path"]
return (image_tensor, video_path, audio_path, info_string)
# Setup API routes
try:
import server
from aiohttp import web
import urllib.parse
import io
from PIL import Image
from .logic import scan_directory
prompt_server = server.PromptServer.instance
@prompt_server.routes.post("/kiko_local_image_loader/set_node_selection")
async def set_node_selection(request):
"""API endpoint to set node selection."""
try:
data = await request.json()
node_id = str(data.get("node_id"))
path = data.get("path")
media_type = data.get("type")
if not all([node_id, path, media_type]):
return web.json_response(
{"status": "error", "message": "Missing required data."}, status=400
)
selections = load_selections()
if node_id not in selections:
selections[node_id] = {}
selections[node_id][media_type] = {"path": path}
save_selections(selections)
return web.json_response({"status": "ok"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
@prompt_server.routes.get("/kiko_local_image_loader/get_saved_paths")
async def get_saved_paths(request):
"""API endpoint to get saved directory paths."""
config = load_config()
return web.json_response({"saved_paths": config.get("saved_paths", [])})
@prompt_server.routes.post("/kiko_local_image_loader/save_paths")
async def save_paths(request):
"""API endpoint to save directory paths."""
try:
data = await request.json()
paths = data.get("paths", [])
config = load_config()
config["saved_paths"] = paths
save_config(config)
return web.json_response({"status": "ok"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
@prompt_server.routes.get("/kiko_local_image_loader/images")
async def get_local_images(request):
"""API endpoint to get images from a directory."""
directory = request.query.get("directory", "")
if not directory or not os.path.isdir(directory):
return web.json_response({"error": "Directory not found."}, status=404)
# Save last path
config = load_config()
config["last_path"] = directory
save_config(config)
show_videos = request.query.get("show_videos", "false").lower() == "true"
show_audio = request.query.get("show_audio", "false").lower() == "true"
page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", 50))
sort_by = request.query.get("sort_by", "name")
sort_order = request.query.get("sort_order", "asc")
try:
items = scan_directory(
directory, show_videos, show_audio, sort_by, sort_order
)
# Get parent directory
parent_directory = os.path.dirname(directory)
if parent_directory == directory:
parent_directory = None
# Paginate results
start = (page - 1) * per_page
end = start + per_page
paginated_items = items[start:end]
return web.json_response(
{
"items": paginated_items,
"total_pages": (len(items) + per_page - 1) // per_page,
"current_page": page,
"current_directory": directory,
"parent_directory": parent_directory,
}
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@prompt_server.routes.get("/kiko_local_image_loader/get_last_path")
async def get_last_path(request):
"""API endpoint to get last used directory path."""
return web.json_response({"last_path": load_config().get("last_path", "")})
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
async def get_thumbnail(request):
"""API endpoint to get image thumbnail."""
filepath = request.query.get("filepath")
if not filepath or ".." in filepath:
return web.Response(status=400)
filepath = urllib.parse.unquote(filepath)
if not os.path.exists(filepath):
return web.Response(status=404)
try:
img = Image.open(filepath)
has_alpha = img.mode == "RGBA" or (
img.mode == "P" and "transparency" in img.info
)
img = img.convert("RGBA") if has_alpha else img.convert("RGB")
img.thumbnail([320, 320], Image.LANCZOS)
buffer = io.BytesIO()
format, content_type = (
("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg")
)
img.save(buffer, format=format, quality=90 if format == "JPEG" else None)
buffer.seek(0)
return web.Response(body=buffer.read(), content_type=content_type)
except Exception as e:
print(f"KikoLocalImageLoader: Error generating thumbnail: {e}")
return web.Response(status=500)
@prompt_server.routes.get("/kiko_local_image_loader/view")
async def view_image(request):
"""API endpoint to view full image."""
filepath = request.query.get("filepath")
if not filepath or ".." in filepath:
return web.Response(status=400)
filepath = urllib.parse.unquote(filepath)
if not os.path.exists(filepath):
return web.Response(status=404)
try:
return web.FileResponse(filepath)
except Exception:
return web.Response(status=500)
except ImportError:
# Server not available during testing
pass
@@ -1,12 +0,0 @@
{
"57": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01/HiDream_00001_.png"
}
},
"58": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
}
}
}
@@ -60,7 +60,6 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("INT", "INT")
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("width", "height")
FUNCTION = "calculate_resolution"
@@ -53,6 +53,7 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
"min": 1.0,
"max": 15.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG",
},
),
@@ -62,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
+2 -1
View File
@@ -58,6 +58,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
"min": 0.0,
"max": 20.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG scale (0-20)",
},
),
@@ -67,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
+1 -1
View File
@@ -38,7 +38,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "output_seed"
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
CATEGORY = "ComfyAssets"
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]:
"""
@@ -283,97 +283,6 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
"Banner",
"Vertical banner 1:3 - extreme tall banner",
),
# Qwen Presets
"1328×1328": PresetMetadata(
1328,
1328,
"1:1",
1.0,
1.76,
"Qwen",
"Square",
"Qwen square 1:1 - optimized square",
),
"1664×928": PresetMetadata(
1664,
928,
"16:9",
1.793,
1.54,
"Qwen",
"Landscape",
"Qwen landscape 16:9 - widescreen format",
),
"928×1664": PresetMetadata(
928,
1664,
"9:16",
0.558,
1.54,
"Qwen",
"Portrait",
"Qwen portrait 9:16 - vertical format",
),
"1472×1104": PresetMetadata(
1472,
1104,
"4:3",
1.333,
1.62,
"Qwen",
"Landscape",
"Qwen landscape 4:3 - classic landscape",
),
"1104×1472": PresetMetadata(
1104,
1472,
"3:4",
0.750,
1.62,
"Qwen",
"Portrait",
"Qwen portrait 3:4 - classic portrait",
),
"1584×1056": PresetMetadata(
1584,
1056,
"3:2",
1.500,
1.67,
"Qwen",
"Landscape",
"Qwen landscape 3:2 - photography standard",
),
"1056×1584": PresetMetadata(
1056,
1584,
"2:3",
0.667,
1.67,
"Qwen",
"Portrait",
"Qwen portrait 2:3 - portrait photography",
),
"2080×688": PresetMetadata(
2080,
688,
"3:1",
3.023,
1.43,
"Qwen",
"Landscape",
"Qwen experimental landscape 3:1 - ultra-wide",
),
"688×2080": PresetMetadata(
688,
2080,
"1:3",
0.331,
1.43,
"Qwen",
"Portrait",
"Qwen experimental portrait 1:3 - ultra-tall",
),
}
# Legacy compatibility - maintain old preset dictionaries
@@ -395,12 +304,6 @@ ULTRA_WIDE_PRESETS: Dict[str, Tuple[int, int]] = {
if v.model_group == "Ultra-Wide"
}
QWEN_PRESETS: Dict[str, Tuple[int, int]] = {
k: (v.width, v.height)
for k, v in PRESET_METADATA.items()
if v.model_group == "Qwen"
}
# Combined preset options for ComfyUI dropdown
PRESET_OPTIONS: Dict[str, Tuple[int, int]] = {
"custom": (0, 0), # Special case for custom dimensions
@@ -483,22 +386,6 @@ PRESET_CATEGORIES = {
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Banner"
],
# Qwen Categories
"Qwen Square": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Qwen" and v.category == "Square"
],
"Qwen Portrait": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Qwen" and v.category == "Portrait"
],
"Qwen Landscape": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Qwen" and v.category == "Landscape"
],
}
# Legacy compatibility - preset descriptions
@@ -511,7 +398,6 @@ MODEL_RECOMMENDATIONS = {
"Ultra-Wide": [
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"
],
"Qwen": [k for k, v in PRESET_METADATA.items() if v.model_group == "Qwen"],
}
@@ -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
+68
View File
@@ -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
+19
View File
@@ -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
+232
View File
@@ -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)
+139
View File
@@ -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"]
+107
View File
@@ -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"
})
+180
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More