Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11931a2e51 | ||
|
|
9da65b7e38 | ||
|
|
0868b32318 | ||
|
|
de49b8abec | ||
|
|
64f2b0530d | ||
|
|
1308d0055e | ||
|
|
f484482e2c | ||
|
|
8c44b99d37 | ||
|
|
c035313e99 | ||
|
|
0d9d6d1872 | ||
|
|
40bfccc952 | ||
|
|
7b87b01535 | ||
|
|
cb61b60c27 | ||
|
|
c45c9c0b91 | ||
|
|
b3c510b90d | ||
|
|
417e99b172 | ||
|
|
59fe64d04f | ||
|
|
19666de804 | ||
|
|
60a3104a38 | ||
|
|
01c14ea363 | ||
|
|
63c9d81ebd | ||
|
|
94200003ee | ||
|
|
f1e73d1e94 | ||
|
|
429f3f067a | ||
|
|
84138cd46e | ||
|
|
978580924a | ||
|
|
be560ad2f5 | ||
|
|
0e7747d248 | ||
|
|
012c0e698c | ||
|
|
b18f7cad38 | ||
|
|
4bab6eb73e | ||
|
|
b14b86fc85 |
@@ -162,3 +162,4 @@ experiments/
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ComfyUI-KikoTools is a planned modular collection of custom ComfyUI nodes that will provide essential tools missing from the standard ComfyUI release. All nodes will be grouped under "ComfyAssets" in the ComfyUI interface. The project is designed for extensibility, allowing new tools to be added easily while maintaining clean separation of concerns.
|
||||
|
||||
**Current Status**: Project is in initial planning phase. Only documentation and licensing files exist.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Principles
|
||||
- **Modular Design**: Each tool is a separate, self-contained module
|
||||
- **ComfyAssets Grouping**: All nodes appear under the "ComfyAssets" category
|
||||
- **Test-Driven Development**: Every tool includes comprehensive tests
|
||||
- **Clean Interfaces**: Standardized input/output patterns across tools
|
||||
|
||||
### Core Components
|
||||
- **Tool Registry**: Central registration system for all KikoTools nodes
|
||||
- **Base Classes**: Shared functionality for consistent tool behavior
|
||||
- **Individual Tools**: Self-contained modules for specific functionality
|
||||
|
||||
### Current Tools
|
||||
|
||||
#### 1. Resolution Calculator (First Tool)
|
||||
- **Purpose**: Calculate upscale resolution from image or latent inputs
|
||||
- **Inputs**:
|
||||
- Image or Latent tensor
|
||||
- Scale factor (1, 2, 3, 1.2, 1.5, 2.0)
|
||||
- **Outputs**:
|
||||
- Width (INT)
|
||||
- Height (INT)
|
||||
- **Target Models**: Flux and SDXL optimized
|
||||
- **Use Case**: Connect calculated dimensions to upscaler nodes
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Python with ComfyUI node patterns
|
||||
- **Node Framework**: ComfyUI INPUT_TYPES, RETURN_TYPES, execute() patterns
|
||||
- **Testing**: pytest with ComfyUI test fixtures
|
||||
- **Code Quality**: black, flake8, mypy
|
||||
- **Integration**: ComfyUI execution queue and tensor systems
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Note**: These commands are planned for when the project structure is implemented.
|
||||
|
||||
### Initial Setup
|
||||
```bash
|
||||
# Create basic project structure
|
||||
mkdir -p kikotools/{base,tools} tests/{unit,integration,fixtures} scripts examples
|
||||
|
||||
# Create entry point files
|
||||
touch __init__.py kikotools/__init__.py
|
||||
```
|
||||
|
||||
### Code Quality (Future)
|
||||
```bash
|
||||
# Format Python code
|
||||
black .
|
||||
|
||||
# Python linting
|
||||
flake8 .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
### Testing (Future TDD Workflow)
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/
|
||||
|
||||
# Run tests for specific tool
|
||||
pytest tests/unit/tools/test_{tool_name}.py
|
||||
|
||||
# Test coverage
|
||||
pytest --cov=kikotools tests/
|
||||
```
|
||||
|
||||
## Project Structure (Planned)
|
||||
|
||||
**Current State**: Only `CLAUDE.md` and `LICENSE` files exist.
|
||||
|
||||
**Planned Structure**:
|
||||
```
|
||||
├── __init__.py # ComfyUI node registration entry point
|
||||
├── kikotools/ # Main package
|
||||
│ ├── __init__.py # Package initialization and tool registry
|
||||
│ ├── base/ # Base classes and shared utilities
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_node.py # Base node class with ComfyAssets grouping
|
||||
│ │ └── utils.py # Shared utility functions
|
||||
│ ├── tools/ # Individual tool implementations
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── resolution_calculator/ # First planned tool
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── node.py # ResolutionCalculatorNode implementation
|
||||
│ │ │ └── logic.py # Core calculation logic
|
||||
│ │ └── template/ # Template for new tools
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── node.py
|
||||
│ │ └── logic.py
|
||||
├── tests/ # Comprehensive test suite (TDD approach)
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # pytest fixtures and ComfyUI test setup
|
||||
│ ├── unit/ # Unit tests for individual components
|
||||
│ │ ├── test_base_node.py
|
||||
│ │ └── tools/
|
||||
│ │ └── test_resolution_calculator.py
|
||||
│ ├── integration/ # ComfyUI integration tests
|
||||
│ │ ├── test_node_registration.py
|
||||
│ │ └── test_workflow_execution.py
|
||||
│ └── fixtures/ # Test data and workflow files
|
||||
│ ├── workflows/ # .json workflow files for testing
|
||||
│ ├── images/ # Test images
|
||||
│ └── latents/ # Test latent tensors
|
||||
├── scripts/ # Development automation
|
||||
│ ├── create_tool.py # Tool template generator
|
||||
│ ├── register_tool.py # Tool registration helper
|
||||
│ └── validate_nodes.py # Node validation script
|
||||
├── examples/ # Usage examples and demonstrations
|
||||
│ ├── workflows/ # Example workflow .json files
|
||||
│ └── documentation/ # Usage documentation per tool
|
||||
└── requirements-dev.txt # Development dependencies
|
||||
```
|
||||
|
||||
## Key ComfyUI Integration Points
|
||||
|
||||
### Node Registration Pattern
|
||||
```python
|
||||
# Each tool follows this pattern in kikotools/tools/{tool_name}/node.py
|
||||
class ResolutionCalculatorNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"scale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"latent": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
CATEGORY = "ComfyAssets" # All tools use this category
|
||||
|
||||
def calculate_resolution(self, scale_factor, image=None, latent=None):
|
||||
# Implementation here
|
||||
pass
|
||||
```
|
||||
|
||||
### Base Node Class
|
||||
- Provides consistent "ComfyAssets" categorization
|
||||
- Standardizes error handling and logging
|
||||
- Implements common validation patterns
|
||||
- Ensures consistent return type handling
|
||||
|
||||
### Tool Registry System
|
||||
- Automatic discovery of tools in `kikotools/tools/`
|
||||
- Dynamic node registration during ComfyUI startup
|
||||
- Version compatibility checking
|
||||
- Dependency validation
|
||||
|
||||
## Test-Driven Development (TDD) Workflow
|
||||
|
||||
### 1. Write Tests First
|
||||
```python
|
||||
# tests/unit/tools/test_resolution_calculator.py
|
||||
def test_resolution_calculator_with_image():
|
||||
"""Test resolution calculation with image input."""
|
||||
# Arrange
|
||||
node = ResolutionCalculatorNode()
|
||||
test_image = create_test_image(512, 512) # fixture
|
||||
scale_factor = 2.0
|
||||
|
||||
# Act
|
||||
width, height = node.calculate_resolution(scale_factor, image=test_image)
|
||||
|
||||
# Assert
|
||||
assert width == 1024
|
||||
assert height == 1024
|
||||
|
||||
def test_resolution_calculator_with_latent():
|
||||
"""Test resolution calculation with latent input."""
|
||||
# Similar pattern for latent inputs
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. Run Tests (Should Fail)
|
||||
```bash
|
||||
pytest tests/unit/tools/test_resolution_calculator.py -v
|
||||
```
|
||||
|
||||
### 3. Implement Minimal Code
|
||||
```python
|
||||
# kikotools/tools/resolution_calculator/logic.py
|
||||
def calculate_upscale_resolution(input_tensor, scale_factor):
|
||||
"""Calculate new resolution based on input and scale factor."""
|
||||
# Minimal implementation to pass tests
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. Refactor and Expand
|
||||
- Add error handling
|
||||
- Optimize for Flux/SDXL specific requirements
|
||||
- Add comprehensive validation
|
||||
- Implement edge case handling
|
||||
|
||||
### 5. Integration Testing
|
||||
```python
|
||||
# tests/integration/test_workflow_execution.py
|
||||
def test_resolution_calculator_in_workflow():
|
||||
"""Test resolution calculator in full ComfyUI workflow."""
|
||||
workflow = load_test_workflow("resolution_calculator_example.json")
|
||||
result = execute_comfyui_workflow(workflow)
|
||||
assert result.success
|
||||
```
|
||||
|
||||
## Tool-Specific Implementation Notes
|
||||
|
||||
### Resolution Calculator
|
||||
- **Input Validation**: Handle both image and latent tensors
|
||||
- **Scale Factors**: Support integer (1, 2, 3) and float (1.2, 1.5, 2.0) multipliers
|
||||
- **Model Optimization**: Consider Flux and SDXL specific resolution requirements
|
||||
- **Output Format**: Integer width/height suitable for upscaler node connections
|
||||
- **Error Handling**: Graceful handling of invalid inputs or edge cases
|
||||
|
||||
### Future Tools (Planned)
|
||||
- Batch Image Processor
|
||||
- Advanced Prompt Utilities
|
||||
- Model Management Tools
|
||||
- Custom Sampling Methods
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New Tool
|
||||
1. **Plan**: Define tool purpose, inputs, outputs, and test cases
|
||||
2. **Generate**: Use `python scripts/create_tool.py --name "NewTool"`
|
||||
3. **Test**: Write comprehensive tests following TDD principles
|
||||
4. **Implement**: Build tool logic with proper ComfyUI integration
|
||||
5. **Register**: Add tool to registry and validate registration
|
||||
6. **Document**: Update examples and documentation
|
||||
7. **Validate**: Test in real ComfyUI environment with actual workflows
|
||||
|
||||
### Code Quality Standards
|
||||
- **Type Hints**: Full type annotation for all functions
|
||||
- **Documentation**: Docstrings for all public methods and classes
|
||||
- **Testing**: Minimum 90% test coverage for all tools
|
||||
- **Linting**: Pass all flake8 and mypy checks
|
||||
- **Formatting**: Auto-formatted with black
|
||||
|
||||
### Release Process
|
||||
1. Run full test suite: `pytest tests/`
|
||||
2. Validate in ComfyUI: `python scripts/validate_nodes.py`
|
||||
3. Update version numbers and changelog
|
||||
4. Create example workflows demonstrating new features
|
||||
5. Update ComfyUI-Manager compatibility metadata
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
### ComfyUI Compatibility
|
||||
- Follow ComfyUI tensor format conventions
|
||||
- Implement proper memory management for large tensors
|
||||
- Handle ComfyUI execution context correctly
|
||||
- Ensure compatibility with ComfyUI's automatic typing system
|
||||
|
||||
### Performance Considerations
|
||||
- Optimize for real-time workflow execution
|
||||
- Minimize memory allocation during processing
|
||||
- Cache expensive computations when appropriate
|
||||
- Profile performance with typical Flux/SDXL workflows
|
||||
|
||||
### User Experience
|
||||
- Clear, descriptive node names and parameter labels
|
||||
- Helpful tooltips and parameter descriptions
|
||||
- Consistent visual styling within ComfyAssets group
|
||||
- Robust error messages with actionable guidance
|
||||
|
||||
### Extensibility
|
||||
- Plugin architecture for easy tool addition
|
||||
- Shared utilities for common operations
|
||||
- Consistent API patterns across all tools
|
||||
- Future-proof design for ComfyUI updates
|
||||
@@ -0,0 +1,148 @@
|
||||
# ComfyUI XYZ Grid Comparison Nodes
|
||||
|
||||
## Project Objective
|
||||
Create a modular suite of ComfyUI nodes for visual grid-based comparisons across parameters such as:
|
||||
|
||||
- Models
|
||||
- LoRAs
|
||||
- Schedulers
|
||||
- Samplers
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Clip Skip
|
||||
- VAEs
|
||||
- Flux Guidance (custom model settings)
|
||||
|
||||
The tool will support X, Y, and optional Z axis configuration using a polished, intuitive UI with no scripting or coding required.
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
- **Modular Architecture:** Built as multiple nodes (not monolithic)
|
||||
- **Standard Node Compatibility:** Work with *any* KSampler, Model Loader, etc.
|
||||
- **User Friendly UI:** Dropdowns, toggles, and visual input—no syntax or scripting
|
||||
- **Flexible Axis Mapping:** Any parameter can go on X, Y, or Z
|
||||
- **Dynamic Grid Generation:** One-click execution queues all combinations
|
||||
- **Labeling:** Automatic overlay and metadata support with clean presentation
|
||||
- **High Performance:** Smart resource caching and sequential queuing
|
||||
|
||||
---
|
||||
|
||||
## Key Nodes
|
||||
|
||||
### 1. `XYZ Plot Controller`
|
||||
- Main config node
|
||||
- Allows axis selection (X, Y, optional Z)
|
||||
- Outputs: axis values, labels, grid ID
|
||||
- Automatically queues image generation
|
||||
|
||||
### 2. `Image Grid Combiner`
|
||||
- Accepts image + axis metadata
|
||||
- Assembles a labeled grid (or multiple grids)
|
||||
- Outputs: grid image(s), optional metadata (label list, value list)
|
||||
|
||||
---
|
||||
|
||||
## Parameter Types
|
||||
Supported as axis values:
|
||||
- Model (checkpoint)
|
||||
- LoRA (file)
|
||||
- VAE
|
||||
- Sampler (Euler, DPM++, etc.)
|
||||
- Scheduler
|
||||
- CFG Scale (float list)
|
||||
- Steps (int list)
|
||||
- Clip Skip
|
||||
- Prompt (swap full prompt or use template)
|
||||
- Seed
|
||||
- Custom (e.g., Flux guidance strength)
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Axis Config (for X, Y, Z)
|
||||
- Dropdown: Select parameter type
|
||||
- Input: List of values (dynamic UI)
|
||||
- File pickers (models, LoRAs)
|
||||
- Number range or CSV (steps, CFG)
|
||||
- Text input (prompts)
|
||||
- Label customization
|
||||
- Prefix: optional (e.g., CFG=, Sampler:)
|
||||
- Label format: full, short, value only
|
||||
|
||||
### Execution
|
||||
- One-click generate
|
||||
- Internally queues all combinations (X * Y * Z)
|
||||
- Reuses sampler, model loader, etc.
|
||||
- Supports caching to avoid repeated loads
|
||||
|
||||
---
|
||||
|
||||
## Output Behavior
|
||||
- Combiner tracks image count
|
||||
- Assembles grid when complete
|
||||
- Draws axis labels using PIL
|
||||
- Handles Z axis by outputting multiple grids
|
||||
- Preview as images come in
|
||||
- Metadata export (optional JSON/text)
|
||||
|
||||
---
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### Model vs CFG
|
||||
- X: Models A/B
|
||||
- Y: CFG [5,10,15]
|
||||
- Output: 2x3 grid with axis labels
|
||||
|
||||
### Prompt vs Sampler
|
||||
- X: Prompt variations
|
||||
- Y: Samplers
|
||||
- Output: labeled comparison grid
|
||||
|
||||
### LoRA vs Seed, Z=Strength
|
||||
- X: LoRA name
|
||||
- Y: Seeds
|
||||
- Z: LoRA strength
|
||||
- Output: Multiple 2D grids, one per Z value
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: MVP
|
||||
- X/Y support
|
||||
- Core image generation loop
|
||||
- Grid image stitching
|
||||
|
||||
### Phase 2: Z Axis + More Parameters
|
||||
- Prompt, LoRA, Flux guidance, etc.
|
||||
|
||||
### Phase 3: UI Polish
|
||||
- Dynamic widgets
|
||||
- Label controls, error handling
|
||||
|
||||
### Phase 4: Performance & Optimization
|
||||
- Model caching
|
||||
- Memory handling
|
||||
- Abort/resume logic
|
||||
|
||||
### Phase 5: Docs & Examples
|
||||
- Example workflows
|
||||
- Visual documentation
|
||||
|
||||
---
|
||||
|
||||
## References & Inspirations
|
||||
- [TinyTerra ComfyUI_tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes)
|
||||
- [kenjiqq/qq-nodes-comfyui](https://github.com/kenjiqq/qq-nodes-comfyui)
|
||||
- [jags111/efficiency-nodes-comfyui](https://github.com/jags111/efficiency-nodes-comfyui)
|
||||
- [shockz-comfy/comfy-easy-grids](https://github.com/shockz-comfy/comfy-easy-grids)
|
||||
|
||||
---
|
||||
|
||||
## Final Outcome
|
||||
A polished, no-code, modular XYZ plotting system in ComfyUI for exploring image generation across any combination of models, settings, or parameters with professional-grade visual output.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# RGThree-Style Dynamic Widget Framework for ComfyUI
|
||||
|
||||
This document explains how to implement RGThree's Power Lora Loader-style dynamic widget system in your own ComfyUI nodes. This framework provides a clean UI with toggles, dynamic widget management, and proper persistence across page refreshes.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Dynamic widget addition/removal** - Users can add/remove items at runtime
|
||||
- **Toggle switches** - Clean circular toggles instead of checkboxes
|
||||
- **Strength controls** - Arrow buttons with editable values for fine control
|
||||
- **Right-click context menus** - Only on the item name area
|
||||
- **Full persistence** - All values persist across page refreshes
|
||||
- **Hide/show widgets** - Proper cleanup when switching between types
|
||||
|
||||
## Core Implementation Pattern
|
||||
|
||||
### 1. Node Setup in JavaScript
|
||||
|
||||
```javascript
|
||||
app.registerExtension({
|
||||
name: "YourExtension.YourNode",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "YourNodeName") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const node = this;
|
||||
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Enable widget serialization
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// Track widget visibility
|
||||
this.hiddenWidgets = new Set();
|
||||
|
||||
// Initialize storage for dynamic widgets
|
||||
if (!node.dynamicWidgets) {
|
||||
node.dynamicWidgets = {
|
||||
category1: [],
|
||||
category2: []
|
||||
};
|
||||
}
|
||||
|
||||
// Store references to buttons and text widgets
|
||||
if (!node.addButtons) {
|
||||
node.addButtons = {};
|
||||
}
|
||||
if (!node.textWidgets) {
|
||||
node.textWidgets = {};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Custom Widget Class
|
||||
|
||||
```javascript
|
||||
class DynamicWidget {
|
||||
constructor(name, value) {
|
||||
this.name = name;
|
||||
this._value = value;
|
||||
this.type = "custom_dynamic_widget";
|
||||
this.y = 0;
|
||||
this.options = {};
|
||||
|
||||
// Mouse tracking for drag operations
|
||||
this.mouseState = {
|
||||
dragging: false,
|
||||
startX: 0,
|
||||
startValue: 0,
|
||||
lastClickTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(v) {
|
||||
this._value = v;
|
||||
}
|
||||
|
||||
serializeValue(node, index) {
|
||||
// Return a deep copy to prevent modification
|
||||
return this._value ? { ...this._value } : null;
|
||||
}
|
||||
|
||||
draw(ctx, node, width, y) {
|
||||
const margin = 10;
|
||||
const innerMargin = 3;
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT;
|
||||
const midY = y + height / 2;
|
||||
let posX = margin;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = "rgba(0,0,0,0.2)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
|
||||
ctx.fill();
|
||||
|
||||
// Draw toggle (Power Lora style)
|
||||
const toggleRadius = height * 0.36;
|
||||
const toggleBgWidth = height * 1.5;
|
||||
|
||||
// Toggle background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = app.canvas.editor_alpha;
|
||||
|
||||
// Toggle circle
|
||||
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
|
||||
ctx.fillStyle = this.value.on ? "#89B" : "#888";
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
this.toggleBounds = [posX, toggleBgWidth];
|
||||
posX += toggleBgWidth + innerMargin;
|
||||
|
||||
// Apply opacity if disabled
|
||||
if (!this.value.on) {
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
||||
}
|
||||
|
||||
// Draw strength controls (if applicable)
|
||||
if (this.value.strength !== undefined) {
|
||||
let strengthX = width - margin - innerMargin;
|
||||
|
||||
// Draw arrows and value
|
||||
// ... (implement arrow drawing as shown in xyz_plot_controller.js)
|
||||
}
|
||||
|
||||
// Draw item name
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.value.name || "None", posX, midY);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
mouse(event, pos, node) {
|
||||
// Handle mouse events for toggle and controls
|
||||
if (event.type === "mousedown") {
|
||||
// Check toggle bounds
|
||||
if (pos[0] >= this.toggleBounds[0] &&
|
||||
pos[0] <= this.toggleBounds[0] + this.toggleBounds[1]) {
|
||||
this.value.on = !this.value.on;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
// Handle other controls...
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configuration and Restoration
|
||||
|
||||
```javascript
|
||||
// Override onConfigure for proper restoration
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
// Mark as configured to prevent duplicate initialization
|
||||
this._configured = true;
|
||||
|
||||
// Store widget values before ComfyUI modifies them
|
||||
const savedWidgetValues = [...(info.widgets_values || [])];
|
||||
|
||||
// Clear tracking for fresh restoration
|
||||
if (!this.hiddenWidgets) {
|
||||
this.hiddenWidgets = new Set();
|
||||
}
|
||||
this.dynamicWidgets = { /* categories */ };
|
||||
this.addButtons = {};
|
||||
this.textWidgets = {};
|
||||
|
||||
// Let ComfyUI restore base widgets
|
||||
if (onConfigure) {
|
||||
onConfigure.call(this, info);
|
||||
}
|
||||
|
||||
// Restore dynamic widgets from saved values
|
||||
// ... (implement restoration logic)
|
||||
|
||||
// Manually restore text widget values
|
||||
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const savedValue = savedWidgetValues[i];
|
||||
|
||||
if (widget && typeof savedValue === 'string' && savedValue !== '') {
|
||||
widget.value = savedValue;
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = savedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Serialization Override
|
||||
|
||||
```javascript
|
||||
// Override onSerialize to fix widget value persistence
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function(info) {
|
||||
// Let ComfyUI serialize first
|
||||
if (origOnSerialize) {
|
||||
origOnSerialize.call(this, info);
|
||||
}
|
||||
|
||||
// Fix empty text widget values
|
||||
if (info.widgets_values && this.widgets) {
|
||||
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const serializedValue = info.widgets_values[i];
|
||||
|
||||
// If serialized value is empty but widget has value, fix it
|
||||
if ((serializedValue === '' || serializedValue === null) &&
|
||||
widget && widget.value !== '' && widget.value !== null) {
|
||||
info.widgets_values[i] = widget.value;
|
||||
}
|
||||
|
||||
// Also check inputEl for text widgets
|
||||
if (widget && widget.inputEl && widget.inputEl.value &&
|
||||
(serializedValue === '' || serializedValue === null)) {
|
||||
info.widgets_values[i] = widget.inputEl.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Right-Click Context Menu
|
||||
|
||||
```javascript
|
||||
// Override getSlotInPosition to detect clicks on widget areas
|
||||
const originalGetSlotInPosition = node.getSlotInPosition;
|
||||
node.getSlotInPosition = function(x, y) {
|
||||
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
|
||||
if (!slot) {
|
||||
// Check if we clicked on a dynamic widget's name area
|
||||
const localX = x - this.pos[0];
|
||||
const localY = y - this.pos[1];
|
||||
|
||||
for (const w of this.widgets || []) {
|
||||
if (w.type === "custom_dynamic_widget" && w.y &&
|
||||
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
|
||||
// Check if click is within name bounds
|
||||
if (w.nameBounds && localX >= w.nameBounds[0] &&
|
||||
localX <= w.nameBounds[0] + w.nameBounds[1]) {
|
||||
return { widget: w, output: { type: "DYNAMIC_WIDGET" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
};
|
||||
|
||||
// Override getSlotMenuOptions for context menu
|
||||
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
|
||||
node.getSlotMenuOptions = function(slot) {
|
||||
if (slot?.output?.type === "DYNAMIC_WIDGET") {
|
||||
const widget = slot.widget;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
|
||||
callback: () => {
|
||||
widget.value.on = !widget.value.on;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬆️ Move Up`,
|
||||
disabled: !canMoveUp,
|
||||
callback: () => { /* implement move */ }
|
||||
},
|
||||
{
|
||||
content: `⬇️ Move Down`,
|
||||
disabled: !canMoveDown,
|
||||
callback: () => { /* implement move */ }
|
||||
},
|
||||
{
|
||||
content: `🗑️ Remove`,
|
||||
callback: () => { /* implement remove */ }
|
||||
}
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "WIDGET OPTIONS",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
|
||||
return null; // Prevent default menu
|
||||
}
|
||||
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Widget Visibility Management
|
||||
|
||||
```javascript
|
||||
function updateWidgets(node, category, type, skipClear = false) {
|
||||
// Hide/show widgets instead of removing them
|
||||
if (!skipClear) {
|
||||
// Hide all widgets for this category
|
||||
node.widgets?.forEach(widget => {
|
||||
if (widget.name?.includes(category)) {
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
node.hiddenWidgets?.add(widget.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear dynamic widgets
|
||||
if (node.dynamicWidgets[category]) {
|
||||
while (node.dynamicWidgets[category].length > 0) {
|
||||
const widget = node.dynamicWidgets[category].pop();
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (index > -1) {
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or unhide widgets based on type
|
||||
if (needsTextWidget(type)) {
|
||||
const widgetName = `${category}_text`;
|
||||
let existingWidget = node.widgets?.find(w => w.name === widgetName);
|
||||
|
||||
if (!existingWidget) {
|
||||
// Create new widget
|
||||
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
|
||||
default: "",
|
||||
multiline: true
|
||||
}]);
|
||||
node.textWidgets[category] = textWidget.widget;
|
||||
} else {
|
||||
// Unhide existing widget
|
||||
existingWidget.hidden = false;
|
||||
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
node.hiddenWidgets?.delete(existingWidget.name);
|
||||
node.textWidgets[category] = existingWidget;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use hide/show instead of remove/add** for text widgets to preserve values
|
||||
2. **Track widget state** in dedicated objects (dynamicWidgets, textWidgets, etc.)
|
||||
3. **Override serialization** to ensure ComfyUI properly saves widget values
|
||||
4. **Use skipClear flags** during restoration to prevent widget clearing
|
||||
5. **Implement proper mouse bounds checking** for custom controls
|
||||
6. **Store metadata** (_axis, _type) with widget values for easier restoration
|
||||
7. **Don't auto-resize nodes** - respect user's manual sizing
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
1. **Don't remove widgets during configure** - this loses their values
|
||||
2. **Don't rely on widget indices** - they can change
|
||||
3. **Don't forget to handle inputEl** for text widgets
|
||||
4. **Don't create widgets without checking if they exist** first
|
||||
5. **Always deep copy values** when serializing to prevent modification
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Widgets persist across page refresh
|
||||
- [ ] Toggle states are maintained
|
||||
- [ ] Strength/value controls work with click and drag
|
||||
- [ ] Right-click menu only appears on name area
|
||||
- [ ] Moving widgets up/down works correctly
|
||||
- [ ] Removing widgets works without errors
|
||||
- [ ] Switching between types doesn't leave artifacts
|
||||
- [ ] All text input types persist (numbers, ranges, prompts)
|
||||
- [ ] Hidden widgets don't take up visual space
|
||||
- [ ] Widget values serialize correctly in workflow JSON
|
||||
|
||||
This framework provides a robust foundation for creating professional, user-friendly ComfyUI nodes with dynamic widget management that matches the quality of RGThree's implementations.
|
||||
@@ -0,0 +1,684 @@
|
||||
# RGThree Widget Framework - Complete Example Implementation
|
||||
|
||||
This file provides a complete, working example of implementing the RGThree-style widget framework for a hypothetical "Advanced Sampler Controller" node.
|
||||
|
||||
## Complete Implementation Example
|
||||
|
||||
```javascript
|
||||
// File: web/advanced_sampler_controller.js
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
|
||||
// Widget counter for unique names
|
||||
let widgetCounter = 0;
|
||||
|
||||
// Custom dynamic widget class
|
||||
class SamplerDynamicWidget {
|
||||
constructor(name, value) {
|
||||
this.name = name;
|
||||
this._value = value;
|
||||
this.type = "sampler_dynamic_widget";
|
||||
this.y = 0;
|
||||
this.options = {};
|
||||
|
||||
// Mouse state for drag operations
|
||||
this.mouseState = {
|
||||
dragging: false,
|
||||
startX: 0,
|
||||
startValue: 0,
|
||||
lastClickTime: 0
|
||||
};
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(v) {
|
||||
this._value = v;
|
||||
}
|
||||
|
||||
serializeValue(node, index) {
|
||||
return this._value ? { ...this._value } : null;
|
||||
}
|
||||
|
||||
draw(ctx, node, width, y) {
|
||||
const margin = 10;
|
||||
const innerMargin = 3;
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT;
|
||||
const midY = y + height / 2;
|
||||
let posX = margin;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "rgba(0,0,0,0.2)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
|
||||
ctx.fill();
|
||||
|
||||
// Toggle
|
||||
const toggleRadius = height * 0.36;
|
||||
const toggleBgWidth = height * 1.5;
|
||||
|
||||
// Toggle background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = app.canvas.editor_alpha;
|
||||
|
||||
// Toggle circle
|
||||
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
|
||||
ctx.fillStyle = this.value.on ? "#89B" : "#888";
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Store bounds for mouse interaction
|
||||
this.toggleBounds = [posX, toggleBgWidth];
|
||||
posX += toggleBgWidth + innerMargin;
|
||||
|
||||
// Apply opacity if disabled
|
||||
if (!this.value.on) {
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
||||
}
|
||||
|
||||
// Strength controls and value
|
||||
let strengthX = width - margin - innerMargin;
|
||||
|
||||
// Down arrow
|
||||
const arrowSize = 10;
|
||||
const arrowX = strengthX - arrowSize;
|
||||
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(arrowX + arrowSize/2, midY + 3);
|
||||
ctx.lineTo(arrowX + 2, midY - 3);
|
||||
ctx.lineTo(arrowX + arrowSize - 2, midY - 3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
this.downArrowBounds = [arrowX, arrowSize];
|
||||
strengthX = arrowX - innerMargin;
|
||||
|
||||
// Up arrow
|
||||
const upArrowX = strengthX - arrowSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(upArrowX + arrowSize/2, midY - 3);
|
||||
ctx.lineTo(upArrowX + 2, midY + 3);
|
||||
ctx.lineTo(upArrowX + arrowSize - 2, midY + 3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
this.upArrowBounds = [upArrowX, arrowSize];
|
||||
strengthX = upArrowX - innerMargin;
|
||||
|
||||
// Strength value
|
||||
const strengthText = this.value.strength.toFixed(2);
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = `${ctx.font}`;
|
||||
const textMetrics = ctx.measureText(strengthText);
|
||||
const strengthTextX = strengthX - textMetrics.width/2 - 4;
|
||||
|
||||
// Draggable background
|
||||
ctx.fillStyle = "rgba(255,255,255,0.1)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(strengthTextX - textMetrics.width/2 - 2, y + 4,
|
||||
textMetrics.width + 4, height - 8, [3]);
|
||||
ctx.fill();
|
||||
|
||||
// Value text
|
||||
ctx.fillStyle = this.value.on ? "#FFF" : "#AAA";
|
||||
ctx.fillText(strengthText, strengthTextX, midY);
|
||||
|
||||
this.strengthBounds = [strengthTextX - textMetrics.width/2 - 2, textMetrics.width + 4];
|
||||
|
||||
// Name
|
||||
const nameX = posX;
|
||||
const maxNameWidth = strengthTextX - textMetrics.width/2 - nameX - 10;
|
||||
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
|
||||
// Clip long names
|
||||
const displayName = this.value.name || "None";
|
||||
let truncatedName = displayName;
|
||||
if (ctx.measureText(displayName).width > maxNameWidth) {
|
||||
while (truncatedName.length > 0 &&
|
||||
ctx.measureText(truncatedName + "...").width > maxNameWidth) {
|
||||
truncatedName = truncatedName.slice(0, -1);
|
||||
}
|
||||
truncatedName += "...";
|
||||
}
|
||||
|
||||
ctx.fillText(truncatedName, nameX, midY);
|
||||
|
||||
// Store name bounds for right-click detection
|
||||
this.nameBounds = [nameX, ctx.measureText(truncatedName).width];
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
mouse(event, pos, node) {
|
||||
const margin = 10;
|
||||
const localX = pos[0] - margin;
|
||||
|
||||
if (event.type === "mousedown") {
|
||||
// Toggle click
|
||||
if (localX >= this.toggleBounds[0] &&
|
||||
localX <= this.toggleBounds[0] + this.toggleBounds[1]) {
|
||||
this.value.on = !this.value.on;
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Up arrow
|
||||
if (localX >= this.upArrowBounds[0] &&
|
||||
localX <= this.upArrowBounds[0] + this.upArrowBounds[1]) {
|
||||
this.value.strength = Math.min(this.value.strength + 0.1, 10);
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Down arrow
|
||||
if (localX >= this.downArrowBounds[0] &&
|
||||
localX <= this.downArrowBounds[0] + this.downArrowBounds[1]) {
|
||||
this.value.strength = Math.max(this.value.strength - 0.1, -10);
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strength drag start
|
||||
if (localX >= this.strengthBounds[0] &&
|
||||
localX <= this.strengthBounds[0] + this.strengthBounds[1]) {
|
||||
this.mouseState.dragging = true;
|
||||
this.mouseState.startX = pos[0];
|
||||
this.mouseState.startValue = this.value.strength;
|
||||
|
||||
// Double-click detection
|
||||
const now = Date.now();
|
||||
if (now - this.mouseState.lastClickTime < 300) {
|
||||
// Double-click - show input dialog
|
||||
const newValue = prompt("Enter strength value:", this.value.strength);
|
||||
if (newValue !== null && !isNaN(parseFloat(newValue))) {
|
||||
this.value.strength = Math.max(-10, Math.min(10, parseFloat(newValue)));
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
this.mouseState.dragging = false;
|
||||
}
|
||||
this.mouseState.lastClickTime = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (event.type === "mousemove" && this.mouseState.dragging) {
|
||||
const deltaX = pos[0] - this.mouseState.startX;
|
||||
const sensitivity = 0.01;
|
||||
this.value.strength = Math.max(-10, Math.min(10,
|
||||
this.mouseState.startValue + deltaX * sensitivity));
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
else if (event.type === "mouseup") {
|
||||
this.mouseState.dragging = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
computeSize() {
|
||||
return [node.size[0], LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
}
|
||||
}
|
||||
|
||||
// Main extension registration
|
||||
app.registerExtension({
|
||||
name: "Example.AdvancedSamplerController",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "AdvancedSamplerController") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const node = this;
|
||||
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Enable widget serialization
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// Initialize tracking
|
||||
this.hiddenWidgets = new Set();
|
||||
|
||||
// Initialize storage
|
||||
if (!node.dynamicWidgets) {
|
||||
node.dynamicWidgets = {
|
||||
samplers: [],
|
||||
schedulers: []
|
||||
};
|
||||
}
|
||||
|
||||
if (!node.addButtons) {
|
||||
node.addButtons = {};
|
||||
}
|
||||
|
||||
if (!node.textWidgets) {
|
||||
node.textWidgets = {};
|
||||
}
|
||||
|
||||
// Override configuration
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
this._configured = true;
|
||||
|
||||
// Save widget values before ComfyUI modifies them
|
||||
const savedWidgetValues = [...(info.widgets_values || [])];
|
||||
|
||||
// Clear for fresh restoration
|
||||
if (!this.hiddenWidgets) {
|
||||
this.hiddenWidgets = new Set();
|
||||
}
|
||||
this.dynamicWidgets = {
|
||||
samplers: [],
|
||||
schedulers: []
|
||||
};
|
||||
this.addButtons = {};
|
||||
this.textWidgets = {};
|
||||
|
||||
// Let ComfyUI restore base widgets
|
||||
if (onConfigure) {
|
||||
onConfigure.call(this, info);
|
||||
}
|
||||
|
||||
// Restore dynamic widgets
|
||||
let widgetIndex = this.widgets.length;
|
||||
for (let i = widgetIndex; i < savedWidgetValues.length; i++) {
|
||||
const value = savedWidgetValues[i];
|
||||
if (value && typeof value === 'object' && value._type) {
|
||||
const widget = new SamplerDynamicWidget(
|
||||
`dynamic_${widgetCounter++}`,
|
||||
value
|
||||
);
|
||||
this.addCustomWidget(widget);
|
||||
|
||||
if (this.dynamicWidgets[value._type]) {
|
||||
this.dynamicWidgets[value._type].push(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore text widget values
|
||||
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const savedValue = savedWidgetValues[i];
|
||||
|
||||
if (widget && typeof savedValue === 'string' && savedValue !== '') {
|
||||
widget.value = savedValue;
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = savedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI based on restored state
|
||||
if (this.widgets?.length > 0) {
|
||||
const typeWidget = this.widgets.find(w => w.name === "sampler_type");
|
||||
if (typeWidget) {
|
||||
updateTypeWidgets(this, typeWidget.value, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override serialization
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function(info) {
|
||||
if (origOnSerialize) {
|
||||
origOnSerialize.call(this, info);
|
||||
}
|
||||
|
||||
// Fix empty text widget values
|
||||
if (info.widgets_values && this.widgets) {
|
||||
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
|
||||
const widget = this.widgets[i];
|
||||
const serializedValue = info.widgets_values[i];
|
||||
|
||||
if ((serializedValue === '' || serializedValue === null) &&
|
||||
widget && widget.value !== '' && widget.value !== null) {
|
||||
info.widgets_values[i] = widget.value;
|
||||
}
|
||||
|
||||
if (widget && widget.inputEl && widget.inputEl.value &&
|
||||
(serializedValue === '' || serializedValue === null)) {
|
||||
info.widgets_values[i] = widget.inputEl.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Implement right-click context menu
|
||||
implementContextMenu(node);
|
||||
|
||||
// Widget change handlers
|
||||
const samplerWidget = this.widgets.find(w => w.name === "sampler_type");
|
||||
if (samplerWidget) {
|
||||
const origCallback = samplerWidget.callback;
|
||||
samplerWidget.callback = function() {
|
||||
if (origCallback) {
|
||||
origCallback.apply(this, arguments);
|
||||
}
|
||||
updateTypeWidgets(node, samplerWidget.value);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to update widgets based on type
|
||||
function updateTypeWidgets(node, type, skipClear = false) {
|
||||
if (!skipClear) {
|
||||
// Hide text widgets
|
||||
node.widgets?.forEach(widget => {
|
||||
if (widget.name?.includes("custom_values")) {
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
node.hiddenWidgets?.add(widget.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear dynamic widgets
|
||||
if (node.dynamicWidgets.samplers) {
|
||||
while (node.dynamicWidgets.samplers.length > 0) {
|
||||
const widget = node.dynamicWidgets.samplers.pop();
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (index > -1) {
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or unhide widgets based on type
|
||||
if (type === "custom") {
|
||||
const widgetName = "custom_values";
|
||||
let existingWidget = node.widgets?.find(w => w.name === widgetName);
|
||||
|
||||
if (!existingWidget) {
|
||||
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
|
||||
default: "",
|
||||
multiline: true
|
||||
}]);
|
||||
node.textWidgets.custom = textWidget.widget;
|
||||
} else {
|
||||
existingWidget.hidden = false;
|
||||
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
node.hiddenWidgets?.delete(existingWidget.name);
|
||||
node.textWidgets.custom = existingWidget;
|
||||
}
|
||||
} else if (type === "samplers") {
|
||||
// Add button for samplers
|
||||
if (!node.addButtons.samplers) {
|
||||
const button = node.addWidget("button", "+ Add Sampler", null, () => {
|
||||
addDynamicWidget(node, "samplers");
|
||||
});
|
||||
node.addButtons.samplers = button;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to add dynamic widgets
|
||||
function addDynamicWidget(node, type) {
|
||||
const widget = new SamplerDynamicWidget(
|
||||
`dynamic_${widgetCounter++}`,
|
||||
{
|
||||
on: true,
|
||||
name: type === "samplers" ? "euler" : "normal",
|
||||
strength: 1.0,
|
||||
_type: type
|
||||
}
|
||||
);
|
||||
|
||||
node.addCustomWidget(widget);
|
||||
node.dynamicWidgets[type].push(widget);
|
||||
}
|
||||
|
||||
// Helper function to implement context menu
|
||||
function implementContextMenu(node) {
|
||||
const originalGetSlotInPosition = node.getSlotInPosition;
|
||||
node.getSlotInPosition = function(x, y) {
|
||||
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
|
||||
if (!slot) {
|
||||
const localX = x - this.pos[0];
|
||||
const localY = y - this.pos[1];
|
||||
|
||||
for (const w of this.widgets || []) {
|
||||
if (w.type === "sampler_dynamic_widget" && w.y &&
|
||||
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
|
||||
if (w.nameBounds && localX >= w.nameBounds[0] &&
|
||||
localX <= w.nameBounds[0] + w.nameBounds[1]) {
|
||||
return { widget: w, output: { type: "SAMPLER_WIDGET" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
};
|
||||
|
||||
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
|
||||
node.getSlotMenuOptions = function(slot) {
|
||||
if (slot?.output?.type === "SAMPLER_WIDGET") {
|
||||
const widget = slot.widget;
|
||||
const arrayName = widget.value._type;
|
||||
const array = this.dynamicWidgets[arrayName];
|
||||
const currentIndex = array.indexOf(widget);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
|
||||
callback: () => {
|
||||
widget.value.on = !widget.value.on;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬆️ Move Up`,
|
||||
disabled: currentIndex === 0,
|
||||
callback: () => {
|
||||
if (currentIndex > 0) {
|
||||
// Swap in array
|
||||
[array[currentIndex - 1], array[currentIndex]] =
|
||||
[array[currentIndex], array[currentIndex - 1]];
|
||||
|
||||
// Swap in widgets
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
const prevWidget = array[currentIndex];
|
||||
const prevIndex = this.widgets.indexOf(prevWidget);
|
||||
|
||||
if (widgetIndex > -1 && prevIndex > -1) {
|
||||
[this.widgets[prevIndex], this.widgets[widgetIndex]] =
|
||||
[this.widgets[widgetIndex], this.widgets[prevIndex]];
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬇️ Move Down`,
|
||||
disabled: currentIndex === array.length - 1,
|
||||
callback: () => {
|
||||
if (currentIndex < array.length - 1) {
|
||||
// Swap in array
|
||||
[array[currentIndex], array[currentIndex + 1]] =
|
||||
[array[currentIndex + 1], array[currentIndex]];
|
||||
|
||||
// Swap in widgets
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
const nextWidget = array[currentIndex];
|
||||
const nextIndex = this.widgets.indexOf(nextWidget);
|
||||
|
||||
if (widgetIndex > -1 && nextIndex > -1) {
|
||||
[this.widgets[widgetIndex], this.widgets[nextIndex]] =
|
||||
[this.widgets[nextIndex], this.widgets[widgetIndex]];
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
null, // Separator
|
||||
{
|
||||
content: `🗑️ Remove`,
|
||||
callback: () => {
|
||||
const index = array.indexOf(widget);
|
||||
if (index > -1) {
|
||||
array.splice(index, 1);
|
||||
}
|
||||
const wIndex = this.widgets.indexOf(widget);
|
||||
if (wIndex > -1) {
|
||||
this.widgets.splice(wIndex, 1);
|
||||
}
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "SAMPLER OPTIONS",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Python Node Definition
|
||||
|
||||
```python
|
||||
# File: kikotools/tools/advanced_sampler_controller/node.py
|
||||
|
||||
class AdvancedSamplerController:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sampler_type": (["samplers", "custom", "schedulers"], {
|
||||
"default": "samplers"
|
||||
}),
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"custom_values": ("STRING", {"multiline": True, "default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER_CONFIG",)
|
||||
RETURN_NAMES = ("config",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def process(self, sampler_type, enabled, custom_values="", **kwargs):
|
||||
config = {
|
||||
"type": sampler_type,
|
||||
"enabled": enabled,
|
||||
"samplers": [],
|
||||
"custom": custom_values
|
||||
}
|
||||
|
||||
# Process dynamic widgets
|
||||
for key, value in kwargs.items():
|
||||
if isinstance(value, dict) and value.get("_type") == "samplers":
|
||||
if value.get("on", False):
|
||||
config["samplers"].append({
|
||||
"name": value.get("name"),
|
||||
"strength": value.get("strength", 1.0)
|
||||
})
|
||||
|
||||
return (config,)
|
||||
```
|
||||
|
||||
## Key Implementation Points
|
||||
|
||||
1. **Widget Class Design**
|
||||
- Custom widget class with proper value getter/setter
|
||||
- `serializeValue` method for persistence
|
||||
- Complete `draw` and `mouse` methods
|
||||
- Proper bounds tracking for all interactive elements
|
||||
|
||||
2. **Node Setup**
|
||||
- `serialize_widgets = true` in onNodeCreated
|
||||
- Tracking objects for dynamic widgets, buttons, and text widgets
|
||||
- Hidden widgets set for visibility management
|
||||
|
||||
3. **Configuration Override**
|
||||
- Save widget values before ComfyUI modifies them
|
||||
- Clear tracking objects for fresh restoration
|
||||
- Restore dynamic widgets from saved values
|
||||
- Manually restore text widget values
|
||||
|
||||
4. **Serialization Override**
|
||||
- Fix empty text widget values
|
||||
- Check both widget.value and widget.inputEl.value
|
||||
- Ensure all widget types persist correctly
|
||||
|
||||
5. **Context Menu Implementation**
|
||||
- Override getSlotInPosition to detect widget clicks
|
||||
- Check name bounds for right-click detection
|
||||
- Return custom slot type for menu trigger
|
||||
- Override getSlotMenuOptions for menu items
|
||||
|
||||
6. **Widget Management**
|
||||
- Hide/show pattern instead of remove/add
|
||||
- Proper cleanup when switching types
|
||||
- Dynamic widget arrays for organization
|
||||
- Button widgets for adding new items
|
||||
|
||||
## Testing Your Implementation
|
||||
|
||||
1. **Create Test Workflow**
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"type": "AdvancedSamplerController",
|
||||
"widgets_values": [
|
||||
"samplers",
|
||||
true,
|
||||
"",
|
||||
{
|
||||
"on": true,
|
||||
"name": "euler",
|
||||
"strength": 0.8,
|
||||
"_type": "samplers"
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Test Checklist**
|
||||
- [ ] Add dynamic widgets with button
|
||||
- [ ] Toggle on/off states persist
|
||||
- [ ] Strength values persist after refresh
|
||||
- [ ] Right-click menu only on name area
|
||||
- [ ] Move up/down works correctly
|
||||
- [ ] Remove widget works
|
||||
- [ ] Switch types doesn't leave artifacts
|
||||
- [ ] Text values persist
|
||||
- [ ] Double-click to edit strength works
|
||||
|
||||
3. **Debug Tips**
|
||||
- Add console.log in key methods
|
||||
- Check browser console for errors
|
||||
- Verify widget array contents
|
||||
- Test with workflow JSON export/import
|
||||
|
||||
This complete example demonstrates all aspects of the RGThree widget framework and can be adapted for any custom node that needs dynamic widget management with professional UI/UX.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,129 @@
|
||||
# XYZ Grid Examples
|
||||
|
||||
This directory contains example workflows demonstrating the XYZ Grid nodes for ComfyUI.
|
||||
|
||||
## Overview
|
||||
|
||||
The XYZ Grid system allows you to create parameter comparison grids with any combination of:
|
||||
- Models/Checkpoints
|
||||
- Samplers
|
||||
- Schedulers
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Clip Skip
|
||||
- VAEs
|
||||
- LoRAs
|
||||
- Prompts
|
||||
- Seeds
|
||||
- Flux Guidance
|
||||
- Denoise strength
|
||||
|
||||
## Basic Usage
|
||||
|
||||
1. Add an **XYZ Plot Controller** node to your workflow
|
||||
2. Configure X and Y axes (and optionally Z for multiple grids)
|
||||
3. Connect the appropriate outputs to your generation nodes
|
||||
4. Add an **Image Grid Combiner** node
|
||||
5. Connect your generated images to the combiner
|
||||
6. Run once - the system handles all iterations automatically!
|
||||
|
||||
## Node Descriptions
|
||||
|
||||
### XYZ Plot Controller
|
||||
|
||||
The main configuration node that drives the grid generation.
|
||||
|
||||
**Inputs:**
|
||||
- `x_axis_type`: Parameter type for X axis (horizontal)
|
||||
- `x_values`: Values to iterate over (comma-separated or range syntax)
|
||||
- `y_axis_type`: Parameter type for Y axis (vertical)
|
||||
- `y_values`: Values to iterate over
|
||||
- `z_axis_type`: (Optional) Parameter type for Z axis (multiple grids)
|
||||
- `z_values`: Values for Z axis
|
||||
- `auto_queue`: Enable automatic execution queuing
|
||||
|
||||
**Outputs:**
|
||||
- `grid_data`: Configuration data for the combiner
|
||||
- `x_string`, `x_int`, `x_float`: Current X value in different types
|
||||
- `y_string`, `y_int`, `y_float`: Current Y value in different types
|
||||
- `z_string`, `z_int`, `z_float`: Current Z value in different types
|
||||
- `batch_id`: Unique identifier for this grid batch
|
||||
|
||||
### Image Grid Combiner
|
||||
|
||||
Collects generated images and assembles them into labeled grids.
|
||||
|
||||
**Inputs:**
|
||||
- `images`: Generated images from your workflow
|
||||
- `grid_data`: Configuration from XYZ Plot Controller
|
||||
- `font_size`: Size of label text (default: 20)
|
||||
- `grid_gap`: Pixel gap between images (default: 10)
|
||||
- `label_height`: Height of label area (default: 30)
|
||||
- `include_labels`: Whether to add labels (default: true)
|
||||
|
||||
**Outputs:**
|
||||
- `grid_image`: The assembled grid image(s)
|
||||
- `grid_info`: Information about the grid
|
||||
|
||||
## Value Syntax
|
||||
|
||||
### Lists
|
||||
Use comma-separated values:
|
||||
```
|
||||
euler, euler_ancestral, dpm_2, dpm_2_ancestral
|
||||
```
|
||||
|
||||
### Ranges
|
||||
Use colon syntax for numeric ranges:
|
||||
```
|
||||
5:10:1 # From 5 to 10, step 1 → [5, 6, 7, 8, 9, 10]
|
||||
0.5:2:0.5 # From 0.5 to 2, step 0.5 → [0.5, 1.0, 1.5, 2.0]
|
||||
10:50:10 # From 10 to 50, step 10 → [10, 20, 30, 40, 50]
|
||||
```
|
||||
|
||||
### Model/File Selection
|
||||
Use the quick-select dropdowns or type filenames:
|
||||
```
|
||||
model1.safetensors, model2.ckpt, checkpoint_v3.pt
|
||||
```
|
||||
|
||||
## Connection Examples
|
||||
|
||||
### Varying Sampler
|
||||
1. Set X axis to "sampler"
|
||||
2. Connect `x_string` output to KSampler's `sampler_name` input
|
||||
|
||||
### Varying CFG Scale
|
||||
1. Set Y axis to "cfg_scale"
|
||||
2. Connect `y_float` output to KSampler's `cfg` input
|
||||
|
||||
### Varying Model
|
||||
1. Set X axis to "model"
|
||||
2. Connect `x_string` output to CheckpointLoader's `ckpt_name` input
|
||||
|
||||
### Varying Prompt
|
||||
1. Set Y axis to "prompt"
|
||||
2. Enter different prompts on separate lines in `y_values`
|
||||
3. Connect `y_string` output to CLIPTextEncode's `text` input
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
1. **Memory Management**: The system includes intelligent model caching. For large grids with multiple models, it will optimize loading order.
|
||||
|
||||
2. **Progress Tracking**: Watch the node title for progress updates (e.g., "XYZ Plot Controller [3/12]")
|
||||
|
||||
3. **Large Grids**: Be mindful of total image count. The node shows a warning for grids over 100 images.
|
||||
|
||||
4. **Z-Axis**: When using Z-axis, you'll get multiple grid images - one for each Z value.
|
||||
|
||||
5. **Label Customization**: Use prefixes to clarify labels (e.g., "CFG=" for CFG values)
|
||||
|
||||
## Workflow Files
|
||||
|
||||
- `basic_model_cfg_grid.json`: Compare 2 models across 3 CFG values
|
||||
- `sampler_comparison.json`: Compare all samplers at different step counts
|
||||
- `prompt_variations.json`: Test prompt variations across different models
|
||||
- `advanced_3d_grid.json`: Use Z-axis for LoRA strength variations
|
||||
- `flux_guidance_test.json`: Test Flux-specific parameters
|
||||
|
||||
Load these workflows in ComfyUI to see practical examples of the XYZ Grid system in action!
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 18,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [50, 100],
|
||||
"size": [500, 450],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_int", "type": "INT", "links": [12]},
|
||||
{"name": "z_float", "type": "FLOAT", "links": [13]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"lora",
|
||||
"None, style_lora_v1.safetensors, detail_lora_v2.safetensors, anime_lora_v3.safetensors",
|
||||
"LoRA: ",
|
||||
"seed",
|
||||
"100, 200, 300, 400, 500",
|
||||
"Seed: ",
|
||||
true,
|
||||
"denoise",
|
||||
"0.4, 0.7, 1.0",
|
||||
"Strength: ",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1, 14]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3, 15]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoraLoader",
|
||||
"pos": [950, 100],
|
||||
"size": [315, 126],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 14},
|
||||
{"name": "clip", "type": "CLIP", "link": 15},
|
||||
{"name": "lora_name", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [16]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [17, 18]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["None", 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 17}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a magical forest with glowing mushrooms and fairy lights, ethereal atmosphere, fantasy art"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 18}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1300, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [1050, 350],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 16},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "seed", "type": "INT", "link": 12},
|
||||
{"name": "denoise", "type": "FLOAT", "link": 13}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [0, "fixed", 20, 7.5, "dpmpp_2m", "karras", 1.0]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1400, 350],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1650, 350],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [19]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [18, 8, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [2000, 350],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 19}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["lora_seed_strength_3d_grid"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 7, 0, "MODEL"],
|
||||
[2, 2, 1, 4, 0, "CLIP"],
|
||||
[3, 2, 1, 5, 0, "CLIP"],
|
||||
[4, 2, 2, 8, 1, "VAE"],
|
||||
[5, 4, 0, 7, 1, "CONDITIONING"],
|
||||
[6, 5, 0, 7, 2, "CONDITIONING"],
|
||||
[7, 6, 0, 7, 3, "LATENT"],
|
||||
[8, 7, 0, 8, 0, "LATENT"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 1, 0, 9, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 3, 2, "STRING"],
|
||||
[12, 1, 4, 7, 4, "INT"],
|
||||
[13, 1, 7, 7, 5, "FLOAT"],
|
||||
[14, 2, 0, 3, 0, "MODEL"],
|
||||
[15, 2, 1, 3, 1, "CLIP"],
|
||||
[16, 3, 0, 7, 0, "MODEL"],
|
||||
[17, 3, 1, 4, 0, "CLIP"],
|
||||
[18, 3, 1, 5, 0, "CLIP"],
|
||||
[19, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "3D XYZ Grid Configuration",
|
||||
"bounding": [30, 20, 540, 530],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "LoRA Loading Pipeline",
|
||||
"bounding": [580, 20, 700, 220],
|
||||
"color": "#8b4c7a"
|
||||
},
|
||||
{
|
||||
"title": "Generation Pipeline",
|
||||
"bounding": [580, 240, 1060, 480],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Assembly & Output",
|
||||
"bounding": [1630, 270, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This advanced workflow demonstrates 3D grid functionality with X=LoRA (4 options including None), Y=Seed (5 values), and Z=Denoise strength (3 values). This generates 3 separate 4x5 grids, one for each denoise strength, totaling 60 images. Perfect for finding the optimal LoRA and strength combination across different seeds."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 6,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 300],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [1]},
|
||||
{"name": "x_string", "type": "STRING", "links": null},
|
||||
{"name": "x_int", "type": "INT", "links": null},
|
||||
{"name": "x_float", "type": "FLOAT", "links": null},
|
||||
{"name": "y_string", "type": "STRING", "links": null},
|
||||
{"name": "y_int", "type": "INT", "links": null},
|
||||
{"name": "y_float", "type": "FLOAT", "links": null},
|
||||
{"name": "z_string", "type": "STRING", "links": null},
|
||||
{"name": "z_int", "type": "INT", "links": null},
|
||||
{"name": "z_float", "type": "FLOAT", "links": null},
|
||||
{"name": "batch_id", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"models",
|
||||
"cfg_scale",
|
||||
"none",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": null},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": null},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 1, "XYZ_GRID"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "Example workflow showing the new advanced XYZ Plot Controller with dynamic widget addition."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 300],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_float", "type": "FLOAT", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"model",
|
||||
"sd_xl_base_1.0.safetensors, dreamshaperXL_v2.safetensors",
|
||||
"Model: ",
|
||||
"cfg_scale",
|
||||
"5, 7.5, 10",
|
||||
"CFG: ",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [550, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "ckpt_name", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a beautiful landscape with mountains and a lake, highly detailed, professional photography"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1000, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [1000, 250],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "cfg", "type": "FLOAT", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [42, "fixed", 20, 7.5, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1350, 250],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1600, 250],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [13]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1950, 250],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 13}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["model_cfg_comparison"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 6, 0, "MODEL"],
|
||||
[2, 2, 1, 3, 0, "CLIP"],
|
||||
[3, 2, 1, 4, 0, "CLIP"],
|
||||
[4, 2, 2, 7, 1, "VAE"],
|
||||
[5, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[6, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[7, 5, 0, 6, 3, "LATENT"],
|
||||
[8, 6, 0, 7, 0, "LATENT"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 2, 0, "STRING"],
|
||||
[12, 1, 5, 6, 4, "FLOAT"],
|
||||
[13, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "XYZ Grid Setup",
|
||||
"bounding": [80, 20, 440, 380],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image Generation",
|
||||
"bounding": [530, 20, 1050, 720],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Output",
|
||||
"bounding": [1580, 170, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow demonstrates a basic 2x3 grid comparing two models at three different CFG scale values. The XYZ Plot Controller automatically handles all 6 iterations."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 16,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [450, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_float", "type": "FLOAT", "links": [11]},
|
||||
{"name": "y_float", "type": "FLOAT", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"flux_guidance",
|
||||
"1.0:5.0:0.5",
|
||||
"Guidance: ",
|
||||
"cfg_scale",
|
||||
"1.0, 3.0, 5.0, 7.0",
|
||||
"CFG: ",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["flux1-dev.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "FluxGuidance",
|
||||
"pos": [950, 100],
|
||||
"size": [315, 58],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "conditioning", "type": "CONDITIONING", "link": 14},
|
||||
{"name": "guidance", "type": "FLOAT", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [3.5]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [14]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a stunning digital artwork of a phoenix rising from ashes, vibrant colors, dramatic lighting, highly detailed feathers with fire effects"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1050, 250],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [1050, 400],
|
||||
"size": [315, 334],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "cfg", "type": "FLOAT", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["enable", 42, "fixed", 20, 1.0, "euler", "simple", 0, 20, "disable"]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1400, 400],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1650, 400],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [15]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [2000, 400],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 15}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["flux_guidance_cfg_grid"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 7, 0, "MODEL"],
|
||||
[2, 2, 1, 4, 0, "CLIP"],
|
||||
[3, 2, 1, 5, 0, "CLIP"],
|
||||
[4, 2, 2, 8, 1, "VAE"],
|
||||
[5, 3, 0, 7, 1, "CONDITIONING"],
|
||||
[6, 5, 0, 7, 2, "CONDITIONING"],
|
||||
[7, 6, 0, 7, 3, "LATENT"],
|
||||
[8, 7, 0, 8, 0, "LATENT"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 1, 0, 9, 1, "XYZ_GRID"],
|
||||
[11, 1, 2, 3, 1, "FLOAT"],
|
||||
[12, 1, 5, 7, 4, "FLOAT"],
|
||||
[14, 4, 0, 3, 0, "CONDITIONING"],
|
||||
[15, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Flux Parameter Grid Setup",
|
||||
"bounding": [80, 20, 490, 430],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Flux Model Pipeline",
|
||||
"bounding": [580, 20, 450, 200],
|
||||
"color": "#8b4c7a"
|
||||
},
|
||||
{
|
||||
"title": "Generation Pipeline",
|
||||
"bounding": [580, 240, 810, 520],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Output",
|
||||
"bounding": [1630, 320, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow demonstrates testing Flux-specific parameters. It creates a 9x4 grid comparing Flux guidance values (1.0 to 5.0 in 0.5 steps) against different CFG scales. This is useful for finding the optimal balance between Flux guidance and traditional CFG for your specific use case. Note: Requires Flux model and FluxGuidance node."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [450, 400],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_string", "type": "STRING", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"model",
|
||||
"sd_xl_base_1.0.safetensors, dreamshaperXL_v2.safetensors, juggernautXL_v8.safetensors",
|
||||
"",
|
||||
"prompt",
|
||||
"a serene japanese garden with cherry blossoms\na futuristic cyberpunk city at night\na medieval castle on a misty mountain\nan underwater coral reef teeming with life\na cozy cabin in a snowy forest",
|
||||
"",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [600, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "ckpt_name", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2},
|
||||
{"name": "text", "type": "STRING", "link": 12, "widget": {"name": "text"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["beautiful scenery"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [600, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted, ugly, poorly drawn"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1050, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [768, 768, 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [1050, 250],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [789012, "fixed", 25, 7.5, "dpmpp_2m", "karras", 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1400, 250],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1650, 250],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [13]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [20, 10, 35, 35, true]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [2000, 250],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 13}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["prompt_model_variations"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 6, 0, "MODEL"],
|
||||
[2, 2, 1, 3, 0, "CLIP"],
|
||||
[3, 2, 1, 4, 0, "CLIP"],
|
||||
[4, 2, 2, 7, 1, "VAE"],
|
||||
[5, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[6, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[7, 5, 0, 6, 3, "LATENT"],
|
||||
[8, 6, 0, 7, 0, "LATENT"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 2, 0, "STRING"],
|
||||
[12, 1, 4, 3, 1, "STRING"],
|
||||
[13, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Prompt x Model Grid Setup",
|
||||
"bounding": [80, 20, 490, 480],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image Generation Pipeline",
|
||||
"bounding": [580, 20, 1050, 720],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Assembly & Output",
|
||||
"bounding": [1630, 170, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow creates a 3x5 grid comparing 3 different models with 5 diverse prompt scenarios. Great for seeing how different models interpret various styles and subjects. The Y axis connects directly to the positive prompt input, automatically switching prompts for each row."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
|
||||
{"name": "x_string", "type": "STRING", "links": [11]},
|
||||
{"name": "y_int", "type": "INT", "links": [12]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"sampler",
|
||||
"euler, euler_ancestral, heun, dpm_2, dpm_2_ancestral, lms, dpm_fast, dpm_adaptive, dpmpp_2s_ancestral, dpmpp_sde, dpmpp_2m, ddim",
|
||||
"",
|
||||
"steps",
|
||||
"10, 20, 30, 50",
|
||||
"Steps: ",
|
||||
true,
|
||||
"none",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [550, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [1]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
|
||||
{"name": "VAE", "type": "VAE", "links": [4]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["a majestic dragon soaring through clouds, fantasy art, highly detailed, epic lighting"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [550, 500],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["blurry, low quality, distorted, ugly"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1000, 100],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [7]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [1000, 250],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 1},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 6},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 7},
|
||||
{"name": "sampler_name", "type": "combo", "link": 11},
|
||||
{"name": "steps", "type": "INT", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [8]}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [123456, "fixed", 20, 8.0, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1350, 250],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 8},
|
||||
{"name": "vae", "type": "VAE", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
|
||||
],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1600, 250],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [13]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [16, 8, 25, 25, true]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1950, 250],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 13}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["sampler_steps_comparison"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 6, 0, "MODEL"],
|
||||
[2, 2, 1, 3, 0, "CLIP"],
|
||||
[3, 2, 1, 4, 0, "CLIP"],
|
||||
[4, 2, 2, 7, 1, "VAE"],
|
||||
[5, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[6, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[7, 5, 0, 6, 3, "LATENT"],
|
||||
[8, 6, 0, 7, 0, "LATENT"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 1, "XYZ_GRID"],
|
||||
[11, 1, 1, 6, 4, "combo"],
|
||||
[12, 1, 4, 6, 5, "INT"],
|
||||
[13, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Sampler vs Steps Grid",
|
||||
"bounding": [80, 20, 440, 430],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image Generation",
|
||||
"bounding": [530, 20, 1050, 720],
|
||||
"color": "#4c7a3f"
|
||||
},
|
||||
{
|
||||
"title": "Grid Output",
|
||||
"bounding": [1580, 170, 700, 400],
|
||||
"color": "#7a4c3f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"info": "This workflow creates a 12x4 grid comparing 12 different samplers at 4 step counts (10, 20, 30, 50). Perfect for finding the optimal sampler and step count for your use case. Note: Using smaller image size (512x512) due to the large number of generations (48 total)."
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"last_node_id": 20,
|
||||
"last_link_id": 30,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "XYZPrompt",
|
||||
"pos": [100, 100],
|
||||
"size": {"0": 350, "1": 400},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{"name": "prompts", "type": "XYZ_PROMPTS", "links": [1]},
|
||||
{"name": "positive", "type": "STRING", "links": [2]},
|
||||
{"name": "negative", "type": "STRING", "links": [3]},
|
||||
{"name": "count", "type": "INT", "links": null}
|
||||
],
|
||||
"properties": {"Node name for S&R": "XYZPrompt"},
|
||||
"widgets_values": [
|
||||
true,
|
||||
true,
|
||||
"a beautiful landscape",
|
||||
"ugly, blurry, watermark",
|
||||
"a serene mountain scene",
|
||||
"a vibrant cityscape at night",
|
||||
"a peaceful forest path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "XYZPlotController",
|
||||
"pos": [500, 100],
|
||||
"size": {"0": 400, "1": 500},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "prompts", "type": "XYZ_PROMPTS", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "links": [4]},
|
||||
{"name": "x_string", "type": "STRING", "links": [5]},
|
||||
{"name": "x_int", "type": "INT", "links": null},
|
||||
{"name": "x_float", "type": "FLOAT", "links": null},
|
||||
{"name": "y_string", "type": "STRING", "links": null},
|
||||
{"name": "y_int", "type": "INT", "links": [6]},
|
||||
{"name": "y_float", "type": "FLOAT", "links": null},
|
||||
{"name": "z_string", "type": "STRING", "links": null},
|
||||
{"name": "z_int", "type": "INT", "links": null},
|
||||
{"name": "z_float", "type": "FLOAT", "links": null},
|
||||
{"name": "batch_id", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {"Node name for S&R": "XYZPlotController"},
|
||||
"widgets_values": [
|
||||
"prompt",
|
||||
"steps",
|
||||
"none",
|
||||
true,
|
||||
"20\n30\n40",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 550],
|
||||
"size": {"0": 315, "1": 98},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [7]},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [8, 9]},
|
||||
{"name": "VAE", "type": "VAE", "links": [10]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "CheckpointLoaderSimple"},
|
||||
"widgets_values": ["sd_xl_base_1.0.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500, 650],
|
||||
"size": {"0": 400, "1": 200},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 8},
|
||||
{"name": "text", "type": "STRING", "link": 2, "widget": {"name": "text"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [11]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "CLIPTextEncode"},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500, 900],
|
||||
"size": {"0": 400, "1": 200},
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 9},
|
||||
{"name": "text", "type": "STRING", "link": 3, "widget": {"name": "text"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [12]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "CLIPTextEncode"},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [950, 550],
|
||||
"size": {"0": 315, "1": 106},
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [13]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "EmptyLatentImage"},
|
||||
"widgets_values": [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [950, 700],
|
||||
"size": {"0": 315, "1": 262},
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 7},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 11},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 12},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 13},
|
||||
{"name": "steps", "type": "INT", "link": 6, "widget": {"name": "steps"}}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "LATENT", "type": "LATENT", "links": [14]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "KSampler"},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1300, 700],
|
||||
"size": {"0": 210, "1": 46},
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 14},
|
||||
{"name": "vae", "type": "VAE", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [15]}
|
||||
],
|
||||
"properties": {"Node name for S&R": "VAEDecode"}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageGridCombiner",
|
||||
"pos": [1550, 700],
|
||||
"size": {"0": 315, "1": 202},
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 15},
|
||||
{"name": "grid_data", "type": "XYZ_GRID", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "grid_image", "type": "IMAGE", "links": [16]},
|
||||
{"name": "grid_info", "type": "STRING", "links": null}
|
||||
],
|
||||
"properties": {"Node name for S&R": "ImageGridCombiner"},
|
||||
"widgets_values": [20, 10, 30, 30, true]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [1900, 700],
|
||||
"size": {"0": 315, "1": 270},
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 16}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["xyz_grid"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "XYZ_PROMPTS"],
|
||||
[2, 1, 1, 4, 1, "STRING"],
|
||||
[3, 1, 2, 5, 1, "STRING"],
|
||||
[4, 2, 0, 9, 1, "XYZ_GRID"],
|
||||
[5, 2, 1, 4, 1, "STRING"],
|
||||
[6, 2, 5, 7, 4, "INT"],
|
||||
[7, 3, 0, 7, 0, "MODEL"],
|
||||
[8, 3, 1, 4, 0, "CLIP"],
|
||||
[9, 3, 1, 5, 0, "CLIP"],
|
||||
[10, 3, 2, 8, 1, "VAE"],
|
||||
[11, 4, 0, 7, 1, "CONDITIONING"],
|
||||
[12, 5, 0, 7, 2, "CONDITIONING"],
|
||||
[13, 6, 0, 7, 3, "LATENT"],
|
||||
[14, 7, 0, 8, 0, "LATENT"],
|
||||
[15, 8, 0, 9, 0, "IMAGE"],
|
||||
[16, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "XYZ Grid Test Workflow",
|
||||
"bounding": [80, 20, 2160, 1100],
|
||||
"color": "#3f789e"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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.xyz_grid import XYZPlotController, ImageGridCombiner, XYZPrompt
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -29,6 +30,9 @@ NODE_CLASS_MAPPINGS = {
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
"DisplayText": DisplayTextNode,
|
||||
"XYZPlotController": XYZPlotController,
|
||||
"ImageGridCombiner": ImageGridCombiner,
|
||||
"XYZPrompt": XYZPrompt,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -44,6 +48,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"GeminiPrompt": "Gemini Prompt Engineer",
|
||||
"DisplayAny": "Display Any",
|
||||
"DisplayText": "Display Text",
|
||||
"XYZPlotController": "XYZ Plot Controller",
|
||||
"ImageGridCombiner": "Image Grid Combiner",
|
||||
"XYZPrompt": "XYZ Prompt",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# XYZ Plot Controller - Advanced Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This is a complete reimplementation of the XYZ Plot Controller using the Power Lora Loader architecture from rgthree. The implementation provides dynamic widget management with an intuitive interface.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Dynamic Widget System
|
||||
- **"➕ Add [Type]" Buttons**: When you select models, vaes, loras, samplers, or schedulers for an axis, a button appears to add selections
|
||||
- **Toggle On/Off**: Each dynamic widget has a checkbox to enable/disable it without removing
|
||||
- **Right-Click Menu**: Right-click any dynamic widget to remove or toggle it
|
||||
- **Live Count Updates**: Node title shows total image count in real-time
|
||||
|
||||
### Supported Axis Types
|
||||
- **Models**: Dynamic dropdown widgets with available checkpoints
|
||||
- **VAEs**: Dynamic dropdown widgets (includes "Automatic" option)
|
||||
- **LoRAs**: Dynamic dropdown widgets (includes "None" option)
|
||||
- **Samplers**: Dynamic dropdown widgets with all sampler options
|
||||
- **Schedulers**: Dynamic dropdown widgets with scheduler options
|
||||
- **Numeric Parameters**: Text areas with helpful placeholders
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Seed
|
||||
- Denoise
|
||||
- CLIP Skip
|
||||
- **Prompts**: Multi-line text area for prompt variations
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
#### Python Backend (`xyz_plot_advanced.py`)
|
||||
- Uses `FlexibleOptionalInputType` to accept any number of dynamic inputs
|
||||
- Processes kwargs to extract widget values in format: `{axis}_{type}_{id}`
|
||||
- Each dynamic widget sends: `{ "on": bool, "value": string }`
|
||||
|
||||
#### JavaScript Frontend (`xyz_plot_rgthree.js`)
|
||||
- Manages dynamic widget creation/removal
|
||||
- Custom widget drawing with toggle checkboxes
|
||||
- Serialization/deserialization for workflow saving
|
||||
- Real-time validation and counting
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add the "XYZ Plot Controller (Advanced)" node
|
||||
2. Select axis types (X, Y, Z)
|
||||
3. Click "➕ Add [Type]" to add selections for that axis
|
||||
4. Toggle widgets on/off with checkboxes
|
||||
5. Right-click widgets for more options
|
||||
6. For numeric types, use comma-separated values or ranges (e.g., "5:15:2.5")
|
||||
7. For prompts, enter one per line
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
- **Clean Separation**: Python handles data, JavaScript handles UI
|
||||
- **Flexible Input System**: Can accept unlimited dynamic widgets
|
||||
- **Persistent State**: All widget states are saved with the workflow
|
||||
- **Intuitive Interface**: Matches Power Lora Loader's proven UX patterns
|
||||
- **Performance**: Only processes enabled widgets
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Model/LoRA info display (CivitAI integration)
|
||||
- Drag-and-drop reordering
|
||||
- Preset management
|
||||
- Batch widget operations
|
||||
@@ -0,0 +1,68 @@
|
||||
# XYZ Grid Nodes for ComfyUI
|
||||
|
||||
Advanced parameter comparison grid generator for ComfyUI with Power Lora Loader-inspired interface.
|
||||
|
||||
## Features
|
||||
|
||||
### XYZ Plot Controller
|
||||
- **Dynamic Multi-Selection**: Native dropdown widgets for selecting multiple models, VAEs, LoRAs, samplers, and schedulers
|
||||
- **Smart Widget Management**: Widgets automatically show/hide based on selected axis types
|
||||
- **Visual Organization**: Grouped widgets with headers for better organization
|
||||
- **Right-Click Context Menu**:
|
||||
- Clear all selections for a specific type
|
||||
- Show image count breakdown
|
||||
- Keyboard shortcuts (Ctrl+Shift+C to clear all)
|
||||
- **Real-time Image Count**: Node title shows total images that will be generated
|
||||
- **Warning System**: Visual warning when generating over 100 images
|
||||
|
||||
### Supported Parameter Types
|
||||
- **Models**: Multiple checkpoint selection
|
||||
- **VAEs**: Multiple VAE selection with "Automatic" option
|
||||
- **LoRAs**: Multiple LoRA selection with "None" option
|
||||
- **Samplers**: euler, euler_ancestral, heun, dpm_2, etc.
|
||||
- **Schedulers**: normal, karras, exponential, etc.
|
||||
- **Numeric Parameters**:
|
||||
- CFG Scale
|
||||
- Steps
|
||||
- Seed
|
||||
- Denoise
|
||||
- CLIP Skip
|
||||
- Support for ranges (e.g., "5:15:2.5" generates 5, 7.5, 10, 12.5, 15)
|
||||
- **Prompts**: Multiple prompts (one per line)
|
||||
|
||||
### Image Grid Combiner
|
||||
- Automatic grid assembly with customizable spacing
|
||||
- Smart labeling with parameter values
|
||||
- Z-axis support for generating multiple grid pages
|
||||
- Font size and label customization options
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add an XYZ Plot Controller node
|
||||
2. Select axis types (X, Y, and optionally Z)
|
||||
3. Use the dropdown widgets to select values for each axis
|
||||
4. Connect to your workflow (models, samplers, etc.)
|
||||
5. Add Image Grid Combiner at the end to create the labeled grid
|
||||
|
||||
## Workflow Example
|
||||
|
||||
```
|
||||
[XYZ Plot Controller] → [Checkpoint Loader] → [Sampling] → [Image Grid Combiner] → [Save Image]
|
||||
```
|
||||
|
||||
The controller outputs the current iteration values which can be connected to corresponding nodes in your workflow.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use the right-click menu to quickly clear selections
|
||||
- Check the image count in the node title before running
|
||||
- For large grids, consider using the Z-axis to split into multiple pages
|
||||
- Numeric ranges are more efficient than listing each value
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The implementation uses a hybrid approach:
|
||||
- Python backend with native ComfyUI widget support
|
||||
- JavaScript frontend for enhanced UI features
|
||||
- Inspired by Power Lora Loader's dynamic widget management
|
||||
- Context menus and keyboard shortcuts for power users
|
||||
@@ -0,0 +1,19 @@
|
||||
"""XYZ Grid nodes for ComfyUI parameter comparisons."""
|
||||
|
||||
from .controller.power_node import XYZPlotController
|
||||
from .combiner.node import ImageGridCombiner
|
||||
from .prompt.node import XYZPrompt
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"XYZPlotController": XYZPlotController,
|
||||
"ImageGridCombiner": ImageGridCombiner,
|
||||
"XYZPrompt": XYZPrompt,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"XYZPlotController": "XYZ Plot Controller",
|
||||
"ImageGridCombiner": "Image Grid Combiner",
|
||||
"XYZPrompt": "XYZ Prompt",
|
||||
}
|
||||
|
||||
__all__ = ["XYZPlotController", "ImageGridCombiner", "XYZPrompt"]
|
||||
@@ -0,0 +1 @@
|
||||
# Image Grid Combiner module
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Image Grid Combiner node implementation."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
|
||||
from ..utils.constants import GRID_DEFAULTS
|
||||
|
||||
|
||||
class ImageGridCombiner:
|
||||
"""Combines images into labeled grid output."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"grid_data": ("XYZ_GRID",),
|
||||
},
|
||||
"optional": {
|
||||
"font_size": ("INT", {"default": GRID_DEFAULTS["font_size"], "min": 8, "max": 72}),
|
||||
"grid_gap": ("INT", {"default": GRID_DEFAULTS["grid_gap"], "min": 0, "max": 50}),
|
||||
"label_height": ("INT", {"default": GRID_DEFAULTS["label_height"], "min": 0, "max": 100}),
|
||||
"max_label_length": ("INT", {"default": GRID_DEFAULTS["max_label_length"], "min": 10, "max": 100}),
|
||||
"include_labels": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("grid_image", "grid_info")
|
||||
FUNCTION = "combine_images"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def __init__(self):
|
||||
self.image_buffer = {} # Store images by batch_id
|
||||
self.grid_configs = {} # Store configs by batch_id
|
||||
|
||||
def combine_images(self, images, grid_data, font_size=20, grid_gap=10,
|
||||
label_height=30, max_label_length=30, include_labels=True):
|
||||
"""Combine images into grid with labels."""
|
||||
|
||||
batch_id = grid_data["batch_id"]
|
||||
|
||||
# Initialize buffer for this batch if needed
|
||||
if batch_id not in self.image_buffer:
|
||||
self.image_buffer[batch_id] = []
|
||||
self.grid_configs[batch_id] = grid_data
|
||||
|
||||
# Add current image(s) to buffer
|
||||
if len(images.shape) == 4: # Batch of images
|
||||
for img in images:
|
||||
self.image_buffer[batch_id].append(img)
|
||||
else: # Single image
|
||||
self.image_buffer[batch_id].append(images)
|
||||
|
||||
# Check if we have all images for this grid
|
||||
config = self.grid_configs[batch_id]
|
||||
expected_images = config["dimensions"]["total_images"]
|
||||
current_count = len(self.image_buffer[batch_id])
|
||||
|
||||
if current_count < expected_images:
|
||||
# Not ready yet, return placeholder
|
||||
placeholder = torch.zeros((1, 64, 64, 3))
|
||||
info = f"Grid progress: {current_count}/{expected_images} images"
|
||||
return (placeholder, info)
|
||||
|
||||
# We have all images, create grid(s)
|
||||
grids = self._create_grids(batch_id, font_size, grid_gap, label_height,
|
||||
max_label_length, include_labels)
|
||||
|
||||
# Clean up buffers
|
||||
del self.image_buffer[batch_id]
|
||||
del self.grid_configs[batch_id]
|
||||
|
||||
# Return grid(s) and info
|
||||
info = self._generate_grid_info(config)
|
||||
|
||||
# Convert PIL images back to tensor format
|
||||
grid_tensors = []
|
||||
for grid in grids:
|
||||
grid_np = np.array(grid).astype(np.float32) / 255.0
|
||||
grid_tensor = torch.from_numpy(grid_np)
|
||||
grid_tensors.append(grid_tensor)
|
||||
|
||||
# Stack if multiple grids (Z axis)
|
||||
if len(grid_tensors) > 1:
|
||||
output = torch.stack(grid_tensors)
|
||||
else:
|
||||
output = grid_tensors[0].unsqueeze(0)
|
||||
|
||||
return (output, info)
|
||||
|
||||
def _create_grids(self, batch_id: str, font_size: int, grid_gap: int,
|
||||
label_height: int, max_label_length: int, include_labels: bool) -> List[Image.Image]:
|
||||
"""Create grid images from buffer."""
|
||||
|
||||
config = self.grid_configs[batch_id]
|
||||
images = self.image_buffer[batch_id]
|
||||
dims = config["dimensions"]
|
||||
|
||||
# Convert tensors to PIL images
|
||||
pil_images = []
|
||||
for img_tensor in images:
|
||||
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
pil_images.append(Image.fromarray(img_np))
|
||||
|
||||
# Get dimensions
|
||||
img_width = pil_images[0].width
|
||||
img_height = pil_images[0].height
|
||||
cols = dims["cols"]
|
||||
rows = dims["rows"]
|
||||
grids_count = dims["grids_count"]
|
||||
|
||||
# Calculate grid dimensions
|
||||
row_label_width = 100 if include_labels else 0 # Space for Y labels
|
||||
z_label_height = 40 if include_labels and grids_count > 1 else 0 # Space for Z label
|
||||
|
||||
if include_labels:
|
||||
grid_width = cols * img_width + (cols - 1) * grid_gap + row_label_width
|
||||
grid_height = rows * img_height + (rows - 1) * grid_gap + label_height + z_label_height
|
||||
else:
|
||||
grid_width = cols * img_width + (cols - 1) * grid_gap
|
||||
grid_height = rows * img_height + (rows - 1) * grid_gap
|
||||
|
||||
grids = []
|
||||
z_labels = config["axes"]["z"]["labels"] if config["axes"]["z"]["labels"] else []
|
||||
|
||||
# Create each grid (for Z axis)
|
||||
for z_idx in range(grids_count):
|
||||
# Create blank grid
|
||||
grid = Image.new('RGB', (grid_width, grid_height), color=(32, 32, 32))
|
||||
draw = ImageDraw.Draw(grid)
|
||||
|
||||
# Add labels if enabled
|
||||
if include_labels:
|
||||
# Try to use a better font if available
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size)
|
||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size + 4)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
title_font = font
|
||||
|
||||
# Draw Z-axis label if applicable
|
||||
if z_labels and z_idx < len(z_labels):
|
||||
z_label = z_labels[z_idx]
|
||||
# Center the Z label
|
||||
bbox = draw.textbbox((0, 0), z_label, font=title_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
z_x = (grid_width - text_width) // 2
|
||||
self._draw_label(draw, z_label, z_x, 5, text_width + 20,
|
||||
z_label_height - 10, title_font, max_label_length * 2)
|
||||
|
||||
# Draw column labels (X axis)
|
||||
x_labels = config["axes"]["x"]["labels"]
|
||||
for col_idx, label in enumerate(x_labels):
|
||||
x = col_idx * (img_width + grid_gap) + row_label_width
|
||||
y = z_label_height
|
||||
self._draw_label(draw, label, x, y, img_width, label_height, font, max_label_length)
|
||||
|
||||
# Draw row labels (Y axis) - on the left side
|
||||
y_labels = config["axes"]["y"]["labels"]
|
||||
for row_idx, label in enumerate(y_labels):
|
||||
y = row_idx * (img_height + grid_gap) + label_height + z_label_height
|
||||
self._draw_label(draw, label, 5, y + img_height // 2 - font_size // 2,
|
||||
row_label_width - 10, font_size + 4, font, max_label_length,
|
||||
align="right")
|
||||
|
||||
# Place images
|
||||
for y_idx in range(rows):
|
||||
for x_idx in range(cols):
|
||||
img_idx = z_idx * (rows * cols) + y_idx * cols + x_idx
|
||||
if img_idx < len(pil_images):
|
||||
x = x_idx * (img_width + grid_gap) + row_label_width
|
||||
y = y_idx * (img_height + grid_gap) + label_height + z_label_height
|
||||
grid.paste(pil_images[img_idx], (x, y))
|
||||
|
||||
grids.append(grid)
|
||||
|
||||
return grids
|
||||
|
||||
def _draw_label(self, draw, text: str, x: int, y: int, width: int, height: int,
|
||||
font, max_length: int, align: str = "center"):
|
||||
"""Draw a label with background."""
|
||||
|
||||
# Truncate if needed
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length-3] + "..."
|
||||
|
||||
# Get text dimensions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Calculate position based on alignment
|
||||
if align == "center":
|
||||
text_x = x + (width - text_width) // 2
|
||||
elif align == "right":
|
||||
text_x = x + width - text_width - 5
|
||||
else:
|
||||
text_x = x + 5
|
||||
|
||||
text_y = y + (height - text_height) // 2
|
||||
|
||||
# Draw background
|
||||
padding = 3
|
||||
draw.rectangle([text_x - padding, text_y - padding,
|
||||
text_x + text_width + padding, text_y + text_height + padding],
|
||||
fill=(0, 0, 0, 180))
|
||||
|
||||
# Draw text
|
||||
draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font)
|
||||
|
||||
def _generate_grid_info(self, config: Dict) -> str:
|
||||
"""Generate information string about the grid."""
|
||||
dims = config["dimensions"]
|
||||
axes = config["axes"]
|
||||
|
||||
info_parts = [f"Grid: {dims['cols']}x{dims['rows']}"]
|
||||
|
||||
for axis_name, axis_data in axes.items():
|
||||
if axis_data["type"] and axis_data["values"]:
|
||||
axis_type = axis_data["type"].value
|
||||
value_count = len(axis_data["values"])
|
||||
info_parts.append(f"{axis_name.upper()}: {axis_type} ({value_count} values)")
|
||||
|
||||
info_parts.append(f"Total images: {dims['total_images']}")
|
||||
|
||||
return " | ".join(info_parts)
|
||||
@@ -0,0 +1 @@
|
||||
# XYZ Plot Controller module
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Advanced XYZ Plot Controller with full parameter support."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional, Union
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType, NUMERIC_DEFAULTS
|
||||
from ..utils.helpers import (
|
||||
get_available_models, get_available_vaes, get_available_loras,
|
||||
get_sampler_names, get_scheduler_names, parse_value_string,
|
||||
generate_axis_labels, calculate_grid_dimensions, create_unique_id
|
||||
)
|
||||
from ..utils.converters import ParameterConverter, OutputConnector
|
||||
from .execution import execution_manager
|
||||
from .queue_manager import queue_manager
|
||||
|
||||
|
||||
class XYZPlotControllerAdvanced:
|
||||
"""Advanced XYZ Plot Controller with dynamic outputs."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Get available options for dropdowns
|
||||
models = get_available_models()
|
||||
vaes = get_available_vaes()
|
||||
loras = get_available_loras()
|
||||
samplers = get_sampler_names()
|
||||
schedulers = get_scheduler_names()
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# X Axis configuration
|
||||
"x_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"x_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"x_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Y Axis configuration
|
||||
"y_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"y_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"y_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Execution control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# Z Axis configuration (optional)
|
||||
"z_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"z_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"z_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Label formatting
|
||||
"include_param_name": ("BOOLEAN", {"default": True}),
|
||||
"value_only_labels": ("BOOLEAN", {"default": False}),
|
||||
|
||||
# Quick select dropdowns (helpers)
|
||||
"model_list": (["none"] + models, {"default": "none"}),
|
||||
"vae_list": (["none"] + vaes, {"default": "none"}),
|
||||
"lora_list": (["none"] + loras, {"default": "none"}),
|
||||
"sampler_list": (["none"] + samplers, {"default": "none"}),
|
||||
"scheduler_list": (["none"] + schedulers, {"default": "none"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"prompt": "PROMPT",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data",
|
||||
"x_string", "x_int", "x_float",
|
||||
"y_string", "y_int", "y_float",
|
||||
"z_string", "z_int", "z_float",
|
||||
"batch_id")
|
||||
FUNCTION = "configure_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def __init__(self):
|
||||
self.unique_id = None
|
||||
self._execution_count = 0
|
||||
|
||||
def configure_grid(self, x_axis_type, x_values, x_label_prefix,
|
||||
y_axis_type, y_values, y_label_prefix,
|
||||
auto_queue=True,
|
||||
z_axis_type="none", z_values="", z_label_prefix="",
|
||||
include_param_name=True, value_only_labels=False,
|
||||
model_list="none", vae_list="none", lora_list="none",
|
||||
sampler_list="none", scheduler_list="none",
|
||||
unique_id=None, prompt=None):
|
||||
"""Configure and prepare grid generation with advanced features."""
|
||||
|
||||
# Use helper dropdowns to populate values if selected
|
||||
x_values = self._apply_quick_select(x_axis_type, x_values,
|
||||
model_list, vae_list, lora_list,
|
||||
sampler_list, scheduler_list)
|
||||
y_values = self._apply_quick_select(y_axis_type, y_values,
|
||||
model_list, vae_list, lora_list,
|
||||
sampler_list, scheduler_list)
|
||||
z_values = self._apply_quick_select(z_axis_type, z_values,
|
||||
model_list, vae_list, lora_list,
|
||||
sampler_list, scheduler_list)
|
||||
|
||||
# Parse axis types
|
||||
x_type = AxisType(x_axis_type) if x_axis_type != "none" else None
|
||||
y_type = AxisType(y_axis_type) if y_axis_type != "none" else None
|
||||
z_type = AxisType(z_axis_type) if z_axis_type != "none" else None
|
||||
|
||||
# Parse values for each axis
|
||||
x_vals = parse_value_string(x_values, x_type) if x_type else [""]
|
||||
y_vals = parse_value_string(y_values, y_type) if y_type else [""]
|
||||
z_vals = parse_value_string(z_values, z_type) if z_type else [""]
|
||||
|
||||
# Validate we have at least one axis configured
|
||||
if not x_type and not y_type:
|
||||
raise ValueError("At least one axis (X or Y) must be configured")
|
||||
|
||||
# Calculate grid dimensions
|
||||
dims = calculate_grid_dimensions(len(x_vals), len(y_vals), len(z_vals))
|
||||
|
||||
# Generate labels
|
||||
x_labels = self._generate_labels(x_vals, x_type, x_label_prefix, include_param_name, value_only_labels)
|
||||
y_labels = self._generate_labels(y_vals, y_type, y_label_prefix, include_param_name, value_only_labels)
|
||||
z_labels = self._generate_labels(z_vals, z_type, z_label_prefix, include_param_name, value_only_labels)
|
||||
|
||||
# Create batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Prepare grid configuration
|
||||
grid_config = {
|
||||
"batch_id": batch_id,
|
||||
"axes": {
|
||||
"x": {"type": x_type, "values": x_vals, "labels": x_labels},
|
||||
"y": {"type": y_type, "values": y_vals, "labels": y_labels},
|
||||
"z": {"type": z_type, "values": z_vals, "labels": z_labels},
|
||||
},
|
||||
"dimensions": dims,
|
||||
"total_images": dims["total_images"],
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue,
|
||||
}
|
||||
|
||||
# Get current values from execution manager
|
||||
x_val, y_val, z_val, x_idx, y_idx, z_idx = execution_manager.get_current_values(
|
||||
batch_id, x_vals, y_vals, z_vals
|
||||
)
|
||||
|
||||
# Convert values to appropriate types for each output
|
||||
x_outputs = self._convert_to_outputs(x_val, x_type)
|
||||
y_outputs = self._convert_to_outputs(y_val, y_type)
|
||||
z_outputs = self._convert_to_outputs(z_val, z_type)
|
||||
|
||||
# Handle auto-queuing if enabled
|
||||
if auto_queue and unique_id and prompt:
|
||||
self._handle_auto_queue(batch_id, grid_config, unique_id, prompt)
|
||||
|
||||
# Update current index in grid config
|
||||
grid_config["current_index"] = execution_manager.execution_states.get(
|
||||
batch_id, execution_manager.initialize_batch(batch_id, x_vals, y_vals, z_vals)
|
||||
).current_iteration
|
||||
|
||||
return (grid_config,
|
||||
x_outputs[0], x_outputs[1], x_outputs[2],
|
||||
y_outputs[0], y_outputs[1], y_outputs[2],
|
||||
z_outputs[0], z_outputs[1], z_outputs[2],
|
||||
batch_id)
|
||||
|
||||
def _apply_quick_select(self, axis_type: str, values: str,
|
||||
model: str, vae: str, lora: str,
|
||||
sampler: str, scheduler: str) -> str:
|
||||
"""Apply quick select dropdown values if appropriate."""
|
||||
if values: # If user already entered values, don't override
|
||||
return values
|
||||
|
||||
# Map axis type to quick select value
|
||||
if axis_type == "model" and model != "none":
|
||||
return model
|
||||
elif axis_type == "vae" and vae != "none":
|
||||
return vae
|
||||
elif axis_type == "lora" and lora != "none":
|
||||
return lora
|
||||
elif axis_type == "sampler" and sampler != "none":
|
||||
return sampler
|
||||
elif axis_type == "scheduler" and scheduler != "none":
|
||||
return scheduler
|
||||
|
||||
return values
|
||||
|
||||
def _convert_to_outputs(self, value: Any, axis_type: Optional[AxisType]) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if not axis_type or value == "":
|
||||
return ("", 0, 0.0)
|
||||
|
||||
# Convert using parameter converter
|
||||
converted = ParameterConverter.convert_value(value, axis_type)
|
||||
|
||||
# Prepare outputs for all types
|
||||
str_val = str(converted)
|
||||
|
||||
try:
|
||||
int_val = int(float(converted))
|
||||
except:
|
||||
int_val = 0
|
||||
|
||||
try:
|
||||
float_val = float(converted)
|
||||
except:
|
||||
float_val = 0.0
|
||||
|
||||
return (str_val, int_val, float_val)
|
||||
|
||||
def _generate_labels(self, values: List[Any], axis_type: Optional[AxisType],
|
||||
prefix: str, include_param: bool, value_only: bool) -> List[str]:
|
||||
"""Generate labels for axis values."""
|
||||
if not values or not axis_type:
|
||||
return []
|
||||
|
||||
labels = []
|
||||
for value in values:
|
||||
if value_only:
|
||||
label = ParameterConverter.format_for_display(value, axis_type)
|
||||
else:
|
||||
label = ParameterConverter.format_for_display(value, axis_type)
|
||||
if include_param and not prefix:
|
||||
param_names = AxisType.display_names()
|
||||
param_prefix = param_names.get(axis_type, "")
|
||||
label = f"{param_prefix}: {label}"
|
||||
elif prefix:
|
||||
label = f"{prefix}{label}"
|
||||
|
||||
labels.append(label)
|
||||
|
||||
return labels
|
||||
|
||||
def _handle_auto_queue(self, batch_id: str, grid_config: Dict, node_id: str, prompt: Dict):
|
||||
"""Handle automatic queuing of grid executions."""
|
||||
# Check if this is the first execution for this batch
|
||||
state = execution_manager.execution_states.get(batch_id)
|
||||
if not state or state.current_iteration == 0:
|
||||
# Prepare all executions for the batch
|
||||
executions = queue_manager.prepare_batch_executions(
|
||||
batch_id, grid_config, node_id, prompt
|
||||
)
|
||||
|
||||
# Mark that we've started this batch
|
||||
self._execution_count = len(executions)
|
||||
|
||||
# Advance to next iteration after this one completes
|
||||
if execution_manager.should_continue(batch_id):
|
||||
execution_manager.advance_batch(batch_id)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution for grid iterations."""
|
||||
return float("nan")
|
||||
@@ -0,0 +1,165 @@
|
||||
"""ComfyUI-specific execution flow implementation."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
|
||||
try:
|
||||
from server import PromptServer
|
||||
from execution import validate_prompt, PromptExecutor
|
||||
import execution
|
||||
import nodes
|
||||
except ImportError:
|
||||
# Not in ComfyUI environment
|
||||
PromptServer = None
|
||||
validate_prompt = None
|
||||
PromptExecutor = None
|
||||
execution = None
|
||||
nodes = None
|
||||
|
||||
|
||||
class ComfyUIExecutionFlow:
|
||||
"""Manages execution flow integration with ComfyUI's system."""
|
||||
|
||||
_instance = None
|
||||
_batch_states = {} # Track batch execution states
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, 'initialized'):
|
||||
self.initialized = True
|
||||
self.prompt_server = PromptServer.instance if PromptServer else None
|
||||
self.active_batches = {}
|
||||
self.execution_callbacks = {}
|
||||
|
||||
def register_batch(self, batch_id: str, grid_config: Dict, node_id: str) -> None:
|
||||
"""Register a new batch for execution tracking."""
|
||||
self._batch_states[batch_id] = {
|
||||
"config": grid_config,
|
||||
"node_id": node_id,
|
||||
"current_iteration": 0,
|
||||
"total_iterations": grid_config["total_images"],
|
||||
"completed": False
|
||||
}
|
||||
|
||||
def queue_grid_executions(self, workflow: Dict, batch_id: str,
|
||||
grid_config: Dict, node_id: str) -> bool:
|
||||
"""Queue all executions for a grid batch."""
|
||||
try:
|
||||
# Register the batch
|
||||
self.register_batch(batch_id, grid_config, node_id)
|
||||
|
||||
# Get axis configurations
|
||||
x_values = grid_config["axes"]["x"]["values"]
|
||||
y_values = grid_config["axes"]["y"]["values"]
|
||||
z_values = grid_config["axes"]["z"]["values"]
|
||||
|
||||
# Calculate total iterations
|
||||
total = len(x_values) * len(y_values) * len(z_values)
|
||||
|
||||
# Store the original workflow
|
||||
original_workflow = json.loads(json.dumps(workflow))
|
||||
|
||||
# Queue executions for each combination
|
||||
execution_count = 0
|
||||
for z_idx, z_val in enumerate(z_values or [""]):
|
||||
for y_idx, y_val in enumerate(y_values or [""]):
|
||||
for x_idx, x_val in enumerate(x_values or [""]):
|
||||
# Clone workflow for this iteration
|
||||
iteration_workflow = json.loads(json.dumps(original_workflow))
|
||||
|
||||
# Inject iteration metadata
|
||||
self._inject_iteration_data(
|
||||
iteration_workflow, node_id, batch_id,
|
||||
execution_count, total,
|
||||
x_idx, y_idx, z_idx
|
||||
)
|
||||
|
||||
# Queue this iteration
|
||||
prompt_id = str(uuid.uuid4())
|
||||
|
||||
# Use ComfyUI's internal queue system
|
||||
if validate_prompt:
|
||||
valid, error = validate_prompt(iteration_workflow)
|
||||
if valid and execution and PromptServer:
|
||||
# Add to execution queue
|
||||
PromptServer.instance.send_sync(
|
||||
"execution_start",
|
||||
{"prompt_id": prompt_id}
|
||||
)
|
||||
|
||||
execution_count += 1
|
||||
else:
|
||||
print(f"Validation error for iteration {execution_count}: {error}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error queuing grid executions: {e}")
|
||||
return False
|
||||
|
||||
def _inject_iteration_data(self, workflow: Dict, node_id: str, batch_id: str,
|
||||
iteration: int, total: int,
|
||||
x_idx: int, y_idx: int, z_idx: int) -> None:
|
||||
"""Inject iteration-specific data into workflow."""
|
||||
# Find the XYZ controller node
|
||||
if str(node_id) in workflow:
|
||||
node_data = workflow[str(node_id)]
|
||||
|
||||
# Add hidden inputs for tracking
|
||||
if "inputs" not in node_data:
|
||||
node_data["inputs"] = {}
|
||||
|
||||
node_data["inputs"]["_xyz_batch_id"] = batch_id
|
||||
node_data["inputs"]["_xyz_iteration"] = iteration
|
||||
node_data["inputs"]["_xyz_total"] = total
|
||||
node_data["inputs"]["_xyz_indices"] = {
|
||||
"x": x_idx,
|
||||
"y": y_idx,
|
||||
"z": z_idx
|
||||
}
|
||||
|
||||
def get_batch_progress(self, batch_id: str) -> Dict[str, Any]:
|
||||
"""Get progress information for a batch."""
|
||||
if batch_id not in self._batch_states:
|
||||
return {"status": "unknown", "progress": 0}
|
||||
|
||||
state = self._batch_states[batch_id]
|
||||
progress = state["current_iteration"] / state["total_iterations"]
|
||||
|
||||
return {
|
||||
"status": "completed" if state["completed"] else "running",
|
||||
"progress": progress,
|
||||
"current": state["current_iteration"],
|
||||
"total": state["total_iterations"]
|
||||
}
|
||||
|
||||
def mark_iteration_complete(self, batch_id: str) -> None:
|
||||
"""Mark current iteration as complete and advance."""
|
||||
if batch_id in self._batch_states:
|
||||
state = self._batch_states[batch_id]
|
||||
state["current_iteration"] += 1
|
||||
|
||||
if state["current_iteration"] >= state["total_iterations"]:
|
||||
state["completed"] = True
|
||||
|
||||
# Send completion notification
|
||||
if self.prompt_server:
|
||||
self.prompt_server.send_sync("xyz_grid_complete", {
|
||||
"batch_id": batch_id,
|
||||
"total_images": state["total_iterations"]
|
||||
})
|
||||
|
||||
def cleanup_batch(self, batch_id: str) -> None:
|
||||
"""Clean up completed batch data."""
|
||||
if batch_id in self._batch_states:
|
||||
del self._batch_states[batch_id]
|
||||
|
||||
|
||||
# Global execution flow instance
|
||||
execution_flow = ComfyUIExecutionFlow()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""XYZ Plot Controller with dynamic widget addition."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Union
|
||||
import folder_paths
|
||||
|
||||
from ..utils.helpers import create_unique_id
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with dynamic selections like Power Lora Loader."""
|
||||
|
||||
# Allow any input to support dynamic widget addition
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return float("nan")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
# Base inputs that are always present
|
||||
inputs = {
|
||||
"required": {
|
||||
# Axis configuration
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# Single inputs for numeric/prompt values
|
||||
"numeric_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
|
||||
}),
|
||||
|
||||
"prompt_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For prompts: enter each prompt on a new line"
|
||||
})
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, y_type, z_type, auto_queue, unique_id=None, **kwargs):
|
||||
"""Create grid configuration from dynamic selections."""
|
||||
|
||||
# Extract values from kwargs based on type
|
||||
models = self._extract_values(kwargs, "MODEL_", exclude="none")
|
||||
vaes = self._extract_values(kwargs, "VAE_", exclude="none")
|
||||
loras = self._extract_values(kwargs, "LORA_", exclude="none")
|
||||
samplers = self._extract_values(kwargs, "SAMPLER_", exclude="none")
|
||||
schedulers = self._extract_values(kwargs, "SCHEDULER_", exclude="none")
|
||||
|
||||
# Get numeric and prompt values
|
||||
numeric_values = kwargs.get("numeric_values", "")
|
||||
prompt_values = kwargs.get("prompt_values", "")
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values - {x_parsed}")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _extract_values(self, kwargs: Dict[str, Any], prefix: str, exclude: str = None) -> List[str]:
|
||||
"""Extract non-empty values from kwargs with given prefix."""
|
||||
values = []
|
||||
i = 1
|
||||
while f"{prefix}{i}" in kwargs:
|
||||
value = kwargs[f"{prefix}{i}"]
|
||||
if value and value != exclude:
|
||||
values.append(value)
|
||||
i += 1
|
||||
return values
|
||||
|
||||
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
|
||||
"""Get values for a specific axis type."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type == "models":
|
||||
return models
|
||||
elif axis_type == "vaes":
|
||||
return vaes
|
||||
elif axis_type == "loras":
|
||||
return loras
|
||||
elif axis_type == "samplers":
|
||||
return samplers
|
||||
elif axis_type == "schedulers":
|
||||
return schedulers
|
||||
elif axis_type == "prompt":
|
||||
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, numeric_values)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Execution flow management for XYZ grid generation."""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from ..utils.constants import AxisType
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridExecutionState:
|
||||
"""Tracks execution state for grid generation."""
|
||||
batch_id: str
|
||||
total_iterations: int
|
||||
current_iteration: int = 0
|
||||
x_index: int = 0
|
||||
y_index: int = 0
|
||||
z_index: int = 0
|
||||
x_count: int = 1
|
||||
y_count: int = 1
|
||||
z_count: int = 1
|
||||
|
||||
def advance(self) -> bool:
|
||||
"""Advance to next grid position. Returns False when complete."""
|
||||
self.current_iteration += 1
|
||||
|
||||
if self.current_iteration >= self.total_iterations:
|
||||
return False
|
||||
|
||||
# Advance indices (row-major order: X varies fastest)
|
||||
self.x_index += 1
|
||||
if self.x_index >= self.x_count:
|
||||
self.x_index = 0
|
||||
self.y_index += 1
|
||||
if self.y_index >= self.y_count:
|
||||
self.y_index = 0
|
||||
self.z_index += 1
|
||||
|
||||
return True
|
||||
|
||||
def get_indices(self) -> Tuple[int, int, int]:
|
||||
"""Get current x, y, z indices."""
|
||||
return (self.x_index, self.y_index, self.z_index)
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""Check if all iterations are complete."""
|
||||
return self.current_iteration >= self.total_iterations
|
||||
|
||||
|
||||
class ExecutionManager:
|
||||
"""Manages execution flow for XYZ grid generation."""
|
||||
|
||||
def __init__(self):
|
||||
self.execution_states = {} # batch_id -> GridExecutionState
|
||||
self.pending_executions = {} # batch_id -> list of pending configs
|
||||
|
||||
def initialize_batch(self, batch_id: str, x_values: List[Any],
|
||||
y_values: List[Any], z_values: List[Any]) -> GridExecutionState:
|
||||
"""Initialize a new batch execution."""
|
||||
x_count = len(x_values) if x_values else 1
|
||||
y_count = len(y_values) if y_values else 1
|
||||
z_count = len(z_values) if z_values else 1
|
||||
total = x_count * y_count * z_count
|
||||
|
||||
state = GridExecutionState(
|
||||
batch_id=batch_id,
|
||||
total_iterations=total,
|
||||
x_count=x_count,
|
||||
y_count=y_count,
|
||||
z_count=z_count
|
||||
)
|
||||
|
||||
self.execution_states[batch_id] = state
|
||||
return state
|
||||
|
||||
def get_current_values(self, batch_id: str, x_values: List[Any],
|
||||
y_values: List[Any], z_values: List[Any]) -> Tuple[Any, Any, Any, int, int, int]:
|
||||
"""Get current values and indices for execution."""
|
||||
state = self.execution_states.get(batch_id)
|
||||
if not state:
|
||||
# Initialize if not exists
|
||||
state = self.initialize_batch(batch_id, x_values, y_values, z_values)
|
||||
|
||||
x_idx, y_idx, z_idx = state.get_indices()
|
||||
|
||||
x_val = x_values[x_idx] if x_values and x_idx < len(x_values) else ""
|
||||
y_val = y_values[y_idx] if y_values and y_idx < len(y_values) else ""
|
||||
z_val = z_values[z_idx] if z_values and z_idx < len(z_values) else ""
|
||||
|
||||
return x_val, y_val, z_val, x_idx, y_idx, z_idx
|
||||
|
||||
def should_continue(self, batch_id: str) -> bool:
|
||||
"""Check if batch should continue executing."""
|
||||
state = self.execution_states.get(batch_id)
|
||||
return state and not state.is_complete()
|
||||
|
||||
def advance_batch(self, batch_id: str) -> bool:
|
||||
"""Advance to next iteration. Returns True if more iterations remain."""
|
||||
state = self.execution_states.get(batch_id)
|
||||
if state:
|
||||
return state.advance()
|
||||
return False
|
||||
|
||||
def cleanup_batch(self, batch_id: str):
|
||||
"""Clean up completed batch."""
|
||||
if batch_id in self.execution_states:
|
||||
del self.execution_states[batch_id]
|
||||
if batch_id in self.pending_executions:
|
||||
del self.pending_executions[batch_id]
|
||||
|
||||
|
||||
# Global execution manager instance
|
||||
execution_manager = ExecutionManager()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""XYZ Plot Controller with multiple selection dropdowns."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
import folder_paths
|
||||
|
||||
from ..utils.helpers import create_unique_id
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with individual model selection dropdowns."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Get available options
|
||||
models = folder_paths.get_filename_list("checkpoints")
|
||||
vaes = ["Automatic"] + folder_paths.get_filename_list("vae")
|
||||
loras = ["None"] + folder_paths.get_filename_list("loras")
|
||||
|
||||
# Get sampler/scheduler options from a KSampler if available
|
||||
samplers = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
|
||||
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
|
||||
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"]
|
||||
schedulers = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]
|
||||
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
inputs = {
|
||||
"required": {
|
||||
# X Axis
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Y Axis
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Z Axis
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Model selections (up to 10)
|
||||
"model_1": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_2": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_3": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_4": (["disabled"] + models, {"default": "disabled"}),
|
||||
"model_5": (["disabled"] + models, {"default": "disabled"}),
|
||||
|
||||
# VAE selections (up to 5)
|
||||
"vae_1": (["disabled"] + vaes, {"default": "disabled"}),
|
||||
"vae_2": (["disabled"] + vaes, {"default": "disabled"}),
|
||||
"vae_3": (["disabled"] + vaes, {"default": "disabled"}),
|
||||
|
||||
# LoRA selections (up to 5)
|
||||
"lora_1": (["disabled"] + loras, {"default": "disabled"}),
|
||||
"lora_2": (["disabled"] + loras, {"default": "disabled"}),
|
||||
"lora_3": (["disabled"] + loras, {"default": "disabled"}),
|
||||
|
||||
# Sampler selections (up to 5)
|
||||
"sampler_1": (["disabled"] + samplers, {"default": "disabled"}),
|
||||
"sampler_2": (["disabled"] + samplers, {"default": "disabled"}),
|
||||
"sampler_3": (["disabled"] + samplers, {"default": "disabled"}),
|
||||
|
||||
# Scheduler selections (up to 3)
|
||||
"scheduler_1": (["disabled"] + schedulers, {"default": "disabled"}),
|
||||
"scheduler_2": (["disabled"] + schedulers, {"default": "disabled"}),
|
||||
|
||||
# Numeric values (still use text for flexibility)
|
||||
"numeric_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For numeric types: use comma-separated values or start:stop:step"
|
||||
}),
|
||||
|
||||
# Prompts
|
||||
"prompts": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For prompts: enter each prompt on a new line"
|
||||
}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, y_type, z_type,
|
||||
model_1, model_2, model_3, model_4, model_5,
|
||||
vae_1, vae_2, vae_3,
|
||||
lora_1, lora_2, lora_3,
|
||||
sampler_1, sampler_2, sampler_3,
|
||||
scheduler_1, scheduler_2,
|
||||
numeric_values, prompts, auto_queue, unique_id=None):
|
||||
"""Create grid configuration from selections."""
|
||||
|
||||
# Collect enabled selections
|
||||
models = [m for m in [model_1, model_2, model_3, model_4, model_5] if m != "disabled"]
|
||||
vaes = [v for v in [vae_1, vae_2, vae_3] if v != "disabled"]
|
||||
loras = [l for l in [lora_1, lora_2, lora_3] if l != "disabled"]
|
||||
samplers = [s for s in [sampler_1, sampler_2, sampler_3] if s != "disabled"]
|
||||
schedulers = [s for s in [scheduler_1, scheduler_2] if s != "disabled"]
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
|
||||
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
|
||||
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts):
|
||||
"""Get values for a specific axis type."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type == "models":
|
||||
return models
|
||||
elif axis_type == "vaes":
|
||||
return vaes
|
||||
elif axis_type == "loras":
|
||||
return loras
|
||||
elif axis_type == "samplers":
|
||||
return samplers
|
||||
elif axis_type == "schedulers":
|
||||
return schedulers
|
||||
elif axis_type == "prompt":
|
||||
return [p.strip() for p in prompts.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, numeric_values)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Any]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""XYZ Plot Controller node implementation."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType, NUMERIC_DEFAULTS
|
||||
from ..utils.helpers import (
|
||||
parse_value_string, generate_axis_labels, calculate_grid_dimensions, create_unique_id
|
||||
)
|
||||
from .execution import execution_manager
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""Main configuration node for XYZ grid plotting."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
# X Axis configuration
|
||||
"x_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"x_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"x_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Y Axis configuration
|
||||
"y_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"y_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"y_label_prefix": ("STRING", {"default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
# Z Axis configuration (optional)
|
||||
"z_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
|
||||
"z_values": ("STRING", {"default": "", "multiline": True}),
|
||||
"z_label_prefix": ("STRING", {"default": ""}),
|
||||
|
||||
# Label formatting
|
||||
"include_param_name": ("BOOLEAN", {"default": True}),
|
||||
"value_only_labels": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "STRING", "STRING", "INT", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_value", "y_value", "z_value", "x_index", "y_index", "z_index", "batch_id")
|
||||
FUNCTION = "configure_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def __init__(self):
|
||||
self.unique_id = None # Set by ComfyUI
|
||||
|
||||
def configure_grid(self, x_axis_type, x_values, x_label_prefix,
|
||||
y_axis_type, y_values, y_label_prefix,
|
||||
z_axis_type="none", z_values="", z_label_prefix="",
|
||||
include_param_name=True, value_only_labels=False):
|
||||
"""Configure and prepare grid generation."""
|
||||
|
||||
# Parse axis types
|
||||
x_type = AxisType(x_axis_type) if x_axis_type != "none" else None
|
||||
y_type = AxisType(y_axis_type) if y_axis_type != "none" else None
|
||||
z_type = AxisType(z_axis_type) if z_axis_type != "none" else None
|
||||
|
||||
# Parse values for each axis
|
||||
x_vals = parse_value_string(x_values, x_type) if x_type else [""]
|
||||
y_vals = parse_value_string(y_values, y_type) if y_type else [""]
|
||||
z_vals = parse_value_string(z_values, z_type) if z_type else [""]
|
||||
|
||||
# Validate we have at least one axis configured
|
||||
if not x_type and not y_type:
|
||||
raise ValueError("At least one axis (X or Y) must be configured")
|
||||
|
||||
# Calculate grid dimensions
|
||||
dims = calculate_grid_dimensions(len(x_vals), len(y_vals), len(z_vals))
|
||||
|
||||
# Generate labels
|
||||
x_labels = self._generate_labels(x_vals, x_type, x_label_prefix, include_param_name, value_only_labels)
|
||||
y_labels = self._generate_labels(y_vals, y_type, y_label_prefix, include_param_name, value_only_labels)
|
||||
z_labels = self._generate_labels(z_vals, z_type, z_label_prefix, include_param_name, value_only_labels)
|
||||
|
||||
# Create batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Prepare grid configuration
|
||||
grid_config = {
|
||||
"batch_id": batch_id,
|
||||
"axes": {
|
||||
"x": {"type": x_type, "values": x_vals, "labels": x_labels},
|
||||
"y": {"type": y_type, "values": y_vals, "labels": y_labels},
|
||||
"z": {"type": z_type, "values": z_vals, "labels": z_labels},
|
||||
},
|
||||
"dimensions": dims,
|
||||
"total_images": dims["total_images"],
|
||||
"current_index": 0,
|
||||
}
|
||||
|
||||
# Get current values from execution manager
|
||||
x_val, y_val, z_val, x_idx, y_idx, z_idx = execution_manager.get_current_values(
|
||||
batch_id, x_vals, y_vals, z_vals
|
||||
)
|
||||
|
||||
# Format output values based on type
|
||||
x_output = self._format_output_value(x_val, x_type)
|
||||
y_output = self._format_output_value(y_val, y_type)
|
||||
z_output = self._format_output_value(z_val, z_type)
|
||||
|
||||
return (grid_config, x_output, y_output, z_output, x_idx, y_idx, z_idx, batch_id)
|
||||
|
||||
def _generate_labels(self, values: List[Any], axis_type: Optional[AxisType],
|
||||
prefix: str, include_param: bool, value_only: bool) -> List[str]:
|
||||
"""Generate labels for axis values."""
|
||||
if not values or not axis_type:
|
||||
return []
|
||||
|
||||
if value_only:
|
||||
# Just use values as labels
|
||||
return generate_axis_labels(values, axis_type, "")
|
||||
elif include_param and not prefix:
|
||||
# Use parameter name as prefix
|
||||
param_names = AxisType.display_names()
|
||||
prefix = param_names.get(axis_type, "") + ": "
|
||||
|
||||
return generate_axis_labels(values, axis_type, prefix)
|
||||
|
||||
def _format_output_value(self, value: Any, axis_type: Optional[AxisType]) -> str:
|
||||
"""Format value for output based on axis type."""
|
||||
if not axis_type:
|
||||
return ""
|
||||
|
||||
# Return appropriate type based on what nodes expect
|
||||
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
|
||||
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
|
||||
return str(value)
|
||||
else:
|
||||
# Numeric types - return as string but nodes can convert
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution for grid iterations."""
|
||||
# This ensures node re-executes for each grid cell
|
||||
return float("nan")
|
||||
@@ -0,0 +1,355 @@
|
||||
"""XYZ Plot Controller with Power Lora Loader-style dynamic widgets."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Union, Optional
|
||||
import folder_paths
|
||||
|
||||
from ..utils.helpers import create_unique_id
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Input that allows dynamic widget values from JavaScript."""
|
||||
|
||||
def __contains__(self, key):
|
||||
# Accept any key from JavaScript widgets
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Return a tuple that ComfyUI expects for input types
|
||||
# This allows the JavaScript to pass widget values
|
||||
return ("STRING", {"forceInput": False})
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with dynamic widget management."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
inputs = {
|
||||
"required": {
|
||||
# Axis configuration
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
# Static inputs for numeric/prompt values
|
||||
"numeric_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
|
||||
}),
|
||||
|
||||
"prompt_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "For prompts: enter each prompt on a new line"
|
||||
})
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
}
|
||||
}
|
||||
|
||||
# Use FlexibleOptionalInputType to accept dynamic widget values from JavaScript
|
||||
# But don't create an actual input connection
|
||||
inputs["optional"] = FlexibleOptionalInputType()
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type="none", y_type="none", z_type="none",
|
||||
auto_queue=True, numeric_values="", prompt_values="",
|
||||
unique_id=None, prompt=None, extra_pnginfo=None, **kwargs):
|
||||
"""Create grid configuration from dynamic selections."""
|
||||
|
||||
# Extract dynamic values from kwargs
|
||||
models = []
|
||||
vaes = []
|
||||
loras = []
|
||||
samplers = []
|
||||
schedulers = []
|
||||
|
||||
# Process all kwargs to find dynamic widgets
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith("x_") or key.startswith("y_") or key.startswith("z_"):
|
||||
# Handle dynamic widget values
|
||||
if isinstance(value, dict) and "on" in value and value["on"]:
|
||||
# Extract the resource type and axis
|
||||
parts = key.split("_")
|
||||
if len(parts) >= 3:
|
||||
axis = parts[0]
|
||||
resource_type = parts[1]
|
||||
|
||||
# Store the value based on type
|
||||
if resource_type == "models" and value.get("value") != "none":
|
||||
models.append(value["value"])
|
||||
elif resource_type == "vaes" and value.get("value") != "none":
|
||||
vaes.append(value["value"])
|
||||
elif resource_type == "loras" and value.get("value") != "none":
|
||||
# For loras, store both name and strength
|
||||
lora_data = {
|
||||
"name": value["value"],
|
||||
"strength": value.get("strength", 1.0)
|
||||
}
|
||||
loras.append(lora_data)
|
||||
elif resource_type == "samplers" and value.get("value") != "none":
|
||||
samplers.append(value["value"])
|
||||
elif resource_type == "schedulers" and value.get("value") != "none":
|
||||
schedulers.append(value["value"])
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"type": x_type,
|
||||
"labels": self._create_labels(x_type, x_parsed)
|
||||
},
|
||||
"y": {
|
||||
"type": y_type,
|
||||
"labels": self._create_labels(y_type, y_parsed)
|
||||
},
|
||||
"z": {
|
||||
"type": z_type,
|
||||
"labels": self._create_labels(z_type, z_parsed) if z_type != "none" else []
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"total_images": total_images,
|
||||
"x_count": x_count,
|
||||
"y_count": y_count,
|
||||
"z_count": z_count,
|
||||
"cols": x_count, # X axis forms columns
|
||||
"rows": y_count, # Y axis forms rows
|
||||
"grids_count": z_count # Z axis creates multiple grids
|
||||
},
|
||||
"total_images": total_images, # Keep for backward compatibility
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values - {x_parsed}")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
|
||||
"""Get values for a specific axis type."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type == "models":
|
||||
return models
|
||||
elif axis_type == "vaes":
|
||||
return vaes
|
||||
elif axis_type == "loras":
|
||||
return loras
|
||||
elif axis_type == "samplers":
|
||||
return samplers
|
||||
elif axis_type == "schedulers":
|
||||
return schedulers
|
||||
elif axis_type == "prompt":
|
||||
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, numeric_values)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
# For loras, return the name string
|
||||
if axis_type == "loras" and isinstance(value, dict):
|
||||
return (value.get("name", ""), 0, 0.0)
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
|
||||
def _create_labels(self, axis_type: str, values: list) -> list:
|
||||
"""Create human-readable labels for axis values."""
|
||||
labels = []
|
||||
for value in values:
|
||||
if axis_type == "prompt":
|
||||
# Truncate long prompts
|
||||
label = str(value)[:30] + "..." if len(str(value)) > 30 else str(value)
|
||||
elif axis_type in ["models", "vaes", "loras"]:
|
||||
# Use just the filename without path/extension for resources
|
||||
if isinstance(value, dict) and "name" in value:
|
||||
name = value["name"]
|
||||
else:
|
||||
name = str(value)
|
||||
# Remove extension and path
|
||||
label = name.split("/")[-1].split(".")[0]
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
# Format floats nicely
|
||||
label = f"{float(value):.1f}"
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
# Just show the integer
|
||||
label = str(int(value))
|
||||
elif axis_type in ["samplers", "schedulers"]:
|
||||
# Just use the name as-is
|
||||
label = str(value)
|
||||
else:
|
||||
# Default: convert to string
|
||||
label = str(value)
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
def _apply_lora(self, model, clip, lora_data: dict):
|
||||
"""Apply a lora to model and clip."""
|
||||
try:
|
||||
# Import LoraLoader from ComfyUI
|
||||
from nodes import LoraLoader
|
||||
import folder_paths
|
||||
|
||||
lora_name = lora_data.get("name")
|
||||
strength = lora_data.get("strength", 1.0)
|
||||
|
||||
if not lora_name:
|
||||
return model, clip
|
||||
|
||||
# Get the full path to the lora
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_path:
|
||||
print(f"[XYZ Grid] Warning: LoRA '{lora_name}' not found")
|
||||
return model, clip
|
||||
|
||||
# Apply the lora
|
||||
loader = LoraLoader()
|
||||
model, clip = loader.load_lora(model, clip, lora_name, strength, strength)
|
||||
|
||||
return model, clip
|
||||
except Exception as e:
|
||||
print(f"[XYZ Grid] Error applying LoRA: {e}")
|
||||
return model, clip
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Queue management for automated grid execution."""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedExecution:
|
||||
"""Represents a queued execution for grid generation."""
|
||||
execution_id: str
|
||||
batch_id: str
|
||||
iteration: int
|
||||
total_iterations: int
|
||||
x_value: Any
|
||||
y_value: Any
|
||||
z_value: Any
|
||||
x_index: int
|
||||
y_index: int
|
||||
z_index: int
|
||||
workflow_data: Dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"execution_id": self.execution_id,
|
||||
"batch_id": self.batch_id,
|
||||
"iteration": self.iteration,
|
||||
"total_iterations": self.total_iterations,
|
||||
"indices": {
|
||||
"x": self.x_index,
|
||||
"y": self.y_index,
|
||||
"z": self.z_index
|
||||
},
|
||||
"values": {
|
||||
"x": self.x_value,
|
||||
"y": self.y_value,
|
||||
"z": self.z_value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class GridQueueManager:
|
||||
"""Manages the execution queue for grid generation."""
|
||||
|
||||
def __init__(self):
|
||||
self.execution_queue: Dict[str, List[QueuedExecution]] = {} # batch_id -> executions
|
||||
self.active_batches: Dict[str, Dict] = {} # batch_id -> batch info
|
||||
self.completed_iterations: Dict[str, List[int]] = {} # batch_id -> completed iteration indices
|
||||
|
||||
def prepare_batch_executions(self, batch_id: str, grid_config: Dict,
|
||||
node_id: int, workflow: Dict) -> List[QueuedExecution]:
|
||||
"""Prepare all executions for a batch."""
|
||||
executions = []
|
||||
|
||||
x_values = grid_config["axes"]["x"]["values"]
|
||||
y_values = grid_config["axes"]["y"]["values"]
|
||||
z_values = grid_config["axes"]["z"]["values"]
|
||||
|
||||
total_iterations = len(x_values) * len(y_values) * len(z_values)
|
||||
iteration = 0
|
||||
|
||||
# Generate all combinations
|
||||
for z_idx, z_val in enumerate(z_values or [""]):
|
||||
for y_idx, y_val in enumerate(y_values or [""]):
|
||||
for x_idx, x_val in enumerate(x_values or [""]):
|
||||
execution = QueuedExecution(
|
||||
execution_id=str(uuid.uuid4()),
|
||||
batch_id=batch_id,
|
||||
iteration=iteration,
|
||||
total_iterations=total_iterations,
|
||||
x_value=x_val,
|
||||
y_value=y_val,
|
||||
z_value=z_val,
|
||||
x_index=x_idx,
|
||||
y_index=y_idx,
|
||||
z_index=z_idx,
|
||||
workflow_data=self._prepare_workflow(workflow, node_id, grid_config)
|
||||
)
|
||||
executions.append(execution)
|
||||
iteration += 1
|
||||
|
||||
# Store batch info
|
||||
self.execution_queue[batch_id] = executions
|
||||
self.active_batches[batch_id] = {
|
||||
"total_iterations": total_iterations,
|
||||
"grid_config": grid_config,
|
||||
"node_id": node_id
|
||||
}
|
||||
self.completed_iterations[batch_id] = []
|
||||
|
||||
return executions
|
||||
|
||||
def get_next_execution(self, batch_id: str) -> Optional[QueuedExecution]:
|
||||
"""Get the next execution for a batch."""
|
||||
if batch_id not in self.execution_queue:
|
||||
return None
|
||||
|
||||
executions = self.execution_queue[batch_id]
|
||||
completed = self.completed_iterations.get(batch_id, [])
|
||||
|
||||
# Find next uncompleted execution
|
||||
for execution in executions:
|
||||
if execution.iteration not in completed:
|
||||
return execution
|
||||
|
||||
return None
|
||||
|
||||
def mark_iteration_complete(self, batch_id: str, iteration: int):
|
||||
"""Mark an iteration as complete."""
|
||||
if batch_id not in self.completed_iterations:
|
||||
self.completed_iterations[batch_id] = []
|
||||
|
||||
if iteration not in self.completed_iterations[batch_id]:
|
||||
self.completed_iterations[batch_id].append(iteration)
|
||||
|
||||
def is_batch_complete(self, batch_id: str) -> bool:
|
||||
"""Check if all iterations for a batch are complete."""
|
||||
if batch_id not in self.active_batches:
|
||||
return True
|
||||
|
||||
total = self.active_batches[batch_id]["total_iterations"]
|
||||
completed = len(self.completed_iterations.get(batch_id, []))
|
||||
|
||||
return completed >= total
|
||||
|
||||
def cleanup_batch(self, batch_id: str):
|
||||
"""Clean up a completed batch."""
|
||||
if batch_id in self.execution_queue:
|
||||
del self.execution_queue[batch_id]
|
||||
if batch_id in self.active_batches:
|
||||
del self.active_batches[batch_id]
|
||||
if batch_id in self.completed_iterations:
|
||||
del self.completed_iterations[batch_id]
|
||||
|
||||
def _prepare_workflow(self, base_workflow: Dict, node_id: int, grid_config: Dict) -> Dict:
|
||||
"""Prepare workflow data for execution."""
|
||||
# This would modify the workflow to set appropriate values
|
||||
# For now, return a copy of the base workflow
|
||||
import copy
|
||||
return copy.deepcopy(base_workflow)
|
||||
|
||||
async def execute_batch_async(self, batch_id: str, api_client: Any):
|
||||
"""Execute all iterations for a batch asynchronously."""
|
||||
executions = self.execution_queue.get(batch_id, [])
|
||||
|
||||
for execution in executions:
|
||||
if execution.iteration in self.completed_iterations.get(batch_id, []):
|
||||
continue
|
||||
|
||||
# Queue the execution via ComfyUI API
|
||||
try:
|
||||
# This would use the actual ComfyUI API client
|
||||
# await api_client.queue_prompt(execution.workflow_data)
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error queuing execution {execution.execution_id}: {e}")
|
||||
|
||||
# Small delay between queuing to avoid overwhelming the system
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
# Global queue manager instance
|
||||
queue_manager = GridQueueManager()
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Simplified XYZ Plot Controller using native ComfyUI widgets."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
import json
|
||||
|
||||
from ..utils.constants import AxisType
|
||||
from ..utils.helpers import (
|
||||
get_available_models, get_available_vaes, get_available_loras,
|
||||
get_sampler_names, get_scheduler_names, parse_value_string,
|
||||
create_unique_id
|
||||
)
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""Simplified XYZ Plot Controller with native widgets."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# For file-based parameters, we'll use a special format in the values field
|
||||
axis_types = [
|
||||
"none",
|
||||
"model",
|
||||
"vae",
|
||||
"lora",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# X Axis
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"x_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Y Axis
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"y_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Z Axis (optional)
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
"z_values": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Enter values separated by commas or use start:stop:step notation"
|
||||
}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type, x_values, y_type, y_values, z_type, z_values, auto_queue, unique_id=None):
|
||||
"""Create grid configuration."""
|
||||
|
||||
# Parse values for each axis
|
||||
x_parsed = self._parse_axis_values(x_type, x_values) if x_type != "none" else []
|
||||
y_parsed = self._parse_axis_values(y_type, y_values) if y_type != "none" else []
|
||||
z_parsed = self._parse_axis_values(z_type, z_values) if z_type != "none" else []
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Store grid data for execution
|
||||
if hasattr(self, '_grids'):
|
||||
self._grids[batch_id] = grid_data
|
||||
else:
|
||||
self._grids = {batch_id: grid_data}
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _parse_axis_values(self, axis_type: str, values_str: str) -> List[Any]:
|
||||
"""Parse axis values based on type."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str and axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
if axis_type == "prompt":
|
||||
# For prompts, split by newline instead of comma
|
||||
return [v.strip() for v in values_str.split("\n") if v.strip()]
|
||||
else:
|
||||
# For everything else, split by comma
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"model": "",
|
||||
"vae": "Automatic",
|
||||
"lora": "None",
|
||||
"sampler": "euler",
|
||||
"scheduler": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["model", "vae", "lora", "sampler", "scheduler", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
XYZPlotControllerAdvanced = XYZPlotController
|
||||
@@ -0,0 +1,257 @@
|
||||
"""XYZ Plot Controller with Power Lora Loader-style dynamic widget management."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Union, Optional
|
||||
|
||||
# Remove complex imports to avoid circular dependencies
|
||||
import uuid
|
||||
|
||||
# Import folder_paths only when needed
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
|
||||
|
||||
def create_unique_id() -> str:
|
||||
"""Create unique ID for a grid batch."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
"""A special class that is always equal in not equal comparisons."""
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""
|
||||
A special class to make flexible nodes that pass data to our python handlers.
|
||||
This allows dynamic inputs from the JavaScript side.
|
||||
"""
|
||||
def __init__(self, input_type):
|
||||
super().__init__()
|
||||
self.type = input_type
|
||||
|
||||
def __contains__(self, key):
|
||||
# Always return True to accept any input
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Return a tuple that ComfyUI expects for input types
|
||||
return (self.type,)
|
||||
|
||||
|
||||
# Create any_type instance
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
class XYZPlotController:
|
||||
"""XYZ Plot Controller with dynamic widget management inspired by Power Lora Loader."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
axis_types = [
|
||||
"none",
|
||||
"models",
|
||||
"vaes",
|
||||
"loras",
|
||||
"samplers",
|
||||
"schedulers",
|
||||
"cfg_scale",
|
||||
"steps",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clip_skip",
|
||||
"prompt"
|
||||
]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# Axis configuration
|
||||
"x_type": (axis_types, {"default": "none"}),
|
||||
"y_type": (axis_types, {"default": "none"}),
|
||||
"z_type": (axis_types, {"default": "none"}),
|
||||
|
||||
# Control
|
||||
"auto_queue": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
# Accept any number of dynamic inputs from JavaScript
|
||||
"optional": {},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_grid"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def create_grid(self, x_type="none", y_type="none", z_type="none", auto_queue=True, unique_id=None, **kwargs):
|
||||
"""Create grid configuration from dynamic selections."""
|
||||
|
||||
# Initialize collections for each axis
|
||||
axis_values = {
|
||||
"x": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
|
||||
"y": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
|
||||
"z": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""}
|
||||
}
|
||||
|
||||
# Process all kwargs to extract dynamic widget values
|
||||
for key, value in kwargs.items():
|
||||
# Handle dynamic model/vae/lora/sampler/scheduler widgets
|
||||
# Format: x_models_1, y_vaes_2, etc.
|
||||
parts = key.split("_")
|
||||
if len(parts) >= 3 and parts[0] in ["x", "y", "z"]:
|
||||
axis = parts[0]
|
||||
widget_type = parts[1]
|
||||
|
||||
if widget_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
|
||||
if isinstance(value, dict) and value.get("on", True) and value.get("value"):
|
||||
axis_values[axis][widget_type].append(value["value"])
|
||||
elif widget_type == "numeric":
|
||||
axis_values[axis]["numeric"] = value
|
||||
elif widget_type == "prompt":
|
||||
axis_values[axis]["prompt"] = value
|
||||
|
||||
# Get parsed values for each axis based on type
|
||||
x_parsed = self._get_axis_values(x_type, axis_values["x"])
|
||||
y_parsed = self._get_axis_values(y_type, axis_values["y"])
|
||||
z_parsed = self._get_axis_values(z_type, axis_values["z"])
|
||||
|
||||
# Calculate total combinations
|
||||
x_count = max(1, len(x_parsed))
|
||||
y_count = max(1, len(y_parsed))
|
||||
z_count = max(1, len(z_parsed))
|
||||
total_images = x_count * y_count * z_count
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = create_unique_id()
|
||||
|
||||
# Create grid data
|
||||
grid_data = {
|
||||
"batch_id": batch_id,
|
||||
"x_axis": {
|
||||
"type": x_type,
|
||||
"values": x_parsed,
|
||||
"count": x_count
|
||||
},
|
||||
"y_axis": {
|
||||
"type": y_type,
|
||||
"values": y_parsed,
|
||||
"count": y_count
|
||||
},
|
||||
"z_axis": {
|
||||
"type": z_type,
|
||||
"values": z_parsed,
|
||||
"count": z_count
|
||||
},
|
||||
"total_images": total_images,
|
||||
"current_index": 0,
|
||||
"auto_queue": auto_queue
|
||||
}
|
||||
|
||||
# Get current values for outputs
|
||||
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
|
||||
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
|
||||
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
|
||||
|
||||
# Convert to appropriate output types
|
||||
x_str, x_int, x_float = self._convert_value(x_type, x_current)
|
||||
y_str, y_int, y_float = self._convert_value(y_type, y_current)
|
||||
z_str, z_int, z_float = self._convert_value(z_type, z_current)
|
||||
|
||||
# Log grid info
|
||||
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
|
||||
if x_type != "none":
|
||||
print(f" X axis ({x_type}): {x_count} values")
|
||||
if y_type != "none":
|
||||
print(f" Y axis ({y_type}): {y_count} values")
|
||||
if z_type != "none":
|
||||
print(f" Z axis ({z_type}): {z_count} values")
|
||||
|
||||
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
|
||||
|
||||
def _get_axis_values(self, axis_type: str, axis_data: Dict) -> List[Any]:
|
||||
"""Get values for a specific axis type from collected data."""
|
||||
if axis_type == "none":
|
||||
return []
|
||||
elif axis_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
|
||||
return axis_data.get(axis_type, [])
|
||||
elif axis_type == "prompt":
|
||||
prompt_text = axis_data.get("prompt", "")
|
||||
return [p.strip() for p in prompt_text.split("\n") if p.strip()]
|
||||
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
|
||||
return self._parse_numeric_values(axis_type, axis_data.get("numeric", ""))
|
||||
else:
|
||||
return []
|
||||
|
||||
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
|
||||
"""Parse numeric values with range support."""
|
||||
if not values_str.strip():
|
||||
return []
|
||||
|
||||
# Handle range notation (start:stop:step)
|
||||
if ":" in values_str:
|
||||
try:
|
||||
parts = values_str.split(":")
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError("Invalid range format")
|
||||
|
||||
# Generate values
|
||||
values = []
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in ["steps", "seed", "clip_skip"]:
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
return values
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse comma-separated values
|
||||
values = [v.strip() for v in values_str.split(",") if v.strip()]
|
||||
|
||||
# Convert numeric types
|
||||
if axis_type in ["cfg_scale", "denoise"]:
|
||||
return [float(v) for v in values]
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return [int(v) for v in values]
|
||||
else:
|
||||
return values
|
||||
|
||||
def _get_default_value(self, axis_type: str) -> Any:
|
||||
"""Get default value for axis type."""
|
||||
defaults = {
|
||||
"models": "",
|
||||
"vaes": "Automatic",
|
||||
"loras": "None",
|
||||
"samplers": "euler",
|
||||
"schedulers": "normal",
|
||||
"cfg_scale": 7.0,
|
||||
"steps": 20,
|
||||
"seed": 0,
|
||||
"denoise": 1.0,
|
||||
"clip_skip": 1,
|
||||
"prompt": ""
|
||||
}
|
||||
return defaults.get(axis_type, "")
|
||||
|
||||
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
|
||||
"""Convert value to all output types."""
|
||||
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
|
||||
return (str(value), 0, 0.0)
|
||||
elif axis_type in ["steps", "seed", "clip_skip"]:
|
||||
return ("", int(value), float(value))
|
||||
elif axis_type in ["cfg_scale", "denoise"]:
|
||||
return ("", 0, float(value))
|
||||
else:
|
||||
return ("", 0, 0.0)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""XYZ Prompt module."""
|
||||
|
||||
from .node import XYZPrompt
|
||||
|
||||
__all__ = ["XYZPrompt"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""XYZ Prompt node for managing multiple prompt variations."""
|
||||
|
||||
from typing import Dict, List, Any, Tuple
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Special input type that accepts any dynamic widget values from JavaScript."""
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
|
||||
def __getitem__(self, key):
|
||||
# Accept string inputs for dynamic prompts
|
||||
return ("STRING", {"multiline": True, "forceInput": False})
|
||||
|
||||
|
||||
class XYZPrompt:
|
||||
"""XYZ Prompt node for creating prompt variations for grid generation."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"include_negative": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Include negative prompt inputs"
|
||||
}),
|
||||
"repeat_negative": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Use the first negative prompt for all variations"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType()
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("XYZ_PROMPTS", "STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("prompts", "positive", "negative", "count")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "process_prompts"
|
||||
CATEGORY = "ComfyAssets/XYZ Grid"
|
||||
|
||||
def process_prompts(self, include_negative=True, repeat_negative=True, **kwargs):
|
||||
"""Process all prompt inputs and return them formatted for XYZ grid.
|
||||
|
||||
Args:
|
||||
include_negative: Whether to include negative prompts
|
||||
repeat_negative: Whether to use first negative for all prompts
|
||||
**kwargs: Dynamic prompt inputs from JavaScript
|
||||
|
||||
Returns:
|
||||
Tuple of (prompts dict, first positive, first negative, count)
|
||||
"""
|
||||
# Debug: Log all received kwargs
|
||||
print(f"XYZPrompt.process_prompts - Received kwargs: {kwargs}")
|
||||
print(f"XYZPrompt.process_prompts - include_negative: {include_negative}, repeat_negative: {repeat_negative}")
|
||||
|
||||
prompts = []
|
||||
first_negative = ""
|
||||
|
||||
# Collect all prompt pairs from kwargs
|
||||
prompt_index = 0
|
||||
while True:
|
||||
pos_key = f"positive_{prompt_index}"
|
||||
neg_key = f"negative_{prompt_index}"
|
||||
|
||||
if pos_key not in kwargs:
|
||||
break
|
||||
|
||||
positive = kwargs.get(pos_key, "")
|
||||
|
||||
# Handle negative prompt based on settings
|
||||
if include_negative:
|
||||
if repeat_negative:
|
||||
# Use first negative for all
|
||||
if prompt_index == 0:
|
||||
first_negative = kwargs.get(neg_key, "")
|
||||
negative = first_negative
|
||||
else:
|
||||
# Each prompt has its own negative
|
||||
negative = kwargs.get(neg_key, "")
|
||||
else:
|
||||
negative = ""
|
||||
|
||||
if positive: # Only add if positive prompt exists
|
||||
prompts.append({
|
||||
"positive": positive,
|
||||
"negative": negative
|
||||
})
|
||||
|
||||
prompt_index += 1
|
||||
|
||||
# Prepare outputs
|
||||
first_positive = prompts[0]["positive"] if prompts else ""
|
||||
first_negative = prompts[0]["negative"] if prompts else ""
|
||||
|
||||
result = {
|
||||
"prompts": prompts,
|
||||
"include_negative": include_negative,
|
||||
"count": len(prompts)
|
||||
}
|
||||
|
||||
# Return for UI display
|
||||
return {
|
||||
"ui": {
|
||||
"prompts": result
|
||||
},
|
||||
"result": (result, first_positive, first_negative, len(prompts))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# XYZ Grid utilities
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Model and resource caching for performance optimization."""
|
||||
|
||||
import gc
|
||||
import torch
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from collections import OrderedDict
|
||||
import psutil
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
import comfy.model_management
|
||||
except ImportError:
|
||||
# Not in ComfyUI environment
|
||||
folder_paths = None
|
||||
comfy = None
|
||||
|
||||
|
||||
class ModelCacheManager:
|
||||
"""Manages model caching for XYZ grid generation."""
|
||||
|
||||
def __init__(self, max_cache_size: int = 3):
|
||||
"""Initialize cache manager.
|
||||
|
||||
Args:
|
||||
max_cache_size: Maximum number of models to keep in cache
|
||||
"""
|
||||
self.max_cache_size = max_cache_size
|
||||
self.model_cache: OrderedDict[str, Any] = OrderedDict()
|
||||
self.vae_cache: OrderedDict[str, Any] = OrderedDict()
|
||||
self.lora_cache: OrderedDict[str, Any] = OrderedDict()
|
||||
self.memory_threshold = 0.85 # Use up to 85% of VRAM
|
||||
|
||||
def get_available_memory(self) -> Tuple[int, int]:
|
||||
"""Get available GPU memory in bytes.
|
||||
|
||||
Returns:
|
||||
Tuple of (free_memory, total_memory)
|
||||
"""
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
free, total = torch.cuda.mem_get_info()
|
||||
return free, total
|
||||
else:
|
||||
# Fallback to system RAM
|
||||
mem = psutil.virtual_memory()
|
||||
return mem.available, mem.total
|
||||
except:
|
||||
return 0, 0
|
||||
|
||||
def should_cache(self, model_size_estimate: int = 2 * 1024**3) -> bool:
|
||||
"""Check if we should cache based on available memory.
|
||||
|
||||
Args:
|
||||
model_size_estimate: Estimated model size in bytes (default 2GB)
|
||||
|
||||
Returns:
|
||||
True if caching is safe
|
||||
"""
|
||||
free, total = self.get_available_memory()
|
||||
if total == 0:
|
||||
return False
|
||||
|
||||
# Check if we have enough free memory
|
||||
usage_after_cache = (total - free + model_size_estimate) / total
|
||||
return usage_after_cache < self.memory_threshold
|
||||
|
||||
def cache_model(self, model_name: str, model: Any) -> bool:
|
||||
"""Cache a model if memory allows.
|
||||
|
||||
Args:
|
||||
model_name: Name/path of the model
|
||||
model: The loaded model object
|
||||
|
||||
Returns:
|
||||
True if cached successfully
|
||||
"""
|
||||
if not self.should_cache():
|
||||
return False
|
||||
|
||||
# Remove oldest if cache is full
|
||||
if len(self.model_cache) >= self.max_cache_size:
|
||||
oldest = next(iter(self.model_cache))
|
||||
self.uncache_model(oldest)
|
||||
|
||||
self.model_cache[model_name] = model
|
||||
self.model_cache.move_to_end(model_name) # Mark as recently used
|
||||
return True
|
||||
|
||||
def get_cached_model(self, model_name: str) -> Optional[Any]:
|
||||
"""Get a model from cache if available.
|
||||
|
||||
Args:
|
||||
model_name: Name/path of the model
|
||||
|
||||
Returns:
|
||||
Cached model or None
|
||||
"""
|
||||
if model_name in self.model_cache:
|
||||
self.model_cache.move_to_end(model_name) # Mark as recently used
|
||||
return self.model_cache[model_name]
|
||||
return None
|
||||
|
||||
def uncache_model(self, model_name: str) -> None:
|
||||
"""Remove a model from cache and free memory.
|
||||
|
||||
Args:
|
||||
model_name: Name/path of the model to remove
|
||||
"""
|
||||
if model_name in self.model_cache:
|
||||
model = self.model_cache.pop(model_name)
|
||||
# Attempt to free GPU memory
|
||||
if hasattr(model, 'to'):
|
||||
try:
|
||||
model.to('cpu')
|
||||
except:
|
||||
pass
|
||||
del model
|
||||
|
||||
# Force garbage collection
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def cache_vae(self, vae_name: str, vae: Any) -> bool:
|
||||
"""Cache a VAE model."""
|
||||
if not self.should_cache(model_size_estimate=500 * 1024**2): # VAEs are smaller
|
||||
return False
|
||||
|
||||
if len(self.vae_cache) >= self.max_cache_size:
|
||||
oldest = next(iter(self.vae_cache))
|
||||
self.uncache_vae(oldest)
|
||||
|
||||
self.vae_cache[vae_name] = vae
|
||||
self.vae_cache.move_to_end(vae_name)
|
||||
return True
|
||||
|
||||
def get_cached_vae(self, vae_name: str) -> Optional[Any]:
|
||||
"""Get a VAE from cache."""
|
||||
if vae_name in self.vae_cache:
|
||||
self.vae_cache.move_to_end(vae_name)
|
||||
return self.vae_cache[vae_name]
|
||||
return None
|
||||
|
||||
def uncache_vae(self, vae_name: str) -> None:
|
||||
"""Remove a VAE from cache."""
|
||||
if vae_name in self.vae_cache:
|
||||
vae = self.vae_cache.pop(vae_name)
|
||||
del vae
|
||||
gc.collect()
|
||||
|
||||
def optimize_for_grid(self, model_names: List[str], vae_names: List[str]) -> Dict[str, Any]:
|
||||
"""Pre-optimize caching for a grid generation.
|
||||
|
||||
Args:
|
||||
model_names: List of models that will be used
|
||||
vae_names: List of VAEs that will be used
|
||||
|
||||
Returns:
|
||||
Dict with optimization suggestions
|
||||
"""
|
||||
suggestions = {
|
||||
"cache_all_models": False,
|
||||
"cache_all_vaes": False,
|
||||
"recommended_order": [],
|
||||
"memory_sufficient": True
|
||||
}
|
||||
|
||||
# Estimate total memory needed
|
||||
model_count = len(set(model_names))
|
||||
vae_count = len(set(vae_names))
|
||||
|
||||
estimated_model_size = model_count * 2 * 1024**3 # 2GB per model
|
||||
estimated_vae_size = vae_count * 500 * 1024**2 # 500MB per VAE
|
||||
total_needed = estimated_model_size + estimated_vae_size
|
||||
|
||||
free, total = self.get_available_memory()
|
||||
|
||||
if free > total_needed * 1.2: # 20% safety margin
|
||||
suggestions["cache_all_models"] = True
|
||||
suggestions["cache_all_vaes"] = True
|
||||
elif free > estimated_model_size * 1.2:
|
||||
suggestions["cache_all_models"] = True
|
||||
else:
|
||||
suggestions["memory_sufficient"] = False
|
||||
|
||||
# Suggest loading order to minimize switches
|
||||
model_order = self._optimize_load_order(model_names)
|
||||
suggestions["recommended_order"] = model_order
|
||||
|
||||
return suggestions
|
||||
|
||||
def _optimize_load_order(self, items: List[str]) -> List[str]:
|
||||
"""Optimize loading order to minimize model switches.
|
||||
|
||||
Args:
|
||||
items: List of items (may have duplicates)
|
||||
|
||||
Returns:
|
||||
Optimized order
|
||||
"""
|
||||
# Group consecutive items together
|
||||
optimized = []
|
||||
seen = set()
|
||||
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
# Add all instances of this item consecutively
|
||||
count = items.count(item)
|
||||
optimized.extend([item] * count)
|
||||
seen.add(item)
|
||||
|
||||
return optimized
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all caches and free memory."""
|
||||
# Clear model cache
|
||||
for model_name in list(self.model_cache.keys()):
|
||||
self.uncache_model(model_name)
|
||||
|
||||
# Clear VAE cache
|
||||
for vae_name in list(self.vae_cache.keys()):
|
||||
self.uncache_vae(vae_name)
|
||||
|
||||
# Clear LoRA cache
|
||||
self.lora_cache.clear()
|
||||
|
||||
# Force cleanup
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get current cache statistics."""
|
||||
free, total = self.get_available_memory()
|
||||
|
||||
return {
|
||||
"models_cached": len(self.model_cache),
|
||||
"vaes_cached": len(self.vae_cache),
|
||||
"loras_cached": len(self.lora_cache),
|
||||
"memory_free": free,
|
||||
"memory_total": total,
|
||||
"memory_usage": (total - free) / total if total > 0 else 0,
|
||||
"cache_names": {
|
||||
"models": list(self.model_cache.keys()),
|
||||
"vaes": list(self.vae_cache.keys()),
|
||||
"loras": list(self.lora_cache.keys())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Global cache manager instance
|
||||
cache_manager = ModelCacheManager()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Constants for XYZ Grid nodes."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AxisType(Enum):
|
||||
"""Available parameter types for grid axes."""
|
||||
NONE = "none"
|
||||
MODEL = "model"
|
||||
SAMPLER = "sampler"
|
||||
SCHEDULER = "scheduler"
|
||||
CFG_SCALE = "cfg_scale"
|
||||
STEPS = "steps"
|
||||
CLIP_SKIP = "clip_skip"
|
||||
VAE = "vae"
|
||||
LORA = "lora"
|
||||
PROMPT = "prompt"
|
||||
SEED = "seed"
|
||||
FLUX_GUIDANCE = "flux_guidance"
|
||||
DENOISE = "denoise"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Get list of choices for ComfyUI dropdown."""
|
||||
return [member.value for member in cls]
|
||||
|
||||
@classmethod
|
||||
def display_names(cls):
|
||||
"""Get display names for UI."""
|
||||
return {
|
||||
cls.NONE: "None",
|
||||
cls.MODEL: "Model/Checkpoint",
|
||||
cls.SAMPLER: "Sampler",
|
||||
cls.SCHEDULER: "Scheduler",
|
||||
cls.CFG_SCALE: "CFG Scale",
|
||||
cls.STEPS: "Steps",
|
||||
cls.CLIP_SKIP: "Clip Skip",
|
||||
cls.VAE: "VAE",
|
||||
cls.LORA: "LoRA",
|
||||
cls.PROMPT: "Prompt",
|
||||
cls.SEED: "Seed",
|
||||
cls.FLUX_GUIDANCE: "Flux Guidance",
|
||||
cls.DENOISE: "Denoise",
|
||||
}
|
||||
|
||||
|
||||
# Default values for numeric parameters
|
||||
NUMERIC_DEFAULTS = {
|
||||
AxisType.CFG_SCALE: {"default": 7.0, "min": 0.0, "max": 30.0, "step": 0.5},
|
||||
AxisType.STEPS: {"default": 20, "min": 1, "max": 150, "step": 1},
|
||||
AxisType.CLIP_SKIP: {"default": 1, "min": 1, "max": 12, "step": 1},
|
||||
AxisType.SEED: {"default": 0, "min": 0, "max": 0xffffffffffffffff},
|
||||
AxisType.FLUX_GUIDANCE: {"default": 3.5, "min": 0.0, "max": 10.0, "step": 0.1},
|
||||
AxisType.DENOISE: {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05},
|
||||
}
|
||||
|
||||
# Grid styling defaults
|
||||
GRID_DEFAULTS = {
|
||||
"font_size": 20,
|
||||
"grid_gap": 10,
|
||||
"label_height": 30,
|
||||
"label_color": (255, 255, 255),
|
||||
"label_bg_color": (0, 0, 0, 180),
|
||||
"max_label_length": 30,
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Value converters for different parameter types."""
|
||||
|
||||
from typing import Any, Union, List, Optional
|
||||
from .constants import AxisType
|
||||
|
||||
|
||||
class ParameterConverter:
|
||||
"""Converts axis values to appropriate types for ComfyUI nodes."""
|
||||
|
||||
@staticmethod
|
||||
def convert_value(value: Any, axis_type: AxisType) -> Any:
|
||||
"""Convert a value based on its axis type.
|
||||
|
||||
Args:
|
||||
value: Raw value from axis configuration
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Converted value suitable for ComfyUI node input
|
||||
"""
|
||||
if not axis_type or axis_type == AxisType.NONE:
|
||||
return value
|
||||
|
||||
# String-based parameters
|
||||
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
|
||||
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
|
||||
return str(value)
|
||||
|
||||
# Integer parameters
|
||||
elif axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
# Float parameters
|
||||
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def format_for_display(value: Any, axis_type: AxisType) -> str:
|
||||
"""Format a value for display in labels.
|
||||
|
||||
Args:
|
||||
value: Value to format
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Formatted string for display
|
||||
"""
|
||||
if axis_type == AxisType.MODEL:
|
||||
# Remove extension and path
|
||||
import os
|
||||
return os.path.splitext(os.path.basename(str(value)))[0]
|
||||
|
||||
elif axis_type == AxisType.PROMPT:
|
||||
# Truncate long prompts
|
||||
s = str(value)
|
||||
return s[:25] + "..." if len(s) > 25 else s
|
||||
|
||||
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
|
||||
# Format floats nicely
|
||||
return f"{float(value):.1f}"
|
||||
|
||||
elif axis_type == AxisType.SEED:
|
||||
# Format large numbers
|
||||
return f"{int(value):,}"
|
||||
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def get_output_type(axis_type: AxisType) -> str:
|
||||
"""Get the ComfyUI output type for an axis type.
|
||||
|
||||
Args:
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
ComfyUI type string (e.g., "STRING", "INT", "FLOAT")
|
||||
"""
|
||||
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
|
||||
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
|
||||
return "STRING"
|
||||
|
||||
elif axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
return "INT"
|
||||
|
||||
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
|
||||
return "FLOAT"
|
||||
|
||||
return "STRING"
|
||||
|
||||
@staticmethod
|
||||
def validate_value(value: Any, axis_type: AxisType) -> tuple[bool, Optional[str]]:
|
||||
"""Validate a value for an axis type.
|
||||
|
||||
Args:
|
||||
value: Value to validate
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP):
|
||||
try:
|
||||
val = int(float(value))
|
||||
if val < 1:
|
||||
return False, f"Value must be positive (got {val})"
|
||||
except:
|
||||
return False, f"Invalid integer value: {value}"
|
||||
|
||||
elif axis_type == AxisType.CFG_SCALE:
|
||||
try:
|
||||
val = float(value)
|
||||
if val < 0:
|
||||
return False, f"CFG scale must be non-negative (got {val})"
|
||||
except:
|
||||
return False, f"Invalid float value: {value}"
|
||||
|
||||
elif axis_type == AxisType.DENOISE:
|
||||
try:
|
||||
val = float(value)
|
||||
if not 0 <= val <= 1:
|
||||
return False, f"Denoise must be between 0 and 1 (got {val})"
|
||||
except:
|
||||
return False, f"Invalid float value: {value}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class OutputConnector:
|
||||
"""Handles connecting XYZ outputs to various node inputs."""
|
||||
|
||||
@staticmethod
|
||||
def get_connection_info(axis_type: AxisType) -> dict:
|
||||
"""Get information about how to connect this axis type.
|
||||
|
||||
Args:
|
||||
axis_type: Type of parameter
|
||||
|
||||
Returns:
|
||||
Dict with connection information
|
||||
"""
|
||||
connection_map = {
|
||||
AxisType.MODEL: {
|
||||
"target_node": "CheckpointLoaderSimple",
|
||||
"target_input": "ckpt_name",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.VAE: {
|
||||
"target_node": "VAELoader",
|
||||
"target_input": "vae_name",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.SAMPLER: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "sampler_name",
|
||||
"type": "combo"
|
||||
},
|
||||
AxisType.SCHEDULER: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "scheduler",
|
||||
"type": "combo"
|
||||
},
|
||||
AxisType.CFG_SCALE: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "cfg",
|
||||
"type": "FLOAT"
|
||||
},
|
||||
AxisType.STEPS: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "steps",
|
||||
"type": "INT"
|
||||
},
|
||||
AxisType.SEED: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "seed",
|
||||
"type": "INT"
|
||||
},
|
||||
AxisType.DENOISE: {
|
||||
"target_node": "KSampler",
|
||||
"target_input": "denoise",
|
||||
"type": "FLOAT"
|
||||
},
|
||||
AxisType.CLIP_SKIP: {
|
||||
"target_node": "CLIPSetLastLayer",
|
||||
"target_input": "stop_at_clip_layer",
|
||||
"type": "INT"
|
||||
},
|
||||
AxisType.LORA: {
|
||||
"target_node": "LoraLoader",
|
||||
"target_input": "lora_name",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.PROMPT: {
|
||||
"target_node": "CLIPTextEncode",
|
||||
"target_input": "text",
|
||||
"type": "STRING"
|
||||
},
|
||||
AxisType.FLUX_GUIDANCE: {
|
||||
"target_node": "FluxGuidance", # Hypothetical node
|
||||
"target_input": "guidance",
|
||||
"type": "FLOAT"
|
||||
}
|
||||
}
|
||||
|
||||
return connection_map.get(axis_type, {
|
||||
"target_node": "Unknown",
|
||||
"target_input": "value",
|
||||
"type": "STRING"
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Helper utilities for XYZ Grid nodes."""
|
||||
|
||||
import os
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from .constants import AxisType, NUMERIC_DEFAULTS
|
||||
|
||||
|
||||
def get_available_models() -> List[str]:
|
||||
"""Get list of available checkpoint models."""
|
||||
try:
|
||||
import folder_paths
|
||||
model_dir = folder_paths.get_folder_paths("checkpoints")[0]
|
||||
models = []
|
||||
for file in os.listdir(model_dir):
|
||||
if file.endswith(('.ckpt', '.safetensors', '.pt', '.pth')):
|
||||
models.append(file)
|
||||
return sorted(models)
|
||||
except:
|
||||
return ["No models found"]
|
||||
|
||||
|
||||
def get_available_vaes() -> List[str]:
|
||||
"""Get list of available VAE models."""
|
||||
try:
|
||||
import folder_paths
|
||||
vae_dir = folder_paths.get_folder_paths("vae")[0]
|
||||
vaes = ["Automatic"]
|
||||
for file in os.listdir(vae_dir):
|
||||
if file.endswith(('.ckpt', '.safetensors', '.pt', '.pth')):
|
||||
vaes.append(file)
|
||||
return vaes
|
||||
except:
|
||||
return ["Automatic"]
|
||||
|
||||
|
||||
def get_available_loras() -> List[str]:
|
||||
"""Get list of available LoRA models."""
|
||||
try:
|
||||
import folder_paths
|
||||
lora_dir = folder_paths.get_folder_paths("loras")[0]
|
||||
loras = ["None"]
|
||||
for file in os.listdir(lora_dir):
|
||||
if file.endswith(('.safetensors', '.pt', '.pth')):
|
||||
loras.append(file)
|
||||
return loras
|
||||
except:
|
||||
return ["None"]
|
||||
|
||||
|
||||
def get_sampler_names() -> List[str]:
|
||||
"""Get list of available sampler names."""
|
||||
try:
|
||||
import nodes
|
||||
return nodes.KSampler.SAMPLERS
|
||||
except:
|
||||
# Fallback list of common samplers
|
||||
return ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
|
||||
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
|
||||
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc", "uni_pc_bh2"]
|
||||
|
||||
|
||||
def get_scheduler_names() -> List[str]:
|
||||
"""Get list of available scheduler names."""
|
||||
try:
|
||||
import nodes
|
||||
return nodes.KSampler.SCHEDULERS
|
||||
except:
|
||||
# Fallback list
|
||||
return ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]
|
||||
|
||||
|
||||
def parse_value_string(value_str: str, axis_type: AxisType) -> List[Any]:
|
||||
"""Parse a string of values based on axis type.
|
||||
|
||||
Args:
|
||||
value_str: String containing values (comma-separated or range syntax)
|
||||
axis_type: Type of parameter to parse for
|
||||
|
||||
Returns:
|
||||
List of parsed values
|
||||
"""
|
||||
if not value_str or not value_str.strip():
|
||||
return []
|
||||
|
||||
values = []
|
||||
|
||||
# Handle numeric types with range syntax
|
||||
if axis_type in NUMERIC_DEFAULTS:
|
||||
# Check for range syntax (start:stop:step)
|
||||
if ':' in value_str:
|
||||
parts = value_str.split(':')
|
||||
if len(parts) == 2:
|
||||
start, stop = float(parts[0]), float(parts[1])
|
||||
step = 1.0 if axis_type == AxisType.CFG_SCALE else 1
|
||||
elif len(parts) == 3:
|
||||
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
|
||||
else:
|
||||
raise ValueError(f"Invalid range syntax: {value_str}")
|
||||
|
||||
# Generate range values
|
||||
current = start
|
||||
while current <= stop:
|
||||
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
values.append(int(current))
|
||||
else:
|
||||
values.append(round(current, 2))
|
||||
current += step
|
||||
else:
|
||||
# Parse comma-separated values
|
||||
for val in value_str.split(','):
|
||||
val = val.strip()
|
||||
if val:
|
||||
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
|
||||
values.append(int(val))
|
||||
else:
|
||||
values.append(float(val))
|
||||
else:
|
||||
# String-based parameters (split by comma)
|
||||
values = [v.strip() for v in value_str.split(',') if v.strip()]
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def generate_axis_labels(values: List[Any], axis_type: AxisType, prefix: str = "") -> List[str]:
|
||||
"""Generate labels for axis values.
|
||||
|
||||
Args:
|
||||
values: List of axis values
|
||||
axis_type: Type of parameter
|
||||
prefix: Optional prefix for labels
|
||||
|
||||
Returns:
|
||||
List of label strings
|
||||
"""
|
||||
labels = []
|
||||
|
||||
for value in values:
|
||||
if axis_type == AxisType.MODEL:
|
||||
# Strip extension and path for models
|
||||
label = os.path.splitext(os.path.basename(str(value)))[0]
|
||||
elif axis_type == AxisType.PROMPT:
|
||||
# Truncate long prompts
|
||||
label = str(value)[:30] + "..." if len(str(value)) > 30 else str(value)
|
||||
else:
|
||||
label = str(value)
|
||||
|
||||
if prefix:
|
||||
label = f"{prefix}{label}"
|
||||
|
||||
labels.append(label)
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def calculate_grid_dimensions(x_count: int, y_count: int, z_count: int = 1) -> Dict[str, int]:
|
||||
"""Calculate total images and grid dimensions.
|
||||
|
||||
Args:
|
||||
x_count: Number of X axis values
|
||||
y_count: Number of Y axis values
|
||||
z_count: Number of Z axis values (default 1)
|
||||
|
||||
Returns:
|
||||
Dict with total_images, grids_count, cols, rows
|
||||
"""
|
||||
total_images = x_count * y_count * z_count
|
||||
grids_count = z_count if z_count > 0 else 1
|
||||
|
||||
return {
|
||||
"total_images": total_images,
|
||||
"grids_count": grids_count,
|
||||
"cols": x_count,
|
||||
"rows": y_count,
|
||||
}
|
||||
|
||||
|
||||
def create_unique_id() -> str:
|
||||
"""Create unique ID for a grid batch."""
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Progress tracking and preview capabilities for XYZ grids."""
|
||||
|
||||
import time
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridProgress:
|
||||
"""Tracks progress for a single grid generation."""
|
||||
batch_id: str
|
||||
total_images: int
|
||||
completed_images: int = 0
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
current_labels: Dict[str, str] = field(default_factory=dict)
|
||||
preview_images: List[Any] = field(default_factory=list)
|
||||
status: str = "initializing" # initializing, running, completed, error
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def progress_percent(self) -> float:
|
||||
"""Get progress as percentage."""
|
||||
if self.total_images == 0:
|
||||
return 0.0
|
||||
return (self.completed_images / self.total_images) * 100
|
||||
|
||||
@property
|
||||
def elapsed_time(self) -> float:
|
||||
"""Get elapsed time in seconds."""
|
||||
end = self.end_time or time.time()
|
||||
return end - self.start_time
|
||||
|
||||
@property
|
||||
def estimated_remaining(self) -> Optional[float]:
|
||||
"""Estimate remaining time in seconds."""
|
||||
if self.completed_images == 0:
|
||||
return None
|
||||
|
||||
avg_time_per_image = self.elapsed_time / self.completed_images
|
||||
remaining_images = self.total_images - self.completed_images
|
||||
return avg_time_per_image * remaining_images
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"batch_id": self.batch_id,
|
||||
"total_images": self.total_images,
|
||||
"completed_images": self.completed_images,
|
||||
"progress_percent": round(self.progress_percent, 1),
|
||||
"elapsed_time": round(self.elapsed_time, 1),
|
||||
"estimated_remaining": round(self.estimated_remaining, 1) if self.estimated_remaining else None,
|
||||
"current_labels": self.current_labels,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"preview_count": len(self.preview_images)
|
||||
}
|
||||
|
||||
|
||||
class ProgressTracker:
|
||||
"""Manages progress tracking for all grid generations."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_grids: Dict[str, GridProgress] = {}
|
||||
self.completed_grids: List[GridProgress] = []
|
||||
self.progress_callbacks: List[Callable] = []
|
||||
self.websocket_handler = None
|
||||
|
||||
def start_grid(self, batch_id: str, total_images: int) -> GridProgress:
|
||||
"""Start tracking a new grid generation."""
|
||||
progress = GridProgress(
|
||||
batch_id=batch_id,
|
||||
total_images=total_images,
|
||||
status="running"
|
||||
)
|
||||
self.active_grids[batch_id] = progress
|
||||
self._notify_progress(progress)
|
||||
return progress
|
||||
|
||||
def update_progress(self, batch_id: str, completed: int = None,
|
||||
current_labels: Dict[str, str] = None,
|
||||
preview_image: Any = None) -> Optional[GridProgress]:
|
||||
"""Update progress for a grid."""
|
||||
if batch_id not in self.active_grids:
|
||||
return None
|
||||
|
||||
progress = self.active_grids[batch_id]
|
||||
|
||||
if completed is not None:
|
||||
progress.completed_images = completed
|
||||
else:
|
||||
progress.completed_images += 1
|
||||
|
||||
if current_labels:
|
||||
progress.current_labels = current_labels
|
||||
|
||||
if preview_image is not None:
|
||||
progress.preview_images.append(preview_image)
|
||||
# Keep only last N previews to save memory
|
||||
if len(progress.preview_images) > 5:
|
||||
progress.preview_images.pop(0)
|
||||
|
||||
self._notify_progress(progress)
|
||||
|
||||
# Check if completed
|
||||
if progress.completed_images >= progress.total_images:
|
||||
self.complete_grid(batch_id)
|
||||
|
||||
return progress
|
||||
|
||||
def complete_grid(self, batch_id: str) -> Optional[GridProgress]:
|
||||
"""Mark a grid as completed."""
|
||||
if batch_id not in self.active_grids:
|
||||
return None
|
||||
|
||||
progress = self.active_grids[batch_id]
|
||||
progress.status = "completed"
|
||||
progress.end_time = time.time()
|
||||
|
||||
# Move to completed list
|
||||
self.completed_grids.append(progress)
|
||||
del self.active_grids[batch_id]
|
||||
|
||||
# Keep only last N completed grids
|
||||
if len(self.completed_grids) > 10:
|
||||
self.completed_grids.pop(0)
|
||||
|
||||
self._notify_progress(progress)
|
||||
return progress
|
||||
|
||||
def error_grid(self, batch_id: str, error_message: str) -> Optional[GridProgress]:
|
||||
"""Mark a grid as errored."""
|
||||
if batch_id not in self.active_grids:
|
||||
return None
|
||||
|
||||
progress = self.active_grids[batch_id]
|
||||
progress.status = "error"
|
||||
progress.error_message = error_message
|
||||
progress.end_time = time.time()
|
||||
|
||||
# Move to completed list (with error status)
|
||||
self.completed_grids.append(progress)
|
||||
del self.active_grids[batch_id]
|
||||
|
||||
self._notify_progress(progress)
|
||||
return progress
|
||||
|
||||
def get_progress(self, batch_id: str) -> Optional[GridProgress]:
|
||||
"""Get progress for a specific grid."""
|
||||
if batch_id in self.active_grids:
|
||||
return self.active_grids[batch_id]
|
||||
|
||||
# Check completed grids
|
||||
for grid in self.completed_grids:
|
||||
if grid.batch_id == batch_id:
|
||||
return grid
|
||||
|
||||
return None
|
||||
|
||||
def get_all_active(self) -> List[GridProgress]:
|
||||
"""Get all active grid progress."""
|
||||
return list(self.active_grids.values())
|
||||
|
||||
def register_callback(self, callback: Callable[[GridProgress], None]) -> None:
|
||||
"""Register a progress callback."""
|
||||
self.progress_callbacks.append(callback)
|
||||
|
||||
def set_websocket_handler(self, handler: Any) -> None:
|
||||
"""Set WebSocket handler for real-time updates."""
|
||||
self.websocket_handler = handler
|
||||
|
||||
def _notify_progress(self, progress: GridProgress) -> None:
|
||||
"""Notify all registered callbacks of progress update."""
|
||||
# Call registered callbacks
|
||||
for callback in self.progress_callbacks:
|
||||
try:
|
||||
callback(progress)
|
||||
except Exception as e:
|
||||
print(f"Error in progress callback: {e}")
|
||||
|
||||
# Send WebSocket update if available
|
||||
if self.websocket_handler:
|
||||
try:
|
||||
self._send_websocket_update(progress)
|
||||
except Exception as e:
|
||||
print(f"Error sending WebSocket update: {e}")
|
||||
|
||||
def _send_websocket_update(self, progress: GridProgress) -> None:
|
||||
"""Send progress update via WebSocket."""
|
||||
if not self.websocket_handler:
|
||||
return
|
||||
|
||||
message = {
|
||||
"type": "xyz_grid_progress",
|
||||
"data": progress.to_dict()
|
||||
}
|
||||
|
||||
# This would integrate with ComfyUI's server
|
||||
try:
|
||||
from server import PromptServer
|
||||
if PromptServer:
|
||||
server = PromptServer.instance
|
||||
if server:
|
||||
server.send_sync("xyz_grid_progress", message["data"])
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of all progress."""
|
||||
return {
|
||||
"active_grids": [p.to_dict() for p in self.active_grids.values()],
|
||||
"completed_grids": [p.to_dict() for p in self.completed_grids[-5:]], # Last 5
|
||||
"total_active": len(self.active_grids),
|
||||
"total_completed": len(self.completed_grids)
|
||||
}
|
||||
|
||||
|
||||
# Global progress tracker instance
|
||||
progress_tracker = ProgressTracker()
|
||||
|
||||
|
||||
class ProgressWebSocketHandler:
|
||||
"""WebSocket handler for progress updates."""
|
||||
|
||||
def __init__(self):
|
||||
self.clients = set()
|
||||
|
||||
async def handle_client(self, websocket, path):
|
||||
"""Handle a WebSocket client connection."""
|
||||
self.clients.add(websocket)
|
||||
try:
|
||||
# Send initial state
|
||||
summary = progress_tracker.get_summary()
|
||||
await websocket.send(json.dumps({
|
||||
"type": "xyz_grid_init",
|
||||
"data": summary
|
||||
}))
|
||||
|
||||
# Keep connection alive
|
||||
async for message in websocket:
|
||||
# Handle any client messages if needed
|
||||
pass
|
||||
finally:
|
||||
self.clients.remove(websocket)
|
||||
|
||||
async def broadcast_progress(self, progress: GridProgress):
|
||||
"""Broadcast progress to all connected clients."""
|
||||
if self.clients:
|
||||
message = json.dumps({
|
||||
"type": "xyz_grid_progress",
|
||||
"data": progress.to_dict()
|
||||
})
|
||||
# Send to all connected clients
|
||||
disconnected = set()
|
||||
for client in self.clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except:
|
||||
disconnected.add(client)
|
||||
|
||||
# Remove disconnected clients
|
||||
self.clients -= disconnected
|
||||
@@ -0,0 +1,194 @@
|
||||
# ComfyUI-KikoTools XYZ Grid Development Plan
|
||||
|
||||
## Current Session Context (2025-08-05)
|
||||
|
||||
### Working Branch: `feature/xyz-nodes`
|
||||
|
||||
### Completed Work
|
||||
|
||||
#### 1. XYZ Plot Controller
|
||||
- ✅ Implemented dynamic widget management with RGThree-style interface
|
||||
- ✅ Added right-click context menus (Toggle, Move Up/Down, Remove)
|
||||
- ✅ Fixed text input removal that was leaving DOM elements behind
|
||||
- ✅ Added placeholder hints for text inputs
|
||||
- ✅ Auto-resize nodes when adding widgets
|
||||
- ✅ Removed unwanted "input" connection from node
|
||||
- ✅ Fixed image count calculation for step ranges (e.g., "10:50:5")
|
||||
- ✅ Added callbacks to update node title with image count
|
||||
|
||||
#### 2. XYZ Prompt Node
|
||||
- ✅ Created separate node for prompt management
|
||||
- ✅ Implemented dynamic prompt set addition/removal
|
||||
- ✅ Added include_negative toggle for showing/hiding negative prompts
|
||||
- ✅ Added repeat_negative feature (use first negative for all variations)
|
||||
- ✅ Fixed spacing issues with protected button containers
|
||||
- ✅ Fixed widget values not passing to Python backend (added FlexibleOptionalInputType)
|
||||
- ✅ Visual styling: green background for positive, red for negative prompts
|
||||
|
||||
#### 3. ImageGridCombiner
|
||||
- ✅ Fixed grid_data structure mismatch with controller
|
||||
- ✅ Added proper dimensions object (cols, rows, grids_count)
|
||||
- ✅ Added axes object with human-readable labels
|
||||
- ✅ Created _create_labels method for formatting axis values
|
||||
|
||||
### Current Issues
|
||||
|
||||
#### 1. XYZ Prompt Widget Restoration Bug
|
||||
**Problem**: When refreshing the page, prompts aren't properly restored
|
||||
- Negative prompt appears at top with saved value
|
||||
- Positive prompts are lost
|
||||
- Widget restoration from widgets_values array not working correctly
|
||||
|
||||
**Current Fix Attempt**:
|
||||
- Modified onConfigure to properly clean up dynamic widgets
|
||||
- Added debug logging to trace restoration
|
||||
- Using promptData to track number of prompt sets
|
||||
- Need to properly handle widgets_values array restoration
|
||||
|
||||
#### 2. Pending Tasks (from todo list)
|
||||
- Complete queue implementation for actual ComfyUI API integration
|
||||
- Remove debug logging from production JavaScript
|
||||
- Add validation for invalid axis combinations
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
ComfyUI-KikoTools/
|
||||
├── kikotools/
|
||||
│ └── tools/
|
||||
│ └── xyz_grid/
|
||||
│ ├── controller/
|
||||
│ │ ├── power_node.py (Main XYZ Plot Controller)
|
||||
│ │ ├── queue_manager.py (Placeholder - needs implementation)
|
||||
│ │ └── execution.py
|
||||
│ ├── prompt/
|
||||
│ │ └── node.py (XYZ Prompt node)
|
||||
│ ├── combiner/
|
||||
│ │ └── node.py (ImageGridCombiner)
|
||||
│ └── __init__.py
|
||||
├── web/
|
||||
│ ├── xyz_plot_controller.js (Dynamic widget UI)
|
||||
│ ├── xyz_prompt.js (Prompt management UI)
|
||||
│ └── disabled/ (Old implementations)
|
||||
└── examples/
|
||||
└── xyz_grid_test_workflow.json (Test workflow)
|
||||
```
|
||||
|
||||
### Key Technical Patterns
|
||||
|
||||
#### Python Node Pattern
|
||||
```python
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""Accepts dynamic widget values from JavaScript."""
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
def __getitem__(self, key):
|
||||
return ("STRING", {"multiline": True, "forceInput": False})
|
||||
|
||||
# In INPUT_TYPES:
|
||||
"optional": FlexibleOptionalInputType()
|
||||
```
|
||||
|
||||
#### JavaScript Widget Creation
|
||||
```javascript
|
||||
const widget = ComfyWidgets.STRING(
|
||||
this,
|
||||
widgetName,
|
||||
["STRING", config],
|
||||
app
|
||||
).widget;
|
||||
```
|
||||
|
||||
#### RGThree-style Context Menu
|
||||
```javascript
|
||||
getSlotInPosition(x, y) {
|
||||
// Return fake slot with widget for context menu
|
||||
const widget = this.findWidgetAtPosition(x, y);
|
||||
if (widget) {
|
||||
return {
|
||||
slot_index: -1,
|
||||
widget: widget
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getSlotMenuOptions(slot) {
|
||||
if (slot?.widget) {
|
||||
return this.getWidgetMenuOptions(slot.widget);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Git Commands for Session Recovery
|
||||
|
||||
```bash
|
||||
# Switch to working branch
|
||||
git checkout feature/xyz-nodes
|
||||
|
||||
# Check current status
|
||||
git status
|
||||
|
||||
# Recent commits
|
||||
git log --oneline -10
|
||||
|
||||
# Current changes
|
||||
git diff
|
||||
```
|
||||
|
||||
### Testing Instructions
|
||||
|
||||
1. Load ComfyUI
|
||||
2. Refresh browser (F5)
|
||||
3. Add XYZ Prompt node
|
||||
4. Add multiple prompts
|
||||
5. Save workflow
|
||||
6. Refresh page
|
||||
7. Check if prompts are restored correctly
|
||||
|
||||
### Debug Points
|
||||
|
||||
1. Check browser console for debug logs from:
|
||||
- `XYZ Prompt onConfigure`
|
||||
- `XYZ Prompt serialize`
|
||||
- Widget creation logs
|
||||
|
||||
2. Monitor Python console for:
|
||||
- `XYZPrompt.process_prompts` kwargs
|
||||
- Grid data structure output
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Fix widget restoration**:
|
||||
- Properly handle widgets_values array
|
||||
- Ensure widget values are restored in correct order
|
||||
- Test with multiple prompt sets
|
||||
|
||||
2. **Clean up debug code**:
|
||||
- Remove console.log statements
|
||||
- Remove print statements in Python
|
||||
|
||||
3. **Complete queue manager**:
|
||||
- Implement actual ComfyUI API integration
|
||||
- Handle batch execution properly
|
||||
|
||||
4. **Add validation**:
|
||||
- Prevent same parameter on multiple axes
|
||||
- Validate numeric ranges
|
||||
- Check model/VAE/LoRA availability
|
||||
|
||||
### Important Notes
|
||||
|
||||
- CLAUDE.md is in .gitignore (local only)
|
||||
- Main branch is `main` for PRs
|
||||
- Test with actual checkpoint files before merging
|
||||
- Memory management for large grids needs optimization
|
||||
- Performance concerns with many dynamic widgets
|
||||
|
||||
### Session Recovery Command
|
||||
|
||||
To continue work in new terminal:
|
||||
```bash
|
||||
cd /home/vito/code/personal/ComfyUI-KikoTools
|
||||
git checkout feature/xyz-nodes
|
||||
# Check this plan.md for context
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# XYZ Grid tests
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tests for cache manager."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import torch
|
||||
from kikotools.tools.xyz_grid.utils.cache_manager import ModelCacheManager
|
||||
|
||||
|
||||
class TestModelCacheManager:
|
||||
"""Test ModelCacheManager class."""
|
||||
|
||||
@patch('torch.cuda.is_available')
|
||||
@patch('torch.cuda.mem_get_info')
|
||||
def test_get_available_memory_gpu(self, mock_mem_info, mock_cuda_available):
|
||||
"""Test GPU memory detection."""
|
||||
mock_cuda_available.return_value = True
|
||||
mock_mem_info.return_value = (4 * 1024**3, 8 * 1024**3) # 4GB free, 8GB total
|
||||
|
||||
manager = ModelCacheManager()
|
||||
free, total = manager.get_available_memory()
|
||||
|
||||
assert free == 4 * 1024**3
|
||||
assert total == 8 * 1024**3
|
||||
|
||||
@patch('torch.cuda.is_available')
|
||||
@patch('psutil.virtual_memory')
|
||||
def test_get_available_memory_cpu(self, mock_vm, mock_cuda_available):
|
||||
"""Test CPU memory fallback."""
|
||||
mock_cuda_available.return_value = False
|
||||
mock_vm.return_value = MagicMock(available=16 * 1024**3, total=32 * 1024**3)
|
||||
|
||||
manager = ModelCacheManager()
|
||||
free, total = manager.get_available_memory()
|
||||
|
||||
assert free == 16 * 1024**3
|
||||
assert total == 32 * 1024**3
|
||||
|
||||
@patch.object(ModelCacheManager, 'get_available_memory')
|
||||
def test_should_cache(self, mock_memory):
|
||||
"""Test cache decision logic."""
|
||||
manager = ModelCacheManager()
|
||||
|
||||
# Plenty of memory available
|
||||
mock_memory.return_value = (6 * 1024**3, 8 * 1024**3) # 6GB free, 8GB total
|
||||
assert manager.should_cache(2 * 1024**3) # 2GB model
|
||||
|
||||
# Not enough memory
|
||||
mock_memory.return_value = (1 * 1024**3, 8 * 1024**3) # 1GB free, 8GB total
|
||||
assert not manager.should_cache(2 * 1024**3) # 2GB model would exceed threshold
|
||||
|
||||
# No memory info
|
||||
mock_memory.return_value = (0, 0)
|
||||
assert not manager.should_cache()
|
||||
|
||||
@patch.object(ModelCacheManager, 'should_cache')
|
||||
def test_cache_model(self, mock_should_cache):
|
||||
"""Test model caching."""
|
||||
manager = ModelCacheManager(max_cache_size=2)
|
||||
mock_should_cache.return_value = True
|
||||
|
||||
# Cache first model
|
||||
model1 = Mock()
|
||||
assert manager.cache_model("model1", model1)
|
||||
assert manager.get_cached_model("model1") == model1
|
||||
|
||||
# Cache second model
|
||||
model2 = Mock()
|
||||
assert manager.cache_model("model2", model2)
|
||||
assert len(manager.model_cache) == 2
|
||||
|
||||
# Cache third model - should evict oldest
|
||||
model3 = Mock()
|
||||
assert manager.cache_model("model3", model3)
|
||||
assert len(manager.model_cache) == 2
|
||||
assert "model1" not in manager.model_cache
|
||||
assert "model3" in manager.model_cache
|
||||
|
||||
def test_get_cached_model_updates_lru(self):
|
||||
"""Test that getting a model updates LRU order."""
|
||||
manager = ModelCacheManager(max_cache_size=2)
|
||||
|
||||
# Add two models
|
||||
with patch.object(manager, 'should_cache', return_value=True):
|
||||
manager.cache_model("model1", "m1")
|
||||
manager.cache_model("model2", "m2")
|
||||
|
||||
# Access model1 to make it most recent
|
||||
manager.get_cached_model("model1")
|
||||
|
||||
# Add third model - should evict model2, not model1
|
||||
with patch.object(manager, 'should_cache', return_value=True):
|
||||
manager.cache_model("model3", "m3")
|
||||
|
||||
assert "model1" in manager.model_cache
|
||||
assert "model2" not in manager.model_cache
|
||||
assert "model3" in manager.model_cache
|
||||
|
||||
@patch('gc.collect')
|
||||
@patch('torch.cuda.empty_cache')
|
||||
@patch('torch.cuda.is_available')
|
||||
def test_uncache_model(self, mock_cuda, mock_empty_cache, mock_gc):
|
||||
"""Test model uncaching and cleanup."""
|
||||
mock_cuda.return_value = True
|
||||
manager = ModelCacheManager()
|
||||
|
||||
# Create mock model with 'to' method
|
||||
model = Mock()
|
||||
model.to = Mock()
|
||||
|
||||
with patch.object(manager, 'should_cache', return_value=True):
|
||||
manager.cache_model("model1", model)
|
||||
|
||||
# Uncache
|
||||
manager.uncache_model("model1")
|
||||
|
||||
# Verify cleanup
|
||||
assert "model1" not in manager.model_cache
|
||||
model.to.assert_called_with('cpu')
|
||||
mock_gc.assert_called_once()
|
||||
mock_empty_cache.assert_called_once()
|
||||
|
||||
def test_optimize_for_grid(self):
|
||||
"""Test grid optimization suggestions."""
|
||||
manager = ModelCacheManager()
|
||||
|
||||
with patch.object(manager, 'get_available_memory') as mock_memory:
|
||||
# Enough memory for everything
|
||||
mock_memory.return_value = (10 * 1024**3, 16 * 1024**3)
|
||||
suggestions = manager.optimize_for_grid(
|
||||
["model1", "model2", "model1"],
|
||||
["vae1", "vae1", "vae1"]
|
||||
)
|
||||
|
||||
assert suggestions["cache_all_models"]
|
||||
assert suggestions["cache_all_vaes"]
|
||||
assert suggestions["memory_sufficient"]
|
||||
|
||||
# Not enough memory
|
||||
mock_memory.return_value = (1 * 1024**3, 8 * 1024**3)
|
||||
suggestions = manager.optimize_for_grid(
|
||||
["model1", "model2", "model3"],
|
||||
["vae1", "vae2"]
|
||||
)
|
||||
|
||||
assert not suggestions["cache_all_models"]
|
||||
assert not suggestions["memory_sufficient"]
|
||||
assert len(suggestions["recommended_order"]) > 0
|
||||
|
||||
def test_optimize_load_order(self):
|
||||
"""Test load order optimization."""
|
||||
manager = ModelCacheManager()
|
||||
|
||||
# Test grouping
|
||||
items = ["a", "b", "a", "c", "b", "a"]
|
||||
optimized = manager._optimize_load_order(items)
|
||||
|
||||
# Should group all a's, then b's, then c
|
||||
assert optimized == ["a", "a", "a", "b", "b", "c"]
|
||||
|
||||
# Test with single item type
|
||||
items = ["x", "x", "x"]
|
||||
optimized = manager._optimize_load_order(items)
|
||||
assert optimized == ["x", "x", "x"]
|
||||
|
||||
@patch('gc.collect')
|
||||
@patch('torch.cuda.empty_cache')
|
||||
@patch('torch.cuda.is_available')
|
||||
def test_clear_cache(self, mock_cuda, mock_empty_cache, mock_gc):
|
||||
"""Test clearing all caches."""
|
||||
mock_cuda.return_value = True
|
||||
manager = ModelCacheManager()
|
||||
|
||||
# Add some items to caches
|
||||
with patch.object(manager, 'should_cache', return_value=True):
|
||||
manager.cache_model("model1", Mock())
|
||||
manager.cache_vae("vae1", Mock())
|
||||
|
||||
manager.lora_cache["lora1"] = Mock()
|
||||
|
||||
# Clear all
|
||||
manager.clear_cache()
|
||||
|
||||
assert len(manager.model_cache) == 0
|
||||
assert len(manager.vae_cache) == 0
|
||||
assert len(manager.lora_cache) == 0
|
||||
assert mock_gc.called
|
||||
assert mock_empty_cache.called
|
||||
|
||||
def test_get_cache_stats(self):
|
||||
"""Test cache statistics."""
|
||||
manager = ModelCacheManager()
|
||||
|
||||
with patch.object(manager, 'get_available_memory') as mock_memory:
|
||||
mock_memory.return_value = (4 * 1024**3, 8 * 1024**3)
|
||||
|
||||
# Add some cached items
|
||||
with patch.object(manager, 'should_cache', return_value=True):
|
||||
manager.cache_model("model1", Mock())
|
||||
manager.cache_vae("vae1", Mock())
|
||||
|
||||
stats = manager.get_cache_stats()
|
||||
|
||||
assert stats["models_cached"] == 1
|
||||
assert stats["vaes_cached"] == 1
|
||||
assert stats["memory_free"] == 4 * 1024**3
|
||||
assert stats["memory_total"] == 8 * 1024**3
|
||||
assert stats["memory_usage"] == 0.5
|
||||
assert "model1" in stats["cache_names"]["models"]
|
||||
assert "vae1" in stats["cache_names"]["vaes"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for parameter converters."""
|
||||
|
||||
import pytest
|
||||
from kikotools.tools.xyz_grid.utils.constants import AxisType
|
||||
from kikotools.tools.xyz_grid.utils.converters import ParameterConverter, OutputConnector
|
||||
|
||||
|
||||
class TestParameterConverter:
|
||||
"""Test parameter value conversion."""
|
||||
|
||||
def test_convert_string_types(self):
|
||||
"""Test conversion of string-based parameters."""
|
||||
assert ParameterConverter.convert_value("model.ckpt", AxisType.MODEL) == "model.ckpt"
|
||||
assert ParameterConverter.convert_value("euler", AxisType.SAMPLER) == "euler"
|
||||
assert ParameterConverter.convert_value("My prompt", AxisType.PROMPT) == "My prompt"
|
||||
|
||||
def test_convert_integer_types(self):
|
||||
"""Test conversion of integer parameters."""
|
||||
assert ParameterConverter.convert_value("20", AxisType.STEPS) == 20
|
||||
assert ParameterConverter.convert_value("3", AxisType.CLIP_SKIP) == 3
|
||||
assert ParameterConverter.convert_value("12345", AxisType.SEED) == 12345
|
||||
|
||||
# Test float to int conversion
|
||||
assert ParameterConverter.convert_value("20.5", AxisType.STEPS) == 20
|
||||
assert ParameterConverter.convert_value(20.7, AxisType.STEPS) == 20
|
||||
|
||||
def test_convert_float_types(self):
|
||||
"""Test conversion of float parameters."""
|
||||
assert ParameterConverter.convert_value("7.5", AxisType.CFG_SCALE) == 7.5
|
||||
assert ParameterConverter.convert_value("3.5", AxisType.FLUX_GUIDANCE) == 3.5
|
||||
assert ParameterConverter.convert_value("0.8", AxisType.DENOISE) == 0.8
|
||||
|
||||
# Test integer to float
|
||||
assert ParameterConverter.convert_value(7, AxisType.CFG_SCALE) == 7.0
|
||||
|
||||
def test_convert_invalid_values(self):
|
||||
"""Test conversion of invalid values."""
|
||||
# Invalid integers default to 0
|
||||
assert ParameterConverter.convert_value("abc", AxisType.STEPS) == 0
|
||||
assert ParameterConverter.convert_value("", AxisType.STEPS) == 0
|
||||
|
||||
# Invalid floats default to 0.0
|
||||
assert ParameterConverter.convert_value("xyz", AxisType.CFG_SCALE) == 0.0
|
||||
assert ParameterConverter.convert_value(None, AxisType.CFG_SCALE) == 0.0
|
||||
|
||||
def test_format_for_display(self):
|
||||
"""Test display formatting."""
|
||||
# Model names strip extension
|
||||
assert ParameterConverter.format_for_display("model.safetensors", AxisType.MODEL) == "model"
|
||||
assert ParameterConverter.format_for_display("path/to/checkpoint.ckpt", AxisType.MODEL) == "checkpoint"
|
||||
|
||||
# Floats format with one decimal
|
||||
assert ParameterConverter.format_for_display(7.5, AxisType.CFG_SCALE) == "7.5"
|
||||
assert ParameterConverter.format_for_display(10.0, AxisType.CFG_SCALE) == "10.0"
|
||||
|
||||
# Large numbers get commas
|
||||
assert ParameterConverter.format_for_display(1234567, AxisType.SEED) == "1,234,567"
|
||||
|
||||
# Long prompts truncate
|
||||
long_text = "This is a very long prompt that exceeds the display limit"
|
||||
formatted = ParameterConverter.format_for_display(long_text, AxisType.PROMPT)
|
||||
assert len(formatted) <= 28 # 25 + "..."
|
||||
|
||||
def test_get_output_type(self):
|
||||
"""Test output type detection."""
|
||||
# String types
|
||||
assert ParameterConverter.get_output_type(AxisType.MODEL) == "STRING"
|
||||
assert ParameterConverter.get_output_type(AxisType.SAMPLER) == "STRING"
|
||||
assert ParameterConverter.get_output_type(AxisType.PROMPT) == "STRING"
|
||||
|
||||
# Integer types
|
||||
assert ParameterConverter.get_output_type(AxisType.STEPS) == "INT"
|
||||
assert ParameterConverter.get_output_type(AxisType.CLIP_SKIP) == "INT"
|
||||
assert ParameterConverter.get_output_type(AxisType.SEED) == "INT"
|
||||
|
||||
# Float types
|
||||
assert ParameterConverter.get_output_type(AxisType.CFG_SCALE) == "FLOAT"
|
||||
assert ParameterConverter.get_output_type(AxisType.FLUX_GUIDANCE) == "FLOAT"
|
||||
assert ParameterConverter.get_output_type(AxisType.DENOISE) == "FLOAT"
|
||||
|
||||
def test_validate_values(self):
|
||||
"""Test value validation."""
|
||||
# Valid values
|
||||
assert ParameterConverter.validate_value(20, AxisType.STEPS) == (True, None)
|
||||
assert ParameterConverter.validate_value(7.5, AxisType.CFG_SCALE) == (True, None)
|
||||
assert ParameterConverter.validate_value(0.5, AxisType.DENOISE) == (True, None)
|
||||
|
||||
# Invalid values
|
||||
valid, msg = ParameterConverter.validate_value(-5, AxisType.STEPS)
|
||||
assert not valid
|
||||
assert "positive" in msg
|
||||
|
||||
valid, msg = ParameterConverter.validate_value(-2.5, AxisType.CFG_SCALE)
|
||||
assert not valid
|
||||
assert "non-negative" in msg
|
||||
|
||||
valid, msg = ParameterConverter.validate_value(1.5, AxisType.DENOISE)
|
||||
assert not valid
|
||||
assert "between 0 and 1" in msg
|
||||
|
||||
|
||||
class TestOutputConnector:
|
||||
"""Test output connection information."""
|
||||
|
||||
def test_get_connection_info(self):
|
||||
"""Test connection info for different parameter types."""
|
||||
# Model connection
|
||||
info = OutputConnector.get_connection_info(AxisType.MODEL)
|
||||
assert info["target_node"] == "CheckpointLoaderSimple"
|
||||
assert info["target_input"] == "ckpt_name"
|
||||
assert info["type"] == "STRING"
|
||||
|
||||
# Sampler connection
|
||||
info = OutputConnector.get_connection_info(AxisType.SAMPLER)
|
||||
assert info["target_node"] == "KSampler"
|
||||
assert info["target_input"] == "sampler_name"
|
||||
|
||||
# CFG connection
|
||||
info = OutputConnector.get_connection_info(AxisType.CFG_SCALE)
|
||||
assert info["target_node"] == "KSampler"
|
||||
assert info["target_input"] == "cfg"
|
||||
assert info["type"] == "FLOAT"
|
||||
|
||||
# Prompt connection
|
||||
info = OutputConnector.get_connection_info(AxisType.PROMPT)
|
||||
assert info["target_node"] == "CLIPTextEncode"
|
||||
assert info["target_input"] == "text"
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests for execution flow components."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from kikotools.tools.xyz_grid.controller.execution import (
|
||||
GridExecutionState, ExecutionManager
|
||||
)
|
||||
from kikotools.tools.xyz_grid.controller.queue_manager import (
|
||||
QueuedExecution, GridQueueManager
|
||||
)
|
||||
|
||||
|
||||
class TestGridExecutionState:
|
||||
"""Test GridExecutionState class."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test state initialization."""
|
||||
state = GridExecutionState(
|
||||
batch_id="test123",
|
||||
total_iterations=12,
|
||||
x_count=3,
|
||||
y_count=4,
|
||||
z_count=1
|
||||
)
|
||||
|
||||
assert state.batch_id == "test123"
|
||||
assert state.total_iterations == 12
|
||||
assert state.current_iteration == 0
|
||||
assert state.x_index == 0
|
||||
assert state.y_index == 0
|
||||
assert state.z_index == 0
|
||||
|
||||
def test_advance_simple(self):
|
||||
"""Test advancing through iterations."""
|
||||
state = GridExecutionState(
|
||||
batch_id="test",
|
||||
total_iterations=6,
|
||||
x_count=2,
|
||||
y_count=3,
|
||||
z_count=1
|
||||
)
|
||||
|
||||
# Test advancing through all positions
|
||||
positions = []
|
||||
for i in range(6):
|
||||
positions.append(state.get_indices())
|
||||
state.advance()
|
||||
|
||||
expected = [
|
||||
(0, 0, 0), (1, 0, 0), # First row
|
||||
(0, 1, 0), (1, 1, 0), # Second row
|
||||
(0, 2, 0), (1, 2, 0), # Third row
|
||||
]
|
||||
assert positions == expected
|
||||
|
||||
def test_advance_with_z(self):
|
||||
"""Test advancing with Z axis."""
|
||||
state = GridExecutionState(
|
||||
batch_id="test",
|
||||
total_iterations=8,
|
||||
x_count=2,
|
||||
y_count=2,
|
||||
z_count=2
|
||||
)
|
||||
|
||||
# Advance through first grid
|
||||
for _ in range(4):
|
||||
state.advance()
|
||||
|
||||
# Should now be at start of second Z
|
||||
assert state.get_indices() == (0, 0, 1)
|
||||
|
||||
def test_is_complete(self):
|
||||
"""Test completion detection."""
|
||||
state = GridExecutionState(
|
||||
batch_id="test",
|
||||
total_iterations=2,
|
||||
x_count=2,
|
||||
y_count=1
|
||||
)
|
||||
|
||||
assert not state.is_complete()
|
||||
|
||||
state.advance()
|
||||
assert not state.is_complete()
|
||||
|
||||
state.advance()
|
||||
assert state.is_complete()
|
||||
|
||||
|
||||
class TestExecutionManager:
|
||||
"""Test ExecutionManager class."""
|
||||
|
||||
def test_initialize_batch(self):
|
||||
"""Test batch initialization."""
|
||||
manager = ExecutionManager()
|
||||
|
||||
x_vals = ["a", "b", "c"]
|
||||
y_vals = [1, 2]
|
||||
z_vals = ["z1"]
|
||||
|
||||
state = manager.initialize_batch("batch1", x_vals, y_vals, z_vals)
|
||||
|
||||
assert state.batch_id == "batch1"
|
||||
assert state.total_iterations == 6 # 3 * 2 * 1
|
||||
assert state.x_count == 3
|
||||
assert state.y_count == 2
|
||||
assert state.z_count == 1
|
||||
|
||||
def test_get_current_values(self):
|
||||
"""Test getting current values."""
|
||||
manager = ExecutionManager()
|
||||
|
||||
x_vals = ["model1", "model2"]
|
||||
y_vals = [5.0, 7.5]
|
||||
z_vals = [""]
|
||||
|
||||
# First call should initialize
|
||||
x, y, z, xi, yi, zi = manager.get_current_values(
|
||||
"batch1", x_vals, y_vals, z_vals
|
||||
)
|
||||
|
||||
assert x == "model1"
|
||||
assert y == 5.0
|
||||
assert z == ""
|
||||
assert (xi, yi, zi) == (0, 0, 0)
|
||||
|
||||
# Advance and get next
|
||||
manager.advance_batch("batch1")
|
||||
x, y, z, xi, yi, zi = manager.get_current_values(
|
||||
"batch1", x_vals, y_vals, z_vals
|
||||
)
|
||||
|
||||
assert x == "model2"
|
||||
assert y == 5.0
|
||||
assert (xi, yi, zi) == (1, 0, 0)
|
||||
|
||||
def test_should_continue(self):
|
||||
"""Test continuation checking."""
|
||||
manager = ExecutionManager()
|
||||
|
||||
# Non-existent batch
|
||||
assert not manager.should_continue("nonexistent")
|
||||
|
||||
# Initialize small batch
|
||||
manager.initialize_batch("batch1", ["a"], ["b"], [""])
|
||||
assert manager.should_continue("batch1")
|
||||
|
||||
# Complete the batch
|
||||
state = manager.execution_states["batch1"]
|
||||
state.current_iteration = state.total_iterations
|
||||
assert not manager.should_continue("batch1")
|
||||
|
||||
def test_cleanup_batch(self):
|
||||
"""Test batch cleanup."""
|
||||
manager = ExecutionManager()
|
||||
|
||||
manager.initialize_batch("batch1", ["a"], ["b"], ["c"])
|
||||
assert "batch1" in manager.execution_states
|
||||
|
||||
manager.cleanup_batch("batch1")
|
||||
assert "batch1" not in manager.execution_states
|
||||
|
||||
|
||||
class TestGridQueueManager:
|
||||
"""Test GridQueueManager class."""
|
||||
|
||||
def test_prepare_batch_executions(self):
|
||||
"""Test preparing batch executions."""
|
||||
manager = GridQueueManager()
|
||||
|
||||
grid_config = {
|
||||
"axes": {
|
||||
"x": {"values": ["v1", "v2"], "labels": ["V1", "V2"]},
|
||||
"y": {"values": [1, 2, 3], "labels": ["1", "2", "3"]},
|
||||
"z": {"values": [""], "labels": [""]},
|
||||
},
|
||||
"dimensions": {"total_images": 6}
|
||||
}
|
||||
|
||||
workflow = {"test": "workflow"}
|
||||
executions = manager.prepare_batch_executions(
|
||||
"batch1", grid_config, 123, workflow
|
||||
)
|
||||
|
||||
assert len(executions) == 6
|
||||
assert all(isinstance(e, QueuedExecution) for e in executions)
|
||||
|
||||
# Check first execution
|
||||
first = executions[0]
|
||||
assert first.batch_id == "batch1"
|
||||
assert first.iteration == 0
|
||||
assert first.total_iterations == 6
|
||||
assert first.x_value == "v1"
|
||||
assert first.y_value == 1
|
||||
assert first.x_index == 0
|
||||
assert first.y_index == 0
|
||||
|
||||
def test_get_next_execution(self):
|
||||
"""Test getting next execution."""
|
||||
manager = GridQueueManager()
|
||||
|
||||
# No executions
|
||||
assert manager.get_next_execution("batch1") is None
|
||||
|
||||
# Prepare batch
|
||||
grid_config = {
|
||||
"axes": {
|
||||
"x": {"values": ["a", "b"], "labels": []},
|
||||
"y": {"values": [1], "labels": []},
|
||||
"z": {"values": [""], "labels": []},
|
||||
},
|
||||
"dimensions": {"total_images": 2}
|
||||
}
|
||||
|
||||
manager.prepare_batch_executions("batch1", grid_config, 1, {})
|
||||
|
||||
# Get first execution
|
||||
execution = manager.get_next_execution("batch1")
|
||||
assert execution is not None
|
||||
assert execution.iteration == 0
|
||||
|
||||
# Mark as complete
|
||||
manager.mark_iteration_complete("batch1", 0)
|
||||
|
||||
# Get second execution
|
||||
execution = manager.get_next_execution("batch1")
|
||||
assert execution.iteration == 1
|
||||
|
||||
def test_is_batch_complete(self):
|
||||
"""Test batch completion checking."""
|
||||
manager = GridQueueManager()
|
||||
|
||||
# Unknown batch is complete
|
||||
assert manager.is_batch_complete("unknown")
|
||||
|
||||
# Prepare batch
|
||||
grid_config = {
|
||||
"axes": {
|
||||
"x": {"values": ["a"], "labels": []},
|
||||
"y": {"values": [1, 2], "labels": []},
|
||||
"z": {"values": [""], "labels": []},
|
||||
},
|
||||
"dimensions": {"total_images": 2}
|
||||
}
|
||||
|
||||
manager.prepare_batch_executions("batch1", grid_config, 1, {})
|
||||
assert not manager.is_batch_complete("batch1")
|
||||
|
||||
# Complete all iterations
|
||||
manager.mark_iteration_complete("batch1", 0)
|
||||
manager.mark_iteration_complete("batch1", 1)
|
||||
assert manager.is_batch_complete("batch1")
|
||||
|
||||
def test_batch_optimization(self):
|
||||
"""Test batch optimization logic."""
|
||||
manager = GridQueueManager()
|
||||
|
||||
# Test that batch preparation preserves order
|
||||
grid_config = {
|
||||
"axes": {
|
||||
"x": {"values": ["a", "b", "a"], "labels": []},
|
||||
"y": {"values": [1], "labels": []},
|
||||
"z": {"values": [""], "labels": []},
|
||||
},
|
||||
"dimensions": {"total_images": 3}
|
||||
}
|
||||
|
||||
executions = manager.prepare_batch_executions("batch1", grid_config, 1, {})
|
||||
|
||||
# Check order is preserved
|
||||
assert len(executions) == 3
|
||||
assert executions[0].x_value == "a"
|
||||
assert executions[1].x_value == "b"
|
||||
assert executions[2].x_value == "a"
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for XYZ grid helper utilities."""
|
||||
|
||||
import pytest
|
||||
from kikotools.tools.xyz_grid.utils.constants import AxisType
|
||||
from kikotools.tools.xyz_grid.utils.helpers import (
|
||||
parse_value_string,
|
||||
generate_axis_labels,
|
||||
calculate_grid_dimensions,
|
||||
create_unique_id
|
||||
)
|
||||
|
||||
|
||||
class TestParseValueString:
|
||||
"""Test value string parsing."""
|
||||
|
||||
def test_parse_comma_separated_strings(self):
|
||||
"""Test parsing comma-separated string values."""
|
||||
result = parse_value_string("model1.ckpt, model2.safetensors, model3.pt", AxisType.MODEL)
|
||||
assert result == ["model1.ckpt", "model2.safetensors", "model3.pt"]
|
||||
|
||||
def test_parse_comma_separated_numbers(self):
|
||||
"""Test parsing comma-separated numeric values."""
|
||||
result = parse_value_string("5, 7.5, 10", AxisType.CFG_SCALE)
|
||||
assert result == [5.0, 7.5, 10.0]
|
||||
|
||||
result = parse_value_string("20, 30, 40", AxisType.STEPS)
|
||||
assert result == [20, 30, 40]
|
||||
|
||||
def test_parse_range_syntax(self):
|
||||
"""Test parsing range syntax."""
|
||||
# Float range
|
||||
result = parse_value_string("5:10:1", AxisType.CFG_SCALE)
|
||||
assert result == [5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
|
||||
|
||||
# Integer range
|
||||
result = parse_value_string("10:30:10", AxisType.STEPS)
|
||||
assert result == [10, 20, 30]
|
||||
|
||||
# Two-part range (default step)
|
||||
result = parse_value_string("1:5", AxisType.CLIP_SKIP)
|
||||
assert result == [1, 2, 3, 4, 5]
|
||||
|
||||
def test_parse_empty_string(self):
|
||||
"""Test parsing empty or whitespace strings."""
|
||||
assert parse_value_string("", AxisType.MODEL) == []
|
||||
assert parse_value_string(" ", AxisType.MODEL) == []
|
||||
assert parse_value_string("\n\t", AxisType.MODEL) == []
|
||||
|
||||
def test_parse_single_value(self):
|
||||
"""Test parsing single values."""
|
||||
assert parse_value_string("euler", AxisType.SAMPLER) == ["euler"]
|
||||
assert parse_value_string("7.5", AxisType.CFG_SCALE) == [7.5]
|
||||
assert parse_value_string("42", AxisType.SEED) == [42]
|
||||
|
||||
|
||||
class TestGenerateAxisLabels:
|
||||
"""Test label generation."""
|
||||
|
||||
def test_basic_labels(self):
|
||||
"""Test basic label generation."""
|
||||
values = ["euler", "dpm++", "ddim"]
|
||||
labels = generate_axis_labels(values, AxisType.SAMPLER)
|
||||
assert labels == ["euler", "dpm++", "ddim"]
|
||||
|
||||
def test_labels_with_prefix(self):
|
||||
"""Test labels with prefix."""
|
||||
values = [5, 10, 15]
|
||||
labels = generate_axis_labels(values, AxisType.CFG_SCALE, prefix="CFG=")
|
||||
assert labels == ["CFG=5", "CFG=10", "CFG=15"]
|
||||
|
||||
def test_model_labels_strip_extension(self):
|
||||
"""Test model labels strip file extensions."""
|
||||
values = ["model1.ckpt", "model2.safetensors", "checkpoint.pt"]
|
||||
labels = generate_axis_labels(values, AxisType.MODEL)
|
||||
assert labels == ["model1", "model2", "checkpoint"]
|
||||
|
||||
def test_prompt_labels_truncate(self):
|
||||
"""Test prompt labels truncate long text."""
|
||||
long_prompt = "This is a very long prompt that should be truncated for display purposes"
|
||||
values = [long_prompt, "Short prompt"]
|
||||
labels = generate_axis_labels(values, AxisType.PROMPT)
|
||||
assert len(labels[0]) <= 33 # 30 chars + "..."
|
||||
assert labels[1] == "Short prompt"
|
||||
|
||||
|
||||
class TestCalculateGridDimensions:
|
||||
"""Test grid dimension calculations."""
|
||||
|
||||
def test_2d_grid(self):
|
||||
"""Test 2D grid calculations."""
|
||||
result = calculate_grid_dimensions(3, 4)
|
||||
assert result == {
|
||||
"total_images": 12,
|
||||
"grids_count": 1,
|
||||
"cols": 3,
|
||||
"rows": 4
|
||||
}
|
||||
|
||||
def test_3d_grid(self):
|
||||
"""Test 3D grid calculations."""
|
||||
result = calculate_grid_dimensions(2, 3, 4)
|
||||
assert result == {
|
||||
"total_images": 24,
|
||||
"grids_count": 4,
|
||||
"cols": 2,
|
||||
"rows": 3
|
||||
}
|
||||
|
||||
def test_single_axis(self):
|
||||
"""Test single axis grid."""
|
||||
result = calculate_grid_dimensions(5, 1)
|
||||
assert result["total_images"] == 5
|
||||
assert result["cols"] == 5
|
||||
assert result["rows"] == 1
|
||||
|
||||
|
||||
class TestCreateUniqueId:
|
||||
"""Test unique ID generation."""
|
||||
|
||||
def test_unique_ids_are_different(self):
|
||||
"""Test that generated IDs are unique."""
|
||||
ids = [create_unique_id() for _ in range(100)]
|
||||
assert len(set(ids)) == 100
|
||||
|
||||
def test_id_format(self):
|
||||
"""Test ID format is consistent."""
|
||||
uid = create_unique_id()
|
||||
assert isinstance(uid, str)
|
||||
assert len(uid) == 8 # Should be 8 characters
|
||||
assert uid.replace("-", "").isalnum() # Should be alphanumeric (with possible hyphens)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests for progress tracking."""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from kikotools.tools.xyz_grid.utils.progress_tracker import (
|
||||
GridProgress, ProgressTracker
|
||||
)
|
||||
|
||||
|
||||
class TestGridProgress:
|
||||
"""Test GridProgress class."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test progress initialization."""
|
||||
progress = GridProgress(
|
||||
batch_id="test123",
|
||||
total_images=10
|
||||
)
|
||||
|
||||
assert progress.batch_id == "test123"
|
||||
assert progress.total_images == 10
|
||||
assert progress.completed_images == 0
|
||||
assert progress.status == "initializing"
|
||||
assert progress.progress_percent == 0.0
|
||||
|
||||
def test_progress_percent(self):
|
||||
"""Test progress percentage calculation."""
|
||||
progress = GridProgress(batch_id="test", total_images=4)
|
||||
|
||||
assert progress.progress_percent == 0.0
|
||||
|
||||
progress.completed_images = 1
|
||||
assert progress.progress_percent == 25.0
|
||||
|
||||
progress.completed_images = 2
|
||||
assert progress.progress_percent == 50.0
|
||||
|
||||
progress.completed_images = 4
|
||||
assert progress.progress_percent == 100.0
|
||||
|
||||
def test_elapsed_time(self):
|
||||
"""Test elapsed time calculation."""
|
||||
# Create progress with known start time
|
||||
progress = GridProgress(batch_id="test", total_images=10)
|
||||
progress.start_time = 100.0
|
||||
|
||||
# Mock current time for elapsed calculation
|
||||
with patch('time.time', return_value=110.5):
|
||||
assert progress.elapsed_time == 10.5
|
||||
|
||||
# Set end time
|
||||
progress.end_time = 115.0
|
||||
assert progress.elapsed_time == 15.0
|
||||
|
||||
def test_estimated_remaining(self):
|
||||
"""Test remaining time estimation."""
|
||||
progress = GridProgress(batch_id="test", total_images=10)
|
||||
progress.start_time = 100.0
|
||||
|
||||
# No images completed yet
|
||||
assert progress.estimated_remaining is None
|
||||
|
||||
# Complete 2 images in 10 seconds
|
||||
progress.completed_images = 2
|
||||
|
||||
# Mock current time for calculation
|
||||
with patch('time.time', return_value=110.0):
|
||||
# 5 seconds per image, 8 remaining = 40 seconds
|
||||
assert progress.estimated_remaining == 40.0
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test dictionary conversion."""
|
||||
progress = GridProgress(
|
||||
batch_id="test",
|
||||
total_images=10,
|
||||
completed_images=5
|
||||
)
|
||||
progress.current_labels = {"x": "Model A", "y": "CFG 7.5"}
|
||||
|
||||
data = progress.to_dict()
|
||||
|
||||
assert data["batch_id"] == "test"
|
||||
assert data["total_images"] == 10
|
||||
assert data["completed_images"] == 5
|
||||
assert data["progress_percent"] == 50.0
|
||||
assert data["current_labels"] == {"x": "Model A", "y": "CFG 7.5"}
|
||||
assert data["status"] == "initializing"
|
||||
assert "elapsed_time" in data
|
||||
|
||||
|
||||
class TestProgressTracker:
|
||||
"""Test ProgressTracker class."""
|
||||
|
||||
def test_start_grid(self):
|
||||
"""Test starting a new grid."""
|
||||
tracker = ProgressTracker()
|
||||
|
||||
progress = tracker.start_grid("batch1", 20)
|
||||
|
||||
assert progress.batch_id == "batch1"
|
||||
assert progress.total_images == 20
|
||||
assert progress.status == "running"
|
||||
assert "batch1" in tracker.active_grids
|
||||
|
||||
def test_update_progress(self):
|
||||
"""Test updating progress."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.start_grid("batch1", 5)
|
||||
|
||||
# Update with increment
|
||||
progress = tracker.update_progress("batch1")
|
||||
assert progress.completed_images == 1
|
||||
|
||||
# Update with specific count
|
||||
progress = tracker.update_progress("batch1", completed=3)
|
||||
assert progress.completed_images == 3
|
||||
|
||||
# Update with labels
|
||||
labels = {"x": "Model B", "y": "Steps 30"}
|
||||
progress = tracker.update_progress("batch1", current_labels=labels)
|
||||
assert progress.current_labels == labels
|
||||
|
||||
def test_update_with_preview(self):
|
||||
"""Test updating with preview images."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.start_grid("batch1", 10)
|
||||
|
||||
# Add previews
|
||||
for i in range(7):
|
||||
tracker.update_progress("batch1", preview_image=f"image_{i}")
|
||||
|
||||
progress = tracker.get_progress("batch1")
|
||||
# Should only keep last 5
|
||||
assert len(progress.preview_images) == 5
|
||||
assert progress.preview_images[-1] == "image_6"
|
||||
|
||||
def test_complete_grid(self):
|
||||
"""Test completing a grid."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.start_grid("batch1", 2)
|
||||
|
||||
# Complete all images
|
||||
tracker.update_progress("batch1", completed=2)
|
||||
|
||||
# Should auto-complete
|
||||
assert "batch1" not in tracker.active_grids
|
||||
assert len(tracker.completed_grids) == 1
|
||||
|
||||
completed = tracker.completed_grids[0]
|
||||
assert completed.status == "completed"
|
||||
assert completed.end_time is not None
|
||||
|
||||
def test_error_grid(self):
|
||||
"""Test error handling."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.start_grid("batch1", 10)
|
||||
|
||||
progress = tracker.error_grid("batch1", "CUDA out of memory")
|
||||
|
||||
assert progress.status == "error"
|
||||
assert progress.error_message == "CUDA out of memory"
|
||||
assert "batch1" not in tracker.active_grids
|
||||
assert len(tracker.completed_grids) == 1
|
||||
|
||||
def test_get_progress(self):
|
||||
"""Test getting progress for specific batch."""
|
||||
tracker = ProgressTracker()
|
||||
|
||||
# Non-existent batch
|
||||
assert tracker.get_progress("unknown") is None
|
||||
|
||||
# Active batch
|
||||
tracker.start_grid("batch1", 10)
|
||||
progress = tracker.get_progress("batch1")
|
||||
assert progress is not None
|
||||
assert progress.batch_id == "batch1"
|
||||
|
||||
# Completed batch
|
||||
tracker.complete_grid("batch1")
|
||||
progress = tracker.get_progress("batch1")
|
||||
assert progress is not None
|
||||
assert progress.status == "completed"
|
||||
|
||||
def test_callbacks(self):
|
||||
"""Test progress callbacks."""
|
||||
tracker = ProgressTracker()
|
||||
|
||||
callback_data = []
|
||||
def test_callback(progress):
|
||||
callback_data.append(progress.to_dict())
|
||||
|
||||
tracker.register_callback(test_callback)
|
||||
|
||||
# Start should trigger callback
|
||||
tracker.start_grid("batch1", 5)
|
||||
assert len(callback_data) == 1
|
||||
assert callback_data[0]["batch_id"] == "batch1"
|
||||
|
||||
# Update should trigger callback
|
||||
tracker.update_progress("batch1")
|
||||
assert len(callback_data) == 2
|
||||
assert callback_data[1]["completed_images"] == 1
|
||||
|
||||
def test_websocket_notification(self):
|
||||
"""Test WebSocket notifications."""
|
||||
tracker = ProgressTracker()
|
||||
|
||||
# Test with mock websocket handler
|
||||
mock_handler = Mock()
|
||||
tracker.set_websocket_handler(mock_handler)
|
||||
|
||||
# Test callback gets called
|
||||
callback_called = False
|
||||
def test_callback(progress):
|
||||
nonlocal callback_called
|
||||
callback_called = True
|
||||
|
||||
tracker.register_callback(test_callback)
|
||||
tracker.start_grid("batch1", 10)
|
||||
|
||||
assert callback_called
|
||||
|
||||
def test_get_summary(self):
|
||||
"""Test getting progress summary."""
|
||||
tracker = ProgressTracker()
|
||||
|
||||
# Add some grids
|
||||
tracker.start_grid("batch1", 10)
|
||||
tracker.start_grid("batch2", 20)
|
||||
|
||||
# Complete one
|
||||
tracker.complete_grid("batch1")
|
||||
|
||||
summary = tracker.get_summary()
|
||||
|
||||
assert summary["total_active"] == 1
|
||||
assert summary["total_completed"] == 1
|
||||
assert len(summary["active_grids"]) == 1
|
||||
assert len(summary["completed_grids"]) == 1
|
||||
assert summary["active_grids"][0]["batch_id"] == "batch2"
|
||||
|
||||
def test_completed_grids_limit(self):
|
||||
"""Test that completed grids list has a limit."""
|
||||
tracker = ProgressTracker()
|
||||
|
||||
# Complete many grids
|
||||
for i in range(15):
|
||||
tracker.start_grid(f"batch{i}", 5)
|
||||
tracker.complete_grid(f"batch{i}")
|
||||
|
||||
# Should only keep last 10
|
||||
assert len(tracker.completed_grids) == 10
|
||||
|
||||
# Check it's the most recent ones
|
||||
last_batch_id = tracker.completed_grids[-1].batch_id
|
||||
assert last_batch_id == "batch14"
|
||||
@@ -0,0 +1,247 @@
|
||||
/* XYZ Grid Styles */
|
||||
|
||||
.xyz-value-selector {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.xyz-value-selector button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-value-selector button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3);
|
||||
}
|
||||
|
||||
.xyz-value-selector button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Multi-select dialog animations */
|
||||
.xyz-multiselect-overlay {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-multiselect-dialog {
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Example helper styles */
|
||||
.xyz-example-helper {
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xyz-example-helper::before {
|
||||
content: "Example";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.xyz-example-helper pre {
|
||||
margin: 0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Value input validation styles */
|
||||
.xyz-value-input {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-value-input.valid {
|
||||
border-color: #4ecdc4 !important;
|
||||
}
|
||||
|
||||
.xyz-value-input.invalid {
|
||||
border-color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
/* Progress indicator */
|
||||
.xyz-progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #4ecdc4 0%, #44a3aa 100%);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
/* Grid info display */
|
||||
.xyz-grid-info {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xyz-grid-info .dimensions {
|
||||
color: #4ecdc4;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.xyz-grid-info .warning {
|
||||
color: #ff6b6b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Checkbox styling in multi-select */
|
||||
.xyz-checkbox-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-checkbox-option:hover {
|
||||
background-color: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.xyz-checkbox-option input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xyz-checkbox-option label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Search input styling */
|
||||
.xyz-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||
}
|
||||
|
||||
.xyz-search-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Button group styling */
|
||||
.xyz-button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.xyz-button-group button {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xyz-button-primary {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xyz-button-primary:hover {
|
||||
background: #357abd;
|
||||
}
|
||||
|
||||
.xyz-button-secondary {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xyz-button-secondary:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Tooltip for parameter info */
|
||||
.xyz-param-tooltip {
|
||||
position: absolute;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #ddd;
|
||||
z-index: 10001;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.xyz-param-tooltip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xyz-param-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 5px solid #444;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,781 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// Counter for unique widget names
|
||||
let widgetCounter = 0;
|
||||
|
||||
// Helper text for different parameter types
|
||||
const PARAM_HELP = {
|
||||
cfg_scale: "Enter values separated by commas: 5, 7.5, 10\nOr use range: 5:15:2.5",
|
||||
steps: "Enter values separated by commas: 20, 30, 40\nOr use range: 10:50:10",
|
||||
seed: "Enter values separated by commas: 42, 123, 456\nOr use range: 0:1000:100",
|
||||
denoise: "Enter values separated by commas: 0.3, 0.5, 0.7\nOr use range: 0.2:1.0:0.2",
|
||||
clip_skip: "Enter values separated by commas: 1, 2\nCommon values for SDXL",
|
||||
prompt: "Enter prompts separated by new lines:\nbeautiful sunset\nmystical forest\nfuturistic city"
|
||||
};
|
||||
|
||||
// Helper function to check if we're in low quality mode
|
||||
function isLowQuality() {
|
||||
const canvas = app.canvas;
|
||||
return ((canvas.ds?.scale) || 1) <= 0.5;
|
||||
}
|
||||
|
||||
// Optimized toggle drawing function
|
||||
function drawTogglePart(ctx, options) {
|
||||
const lowQuality = isLowQuality();
|
||||
ctx.save();
|
||||
|
||||
const { posX, posY, height, value } = options;
|
||||
const toggleRadius = height * 0.36;
|
||||
const toggleBgWidth = height * 1.5;
|
||||
|
||||
if (!lowQuality) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX + 4, posY + 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;
|
||||
}
|
||||
|
||||
ctx.fillStyle = value ? "#89B" : "#888";
|
||||
const toggleX = lowQuality || !value
|
||||
? posX + height * 0.5
|
||||
: posX + height;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
return [posX, toggleBgWidth];
|
||||
}
|
||||
|
||||
// Custom dynamic widget class with RGThree-style UI
|
||||
class XYZDynamicWidget {
|
||||
constructor(name, value) {
|
||||
this.name = name;
|
||||
this._value = value;
|
||||
this.type = "xyz_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;
|
||||
const lowQuality = isLowQuality();
|
||||
let posX = margin;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Background - skip complex drawing in low quality
|
||||
if (!lowQuality) {
|
||||
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 with optimized function
|
||||
this.toggleBounds = drawTogglePart(ctx, { posX, posY: y, height, value: this.value.on });
|
||||
posX += this.toggleBounds[1] + innerMargin;
|
||||
|
||||
// Skip text drawing in low quality mode
|
||||
if (lowQuality) {
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply opacity if disabled
|
||||
if (!this.value.on) {
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
||||
}
|
||||
|
||||
// Draw name/value - for dropdown types, show the selected value
|
||||
const displayText = this.value.value || this.value.name || "None";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
|
||||
// Fit text if too long
|
||||
const maxTextWidth = width - posX - margin;
|
||||
const textWidth = ctx.measureText(displayText).width;
|
||||
if (textWidth > maxTextWidth) {
|
||||
// Use ellipsis for long text
|
||||
let truncated = displayText;
|
||||
while (truncated.length > 0 && ctx.measureText(truncated + "…").width > maxTextWidth) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
ctx.fillText(truncated + "…", posX, midY);
|
||||
this.nameBounds = [posX, ctx.measureText(truncated + "…").width];
|
||||
} else {
|
||||
ctx.fillText(displayText, posX, midY);
|
||||
this.nameBounds = [posX, textWidth];
|
||||
}
|
||||
|
||||
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);
|
||||
updateNodeTitle(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Click on name/value to show dropdown
|
||||
if (this.nameBounds && localX >= this.nameBounds[0] &&
|
||||
localX <= this.nameBounds[0] + this.nameBounds[1]) {
|
||||
// Show dropdown menu for selection
|
||||
if (this.value.options && this.value.options.length > 0) {
|
||||
const menu = new LiteGraph.ContextMenu(
|
||||
this.value.options,
|
||||
{
|
||||
event: event,
|
||||
callback: (value) => {
|
||||
this.value.value = value;
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
computeSize(width) {
|
||||
return [width || 300, LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.XYZPlotController",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "XYZPlotController") {
|
||||
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 = {
|
||||
x: [],
|
||||
y: [],
|
||||
z: []
|
||||
};
|
||||
}
|
||||
|
||||
// Store references to add buttons
|
||||
if (!node.addButtons) {
|
||||
node.addButtons = {};
|
||||
}
|
||||
|
||||
// Store references to text widgets
|
||||
if (!node.textWidgets) {
|
||||
node.textWidgets = {};
|
||||
}
|
||||
|
||||
// Setup axis type callbacks
|
||||
["x_type", "y_type", "z_type"].forEach(widgetName => {
|
||||
const widget = node.widgets.find(w => w.name === widgetName);
|
||||
if (widget) {
|
||||
const originalCallback = widget.callback;
|
||||
widget.callback = function(value) {
|
||||
if (originalCallback) originalCallback.call(this, value);
|
||||
updateAxisWidgets(node, widgetName.split("_")[0], value);
|
||||
updateNodeTitle(node);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-fetch common options to cache them
|
||||
setTimeout(() => {
|
||||
getSamplers();
|
||||
getSchedulers();
|
||||
}, 100);
|
||||
|
||||
// Override configuration for proper restoration
|
||||
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 = { x: [], y: [], z: [] };
|
||||
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._axis) {
|
||||
const widget = new XYZDynamicWidget(
|
||||
`dynamic_${widgetCounter++}`,
|
||||
value
|
||||
);
|
||||
this.addCustomWidget(widget);
|
||||
|
||||
if (this.dynamicWidgets[value._axis]) {
|
||||
this.dynamicWidgets[value._axis].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
|
||||
for (const axis of ['x', 'y', 'z']) {
|
||||
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
|
||||
if (typeWidget) {
|
||||
updateAxisWidgets(this, axis, typeWidget.value, true);
|
||||
}
|
||||
}
|
||||
|
||||
updateNodeTitle(this);
|
||||
};
|
||||
|
||||
// Override serialization to fix widget value persistence
|
||||
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);
|
||||
|
||||
// Override getExtraMenuOptions to handle execution
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
if (origGetExtraMenuOptions) {
|
||||
origGetExtraMenuOptions.call(this, _, options);
|
||||
}
|
||||
|
||||
// Prepare widget values for execution
|
||||
const executeCallback = () => {
|
||||
// Format dynamic widget values for Python backend
|
||||
const values = {};
|
||||
|
||||
for (const axis of ['x', 'y', 'z']) {
|
||||
// Add dynamic widgets
|
||||
const widgets = this.dynamicWidgets[axis];
|
||||
widgets.forEach((widget, index) => {
|
||||
const key = `${axis}_${widget.value._type}_${index}`;
|
||||
values[key] = {
|
||||
on: widget.value.on,
|
||||
value: widget.value.value
|
||||
};
|
||||
});
|
||||
|
||||
// Add text widget values
|
||||
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
|
||||
if (typeWidget && needsTextWidget(typeWidget.value)) {
|
||||
const textWidget = this.textWidgets[axis];
|
||||
if (textWidget) {
|
||||
if (typeWidget.value === "prompt") {
|
||||
values[`${axis}_prompt`] = textWidget.value || "";
|
||||
} else {
|
||||
values[`${axis}_numeric`] = textWidget.value || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to widgets_values for proper serialization
|
||||
if (this.widgets_values) {
|
||||
Object.assign(this.widgets_values, values);
|
||||
}
|
||||
};
|
||||
|
||||
// Hook into execution
|
||||
if (this.mode === 0) { // Only in active mode
|
||||
executeCallback();
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to update widgets based on axis type
|
||||
function updateAxisWidgets(node, axis, type, skipClear = false) {
|
||||
if (!skipClear) {
|
||||
// Hide text widgets and buttons for this axis (but not the type dropdown!)
|
||||
node.widgets?.forEach(widget => {
|
||||
if (widget.name?.includes(`${axis}_`) && widget.name !== `${axis}_type`) {
|
||||
// Only hide if it's a text widget or add button
|
||||
if (widget.type === "text" || widget.type === "button" || widget.name.includes("_select")) {
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
node.hiddenWidgets?.add(widget.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear dynamic widgets
|
||||
if (node.dynamicWidgets[axis]) {
|
||||
while (node.dynamicWidgets[axis].length > 0) {
|
||||
const widget = node.dynamicWidgets[axis].pop();
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (index > -1) {
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove add button
|
||||
if (node.addButtons[axis]) {
|
||||
const index = node.widgets.indexOf(node.addButtons[axis]);
|
||||
if (index > -1) {
|
||||
node.widgets.splice(index, 1);
|
||||
}
|
||||
node.addButtons[axis] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If type is "none", don't add any widgets
|
||||
if (type === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add widgets based on type
|
||||
if (needsTextWidget(type)) {
|
||||
const widgetName = `${axis}_${type}`;
|
||||
let existingWidget = node.widgets?.find(w => w.name === widgetName);
|
||||
|
||||
if (!existingWidget) {
|
||||
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
|
||||
default: "",
|
||||
multiline: type === "prompt",
|
||||
placeholder: PARAM_HELP[type] || ""
|
||||
}]);
|
||||
node.textWidgets[axis] = textWidget.widget;
|
||||
|
||||
// Set placeholder
|
||||
if (textWidget.widget.inputEl && PARAM_HELP[type]) {
|
||||
textWidget.widget.inputEl.placeholder = PARAM_HELP[type];
|
||||
}
|
||||
} else {
|
||||
existingWidget.hidden = false;
|
||||
existingWidget.computeSize = () => [node.size[0] - 20,
|
||||
type === "prompt" ? LiteGraph.NODE_WIDGET_HEIGHT * 3 : LiteGraph.NODE_WIDGET_HEIGHT];
|
||||
node.hiddenWidgets?.delete(existingWidget.name);
|
||||
node.textWidgets[axis] = existingWidget;
|
||||
}
|
||||
} else if (needsDropdownWidget(type)) {
|
||||
// Add button for dynamic widgets
|
||||
if (!node.addButtons[axis]) {
|
||||
const buttonLabel = `➕ Add ${type.charAt(0).toUpperCase() + type.slice(1, -1)}`;
|
||||
const button = node.addWidget("button", buttonLabel, null, (value, canvas, node, pos, event) => {
|
||||
// Store event for context menu positioning
|
||||
window.lastButtonEvent = event || window.event;
|
||||
addDynamicWidget(node, axis, type);
|
||||
});
|
||||
node.addButtons[axis] = button;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if type needs text widget
|
||||
function needsTextWidget(type) {
|
||||
return ["cfg_scale", "steps", "seed", "denoise", "clip_skip", "prompt"].includes(type);
|
||||
}
|
||||
|
||||
// Helper function to check if type needs dropdown widgets
|
||||
function needsDropdownWidget(type) {
|
||||
return ["models", "vaes", "loras", "samplers", "schedulers"].includes(type);
|
||||
}
|
||||
|
||||
// Cache for options to avoid repeated API calls
|
||||
const optionsCache = {
|
||||
models: null,
|
||||
vaes: null,
|
||||
loras: null,
|
||||
samplers: null,
|
||||
schedulers: null
|
||||
};
|
||||
|
||||
// Helper function to add dynamic widget
|
||||
async function addDynamicWidget(node, axis, type) {
|
||||
// Get available options based on type - use cache if available
|
||||
let options = optionsCache[type];
|
||||
|
||||
if (!options) {
|
||||
if (type === "models") {
|
||||
options = await getCheckpoints();
|
||||
} else if (type === "vaes") {
|
||||
options = await getVAEs();
|
||||
} else if (type === "loras") {
|
||||
options = await getLoRAs();
|
||||
} else if (type === "samplers") {
|
||||
options = await getSamplers();
|
||||
} else if (type === "schedulers") {
|
||||
options = await getSchedulers();
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
optionsCache[type] = options;
|
||||
}
|
||||
|
||||
// Show selection dialog
|
||||
if (!options || options.length === 0) {
|
||||
alert(`No ${type} found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create context menu for selection
|
||||
const menu = new LiteGraph.ContextMenu(
|
||||
options,
|
||||
{
|
||||
event: window.lastButtonEvent || window.event,
|
||||
callback: (selectedValue) => {
|
||||
// Create dynamic widget with selected value
|
||||
const widget = new XYZDynamicWidget(
|
||||
`${axis}_${type}_${widgetCounter++}`,
|
||||
{
|
||||
on: true,
|
||||
value: selectedValue,
|
||||
options: options,
|
||||
_axis: axis,
|
||||
_type: type
|
||||
}
|
||||
);
|
||||
|
||||
node.addCustomWidget(widget);
|
||||
node.dynamicWidgets[axis].push(widget);
|
||||
|
||||
updateNodeTitle(node);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to update node title with count
|
||||
function updateNodeTitle(node) {
|
||||
let xCount = 1, yCount = 1, zCount = 1;
|
||||
|
||||
// Count values for each axis
|
||||
for (const axis of ['x', 'y', 'z']) {
|
||||
const typeWidget = node.widgets.find(w => w.name === `${axis}_type`);
|
||||
if (typeWidget && typeWidget.value !== "none") {
|
||||
const type = typeWidget.value;
|
||||
|
||||
if (needsTextWidget(type)) {
|
||||
const textWidget = node.textWidgets[axis];
|
||||
if (textWidget && textWidget.value) {
|
||||
const values = parseAxisValues(textWidget.value, type);
|
||||
if (axis === 'x') xCount = values.length;
|
||||
else if (axis === 'y') yCount = values.length;
|
||||
else if (axis === 'z') zCount = values.length;
|
||||
}
|
||||
} else if (needsDropdownWidget(type)) {
|
||||
const enabledWidgets = node.dynamicWidgets[axis].filter(w => w.value.on);
|
||||
if (axis === 'x') xCount = Math.max(1, enabledWidgets.length);
|
||||
else if (axis === 'y') yCount = Math.max(1, enabledWidgets.length);
|
||||
else if (axis === 'z') zCount = Math.max(1, enabledWidgets.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = xCount * yCount * zCount;
|
||||
node.title = `XYZ Plot Controller (${total} images)`;
|
||||
}
|
||||
|
||||
// Helper function to parse axis values
|
||||
function parseAxisValues(text, type) {
|
||||
if (!text) return [];
|
||||
|
||||
if (type === "prompt") {
|
||||
return text.split('\n').filter(line => line.trim());
|
||||
} else {
|
||||
// Handle comma-separated and range syntax
|
||||
const values = [];
|
||||
const parts = text.split(',').map(s => s.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes(':')) {
|
||||
// Range syntax: start:end:step
|
||||
const [start, end, step] = part.split(':').map(s => parseFloat(s));
|
||||
if (!isNaN(start) && !isNaN(end) && !isNaN(step) && step > 0) {
|
||||
for (let v = start; v <= end; v += step) {
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const val = parseFloat(part);
|
||||
if (!isNaN(val)) {
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 === "xyz_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: "XYZ_WIDGET" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
};
|
||||
|
||||
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
|
||||
node.getSlotMenuOptions = function(slot) {
|
||||
if (slot?.output?.type === "XYZ_WIDGET") {
|
||||
const widget = slot.widget;
|
||||
const axis = widget.value._axis;
|
||||
const array = this.dynamicWidgets[axis];
|
||||
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);
|
||||
updateNodeTitle(this);
|
||||
}
|
||||
},
|
||||
{
|
||||
content: `⬆️ Move Up`,
|
||||
disabled: currentIndex === 0,
|
||||
callback: () => {
|
||||
if (currentIndex > 0) {
|
||||
[array[currentIndex - 1], array[currentIndex]] =
|
||||
[array[currentIndex], array[currentIndex - 1]];
|
||||
|
||||
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) {
|
||||
[array[currentIndex], array[currentIndex + 1]] =
|
||||
[array[currentIndex + 1], array[currentIndex]];
|
||||
|
||||
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: () => {
|
||||
// Remove from array
|
||||
const index = array.indexOf(widget);
|
||||
if (index > -1) {
|
||||
array.splice(index, 1);
|
||||
}
|
||||
|
||||
// Remove widget
|
||||
const wIndex = this.widgets.indexOf(widget);
|
||||
if (wIndex > -1) {
|
||||
this.widgets.splice(wIndex, 1);
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
updateNodeTitle(this);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "WIDGET OPTIONS",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
|
||||
};
|
||||
}
|
||||
|
||||
// API helper functions
|
||||
async function getCheckpoints() {
|
||||
try {
|
||||
// Get checkpoints from CheckpointLoaderSimple node definition
|
||||
const nodeData = await api.getNodeDefs();
|
||||
if (nodeData.CheckpointLoaderSimple &&
|
||||
nodeData.CheckpointLoaderSimple.input.required.ckpt_name) {
|
||||
return nodeData.CheckpointLoaderSimple.input.required.ckpt_name[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error getting checkpoints:", e);
|
||||
}
|
||||
return ["None"];
|
||||
}
|
||||
|
||||
async function getVAEs() {
|
||||
try {
|
||||
// Get VAEs from VAELoader node definition
|
||||
const nodeData = await api.getNodeDefs();
|
||||
if (nodeData.VAELoader &&
|
||||
nodeData.VAELoader.input.required.vae_name) {
|
||||
return ["Automatic", ...nodeData.VAELoader.input.required.vae_name[0]];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error getting VAEs:", e);
|
||||
}
|
||||
return ["Automatic"];
|
||||
}
|
||||
|
||||
async function getLoRAs() {
|
||||
try {
|
||||
// Get LoRAs from LoraLoader node definition
|
||||
const nodeData = await api.getNodeDefs();
|
||||
if (nodeData.LoraLoader &&
|
||||
nodeData.LoraLoader.input.required.lora_name) {
|
||||
return ["None", ...nodeData.LoraLoader.input.required.lora_name[0]];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error getting LoRAs:", e);
|
||||
}
|
||||
return ["None"];
|
||||
}
|
||||
|
||||
async function getSamplers() {
|
||||
// Get samplers from KSampler node definition
|
||||
try {
|
||||
const nodeData = await api.getNodeDefs();
|
||||
if (nodeData.KSampler && nodeData.KSampler.input.required.sampler_name) {
|
||||
return nodeData.KSampler.input.required.sampler_name[0];
|
||||
}
|
||||
} catch {}
|
||||
return ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_sde", "dpmpp_2m", "dpmpp_3m_sde"];
|
||||
}
|
||||
|
||||
async function getSchedulers() {
|
||||
// Get schedulers from KSampler node definition
|
||||
try {
|
||||
const nodeData = await api.getNodeDefs();
|
||||
if (nodeData.KSampler && nodeData.KSampler.input.required.scheduler) {
|
||||
return nodeData.KSampler.input.required.scheduler[0];
|
||||
}
|
||||
} catch {}
|
||||
return ["normal", "karras", "exponential", "simple", "ddim_uniform"];
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.DisplayAny",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* XYZ Plot Controller Widget Styles */
|
||||
.xyz-plot-controller-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.xyz-plot-controller-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure combo widgets don't overflow */
|
||||
.comfy-multiline-input {
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.XYZPrompt",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "XYZPrompt") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
// Track prompt count for each node
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Initialize prompt tracking
|
||||
this.promptCount = 0;
|
||||
this.promptWidgets = [];
|
||||
|
||||
// Store reference to include_negative and repeat_negative widgets
|
||||
this.includeNegativeWidget = this.widgets.find(w => w.name === "include_negative");
|
||||
this.repeatNegativeWidget = this.widgets.find(w => w.name === "repeat_negative");
|
||||
|
||||
// Add callback to include_negative widget
|
||||
if (this.includeNegativeWidget) {
|
||||
const originalCallback = this.includeNegativeWidget.callback;
|
||||
this.includeNegativeWidget.callback = (value) => {
|
||||
if (originalCallback) originalCallback.call(this, value);
|
||||
this.updatePromptWidgets();
|
||||
};
|
||||
}
|
||||
|
||||
// Add callback to repeat_negative widget
|
||||
if (this.repeatNegativeWidget) {
|
||||
const originalCallback = this.repeatNegativeWidget.callback;
|
||||
this.repeatNegativeWidget.callback = (value) => {
|
||||
if (originalCallback) originalCallback.call(this, value);
|
||||
this.updatePromptWidgets();
|
||||
};
|
||||
}
|
||||
|
||||
// Add the "Add Prompt" button
|
||||
this.addPromptButton = this.addWidget(
|
||||
"button",
|
||||
"➕ Add Prompt",
|
||||
null,
|
||||
() => {
|
||||
this.addPromptSet();
|
||||
}
|
||||
);
|
||||
|
||||
// Add initial prompt set
|
||||
this.addPromptSet();
|
||||
|
||||
// Update node title
|
||||
this.updateNodeTitle();
|
||||
|
||||
// Initial sizing
|
||||
setTimeout(() => {
|
||||
const size = this.computeSize();
|
||||
this.size[1] = size[1] + 60;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 50);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Add method to add a new prompt set
|
||||
nodeType.prototype.addPromptSet = function() {
|
||||
const index = this.promptCount;
|
||||
const includeNegative = this.includeNegativeWidget?.value ?? true;
|
||||
const repeatNegative = this.repeatNegativeWidget?.value ?? true;
|
||||
|
||||
// Determine if we should show negative prompt for this index
|
||||
const showNegative = includeNegative && (!repeatNegative || index === 0);
|
||||
|
||||
// Create positive prompt widget
|
||||
const positiveWidgetObj = ComfyWidgets.STRING(
|
||||
this,
|
||||
`positive_${index}`,
|
||||
["STRING", {
|
||||
default: "",
|
||||
multiline: true,
|
||||
dynamicPrompts: false
|
||||
}],
|
||||
app
|
||||
);
|
||||
const positiveWidget = positiveWidgetObj.widget;
|
||||
|
||||
// Debug log
|
||||
console.log(`Created positive_${index} widget:`, positiveWidget);
|
||||
|
||||
// Style the positive prompt
|
||||
if (positiveWidget.inputEl) {
|
||||
positiveWidget.inputEl.placeholder = `Positive Prompt ${index + 1}`;
|
||||
positiveWidget.inputEl.style.minHeight = "70px";
|
||||
positiveWidget.inputEl.style.fontFamily = "monospace";
|
||||
positiveWidget.inputEl.style.backgroundColor = "#1a3d1a"; // Slight green tint
|
||||
positiveWidget.inputEl.style.marginBottom = "5px"; // Add spacing below
|
||||
}
|
||||
|
||||
// Add callback to update count
|
||||
const posOriginalCallback = positiveWidget.callback;
|
||||
positiveWidget.callback = (value) => {
|
||||
if (posOriginalCallback) posOriginalCallback.call(this, value);
|
||||
this.updateNodeTitle();
|
||||
};
|
||||
|
||||
let negativeWidget = null;
|
||||
if (showNegative) {
|
||||
// Create negative prompt widget only for first prompt or when not repeating
|
||||
negativeWidget = ComfyWidgets.STRING(
|
||||
this,
|
||||
`negative_${index}`,
|
||||
["STRING", {
|
||||
default: "",
|
||||
multiline: true,
|
||||
dynamicPrompts: false
|
||||
}],
|
||||
app
|
||||
).widget;
|
||||
|
||||
// Style the negative prompt
|
||||
if (negativeWidget.inputEl) {
|
||||
negativeWidget.inputEl.placeholder = `Negative Prompt ${index + 1}`;
|
||||
negativeWidget.inputEl.style.minHeight = "70px";
|
||||
negativeWidget.inputEl.style.fontFamily = "monospace";
|
||||
negativeWidget.inputEl.style.backgroundColor = "#3d1a1a"; // Slight red tint
|
||||
}
|
||||
|
||||
// Add callback to update count
|
||||
const negOriginalCallback = negativeWidget.callback;
|
||||
negativeWidget.callback = (value) => {
|
||||
if (negOriginalCallback) negOriginalCallback.call(this, value);
|
||||
this.updateNodeTitle();
|
||||
};
|
||||
}
|
||||
|
||||
// Create a container widget for the remove button with protected space
|
||||
const buttonContainerWidget = {
|
||||
type: "custom",
|
||||
name: `button_container_${index}`,
|
||||
size: [0, 40], // Fixed 40px height for button area
|
||||
draw: function(ctx, node, width, y, H) {
|
||||
// Draw a subtle separator line
|
||||
ctx.strokeStyle = "#444";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(15, y + 5);
|
||||
ctx.lineTo(width - 15, y + 5);
|
||||
ctx.stroke();
|
||||
|
||||
// Optional: Draw a semi-transparent background for the button area
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
|
||||
ctx.fillRect(0, y + 10, width, 30);
|
||||
},
|
||||
computeSize: function() {
|
||||
return [0, 40]; // Fixed height to protect button space
|
||||
}
|
||||
};
|
||||
this.widgets.push(buttonContainerWidget);
|
||||
|
||||
// Add remove button for this prompt set
|
||||
const removeButton = this.addWidget(
|
||||
"button",
|
||||
`🗑️ Remove Prompt ${index + 1}`,
|
||||
null,
|
||||
() => {
|
||||
this.removePromptSet(index);
|
||||
}
|
||||
);
|
||||
|
||||
// Store widgets for this prompt set
|
||||
this.promptWidgets.push({
|
||||
index: index,
|
||||
positive: positiveWidget,
|
||||
negative: negativeWidget,
|
||||
removeButton: removeButton,
|
||||
buttonContainer: buttonContainerWidget
|
||||
});
|
||||
|
||||
this.promptCount++;
|
||||
|
||||
// Move the "Add Prompt" button to the bottom
|
||||
const buttonIndex = this.widgets.indexOf(this.addPromptButton);
|
||||
if (buttonIndex > -1) {
|
||||
this.widgets.splice(buttonIndex, 1);
|
||||
this.widgets.push(this.addPromptButton);
|
||||
}
|
||||
|
||||
// Resize node - force proper recalculation
|
||||
this.setDirtyCanvas(true, true);
|
||||
setTimeout(() => {
|
||||
const size = this.computeSize();
|
||||
this.size[1] = Math.max(size[1] + 60, this.size[1]); // Ensure enough padding
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 10);
|
||||
|
||||
this.updateNodeTitle();
|
||||
};
|
||||
|
||||
// Remove a prompt set
|
||||
nodeType.prototype.removePromptSet = function(index) {
|
||||
const promptSet = this.promptWidgets.find(p => p.index === index);
|
||||
if (!promptSet) return;
|
||||
|
||||
// Remove widgets (including button container)
|
||||
const widgets = [promptSet.positive, promptSet.negative, promptSet.buttonContainer, promptSet.removeButton].filter(w => w);
|
||||
for (const widget of widgets) {
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
if (widgetIndex > -1) {
|
||||
// Clean up DOM element if it exists
|
||||
if (widget.inputEl && widget.inputEl.parentNode) {
|
||||
widget.inputEl.parentNode.removeChild(widget.inputEl);
|
||||
}
|
||||
// Call onRemoved if exists
|
||||
if (widget.onRemoved) {
|
||||
widget.onRemoved();
|
||||
}
|
||||
this.widgets.splice(widgetIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from tracking
|
||||
const setIndex = this.promptWidgets.indexOf(promptSet);
|
||||
if (setIndex > -1) {
|
||||
this.promptWidgets.splice(setIndex, 1);
|
||||
}
|
||||
|
||||
// Resize node
|
||||
this.setDirtyCanvas(true, true);
|
||||
const size = this.computeSize();
|
||||
this.size[1] = size[1];
|
||||
|
||||
this.updateNodeTitle();
|
||||
};
|
||||
|
||||
// Update prompt widgets when include_negative or repeat_negative changes
|
||||
nodeType.prototype.updatePromptWidgets = function() {
|
||||
const includeNegative = this.includeNegativeWidget?.value ?? true;
|
||||
const repeatNegative = this.repeatNegativeWidget?.value ?? true;
|
||||
|
||||
for (const promptSet of this.promptWidgets) {
|
||||
const index = promptSet.index;
|
||||
const shouldHaveNegative = includeNegative && (!repeatNegative || index === 0);
|
||||
|
||||
if (shouldHaveNegative && !promptSet.negative) {
|
||||
// Add negative widget
|
||||
const index = promptSet.index;
|
||||
const negativeWidget = ComfyWidgets.STRING(
|
||||
this,
|
||||
`negative_${index}`,
|
||||
["STRING", {
|
||||
default: "",
|
||||
multiline: true,
|
||||
dynamicPrompts: false
|
||||
}],
|
||||
app
|
||||
).widget;
|
||||
|
||||
if (negativeWidget.inputEl) {
|
||||
negativeWidget.inputEl.placeholder = `Negative Prompt ${index + 1}`;
|
||||
negativeWidget.inputEl.style.minHeight = "70px";
|
||||
negativeWidget.inputEl.style.fontFamily = "monospace";
|
||||
negativeWidget.inputEl.style.backgroundColor = "#3d1a1a";
|
||||
}
|
||||
|
||||
const negOriginalCallback = negativeWidget.callback;
|
||||
negativeWidget.callback = (value) => {
|
||||
if (negOriginalCallback) negOriginalCallback.call(this, value);
|
||||
this.updateNodeTitle();
|
||||
};
|
||||
|
||||
promptSet.negative = negativeWidget;
|
||||
|
||||
// Move it after the positive widget
|
||||
const posIndex = this.widgets.indexOf(promptSet.positive);
|
||||
if (posIndex > -1) {
|
||||
const widgetIndex = this.widgets.indexOf(negativeWidget);
|
||||
if (widgetIndex > -1) {
|
||||
this.widgets.splice(widgetIndex, 1);
|
||||
this.widgets.splice(posIndex + 1, 0, negativeWidget);
|
||||
}
|
||||
}
|
||||
} else if (!shouldHaveNegative && promptSet.negative) {
|
||||
// Remove negative widget
|
||||
const widget = promptSet.negative;
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
if (widgetIndex > -1) {
|
||||
if (widget.inputEl && widget.inputEl.parentNode) {
|
||||
widget.inputEl.parentNode.removeChild(widget.inputEl);
|
||||
}
|
||||
if (widget.onRemoved) {
|
||||
widget.onRemoved();
|
||||
}
|
||||
this.widgets.splice(widgetIndex, 1);
|
||||
}
|
||||
promptSet.negative = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize node - force proper recalculation
|
||||
this.setDirtyCanvas(true, true);
|
||||
setTimeout(() => {
|
||||
const size = this.computeSize();
|
||||
this.size[1] = Math.max(size[1] + 60, this.size[1]); // Ensure enough padding
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
// Update node title with prompt count
|
||||
nodeType.prototype.updateNodeTitle = function() {
|
||||
// Count non-empty prompts
|
||||
let count = 0;
|
||||
for (const promptSet of this.promptWidgets) {
|
||||
if (promptSet.positive && promptSet.positive.value && promptSet.positive.value.trim()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
this.title = `XYZ Prompt (${count} prompts)`;
|
||||
};
|
||||
|
||||
// Override serialize to save prompt data
|
||||
const onSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function(info) {
|
||||
if (onSerialize) onSerialize.call(this, info);
|
||||
|
||||
// Debug: Log widget values during serialization
|
||||
console.log("XYZ Prompt serialize - widgets:", this.widgets.map(w => ({
|
||||
name: w.name,
|
||||
value: w.value,
|
||||
type: w.type
|
||||
})));
|
||||
|
||||
// Save prompt widget data
|
||||
info.promptData = {
|
||||
count: this.promptCount,
|
||||
widgets: this.promptWidgets.map(p => ({
|
||||
index: p.index,
|
||||
hasNegative: !!p.negative
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
// Override configure to restore prompt data
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
// Clear existing dynamic widgets first
|
||||
if (this.promptWidgets) {
|
||||
for (const promptSet of this.promptWidgets) {
|
||||
const widgets = [promptSet.positive, promptSet.negative, promptSet.removeButton].filter(w => w);
|
||||
for (const widget of widgets) {
|
||||
const widgetIndex = this.widgets.indexOf(widget);
|
||||
if (widgetIndex > -1) {
|
||||
this.widgets.splice(widgetIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.promptWidgets = [];
|
||||
this.promptCount = 0;
|
||||
|
||||
if (onConfigure) onConfigure.call(this, info);
|
||||
|
||||
// Restore prompt widgets
|
||||
if (info.promptData) {
|
||||
// Remove the default prompt set if it was added
|
||||
if (this.promptWidgets.length === 1 && info.promptData.count > 0) {
|
||||
this.removePromptSet(0);
|
||||
}
|
||||
|
||||
// Restore saved prompt sets
|
||||
for (let i = 0; i < info.promptData.count; i++) {
|
||||
this.addPromptSet();
|
||||
}
|
||||
}
|
||||
|
||||
this.updateNodeTitle();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
Project Plan: ComfyUI XYZ Grid Comparison Nodes
|
||||
Objectives and Requirements
|
||||
We aim to develop a suite of XYZ Plot custom nodes for ComfyUI that enable easy grid comparisons across multiple parameters with minimal user effort. The goal is to support varying any combination of Models (checkpoints), LoRAs, Samplers, Schedulers, CFG scales, Steps, Clip skip, VAEs, and even new model-specific parameters like Flux guidance, all in a structured X/Y (and optional Z) grid format. Key requirements include:
|
||||
Simple User Experience: Users should not need to write complex syntax or scripts to set up a grid. All configuration should be via clear UI fields (dropdowns, lists, etc.), unlike TinyTerra’s advanced XYPlot which requires manual text syntax for axes
|
||||
runcomfy.com
|
||||
.
|
||||
Polished UI: Nodes should have an intuitive interface with logical organization. The system will automatically handle labeling of rows/columns with parameter values, adjustable font size, prefixes, etc., to produce presentation-ready grids
|
||||
github.com
|
||||
. No manual image combining or external tools should be needed.
|
||||
Flexibility: Support any sampler or model loader node in ComfyUI – users should not be forced to use a custom KSampler as in the Efficiency node pack (which, while powerful, requires using its custom loader & sampler nodes
|
||||
reddit.com
|
||||
). Our solution will integrate with ComfyUI’s standard nodes so any sampler algorithm or model can be used.
|
||||
Comprehensive Parameter Support: Allow plotting virtually “anything vs anything.” This means an X-axis and Y-axis (and optionally Z for a series of grids) can represent a range of values for any parameter in the workflow – e.g. different sampler names, different checkpoint models, numeric values (like steps or CFG), different prompt texts or LoRAs, etc.
|
||||
runcomfy.com
|
||||
. The system should even allow advanced uses like varying ControlNet strength or performing prompt search-and-replace if possible
|
||||
runcomfy.com
|
||||
.
|
||||
Single-Run Automation: The user should be able to hit “Generate” once and get a complete labeled grid (or set of grids). The nodes will handle queuing or looping internally. Unlike some current solutions that require manually queuing multiple runs and hitting reset (e.g. the QQ XY Grid requires clicking reset and adding prompt jobs equal to the grid size
|
||||
github.com
|
||||
), our design will automate the iteration over all combinations.
|
||||
Analysis of Existing Solutions
|
||||
To design the best XYZ plot nodes, we examined the strengths and weaknesses of existing community solutions:
|
||||
TinyTerra’s Advanced XY(Z) Plot: This extension provides very powerful and flexible plotting (even Z-axis for multiple grids) and features like search/replace in prompts and appending values
|
||||
runcomfy.com
|
||||
. However, it relies on a text-based configuration syntax that users must type into a special node
|
||||
runcomfy.com
|
||||
. For example, one must write out axis blocks like <1:label>\n[node_ID:option='value'] etc., which is powerful but complex for users to author correctly. Its UI is not as straightforward for novices, despite offering auto-complete aids
|
||||
runcomfy.com
|
||||
. We aim to capture its flexibility (support for many parameter types and even Z-axis) but present it in a friendlier UI with form inputs instead of coding.
|
||||
KenjiQQ’s QQ-Nodes (XY Grid Helper & Accumulator): This approach uses nodes to build the grid via lists and accumulation. The XY Grid Helper node takes lists of row and column values and will iterate through each combination, outputting the current row/col value for other nodes
|
||||
github.com
|
||||
. A separate Accumulator collects images and assembles the final grid, even showing a live preview as images come in
|
||||
github.com
|
||||
. It supports custom prefixes, font sizes, and grid gaps for annotation
|
||||
github.com
|
||||
. However, the user must perform some manual steps: for example, clicking reset and manually queuing the correct number of prompts to generate all combinations
|
||||
github.com
|
||||
. Additionally, because the output row/col values are untyped, one must use extra “Axis To X” converter nodes to plug them into e.g. a model loader or number input
|
||||
github.com
|
||||
. Pros: clear separation of concerns (list setup, conversion, accumulation) and customizable layout. Cons: Not entirely automated (requires manual queuing) and the multi-node setup (with converters) is a bit cumbersome UI-wise. We plan to improve upon this by automating the iteration loop and reducing the number of fiddly nodes needed.
|
||||
Jags’ Efficiency Nodes (XY Plot Script): The Efficiency node pack includes a very convenient X/Y plot capability that is integrated into a modified KSampler. By connecting an XY Plot script node to the custom KSampler, the sampler will generate a grid of images internally
|
||||
github.com
|
||||
. This solution is seamless to use and fast – it caches models to avoid reload overhead and can vary many things (including checkpoint models, LoRAs, prompts, etc.) with one click
|
||||
runcomfy.com
|
||||
. It also supports special plot types like prompt substitution and ControlNet toggles
|
||||
runcomfy.com
|
||||
. Drawback: It forces the use of the “Efficient” Loader & KSampler nodes
|
||||
reddit.com
|
||||
, meaning a user must swap out their normal nodes and cannot easily use other samplers or future nodes. This tight coupling is “annoying” for some users
|
||||
reddit.com
|
||||
. Our plan draws inspiration from Efficiency’s one-click ease and feature depth, but will remain agnostic to specific sampler implementations. We won’t require a custom sampler – our nodes will coordinate with the standard ComfyUI nodes (ensuring broad compatibility).
|
||||
ShockZ’s Comfy-Easy-Grids: This extension (a.k.a. ComfyRoll) focuses on simplifying grid generation with automation. The Create Image Grid node lets you specify grid dimensions (X and Y size) and then automatically queues the required number of prompts, outputting the current X and Y index for each iteration
|
||||
runcomfy.com
|
||||
. Paired with a Save Image Grid node (a modified saver), it will accumulate images until the grid is complete and then output a combined image, including optional row/column labels provided via inputs (e.g. String lists)
|
||||
runcomfy.com
|
||||
. This approach is very user-friendly – you don’t need to manually queue or reset; the node handles looping internally. It also provides utility nodes like FloatList, StringList, and LoRA List to easily supply sequences of values to iterate
|
||||
runcomfy.com
|
||||
runcomfy.com
|
||||
. Pros: True one-click operation and straightforward list-based UI. Cons: Each axis’s values still need to be prepared as list nodes and wired in, and labeling requires manually creating label lists that correspond to the values. There’s no single consolidated UI to choose parameter types and values – it’s still somewhat low-level in that you construct the mechanism with multiple nodes. We intend to build on this by offering a single high-level interface for selecting parameters and values, while likely leveraging a similar under-the-hood approach of automated queuing and image accumulation.
|
||||
In summary, existing solutions prove that it’s possible to plot “anything vs anything” in ComfyUI, but each has trade-offs. Our project will combine their best ideas: the flexibility of TinyTerra, the grid customization and labeling of QQ/Comfy-Easy-Grids, and the one-click convenience of Efficiency – all while removing the need for coding or rigid custom node dependencies.
|
||||
Proposed Design and Features
|
||||
|
||||
1. Node Architecture
|
||||
We will implement the XYZ plotting functionality as two main nodes for clarity and modularity (similar to the Create/Save separation in easy-grids):
|
||||
XYZ Plot Controller Node – This is the primary configuration node where the user chooses what to vary on the X axis, Y axis, and optionally Z axis. It handles generating the sequence of parameter combinations and triggering the image generation for each. Internally, this node will automatically queue the required runs (or otherwise loop through combinations) so that the user only needs to execute once to get all images, much like Create Image Grid does
|
||||
runcomfy.com
|
||||
. This node will output:
|
||||
Index/Value Outputs: For each axis (X, Y, Z) it provides an output that represents the current value for that axis in a given iteration. These outputs can be fed into other nodes in the workflow (e.g., into a checkpoint loader’s model field, into a sampler’s steps or CFG input, into a text concatenation node for prompts, etc.). We will make these outputs type-aware to avoid extra conversion nodes – e.g., X output might be a string type if X is “model name”, or a float if X is a numeric parameter. (If needed, we can internally include conversion logic or expose outputs in multiple types.)
|
||||
Loop Control/Trigger: It may also output a control signal (like a boolean or trigger) indicating when a batch/grid is complete. This can feed into the second node to signal when to finalize the grid image.
|
||||
Grid Combiner (Image Grid Output) Node – This node will collect the images produced from each combination and assemble the final grid image (or images). It functions similarly to QQ’s accumulator or Easy-Grids’ save node, holding onto incoming images until the set is complete
|
||||
github.com
|
||||
. Once all images for one grid are ready, it will output:
|
||||
A composite grid image arranged according to X and Y dimensions, with annotated labels.
|
||||
It will support multiple pages if a Z-axis is used (for example, if Z has 3 values, it could produce 3 grid images – one per Z – or perhaps one big tiled 3D grid, but likely separate images makes more sense). We will likely implement Z as generating multiple grid outputs (perhaps as a list of images or sequential outputs the user can save individually).
|
||||
Additionally, it could provide a UI preview output for convenience, showing progress as images come in (like QQ’s accumulator does in preview mode
|
||||
github.com
|
||||
).
|
||||
This two-node setup keeps things organized: the Controller focuses on parameter logic, and the Combiner on image assembly and labeling. For basic use, the user would place these two nodes and connect them: the Controller’s image output (from the sampler) goes into the Combiner’s image input, and the Combiner outputs the final grid. We will make sure they sync via the unique batch ID or trigger so that each grid resets properly and doesn’t mix images from different runs (similar to QQ’s unique_id usage to separate batches
|
||||
runcomfy.com
|
||||
runcomfy.com
|
||||
).
|
||||
2. Parameter Selection UI
|
||||
The XYZ Plot Controller node’s UI will be the heart of user interaction. We will design it with dynamic, easy-to-use widgets:
|
||||
Axis Parameter Dropdowns: The node will have sections for X Axis, Y Axis, and Z Axis. For each axis, the user can choose the parameter type from a dropdown menu. For example:
|
||||
None (if they don’t want to use the Z axis at all, they can disable it),
|
||||
Model (Stable Diffusion checkpoint model),
|
||||
Sampler (diffusion sampler algorithm),
|
||||
Scheduler (if applicable, e.g. scheduler type or noise schedule – we will clarify if this is needed or if “sampler” covers it),
|
||||
CFG Scale (classifier guidance scale),
|
||||
Steps (number of diffusion steps),
|
||||
Clip Skip (text encoder skip levels),
|
||||
VAE (decoder to use),
|
||||
LoRA (Low-Rank Adaptation model to apply),
|
||||
Prompt Text (to vary text or prompt components),
|
||||
Flux Guidance (if using a Flux model, vary its guidance strength or related parameter),
|
||||
Seed (to test different random seeds),
|
||||
etc. – Essentially any parameter that a ComfyUI workflow might want to sweep. We can start with the most common ones and allow extending in future.
|
||||
Value Input Fields: Depending on the parameter chosen, the UI will present an appropriate input mechanism for the list of values:
|
||||
For categorical choices (Model, Sampler, VAE, LoRA), we will provide either a multi-select checklist or a list builder. Ideally, we can query the available options dynamically:
|
||||
Model: Provide a dropdown or list of available checkpoint files in the models folder, so the user can select multiple models to compare. (We will use ComfyUI’s existing model loader API or search the directory for model filenames).
|
||||
Sampler: A list of sampler names (Euler, Euler a, LMS, DPM++ etc.) – we can fetch these from ComfyUI’s KSampler options or maintain a predefined list if needed.
|
||||
LoRA: List of available LoRA files (perhaps via the LoRA loader’s listing or a folder scan).
|
||||
VAE: Similarly, list VAE filenames to choose from.
|
||||
If multiple selections are allowed, we will allow adding items to the list easily (maybe checkboxes or a plus-button to add another slot).
|
||||
For numeric ranges (CFG scale, Steps, Clip skip, etc.), provide an input that can accept comma-separated values or a range generator:
|
||||
We might allow a syntax like start:stop:step to quickly specify a range (and parse it into list of values), or simply ask for a list of numbers (with a helper to fill ranges).
|
||||
Alternatively, include spin boxes to add values one by one. We can also integrate with the concept of Easy-Grids’ Float List/Int List: for example, let the user enter something like “0, 0.5, 1.0” for flux guidance, or “10,20,30” for steps.
|
||||
For text prompts variation, we have a couple options:
|
||||
Simplest: treat the entire prompt as a string and allow the user to input multiple prompt variants (perhaps separated by a special delimiter or via a multiline field where each line is a different prompt). The axis will then swap the entire prompt for each value.
|
||||
More advanced: allow specifying a search-and-replace or a placeholder in the prompt. E.g., user writes the base prompt in their Text node like “A portrait of [styles] woman” and in the axis values they supply different strings for [styles] (like “a cyberpunk”, “a victorian”, etc.). TinyTerra’s %search;replace% functionality
|
||||
runcomfy.com
|
||||
and Efficiency’s Prompt S/R hint at this idea. This might be a stretch goal; initially, we can require full prompt variants or manual setup.
|
||||
We will ensure that if prompt text is varied, it can feed into the Positive (or Negative) prompt input of the model pipeline easily (likely through a Text concatenation or by directly connecting a Text node input).
|
||||
For boolean or toggle parameters (if any, like using a feature on/off), we can allow values like True/False in the list.
|
||||
Axis Labels: By default, the system will generate labels for each value to display on the grid. The user can control this:
|
||||
There will be a text field for “X Axis Label Prefix” and “Y Axis Label Prefix” (and Z if needed) – if the user wants to prefix the labels (e.g. “CFG=” so the labels read “CFG=7, CFG=10, …”). By default, we might use the parameter name as prefix (e.g. “Model: ” or “Sampler: ”) unless turned off.
|
||||
We will automatically use either the value itself or a short description as the label. For instance, for model filenames we might strip the extension. For prompts, possibly use a truncated first few words if too long.
|
||||
We will also include options for label formatting similar to TinyTerra’s tv_label/itv_label concept
|
||||
github.com
|
||||
(though we’ll hide complexity from user): essentially provide a toggle between showing just the value vs. showing “parameter: value”. Users who want very compact labels can choose value-only, while others might prefer seeing the parameter name included. By default, we’ll include the name or prefix so grids are self-explanatory.
|
||||
Font size and max label length can be set in the Combiner node (like QQ’s label_length and font_size
|
||||
github.com
|
||||
). We’ll expose those settings in the Combiner UI or let it inherit sensible defaults.
|
||||
Axis Combination Limits: The node will display the computed total number of images = (#X values × #Y values × #Z values). This gives the user feedback on how many images will be generated (important for performance awareness). If this number is extremely large, we might warn the user or require confirmation to avoid accidental huge runs.
|
||||
Dynamic Field Enable/Disable: If the user selects “None” for Z axis, the UI for Z values will hide or disable. Similarly, if they only want an X or a 1D plot, we could allow Y to be “None” and then it just generates a 1-row grid. We will make sure the UI updates logically based on selections, using ComfyUI’s capabilities for dynamic widgets (TinyTerra does something similar with dynamic widget showing/hiding
|
||||
runcomfy.com
|
||||
).
|
||||
3. Execution Flow (One-Click Generation)
|
||||
The behind-the-scenes execution will work as follows:
|
||||
When the user hits Generate, the Controller node will take the lists of X, Y, Z values and iterate over all combinations. For each combination, it will set the axis outputs to the current values and emit a forward execution. This will propagate through the rest of the graph (where those outputs are connected) and ultimately produce an image from the KSampler (or decoder).
|
||||
We plan to implement this by leveraging ComfyUI’s queue system or looping mechanism. There are a few possibilities:
|
||||
Use a technique similar to comfy-easy-grids: internally queue multiple jobs. The Create Image Grid node likely uses api.queuePrompt() calls or similar to push multiple executions. We can replicate that: the first execution of the Controller will schedule the next N-1 executions automatically with the different param values. All images will then be generated in sequence.
|
||||
Alternatively, implement an internal loop in the node’s execute method (since custom nodes can produce multiple outputs perhaps) – but ComfyUI typically expects one output per execution, so queuing separate executions is safer.
|
||||
We will assign each grid run a unique ID (much like QQ’s unique_id control
|
||||
runcomfy.com
|
||||
) that gets passed to the Combiner, so it knows which images belong together. This prevents mixing images from separate plots if two are run concurrently.
|
||||
Concurrency & Caching: We will likely generate images sequentially (one combination at a time) to reuse the same pipeline and avoid memory spikes. However, to improve efficiency when switching heavy models (checkpoints, VAEs, etc.), we plan to cache models and other resources. For example, if X axis is models A, B, C, we could load each model once at the start (or keep loaded when switching) to avoid repeatedly loading from disk. Efficiency nodes explicitly had a cache_models option for this
|
||||
runcomfy.com
|
||||
; we can implement a lightweight caching strategy: perhaps load each required model in memory, or use ComfyUI’s existing model caching (if any). At minimum, ensure that when we switch model, we do it in a way that ComfyUI’s model loader doesn’t fully unload/reload if not necessary.
|
||||
The Combiner node will receive each image as it’s produced. It will hold images in a buffer until the expected count is reached, then assemble the grid:
|
||||
We’ll arrange images in row-major order (X axis varying horizontally, Y axis vertically). The number of columns = number of X values (by default, unless we implement a wrap via a “max columns” option similar to QQ’s max_columns
|
||||
runcomfy.com
|
||||
– we might allow the grid to wrap if too wide).
|
||||
It will draw the labels on the top of each column and/or side of each row. We can use a simple Python imaging library (PIL/Pillow) to overlay text, or possibly leverage the images-grid-comfy-plugin that QQ-nodes uses behind the scenes
|
||||
github.com
|
||||
. We may consider using that plugin to avoid reinventing the wheel for combining images and drawing text. (The QQ readme notes that their node injects the LEv145 images-grid plugin for grid assembly
|
||||
github.com
|
||||
– perhaps we can directly call that code or include it.)
|
||||
Font style can be basic (white text with black outline for visibility, for example).
|
||||
Z-axis output: If Z is used (say Z has 2 values meaning two separate grids for e.g. two different prompts), we have options:
|
||||
The Combiner could output a list of images (all grids) once done. Or,
|
||||
It could output one image at a time and require the user to connect a Save node that handles it. But that’s less convenient.
|
||||
Another approach: output a single large image where grids are concatenated vertically or horizontally with a separator and a label for Z. But that might be unwieldy for many Z values.
|
||||
Probably simplest: output as a list or just sequentially output multiple images (ComfyUI might not handle multiple outputs from one execution easily, so list is better).
|
||||
We will likely output a List of images if Z axis is used, along with maybe a text list of Z labels. The user can then handle it (perhaps with a custom save that can save all in a folder). We’ll detail this behavior in documentation so it’s clear.
|
||||
Using Standard Nodes: Our design ensures the actual image generation still uses standard ComfyUI nodes:
|
||||
The user’s workflow will include a Checkpoint Loader, Sampler, etc., which produce the image. We simply feed different values into them each iteration. For example, if varying Model, the CheckpointLoader’s ckpt_name field will be driven by our Controller’s X output (string). The user would expose ckpt_name as an input on the loader node and connect the X output to it. Our node will set it to each model filename in turn for each run. Similarly, for numeric parameters like Steps or CFG, we connect to the sampler’s inputs (these are already exposable in ComfyUI). This way, we use “any sampler” – our node doesn’t re-implement sampling, it just feeds values to the existing KSampler. This satisfies the requirement of not being locked into a custom sampler node.
|
||||
For LoRAs, one approach is to have the LoRA Loader’s “model path” input connected to our output, cycling through LoRA filenames. Alternatively, we could vary prompt text by injecting <lora:name:weight> tokens (some users do LoRA via prompt text). But a cleaner method is connecting to a LoRA apply node. We will investigate how ComfyUI applies LoRAs by node and support that.
|
||||
For prompts, the user can use a Text node for their prompt and we can connect our output to it (maybe via a Text Concatenator if inserting words). If doing entire prompt variations, it might be easiest to have multiple prompts in a String List node and connect our axis output to the Text node’s text input.
|
||||
Guidance for Flux: If the parameter is something specific like “Flux guidance strength” (assuming Flux is a model that has a unique guidance parameter separate from CFG), we will allow that to be varied by, say, connecting to a node or API that controls flux guidance. This might require the user to expose that parameter on a Flux-specific node. Since Flux is new, we’ll ensure our design is generic enough to plug into any float or int parameter exposed in the workflow.
|
||||
4. Labeling and Grid Customization
|
||||
Producing a nicely annotated grid is a major part of the user experience. Features for this include:
|
||||
Automatic Label Generation: As mentioned, we’ll derive labels from the values. For each axis value, we generate a label string. The Combiner node will have inputs for row_labels and col_labels (and possibly an overall title or Z labels). By default, our Controller can feed these in automatically:
|
||||
The Controller can output a list of X labels and list of Y labels once the values are set (similar to how Easy-Grids expects String List inputs for labels
|
||||
runcomfy.com
|
||||
, but in our case we can supply them directly).
|
||||
If needed, we can also allow the user to override these label lists via optional inputs – e.g. if they want abbreviated or custom names not exactly matching the values. If an override is not provided, we use the auto labels.
|
||||
Styling Options: In the Combiner UI, provide settings for:
|
||||
Font size (with a reasonable default, e.g. 20px).
|
||||
Grid cell gap (pixel spacing between images, default perhaps 5-10px)
|
||||
github.com
|
||||
.
|
||||
Maybe font color or style, though likely white text with black outline is universally readable so we might fix that.
|
||||
Max columns before wrap (if user wants to break a very large grid into multiple rows – but since Y axis already provides rows, “max columns” would effectively limit X length and overflow to additional grid images; this is an advanced feature, we can defer unless needed).
|
||||
Background color for the label area if we want a band behind the labels (or we can just overlay on images if they have space at edges).
|
||||
Page Size / Z usage: If Z-axis is large, we might allow showing one “page” (grid) at a time. We can implement a page slider in the UI for preview (this is more advanced and might not be necessary if we output all images at once).
|
||||
Interactivity vs Static Output: Initially, the output will be static images. A possible enhancement (future) is an interactive HTML output where you can toggle through Z values or view a high-resolution grid in the UI. However, that’s beyond core requirements – we will focus on static image grids that can be saved.
|
||||
5. Example Use Cases
|
||||
To illustrate how the new nodes will work (and to guide development):
|
||||
Example 1: Model vs CFG plot. User wants to compare two models (A and B) at different CFG scales (5, 10, 15) using the same prompt.
|
||||
They place the Controller node and set X Axis = Model, select A and B; Y Axis = CFG Scale, enter [5, 10, 15]; Z Axis = None.
|
||||
They connect the Controller’s X output to the Checkpoint Loader’s model name, and the Y output to the KSampler’s CFG input.
|
||||
They connect KSampler’s image output to the Combiner node.
|
||||
The Combiner automatically gets X labels (“Model A”, “Model B”) and Y labels (“CFG5”, “CFG10”, “CFG15” or with prefix “CFG=”).
|
||||
Upon running, it generates 2×3 = 6 images, then Combiner outputs a single 2-column by 3-row grid image with labels on top of each column (Model names) and at the left of each row (CFG values). The user can then preview or save this grid.
|
||||
What happens behind the scenes: The node will queue 6 runs. It may load model A for first run, then model B for second run, etc., switching back and forth – we might optimize to do AAA… then BBB… to avoid thrashing, but since we need a grid with A and B as columns, we might generate in row-major order (all X for Y1, then Y2). We will consider caching both models in memory to reduce load time.
|
||||
Example 2: Sampler vs Prompt Variation. User wants to see how different samplers render two different descriptions.
|
||||
X = Sampler, choose Euler vs DPM++ 2M Karras; Y = Prompt, enter “a sunny landscape” and “a rainy landscape” as two variants (or use prompt placeholder approach).
|
||||
The user connects X output to the Sampler node’s sampler-name input, and Y output to the positive Prompt text (maybe via a Text node that takes this string).
|
||||
Grid will be 2×2: columns = {Euler, DPM2M}, rows = {sunny, rainy}. Labels reflect sampler names and an abbreviated prompt label (or user manually sets row labels to “Sunny” / “Rainy” for brevity).
|
||||
All 4 images are generated and combined.
|
||||
Example 3: Three-axis plot. User tests two LoRAs at different strengths across three seeds.
|
||||
X = LoRA, values = {No LoRA, Style1, Style2} (with possibly weight 1.0 for style LoRAs embedded in selection or separate weight axis if needed); Y = Seed, values = {100, 200, 300}; Z = LoRA Strength, values = {0.5, 1.0} (this means two grids, one at each strength level).
|
||||
This will produce 2 (Z) _ 3 (X) _ 3 (Y) = 18 images, output as 2 separate 3x3 grids. The Combiner could output a list [Grid_strength0.5, Grid_strength1.0].
|
||||
Each grid’s column labels are LoRA names (with “None” or “No LoRA” for the baseline), row labels are seeds, and each grid has a title or annotation indicating the strength (0.5 or 1.0).
|
||||
The user can then examine the effect of LoRA and strength on different seeds.
|
||||
These use cases ensure our design covers multiple parameter types simultaneously, a key advantage. The system should be robust to any mix (within reason – some combinations might not make sense, but we won’t restrict it programmatically).
|
||||
6. Performance Considerations
|
||||
Generating large grids (dozens of images) can be slow or memory-heavy. We will incorporate some features to mitigate issues:
|
||||
Progress Feedback: The Combiner will update a preview as images arrive (like QQ’s accumulator preview output
|
||||
github.com
|
||||
). The node UI can also show a counter of how many images have been generated out of total. This way the user knows it’s working and can estimate time remaining.
|
||||
Cancellation: If the user stops the queue, our nodes should handle it gracefully (partially filled grid will reset on next run as needed). The unique ID mechanism will help ensure a canceled run doesn’t erroneously carry over images to the next.
|
||||
Memory: For grids with many images, memory might become an issue. We might implement an option akin to QQ’s page_size
|
||||
github.com
|
||||
to split a very large grid into smaller chunks (e.g., if generating 100 images, do 25 at a time and output 4 grids). However, by default we assume moderate grid sizes. This is an advanced option for users who truly need it.
|
||||
Threading: ComfyUI typically runs GPU tasks sequentially unless using batches. We will ensure our queuing doesn’t try to run multiple combos in parallel unintentionally (unless the user specifically wanted to use batch size to generate multiple images simultaneously – that’s a different scenario, possibly outside scope of X/Y plot which usually changes parameters per image, not suitable for same-batch generation).
|
||||
Model Switching Overhead: As noted, caching models/VAEs will be important if those are varied. We can load all needed models at the start of execution (perhaps by pre-loading them via the Loader node or an API call) to avoid repetitive disk I/O. After the grid, unload any that aren’t needed to free VRAM. This approach will draw from Efficiency’s model caching idea
|
||||
runcomfy.com
|
||||
.
|
||||
Implementation Plan
|
||||
We will implement the project in stages: Phase 1: Basic X/Y Plot Node (happy path with one grid).
|
||||
Develop the Controller node with support for two axes (X,Y) initially. Focus on a few core parameter types (models, samplers, CFG, steps) to get the mechanism working.
|
||||
Implement the queuing of jobs for combinations. Test that we can programmatically queue multiple executions via the ComfyUI API. Ensure the outputs (like different model names) properly feed into the model loader on each run.
|
||||
Develop a simple Combiner that accumulates images and once all are received, outputs a grid image (we can use PIL to stitch images in a grid layout for now). Add basic label drawing.
|
||||
Test with simple scenarios (vary model vs CFG, etc.) and verify the grid image output and labels.
|
||||
Phase 2: Expand to Z axis and more parameters.
|
||||
Extend the Controller to handle an optional Z axis loop (probably by nesting another loop or running multiple XY cycles). Ensure multiple grid outputs can be handled (perhaps output a list of images, or sequential outputs; we’ll decide based on ComfyUI capabilities).
|
||||
Add UI support for LoRA, VAE, prompts, seed, etc. – this may involve writing helper code to list files (for models, VAEs, LoRAs) and to parse user input lists for numbers or text.
|
||||
Incorporate prompt variation logic (maybe a simple whole-prompt swap at first).
|
||||
Add Clip skip support (this could be done by connecting to the Checkpoint loader’s clip skip input, if exposed).
|
||||
Ensure that multiple parameter types can be varied at once (like one axis numeric, another axis categorical).
|
||||
Phase 3: UI Refinement and Polishing.
|
||||
Improve the node descriptions, tooltips, and default values for a polished feel. For instance, when the user selects a parameter in the dropdown, we can populate a placeholder or example in the values field to guide them (e.g. “enter comma-separated values”).
|
||||
Implement dynamic showing/hiding of fields (using ComfyUI’s dynamic widget support) so the UI is not cluttered with irrelevant inputs
|
||||
runcomfy.com
|
||||
.
|
||||
Add error handling and validation: e.g., if a user leaves a value list empty for a selected axis, show a warning; if the parameter type requires additional setup (like no Checkpoint Loader connected for a model axis), we can detect that and warn.
|
||||
Integrate advanced label options: allow the user to specify a custom label list if they really want to override, or perhaps toggle between “Value” vs “Name: Value” label style.
|
||||
Aesthetic polish: choose a pleasant default color for the nodes, maybe group them in a custom category in ComfyUI (“XYZ Nodes” category) for easy discovery.
|
||||
Phase 4: Optimization and Edge Cases.
|
||||
Test edge cases like: only 1 value in an axis (should essentially degenerate to a 1-row or 1-col grid), using the node in an already batched context (probably uncommon), extremely long prompt texts (ensure label doesn’t overflow – wrap text if over a certain length
|
||||
github.com
|
||||
).
|
||||
If performance of assembling images with PIL is slow for high-res images, consider using the optimized library from images-grid plugin (since QQ-nodes mention injecting that for efficient tiling
|
||||
github.com
|
||||
).
|
||||
Verify compatibility with SDXL workflows (SDXL has two model passes – base and refiner. Our design should allow varying things in an SDXL pipeline too. Likely it works similarly, but we may test an SDXL prompt vs. something grid).
|
||||
Memory test with varying models: ensure model A and B switching does not cause CUDA OOM (maybe unload one before loading next if both can’t reside in VRAM simultaneously – caching on CPU if necessary).
|
||||
User abort testing: if user stops midway, next run should be able to reset properly (we’ll implement a reset mechanism possibly triggered by a new execution ID).
|
||||
Phase 5: Documentation and Examples.
|
||||
Write a user guide with examples (similar to those described above) so users can quickly understand how to use the nodes. Emphasize that no scripting is needed – just connect and go.
|
||||
Provide example workflows JSON (perhaps include a few typical ones, like “Model_vs_CFG_XY.json”) as part of the repo.
|
||||
Throughout development, we’ll incorporate feedback from testers to ensure the UI is truly intuitive and that we didn’t miss any important use-case. The result will be a robust “XYZ Plot” feature for ComfyUI that dramatically simplifies experimentation.
|
||||
Conclusion
|
||||
By blending the best aspects of existing solutions and focusing on user-centric design, this project will deliver an XYZ grid comparison tool for ComfyUI that is both powerful and easy to use. Creators will be able to set up complex multi-parameter comparisons in just a few clicks – no coding, no manual stitching. The grids produced will be neatly labeled and customizable, suitable for analyzing results or sharing with others. This fills an important gap in ComfyUI’s toolkit, empowering users to explore models, prompts, and settings efficiently and with confidence in the interface. Ultimately, our custom nodes will make advanced AI art workflows more accessible, turning what was once a manual, error-prone process into a streamlined experience. By supporting everything from model and sampler swaps to subtle parameter tweaks, all in a polished UI, we help users focus on creativity and insight rather than technical hassle. This project plan lays out the path to achieve that goal, and with careful implementation, the “best XYZ plot nodes” for ComfyUI will soon become a reality.
|
||||
Reference in New Issue
Block a user