Compare 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 | ||
|
|
a8ee5930ff | ||
|
|
9fd80793db | ||
|
|
972c487dd4 | ||
|
|
1a3efd3802 | ||
|
|
34f54e515d | ||
|
|
c844abea51 | ||
|
|
404a1efd61 | ||
|
|
4ae514dacf | ||
|
|
5efae8eeb8 | ||
|
|
85288c8fd8 | ||
|
|
7a97f7c2bc | ||
|
|
a4692a286c | ||
|
|
72a3fea3cb | ||
|
|
d5d4145a04 | ||
|
|
0e288dd109 | ||
|
|
c4882f894e | ||
|
|
6cbe6e5ae6 | ||
|
|
df20afb83e | ||
|
|
7d63e11e18 | ||
|
|
a8364b5c57 | ||
|
|
332a74225d | ||
|
|
d757b623d6 | ||
|
|
64e844ec42 | ||
|
|
3ed188d63f | ||
|
|
b9cc9f295d | ||
|
|
271cd020c1 | ||
|
|
e34807855a | ||
|
|
d32e18f844 | ||
|
|
228b74ae5e | ||
|
|
6197b482df | ||
|
|
f62129afda | ||
|
|
8d5065c975 | ||
|
|
2d27c32bfd | ||
|
|
3ecab5ac08 | ||
|
|
70592114f9 | ||
|
|
407fc4ca7b | ||
|
|
cb7d5246f9 | ||
|
|
9829fc001d | ||
|
|
e84ec6721c | ||
|
|
80fac8e544 | ||
|
|
efc079a95b | ||
|
|
bbd239cbd6 | ||
|
|
589fbf3568 | ||
|
|
c595cabaa0 | ||
|
|
ca504d5f74 | ||
|
|
f559fe220e | ||
|
|
90c1aa402d | ||
|
|
932e30ade0 | ||
|
|
f9540bd984 | ||
|
|
67a59a0d3b | ||
|
|
bb5653fc0e | ||
|
|
ab23992c29 |
@@ -0,0 +1,35 @@
|
||||
[flake8]
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
.pytest_cache,
|
||||
venv,
|
||||
env,
|
||||
build,
|
||||
dist,
|
||||
*.egg-info,
|
||||
.tox
|
||||
ignore =
|
||||
# W503: line break before binary operator (conflicts with Black)
|
||||
W503,
|
||||
# E203: whitespace before ':' (conflicts with Black)
|
||||
E203,
|
||||
# E501: line too long (we use max-line-length)
|
||||
E501
|
||||
|
||||
per-file-ignores =
|
||||
# Allow unused imports in __init__.py files
|
||||
__init__.py:F401,F403
|
||||
# Allow assertions in tests
|
||||
tests/*:S101
|
||||
# Allow higher complexity for Gemini prompt module
|
||||
kikotools/tools/gemini_prompt/logic.py:C901
|
||||
kikotools/tools/gemini_prompt/models.py:C901
|
||||
kikotools/tools/gemini_prompt/node.py:C901
|
||||
|
||||
# Statistics
|
||||
count = True
|
||||
statistics = True
|
||||
@@ -0,0 +1,41 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Python files
|
||||
*.py text eol=lf
|
||||
*.pyi text eol=lf
|
||||
|
||||
# Configuration files
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ini text eol=lf
|
||||
*.cfg text eol=lf
|
||||
|
||||
# Documentation
|
||||
*.md text eol=lf
|
||||
*.rst text eol=lf
|
||||
*.txt text eol=lf
|
||||
|
||||
# Scripts
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Git files
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
|
||||
# ComfyUI specific
|
||||
*.workflow text eol=lf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.safetensors binary
|
||||
*.ckpt binary
|
||||
*.pt binary
|
||||
*.pth binary
|
||||
@@ -45,4 +45,4 @@ Paste any error messages or stack traces here
|
||||
If possible, attach the ComfyUI workflow file (.json) that reproduces the issue.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -37,7 +37,7 @@ Describe how the tool should process inputs and generate outputs.
|
||||
|
||||
**Model Compatibility:**
|
||||
- [ ] SDXL optimized
|
||||
- [ ] FLUX optimized
|
||||
- [ ] FLUX optimized
|
||||
- [ ] General purpose
|
||||
- [ ] Specific model requirements: [describe]
|
||||
|
||||
@@ -64,4 +64,4 @@ Are there existing ComfyUI nodes that do something similar? How would this be di
|
||||
- [ ] Yes, I can help with implementation
|
||||
- [ ] Yes, I can help with testing
|
||||
- [ ] Yes, I can help with documentation
|
||||
- [ ] No, but I'd be happy to test it
|
||||
- [ ] No, but I'd be happy to test it
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,4 +1,6 @@
|
||||
name: Code Quality
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,12 +16,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -59,7 +61,7 @@ jobs:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
|
||||
# Test that all imports work correctly
|
||||
try:
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
@@ -67,64 +69,64 @@ jobs:
|
||||
except ImportError as e:
|
||||
print(f'Warning: Package-level imports failed: {e}')
|
||||
# This is expected since we don't have ComfyUI installed
|
||||
|
||||
|
||||
# Test individual module imports
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
from kikotools.tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from kikotools.tools.resolution_calculator.logic import extract_dimensions
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode as NodeClass
|
||||
|
||||
|
||||
# Test Width Height Selector imports
|
||||
from kikotools.tools.width_height_selector import WidthHeightSelectorNode
|
||||
from kikotools.tools.width_height_selector.logic import get_preset_dimensions
|
||||
from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS, PRESET_METADATA
|
||||
|
||||
|
||||
# Test Sampler Combo imports
|
||||
from kikotools.tools.sampler_combo import SamplerComboNode
|
||||
from kikotools.tools.sampler_combo.logic import get_sampler_combo, SAMPLERS, SCHEDULERS
|
||||
|
||||
|
||||
# Test Seed History imports
|
||||
from kikotools.tools.seed_history import SeedHistoryNode
|
||||
from kikotools.tools.seed_history.logic import generate_random_seed, validate_seed_value
|
||||
|
||||
|
||||
# Test Kiko Save Image imports
|
||||
from kikotools.tools.kiko_save_image import KikoSaveImageNode
|
||||
from kikotools.tools.kiko_save_image.logic import process_image_batch, validate_save_inputs
|
||||
|
||||
|
||||
print('✓ All module imports successful')
|
||||
"
|
||||
|
||||
- name: Check code style consistency
|
||||
run: |
|
||||
echo "Checking code style consistency..."
|
||||
|
||||
|
||||
# Check for consistent naming
|
||||
find kikotools/ -name "*.py" -exec grep -l "class.*Node" {} \; | while read file; do
|
||||
if ! grep -q "ComfyAssetsBaseNode" "$file" && ! grep -q "class ComfyAssetsBaseNode" "$file"; then
|
||||
echo "Checking $file for ComfyUI node inheritance..."
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# Check for proper docstrings
|
||||
python -c "
|
||||
import ast
|
||||
import os
|
||||
|
||||
|
||||
def check_docstrings(filepath):
|
||||
with open(filepath, 'r') as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
|
||||
if not ast.get_docstring(node) and not node.name.startswith('_'):
|
||||
print(f'Warning: {filepath}:{node.lineno} - {node.name} missing docstring')
|
||||
|
||||
|
||||
for root, dirs, files in os.walk('kikotools'):
|
||||
for file in files:
|
||||
if file.endswith('.py') and not file.startswith('__'):
|
||||
filepath = os.path.join(root, file)
|
||||
check_docstrings(filepath)
|
||||
|
||||
|
||||
print('✓ Docstring check completed')
|
||||
"
|
||||
|
||||
@@ -134,7 +136,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -151,7 +153,7 @@ jobs:
|
||||
- name: Check for hardcoded secrets
|
||||
run: |
|
||||
echo "Checking for potential secrets..."
|
||||
|
||||
|
||||
# Check for common secret patterns
|
||||
if grep -r -i "password\|secret\|key\|token" kikotools/ --include="*.py" | grep -v "# " | grep -v "def " | grep -v "class "; then
|
||||
echo "Warning: Potential hardcoded secrets found"
|
||||
@@ -165,7 +167,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -180,103 +182,103 @@ jobs:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
|
||||
print('Checking architecture compliance...')
|
||||
|
||||
|
||||
# Test separation of concerns
|
||||
from kikotools.tools.resolution_calculator import logic, node
|
||||
|
||||
|
||||
# Logic module should not import node-specific things
|
||||
import inspect
|
||||
logic_source = inspect.getsource(logic)
|
||||
|
||||
|
||||
if 'ComfyUI' in logic_source and 'INPUT_TYPES' not in logic_source:
|
||||
print('⚠️ Warning: Logic module contains ComfyUI-specific code')
|
||||
else:
|
||||
print('✓ Logic module properly separated')
|
||||
|
||||
|
||||
# Node module should inherit from base
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
if issubclass(ResolutionCalculatorNode, ComfyAssetsBaseNode):
|
||||
print('✓ Node properly inherits from base class')
|
||||
else:
|
||||
print('❌ Node does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Check that nodes have proper ComfyUI interface
|
||||
required_attrs = ['INPUT_TYPES', 'RETURN_TYPES', 'RETURN_NAMES', 'FUNCTION', 'CATEGORY']
|
||||
|
||||
|
||||
# Test Resolution Calculator Node
|
||||
for attr in required_attrs:
|
||||
if not hasattr(ResolutionCalculatorNode, attr):
|
||||
print(f'❌ ResolutionCalculatorNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Width Height Selector Node
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
|
||||
|
||||
if issubclass(WidthHeightSelectorNode, ComfyAssetsBaseNode):
|
||||
print('✓ WidthHeightSelectorNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ WidthHeightSelectorNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(WidthHeightSelectorNode, attr):
|
||||
print(f'❌ WidthHeightSelectorNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Sampler Combo Node
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
|
||||
|
||||
if issubclass(SamplerComboNode, ComfyAssetsBaseNode):
|
||||
print('✓ SamplerComboNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ SamplerComboNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(SamplerComboNode, attr):
|
||||
print(f'❌ SamplerComboNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Seed History Node
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
|
||||
|
||||
if issubclass(SeedHistoryNode, ComfyAssetsBaseNode):
|
||||
print('✓ SeedHistoryNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ SeedHistoryNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(SeedHistoryNode, attr):
|
||||
print(f'❌ SeedHistoryNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Kiko Save Image Node
|
||||
from kikotools.tools.kiko_save_image.node import KikoSaveImageNode
|
||||
|
||||
|
||||
if issubclass(KikoSaveImageNode, ComfyAssetsBaseNode):
|
||||
print('✓ KikoSaveImageNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ KikoSaveImageNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# KikoSaveImage is an output node, so it doesn't have RETURN_TYPES/RETURN_NAMES
|
||||
save_required_attrs = ['INPUT_TYPES', 'FUNCTION', 'CATEGORY']
|
||||
for attr in save_required_attrs:
|
||||
if not hasattr(KikoSaveImageNode, attr):
|
||||
print(f'❌ KikoSaveImageNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Check that it's properly marked as an output node
|
||||
if not hasattr(KikoSaveImageNode, 'OUTPUT_NODE') or not KikoSaveImageNode.OUTPUT_NODE:
|
||||
print('❌ KikoSaveImageNode missing OUTPUT_NODE = True')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
print('✓ All architecture checks passed for all tools')
|
||||
"
|
||||
|
||||
@@ -284,22 +286,22 @@ jobs:
|
||||
run: |
|
||||
python -c "
|
||||
import os
|
||||
|
||||
|
||||
# Count test files vs implementation files
|
||||
test_files = 0
|
||||
impl_files = 0
|
||||
|
||||
|
||||
for root, dirs, files in os.walk('tests'):
|
||||
test_files += len([f for f in files if f.startswith('test_') and f.endswith('.py')])
|
||||
|
||||
|
||||
for root, dirs, files in os.walk('kikotools'):
|
||||
impl_files += len([f for f in files if f.endswith('.py') and not f.startswith('__')])
|
||||
|
||||
|
||||
print(f'Implementation files: {impl_files}')
|
||||
print(f'Test files: {test_files}')
|
||||
|
||||
|
||||
if test_files >= impl_files * 0.5: # At least 50% test coverage by file count
|
||||
print('✓ Adequate test file coverage')
|
||||
else:
|
||||
print('⚠️ Warning: Low test file coverage')
|
||||
"
|
||||
"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -8,12 +11,14 @@ on:
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -28,30 +33,30 @@ jobs:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
|
||||
# Run comprehensive tests before release
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
import torch
|
||||
|
||||
|
||||
print('Running pre-release validation...')
|
||||
|
||||
|
||||
# Test all major functionality
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
|
||||
# Test various scenarios
|
||||
test_cases = [
|
||||
(torch.randn(1, 512, 512, 3), 2.0),
|
||||
(torch.randn(1, 1024, 1024, 3), 1.5),
|
||||
(torch.randn(1, 1216, 832, 3), 1.53), # User scenario
|
||||
]
|
||||
|
||||
|
||||
for i, (image, scale) in enumerate(test_cases):
|
||||
width, height = node.calculate_resolution(scale, image=image)
|
||||
print(f'✓ Test case {i+1}: {image.shape[2]}×{image.shape[1]} → {width}×{height} (scale: {scale})')
|
||||
assert width % 8 == 0 and height % 8 == 0
|
||||
|
||||
|
||||
print('🎉 All pre-release tests passed!')
|
||||
"
|
||||
|
||||
@@ -64,22 +69,22 @@ jobs:
|
||||
run: |
|
||||
cat > release_notes.md << 'EOF'
|
||||
## ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
|
||||
|
||||
|
||||
### 🎉 What's New
|
||||
|
||||
|
||||
#### Resolution Calculator Tool
|
||||
- **Smart Input Handling**: Works with both IMAGE and LATENT tensors
|
||||
- **Model Optimized**: Specific optimizations for SDXL and FLUX models
|
||||
- **Model Optimized**: Specific optimizations for SDXL and FLUX models
|
||||
- **Constraint Enforcement**: Automatically ensures dimensions divisible by 8
|
||||
- **Flexible Scaling**: Supports scale factors from 1.0x to 8.0x
|
||||
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
|
||||
#### ComfyUI Manager
|
||||
1. Search for "ComfyUI-KikoTools"
|
||||
2. Click Install
|
||||
3. Restart ComfyUI
|
||||
|
||||
|
||||
#### Manual Installation
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
@@ -87,29 +92,29 @@ jobs:
|
||||
cd ComfyUI-KikoTools
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
|
||||
Look for **ComfyAssets** nodes in your ComfyUI node browser!
|
||||
|
||||
|
||||
### 📊 Technical Details
|
||||
|
||||
|
||||
- **Nodes**: 1 (Resolution Calculator)
|
||||
- **Test Coverage**: 100%
|
||||
- **Python Support**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
|
||||
|
||||
### 🐛 Bug Reports
|
||||
|
||||
|
||||
Found an issue? Please report it [here](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues).
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/ComfyAssets/ComfyUI-KikoTools/compare/v0.0.0...${{ steps.get_version.outputs.version }}
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
|
||||
@@ -128,18 +133,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
if: success()
|
||||
|
||||
|
||||
steps:
|
||||
- name: Community notification placeholder
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.create-release.outputs.version }} created!"
|
||||
echo "Consider posting to:"
|
||||
echo "- ComfyUI Discord"
|
||||
echo "- Reddit r/ComfyUI"
|
||||
echo "- Reddit r/ComfyUI"
|
||||
echo "- ComfyUI-Manager database"
|
||||
echo ""
|
||||
echo "Release includes:"
|
||||
echo "- Resolution Calculator tool"
|
||||
echo "- Complete documentation"
|
||||
echo "- Example workflows"
|
||||
echo "- 100% test coverage"
|
||||
echo "- 100% test coverage"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
@@ -17,12 +20,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -398,7 +401,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -424,24 +427,24 @@ jobs:
|
||||
# Check key files
|
||||
test -f kikotools/__init__.py || (echo "kikotools/__init__.py missing" && exit 1)
|
||||
test -f kikotools/base/base_node.py || (echo "base_node.py missing" && exit 1)
|
||||
|
||||
|
||||
# Resolution Calculator files
|
||||
test -f kikotools/tools/resolution_calculator/node.py || (echo "resolution_calculator node.py missing" && exit 1)
|
||||
test -f kikotools/tools/resolution_calculator/logic.py || (echo "resolution_calculator logic.py missing" && exit 1)
|
||||
|
||||
|
||||
# Width Height Selector files
|
||||
test -f kikotools/tools/width_height_selector/node.py || (echo "width_height_selector node.py missing" && exit 1)
|
||||
test -f kikotools/tools/width_height_selector/logic.py || (echo "width_height_selector logic.py missing" && exit 1)
|
||||
test -f kikotools/tools/width_height_selector/presets.py || (echo "width_height_selector presets.py missing" && exit 1)
|
||||
|
||||
|
||||
# Sampler Combo files
|
||||
test -f kikotools/tools/sampler_combo/node.py || (echo "sampler_combo node.py missing" && exit 1)
|
||||
test -f kikotools/tools/sampler_combo/logic.py || (echo "sampler_combo logic.py missing" && exit 1)
|
||||
|
||||
|
||||
# Seed History files
|
||||
test -f kikotools/tools/seed_history/node.py || (echo "seed_history node.py missing" && exit 1)
|
||||
test -f kikotools/tools/seed_history/logic.py || (echo "seed_history logic.py missing" && exit 1)
|
||||
|
||||
|
||||
# Web files
|
||||
test -f web/width_height_swap.js || (echo "width_height_swap.js missing" && exit 1)
|
||||
test -f web/seed_history_ui.js || (echo "seed_history_ui.js missing" && exit 1)
|
||||
|
||||
@@ -158,4 +158,8 @@ input/
|
||||
test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.claude/
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Pre-commit hooks configuration for ComfyUI-KikoTools
|
||||
# This ensures code quality checks are run before each commit
|
||||
|
||||
repos:
|
||||
# Python code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.10
|
||||
args: ['--line-length=88'] # Match CI configuration
|
||||
|
||||
# Python linting with flake8
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.3.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ['--max-line-length=88', '--max-complexity=10']
|
||||
exclude: '^tests/'
|
||||
|
||||
# Python type checking with mypy
|
||||
# Note: Mypy is disabled in pre-commit due to package name issue
|
||||
# Run manually with: mypy kikotools/
|
||||
# - repo: https://github.com/pre-commit/mirrors-mypy
|
||||
# rev: v1.8.0
|
||||
# hooks:
|
||||
# - id: mypy
|
||||
# args: ['--config-file=mypy.ini']
|
||||
# files: '^kikotools/'
|
||||
# exclude: '^tests/'
|
||||
# additional_dependencies: ['types-requests']
|
||||
|
||||
# Security checks with bandit
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.6
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: ['-ll', '-r']
|
||||
files: '^kikotools/'
|
||||
|
||||
# General file checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-docstring-first
|
||||
- id: debug-statements
|
||||
- id: mixed-line-ending
|
||||
|
||||
# Check for hardcoded secrets
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args: ['--baseline', '.secrets.baseline']
|
||||
exclude: '^(tests/|\.git/)'
|
||||
|
||||
# Configuration for specific hooks
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
|
||||
# Run hooks on all files by default
|
||||
fail_fast: false
|
||||
|
||||
# Exclude patterns
|
||||
exclude: |
|
||||
(?x)^(
|
||||
\.git/|
|
||||
\.mypy_cache/|
|
||||
\.pytest_cache/|
|
||||
__pycache__/|
|
||||
build/|
|
||||
dist/|
|
||||
\.eggs/|
|
||||
.*\.egg-info/|
|
||||
venv/|
|
||||
env/
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "AzureStorageKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "DiscordBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitHubTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitLabTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "IPPublicDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "NpmDetector"
|
||||
},
|
||||
{
|
||||
"name": "OpenAIDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "PypiTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "SendGridDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "SquareOAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TelegramBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
"examples/workflows/resolution_calculator_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/resolution_calculator_example.json",
|
||||
"hashed_secret": "5264b0f1a47aeafad88f33511dda3191b32dbf38",
|
||||
"is_verified": false,
|
||||
"line_number": 57
|
||||
}
|
||||
],
|
||||
"examples/workflows/sampler_combo_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/sampler_combo_example.json",
|
||||
"hashed_secret": "e3c1848dd1141985e412fa39922ac9ba37c4714d",
|
||||
"is_verified": false,
|
||||
"line_number": 348
|
||||
}
|
||||
],
|
||||
"examples/workflows/seed_history_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/seed_history_example.json",
|
||||
"hashed_secret": "e3c1848dd1141985e412fa39922ac9ba37c4714d",
|
||||
"is_verified": false,
|
||||
"line_number": 408
|
||||
}
|
||||
],
|
||||
"examples/workflows/width_height_selector_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/width_height_selector_example.json",
|
||||
"hashed_secret": "e3c1848dd1141985e412fa39922ac9ba37c4714d",
|
||||
"is_verified": false,
|
||||
"line_number": 425
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2025-07-31T23:51:20Z"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -123,7 +123,7 @@ test-fast: $(VENV_DIR)
|
||||
test: test-fast
|
||||
@echo "Running comprehensive test suite..."
|
||||
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)"
|
||||
@echo "✅ Error handling test passed"
|
||||
@echo "🎉 All comprehensive tests passed!"
|
||||
@@ -196,4 +196,4 @@ test-width-height-selector: $(VENV_DIR)
|
||||
"
|
||||
|
||||
test-all-tools: test-resolution-calculator test-width-height-selector
|
||||
@echo "🎉 All tool-specific tests completed!"
|
||||
@echo "🎉 All tool-specific tests completed!"
|
||||
|
||||
@@ -14,6 +14,19 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
|
||||
|
||||
### ✨ Current Tools
|
||||
|
||||
| Tool | Description | Category |
|
||||
|------|-------------|----------|
|
||||
| [📐 Resolution Calculator](#-resolution-calculator) | Calculate upscaled dimensions with model optimization | Image Processing |
|
||||
| [📏 Width Height Selector](#-width-height-selector) | Preset-based dimension selection with visual swap | Dimension Control |
|
||||
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | Generation Control |
|
||||
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | Sampling |
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | Latent Generation |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | File Management |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | Text Display |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | AI Integration |
|
||||
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | Debugging |
|
||||
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | Image Processing |
|
||||
|
||||
#### 📐 Resolution Calculator
|
||||
Calculate upscaled dimensions from image or latent inputs with precision.
|
||||
|
||||
@@ -25,10 +38,12 @@ Calculate upscaled dimensions from image or latent inputs with precision.
|
||||
|
||||
**Use Cases:**
|
||||
- Calculate target dimensions for upscaler nodes
|
||||
- Plan memory usage for large generations
|
||||
- Plan memory usage for large generations
|
||||
- Ensure ComfyUI tensor compatibility
|
||||
- Optimize batch processing workflows
|
||||
|
||||

|
||||
|
||||
#### 📏 Width Height Selector
|
||||
Advanced preset-based dimension selection with visual swap button.
|
||||
|
||||
@@ -60,6 +75,8 @@ Advanced seed tracking with interactive history management and UI.
|
||||
- Maintain reproducibility across sessions
|
||||
- Compare results from different seeds efficiently
|
||||
|
||||
![Seed History functionality is shown in various workflow examples]
|
||||
|
||||
#### ⚙️ Sampler Combo
|
||||
Unified sampling configuration interface combining sampler, scheduler, steps, and CFG.
|
||||
|
||||
@@ -92,6 +109,8 @@ Advanced empty latent creation with preset support and batch processing capabili
|
||||
- Optimize memory usage with batch size planning
|
||||
- Quick preset-based latent generation for different aspect ratios
|
||||
|
||||

|
||||
|
||||
#### 💾 Kiko Save Image
|
||||
Enhanced image saving with format selection, quality control, and floating popup viewer.
|
||||
|
||||
@@ -104,6 +123,90 @@ Enhanced image saving with format selection, quality control, and floating popup
|
||||
- **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality
|
||||
- **Popup Toggle**: Enable/disable popup viewer per save operation
|
||||
|
||||

|
||||
|
||||
#### 📋 Display Text
|
||||
Advanced text display node with intelligent formatting and enhanced user interaction.
|
||||
|
||||
- **Smart Prompt Detection**: Automatically detects positive/negative prompt pairs and displays in split view
|
||||
- **Text Wrapping**: Proper word wrapping that reflows when node is resized
|
||||
- **Scrollable Content**: Mouse wheel scrolling for long texts with visual scroll indicators
|
||||
- **Copy Functionality**: Always-visible copy button with visual feedback
|
||||
- **Split View Mode**: Automatic detection and formatting of SDXL-style prompts
|
||||
- **Responsive Design**: Content adapts to node resizing with proper text reflow
|
||||
- **Clean Formatting**: Strips prompt labels when copying for direct use
|
||||
|
||||
**Use Cases:**
|
||||
- Display generated prompts with proper formatting
|
||||
- Compare positive and negative prompts side-by-side
|
||||
- Copy prompts without manual label removal
|
||||
- View long text content with proper wrapping
|
||||
- Debug prompt generation workflows
|
||||
|
||||

|
||||
|
||||
#### 🤖 Gemini Prompt Engineer
|
||||
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
|
||||
|
||||
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
|
||||
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
|
||||
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
|
||||
- **Custom System Prompts**: Override templates with your own analysis instructions
|
||||
- **Flexible API Key Management**: Environment variable, config file, or direct input
|
||||
- **Visual Status Feedback**: Real-time processing indicators and error states
|
||||
- **Help Integration**: Built-in setup guide and documentation
|
||||
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
|
||||
- **Model Caching**: Persistent model list storage for offline access
|
||||
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
|
||||
|
||||
**Use Cases:**
|
||||
- Reverse-engineer prompts from reference images
|
||||
- Convert artistic descriptions between different AI model formats
|
||||
- Generate consistent style descriptions across workflows
|
||||
- Create detailed scene breakdowns for complex compositions
|
||||
- Analyze and replicate lighting/mood from existing artwork
|
||||
- Access latest Gemini models including 2.0 and 2.5 versions
|
||||
|
||||

|
||||
|
||||
#### 🔍 Display Any
|
||||
Universal debugging node that displays any type of input value or tensor information.
|
||||
|
||||
- **Universal Input Acceptance**: Works with any data type (tensors, strings, numbers, lists, dicts)
|
||||
- **Two Display Modes**: Raw value showing string representation, or tensor shape extraction
|
||||
- **Nested Structure Support**: Finds tensors within complex nested data structures
|
||||
- **Debugging Focus**: Essential tool for understanding data flow and tensor dimensions
|
||||
- **Clean Output**: Formatted display directly in ComfyUI interface
|
||||
|
||||
**Use Cases:**
|
||||
- Debug tensor dimensions at any point in workflow
|
||||
- Inspect latent space data structures
|
||||
- View metadata and configuration objects
|
||||
- Track shape changes through processing nodes
|
||||
- Understand complex data types in ComfyUI
|
||||
|
||||

|
||||
|
||||
#### 🖼️ Image to Multiple Of
|
||||
Adjusts image dimensions to be multiples of a specified value for model compatibility.
|
||||
|
||||
- **Dimension Adjustment**: Ensures image dimensions are multiples of specified value (e.g., 64, 128)
|
||||
- **Two Processing Methods**: Center crop for minimal loss, or rescale to fit
|
||||
- **Model Compatibility**: Essential for models requiring specific dimension constraints
|
||||
- **Flexible Multiple Values**: Support from 1 to 256 with 16-step increments
|
||||
- **Preserves Quality**: Smart processing maintains image quality
|
||||
|
||||
**Use Cases:**
|
||||
- Prepare images for VAE encoding (multiple of 8 requirement)
|
||||
- Ensure compatibility with specific model architectures
|
||||
- Standardize dimensions across image batches
|
||||
- Fix dimension errors in complex workflows
|
||||
- Optimize for tiled processing requirements
|
||||
|
||||

|
||||
|
||||
### 💾 Kiko Save Image Features
|
||||
|
||||
**Use Cases:**
|
||||
- Quick preview and management of saved images without file browser navigation
|
||||
- Compare multiple format outputs side-by-side (PNG vs JPEG vs WebP)
|
||||
@@ -157,8 +260,8 @@ Image Loader → Resolution Calculator → Upscaler
|
||||
↘ scale_factor: 1.5 ↗
|
||||
```
|
||||
|
||||
**Input:** 832×1216 (SDXL portrait format)
|
||||
**Scale:** 1.5x
|
||||
**Input:** 832×1216 (SDXL portrait format)
|
||||
**Scale:** 1.5x
|
||||
**Output:** 1248×1824 (ready for upscaling)
|
||||
|
||||
### Width Height Selector Example
|
||||
@@ -169,8 +272,8 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
[swap button]
|
||||
```
|
||||
|
||||
**Preset:** FLUX HD (1920×1080)
|
||||
**Output:** 1920×1080 (16:9 cinematic)
|
||||
**Preset:** FLUX HD (1920×1080)
|
||||
**Output:** 1920×1080 (16:9 cinematic)
|
||||
**Swap Button:** Click to get 1080×1920 (9:16 portrait)
|
||||
|
||||
### Seed History Example
|
||||
@@ -181,8 +284,8 @@ Seed History → KSampler → VAE Decode → Save Image
|
||||
[History UI: 54321, 99999, 11111...]
|
||||
```
|
||||
|
||||
**Current Seed:** 12345
|
||||
**History:** Auto-tracked previous seeds with timestamps
|
||||
**Current Seed:** 12345
|
||||
**History:** Auto-tracked previous seeds with timestamps
|
||||
**Interaction:** Click any historical seed to reload instantly
|
||||
|
||||
### Sampler Combo Example
|
||||
@@ -192,8 +295,8 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
⚙️ All Settings ↘ sampler/scheduler/steps/cfg ↗
|
||||
```
|
||||
|
||||
**Configuration:** euler, normal, 20 steps, CFG 7.0
|
||||
**Output:** Complete sampling configuration in one node
|
||||
**Configuration:** euler, normal, 20 steps, CFG 7.0
|
||||
**Output:** Complete sampling configuration in one node
|
||||
**Smart Features:** Recommendations and compatibility validation
|
||||
|
||||
### Empty Latent Batch Example
|
||||
@@ -205,9 +308,9 @@ Empty Latent Batch → KSampler → VAE Decode → Kiko Save Image
|
||||
[swap button]
|
||||
```
|
||||
|
||||
**Preset:** SDXL Square (1024×1024)
|
||||
**Batch Size:** 4 empty latents
|
||||
**Output:** 4×4×128×128 latent tensor ready for sampling
|
||||
**Preset:** SDXL Square (1024×1024)
|
||||
**Batch Size:** 4 empty latents
|
||||
**Output:** 4×4×128×128 latent tensor ready for sampling
|
||||
**Swap Button:** Click to switch to any available swapped preset
|
||||
|
||||
### Kiko Save Image Example
|
||||
@@ -219,12 +322,62 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
[popup: enabled]
|
||||
```
|
||||
|
||||
**Format:** WebP (efficient compression, modern format)
|
||||
**Quality:** 85% (balanced size/quality)
|
||||
**Popup Viewer:** Floating, draggable window with saved images
|
||||
**Features:** Click images to open in new tabs, download individual files, batch selection
|
||||
**Format:** WebP (efficient compression, modern format)
|
||||
**Quality:** 85% (balanced size/quality)
|
||||
**Popup Viewer:** Floating, draggable window with saved images
|
||||
**Features:** Click images to open in new tabs, download individual files, batch selection
|
||||
**Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls
|
||||
|
||||
### Display Text Example
|
||||
|
||||
```
|
||||
Gemini Prompt → Display Text → Copy to Clipboard
|
||||
📋 SDXL prompt ↘ auto-split ↘ [📋 Positive] [📋 Negative]
|
||||
view → formatted display
|
||||
```
|
||||
|
||||
**Input:** Text with "Positive prompt:" and "Negative prompt:" sections
|
||||
**Output:** Split view with individual copy buttons
|
||||
**Features:** Text wrapping, scrolling, responsive resizing
|
||||
**Smart Detection:** Automatically formats SDXL-style prompts
|
||||
|
||||
### Gemini Prompt Engineer Example
|
||||
```
|
||||
Load Image → Gemini Prompt → Display Text → Text Generation Model
|
||||
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
|
||||
[Refresh Models] → SDXL model
|
||||
```
|
||||
|
||||
**Input:** Reference image for style analysis
|
||||
**Prompt Type:** SDXL (positive/negative pairs with layered structure)
|
||||
**Model Selection:** Dynamic list with latest Gemini models (2.0, 2.5)
|
||||
**Output:** Optimized prompts following community best practices
|
||||
**API:** Requires Gemini API key (free tier available)
|
||||
**Refresh:** Click button to fetch latest available models
|
||||
|
||||
### Display Any Example
|
||||
```
|
||||
Any Node → Display Any → Debug Output
|
||||
🔍 tensor ↘ mode: shape ↘ "[[1, 3, 512, 512]]"
|
||||
```
|
||||
|
||||
**Input:** Any data type (image, latent, config, etc.)
|
||||
**Mode:** "raw value" or "tensor shape"
|
||||
**Output:** Formatted display of value or tensor dimensions
|
||||
**Use Case:** Debug workflows, inspect data structures
|
||||
|
||||
### Image to Multiple Of Example
|
||||
```
|
||||
Load Image → Image to Multiple Of → VAE Encode → KSampler
|
||||
🖼️ 513×769 ↘ multiple: 64 ↘ 512×768 → latent
|
||||
method: crop
|
||||
```
|
||||
|
||||
**Input:** Image with arbitrary dimensions
|
||||
**Multiple Of:** 64 (common for VAE compatibility)
|
||||
**Method:** "center crop" or "rescale"
|
||||
**Output:** Adjusted image with compatible dimensions
|
||||
|
||||
### Common Workflows
|
||||
|
||||
<details>
|
||||
@@ -233,7 +386,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
```json
|
||||
{
|
||||
"workflow": "Load SDXL portrait → Calculate 1.5x dimensions → Feed to upscaler",
|
||||
"input_resolution": "832×1216",
|
||||
"input_resolution": "832×1216",
|
||||
"scale_factor": 1.5,
|
||||
"output_resolution": "1248×1824",
|
||||
"memory_efficient": true
|
||||
@@ -248,7 +401,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
{
|
||||
"workflow": "Generate latents → Calculate target size → Batch upscale",
|
||||
"input_resolution": "1024×1024",
|
||||
"scale_factor": 2.0,
|
||||
"scale_factor": 2.0,
|
||||
"output_resolution": "2048×2048",
|
||||
"batch_optimized": true
|
||||
}
|
||||
@@ -267,6 +420,10 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
|
||||
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
|
||||
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
|
||||
| **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) |
|
||||
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
|
||||
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
|
||||
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
|
||||
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
|
||||
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
|
||||
|
||||
@@ -276,7 +433,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**Inputs:**
|
||||
- `scale_factor` (FLOAT): 1.0-8.0, default 2.0
|
||||
- `image` (IMAGE, optional): Input image tensor
|
||||
- `image` (IMAGE, optional): Input image tensor
|
||||
- `latent` (LATENT, optional): Input latent tensor
|
||||
|
||||
**Outputs:**
|
||||
@@ -298,7 +455,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**Outputs:**
|
||||
- `width` (INT): Selected or calculated width
|
||||
- `height` (INT): Selected or calculated height
|
||||
- `height` (INT): Selected or calculated height
|
||||
|
||||
**UI Features:**
|
||||
- Visual blue swap button in bottom-right corner
|
||||
@@ -342,7 +499,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**Outputs:**
|
||||
- `sampler_name` (STRING): Selected sampler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `steps` (INT): Validated step count
|
||||
- `cfg` (FLOAT): Validated CFG scale
|
||||
|
||||
@@ -400,7 +557,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**UI Features:**
|
||||
- Floating, draggable popup window showing saved images immediately
|
||||
- Interactive image grid with click-to-open functionality
|
||||
- Interactive image grid with click-to-open functionality
|
||||
- Individual image download buttons with format-specific quality indicators
|
||||
- Batch selection with multi-select checkboxes for bulk operations
|
||||
- Window controls: minimize, maximize, roll-up, close, and dragging
|
||||
@@ -440,6 +597,9 @@ source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
# Install development dependencies
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Install pre-commit hooks
|
||||
pre-commit install
|
||||
|
||||
# Run tests
|
||||
python -c "
|
||||
import sys, os
|
||||
@@ -456,13 +616,32 @@ print(f'✅ Development setup successful! Test result: {result[0]}x{result[1]}')
|
||||
|
||||
### Code Quality
|
||||
|
||||
We maintain high code quality standards:
|
||||
We maintain high code quality standards with automated pre-commit hooks:
|
||||
|
||||
#### Pre-commit Hooks
|
||||
|
||||
Our pre-commit configuration automatically runs:
|
||||
- **Black**: Code formatting (127 char line length)
|
||||
- **Flake8**: Linting and style checks
|
||||
- **Bandit**: Security vulnerability scanning
|
||||
- **detect-secrets**: Prevents accidental secret commits
|
||||
- File checks: trailing whitespace, YAML validation, merge conflicts
|
||||
|
||||
```bash
|
||||
# Run all pre-commit hooks manually
|
||||
pre-commit run --all-files
|
||||
|
||||
# Update hooks to latest versions
|
||||
pre-commit autoupdate
|
||||
```
|
||||
|
||||
#### Manual Code Quality Checks
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black .
|
||||
|
||||
# Lint code
|
||||
# Lint code
|
||||
flake8 .
|
||||
|
||||
# Type checking
|
||||
@@ -485,7 +664,7 @@ Following **Test-Driven Development (TDD)**:
|
||||
# Test structure
|
||||
tests/
|
||||
├── unit/ # Individual component tests
|
||||
├── integration/ # ComfyUI workflow tests
|
||||
├── integration/ # ComfyUI workflow tests
|
||||
└── fixtures/ # Test data and workflows
|
||||
```
|
||||
|
||||
@@ -538,14 +717,15 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 6 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image)
|
||||
- **Nodes**: 10 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image, Display Text, Gemini Prompt Engineer, Display Any, Image to Multiple Of)
|
||||
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
|
||||
- **Presets**: 26 curated resolution presets
|
||||
- **Interactive Features**: 4 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer)
|
||||
- **Interactive Features**: 6 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer, Display Text Split View, Gemini Model Refresh)
|
||||
- **AI Integration**: Gemini API with 40+ model support
|
||||
- **Test Coverage**: 100% (200+ comprehensive tests)
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow)
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
|
||||
---
|
||||
|
||||
@@ -555,4 +735,4 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
[⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues)
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
ComfyUI-KikoTools is actively maintained. We provide security updates for the following versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of ComfyUI-KikoTools seriously. If you believe you have found a security vulnerability, please report it to us as described below.
|
||||
|
||||
### How to Report
|
||||
|
||||
Please report security vulnerabilities by [opening a new issue](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/new) with the following:
|
||||
|
||||
- Use the title prefix `[SECURITY]`
|
||||
- Provide a clear description of the vulnerability
|
||||
- Include steps to reproduce the issue
|
||||
- Specify the version(s) affected
|
||||
- If possible, suggest a fix or mitigation
|
||||
|
||||
### What to Expect
|
||||
|
||||
- **Response Time**: We aim to acknowledge receipt within 48 hours
|
||||
- **Investigation**: We will investigate and validate the reported vulnerability
|
||||
- **Updates**: We will keep you informed about the progress
|
||||
- **Resolution**: Once verified, we will work on a fix and release it as soon as possible
|
||||
- **Credit**: We will acknowledge your contribution in the release notes (unless you prefer to remain anonymous)
|
||||
|
||||
### Scope
|
||||
|
||||
Security vulnerabilities in scope include:
|
||||
|
||||
- Code execution vulnerabilities in node implementations
|
||||
- Path traversal or file system access issues
|
||||
- API key or credential exposure
|
||||
- Dependency vulnerabilities that affect the project
|
||||
- Any issue that could compromise user data or system security
|
||||
|
||||
### Out of Scope
|
||||
|
||||
The following are generally not considered security vulnerabilities:
|
||||
|
||||
- Issues in ComfyUI core (report these to the ComfyUI project)
|
||||
- Performance issues
|
||||
- Bugs that don't have security implications
|
||||
- Feature requests
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When using ComfyUI-KikoTools:
|
||||
|
||||
- Keep your installation up to date
|
||||
- Store API keys (like Gemini API keys) securely using environment variables
|
||||
- Review generated files before sharing them
|
||||
- Be cautious with custom prompts that might expose sensitive information
|
||||
|
||||
## Contact
|
||||
|
||||
For urgent security matters, you can also reach out to the maintainers directly through GitHub.
|
||||
|
||||
Thank you for helping keep ComfyUI-KikoTools secure!
|
||||
@@ -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.
|
||||
@@ -0,0 +1,366 @@
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
import comfy.sd
|
||||
import comfy.utils
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from comfy.sd import CLIP
|
||||
from diffusers import ConsistencyDecoderVAE
|
||||
from folder_paths import get_folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def find_or_create_cache():
|
||||
cwd = os.getcwd()
|
||||
if os.path.exists(os.path.join(cwd, "ComfyUI")):
|
||||
cwd = os.path.join(cwd, "ComfyUI")
|
||||
if os.path.exists(os.path.join(cwd, "models")):
|
||||
cwd = os.path.join(cwd, "models")
|
||||
if not os.path.exists(os.path.join(cwd, "huggingface_cache")):
|
||||
print("Creating huggingface_cache directory within comfy")
|
||||
os.mkdir(os.path.join(cwd, "huggingface_cache"))
|
||||
|
||||
return str(os.path.join(cwd, "huggingface_cache"))
|
||||
|
||||
|
||||
class ConsistencyDecoder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"latent": ("LATENT",)}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "decode"
|
||||
CATEGORY = "latent"
|
||||
|
||||
def __init__(self):
|
||||
self.vae = (
|
||||
ConsistencyDecoderVAE.from_pretrained(
|
||||
"openai/consistency-decoder",
|
||||
torch_dtype=torch.float16,
|
||||
variant="fp16",
|
||||
use_safetensors=True,
|
||||
cache_dir=find_or_create_cache(),
|
||||
)
|
||||
.eval()
|
||||
.to("cuda")
|
||||
)
|
||||
|
||||
def _decode(self, latent):
|
||||
"""Used when patching another vae."""
|
||||
return self.vae.decode(latent.half().cuda()).sample
|
||||
|
||||
def decode(self, latent):
|
||||
"""Used for standalone decoding."""
|
||||
sample = self._decode(latent["samples"])
|
||||
sample = sample.clamp(-1, 1).movedim(1, -1).add(1.0).mul(0.5).cpu()
|
||||
return (sample,)
|
||||
|
||||
|
||||
class PatchDecoderTiled:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"vae": ("VAE",)}}
|
||||
|
||||
RETURN_TYPES = ("VAE",)
|
||||
FUNCTION = "patch"
|
||||
category = "vae"
|
||||
|
||||
def __init__(self):
|
||||
self.vae = ConsistencyDecoder()
|
||||
|
||||
def patch(self, vae):
|
||||
del vae.first_stage_model.decoder
|
||||
vae.first_stage_model.decode = self.vae._decode
|
||||
vae.decode = (
|
||||
lambda x: vae.decode_tiled_(
|
||||
x,
|
||||
tile_x=512,
|
||||
tile_y=512,
|
||||
overlap=64,
|
||||
)
|
||||
.to("cuda")
|
||||
.movedim(1, -1)
|
||||
)
|
||||
|
||||
return (vae,)
|
||||
|
||||
|
||||
# quick node to set SDXL-friendly aspect ratios in 1024^2
|
||||
# adapted from throttlekitty
|
||||
class SDXLAspectRatio:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image"
|
||||
|
||||
def run(self, image: Tensor) -> Tuple[int, int]:
|
||||
_, height, width, _ = image.shape
|
||||
aspect_ratio = width / height
|
||||
|
||||
aspect_ratios = (
|
||||
(1 / 1, 1024, 1024),
|
||||
(2 / 3, 832, 1216),
|
||||
(3 / 4, 896, 1152),
|
||||
(5 / 8, 768, 1216),
|
||||
(9 / 16, 768, 1344),
|
||||
(9 / 19, 704, 1472),
|
||||
(9 / 21, 640, 1536),
|
||||
(3 / 2, 1216, 832),
|
||||
(4 / 3, 1152, 896),
|
||||
(8 / 5, 1216, 768),
|
||||
(16 / 9, 1344, 768),
|
||||
(19 / 9, 1472, 704),
|
||||
(21 / 9, 1536, 640),
|
||||
)
|
||||
|
||||
# find the closest aspect ratio
|
||||
closest = min(aspect_ratios, key=lambda x: abs(x[0] - aspect_ratio))
|
||||
|
||||
return (closest[1], closest[2])
|
||||
|
||||
|
||||
class ImageToMultipleOf:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"multiple_of": (
|
||||
"INT",
|
||||
{
|
||||
"default": 64,
|
||||
"min": 1,
|
||||
"max": 256,
|
||||
"step": 16,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
"method": (["center crop", "rescale"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image"
|
||||
|
||||
def run(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
|
||||
"""Center crop the image to a specific multiple of a number."""
|
||||
_, height, width, _ = image.shape
|
||||
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
|
||||
if method == "rescale":
|
||||
return (
|
||||
F.interpolate(
|
||||
image.unsqueeze(0),
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
).squeeze(0),
|
||||
)
|
||||
else:
|
||||
top = (height - new_height) // 2
|
||||
left = (width - new_width) // 2
|
||||
bottom = top + new_height
|
||||
right = left + new_width
|
||||
return (image[:, top:bottom, left:right, :],)
|
||||
|
||||
|
||||
class HFHubLoraLoader:
|
||||
def __init__(self):
|
||||
self.loaded_lora = None
|
||||
self.loaded_lora_path = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"repo_id": ("STRING", {"default": ""}),
|
||||
"subfolder": ("STRING", {"default": ""}),
|
||||
"filename": ("STRING", {"default": ""}),
|
||||
"strength_model": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
|
||||
),
|
||||
"strength_clip": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP")
|
||||
FUNCTION = "load_lora"
|
||||
|
||||
CATEGORY = "loaders"
|
||||
|
||||
def load_lora(
|
||||
self,
|
||||
model,
|
||||
clip,
|
||||
repo_id: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
strength_model: float,
|
||||
strength_clip: float,
|
||||
):
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
return (model, clip)
|
||||
|
||||
lora_path = hf_hub_download(
|
||||
repo_id=repo_id.strip(),
|
||||
subfolder=(
|
||||
None
|
||||
if subfolder is None or subfolder.strip() == ""
|
||||
else subfolder.strip()
|
||||
),
|
||||
filename=filename.strip(),
|
||||
cache_dir=find_or_create_cache(),
|
||||
)
|
||||
|
||||
lora = None
|
||||
if self.loaded_lora is not None:
|
||||
if self.loaded_lora_path == lora_path:
|
||||
lora = self.loaded_lora
|
||||
else:
|
||||
self.loaded_lora = None
|
||||
self.loaded_lora_path = None
|
||||
|
||||
if lora is None:
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
self.loaded_lora = lora
|
||||
self.loaded_lora_path = lora_path
|
||||
|
||||
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
||||
model, clip, lora, strength_model, strength_clip
|
||||
)
|
||||
return (model_lora, clip_lora)
|
||||
|
||||
|
||||
class HFHubEmbeddingLoader:
|
||||
"""Load a text model embedding from Huggingface Hub.
|
||||
The connected CLIP model is not manipulated."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"clip": ("CLIP",),
|
||||
"repo_id": ("STRING", {"default": ""}),
|
||||
"subfolder": ("STRING", {"default": ""}),
|
||||
"filename": ("STRING", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
FUNCTION = "download_embedding"
|
||||
|
||||
CATEGORY = "n/a"
|
||||
|
||||
def download_embedding(
|
||||
self,
|
||||
clip: CLIP, # added to signify it's best put in between nodes
|
||||
repo_id: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
):
|
||||
hf_hub_download(
|
||||
repo_id=repo_id.strip(),
|
||||
subfolder=(
|
||||
None
|
||||
if subfolder is None or subfolder.strip() == ""
|
||||
else subfolder.strip()
|
||||
),
|
||||
filename=filename.strip(),
|
||||
local_dir=get_folder_paths("embeddings")[0],
|
||||
)
|
||||
|
||||
return (clip,)
|
||||
|
||||
|
||||
class GlifVariable:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"variable": (
|
||||
[
|
||||
"",
|
||||
],
|
||||
),
|
||||
"fallback": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"single_line": True,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "FLOAT")
|
||||
FUNCTION = "do_it"
|
||||
|
||||
CATEGORY = "glif/variables"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, variable: str, fallback: str):
|
||||
# Since we populate dynamically, comfy will report invalid inputs. Override to always return True
|
||||
return True
|
||||
|
||||
def do_it(self, variable: str, fallback: str):
|
||||
variable = variable.strip()
|
||||
fallback = fallback.strip()
|
||||
if variable == "" or (variable.startswith("{") and variable.endswith("}")):
|
||||
variable = fallback
|
||||
|
||||
int_val = 0
|
||||
float_val = 0.0
|
||||
string_val = f"{variable}"
|
||||
try:
|
||||
int_val = int(variable)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
float_val = float(variable)
|
||||
except Exception:
|
||||
pass
|
||||
return (string_val, int_val, float_val)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"GlifConsistencyDecoder": ConsistencyDecoder,
|
||||
"GlifPatchConsistencyDecoderTiled": PatchDecoderTiled,
|
||||
"SDXLAspectRatio": SDXLAspectRatio,
|
||||
"ImageToMultipleOf": ImageToMultipleOf,
|
||||
"HFHubLoraLoader": HFHubLoraLoader,
|
||||
"HFHubEmbeddingLoader": HFHubEmbeddingLoader,
|
||||
"GlifVariable": GlifVariable,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"GlifConsistencyDecoder": "Consistency VAE Decoder",
|
||||
"GlifPatchConsistencyDecoderTiled": "Patch Consistency VAE Decoder",
|
||||
"SDXLAspectRatio": "Image to SDXL compatible WH",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"HFHubLoraLoader": "Load HF Lora",
|
||||
"HFHubEmbeddingLoader": "Load HF Embedding",
|
||||
"GlifVariable": "Glif Variable",
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# Display Any
|
||||
|
||||
The Display Any node is a debugging and inspection tool that can display any type of input value in ComfyUI. It's particularly useful for understanding data structures and tensor shapes during workflow development.
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal Input**: Accepts any type of input data (tensors, strings, numbers, lists, dictionaries, etc.)
|
||||
- **Two Display Modes**:
|
||||
- **Raw Value**: Shows the string representation of the input
|
||||
- **Tensor Shape**: Extracts and displays the shapes of any tensors found in the input
|
||||
- **Nested Structure Support**: Can find tensors within nested dictionaries and lists
|
||||
- **UI Output**: Displays results directly in the ComfyUI interface
|
||||
|
||||
## Inputs
|
||||
|
||||
- **input** (*): Any value you want to display or inspect
|
||||
- **mode** (DROPDOWN): Display mode selection
|
||||
- `raw value`: Shows the complete string representation of the input
|
||||
- `tensor shape`: Extracts and shows shapes of any tensors in the input
|
||||
|
||||
## Outputs
|
||||
|
||||
- **display_text** (STRING): The formatted display text
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Display Simple Values
|
||||
|
||||
Connect any output to see its raw value:
|
||||
```
|
||||
String Input: "Hello, ComfyUI!"
|
||||
Mode: raw value
|
||||
Output: "Hello, ComfyUI!"
|
||||
```
|
||||
|
||||
### 2. Inspect Tensor Shapes
|
||||
|
||||
Great for debugging image processing pipelines:
|
||||
```
|
||||
Image Tensor: [1, 3, 512, 512]
|
||||
Mode: tensor shape
|
||||
Output: "[[1, 3, 512, 512]]"
|
||||
```
|
||||
|
||||
### 3. Debug Complex Data Structures
|
||||
|
||||
View nested data structures with multiple tensors:
|
||||
```python
|
||||
Input: {
|
||||
"images": tensor([1, 3, 256, 256]),
|
||||
"masks": [tensor([256, 256]), tensor([256, 256, 1])],
|
||||
"config": {"steps": 20}
|
||||
}
|
||||
Mode: tensor shape
|
||||
Output: "[[1, 3, 256, 256], [256, 256], [256, 256, 1]]"
|
||||
```
|
||||
|
||||
### 4. Workflow Debugging
|
||||
|
||||
Use Display Any nodes at various points in your workflow to understand data flow:
|
||||
- After loading images to verify dimensions
|
||||
- Before/after processing nodes to track shape changes
|
||||
- To inspect conditioning or latent data structures
|
||||
- To view metadata or configuration dictionaries
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Image Pipeline Debugging
|
||||
Place Display Any nodes after image loading and processing nodes to track dimension changes:
|
||||
```
|
||||
Load Image → Display Any (tensor shape) → Resize → Display Any (tensor shape)
|
||||
```
|
||||
|
||||
### Latent Space Inspection
|
||||
Understand latent dimensions in your workflows:
|
||||
```
|
||||
VAE Encode → Display Any (tensor shape) → KSampler → Display Any (raw value)
|
||||
```
|
||||
|
||||
### Configuration Verification
|
||||
Display complex configuration objects to ensure correct settings:
|
||||
```
|
||||
Config Node → Display Any (raw value) → Processing Node
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Multiple Display Nodes**: You can use multiple Display Any nodes in a single workflow to track data at different stages
|
||||
|
||||
2. **Tensor Shape Mode**: Particularly useful when working with:
|
||||
- Image batches to verify batch size
|
||||
- Latent tensors to understand dimensions
|
||||
- Mask arrays to check compatibility
|
||||
|
||||
3. **Raw Value Mode**: Best for:
|
||||
- String prompts and text
|
||||
- Configuration dictionaries
|
||||
- Debugging node outputs
|
||||
- Understanding data structure
|
||||
|
||||
4. **No Tensors Found**: If you see "No tensors found in input" in tensor shape mode, the input doesn't contain any tensor-like objects (numpy arrays, torch tensors, etc.)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- The node uses `str()` for raw value display, providing Python's string representation
|
||||
- Tensor shape detection works with any object that has a `shape` attribute
|
||||
- Nested structure traversal supports dictionaries, lists, and tuples
|
||||
- The output is both displayed in the UI and available as a string output for further processing
|
||||
|
||||
## Example Workflow Integration
|
||||
|
||||
```
|
||||
[Load Image] → [Image Processing] → [Display Any (tensor shape)]
|
||||
↓
|
||||
"[[1, 3, 512, 512]]"
|
||||
↓
|
||||
[Text Multiline] ← [Concatenate] ← "Image dimensions: "
|
||||
```
|
||||
|
||||
This creates a text output showing the current image dimensions that can be used elsewhere in your workflow.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Display Text
|
||||
|
||||
The Display Text node provides advanced text display capabilities with smart formatting, interactive features, and responsive design for ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart Prompt Detection**: Automatically detects and formats SDXL-style positive/negative prompt pairs
|
||||
- **Text Wrapping**: Proper word wrapping that reflows when node is resized
|
||||
- **Scrollable Content**: Mouse wheel scrolling for long texts with visual indicators
|
||||
- **Copy Functionality**: Always-visible copy button with visual feedback
|
||||
- **Split View Mode**: Side-by-side display for prompt pairs
|
||||
- **Responsive Design**: Content adapts to node resizing
|
||||
|
||||
## Inputs
|
||||
|
||||
- **text** (STRING): The text to display
|
||||
- Can be a single text block
|
||||
- Can contain "Positive prompt:" and "Negative prompt:" sections for automatic split view
|
||||
|
||||
## Outputs
|
||||
|
||||
- **text** (STRING): Pass-through of the input text
|
||||
|
||||
## Display Modes
|
||||
|
||||
### Single Text Mode
|
||||
|
||||
When the input is regular text without prompt markers, it displays as a single scrollable text area with:
|
||||
- Word wrapping at word boundaries
|
||||
- Vertical scrolling for long content
|
||||
- Single copy button for the entire text
|
||||
|
||||
### Split View Mode
|
||||
|
||||
Automatically activated when text contains both "Positive prompt:" and "Negative prompt:" sections:
|
||||
- Side-by-side display with 50/50 split
|
||||
- Independent scrolling for each section
|
||||
- Separate copy buttons for each prompt
|
||||
- Labels are stripped when copying (clean prompts)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Display Generated Prompts
|
||||
|
||||
```
|
||||
Gemini Prompt → Display Text → Copy to workflow
|
||||
```
|
||||
The node automatically detects SDXL format and shows positive/negative prompts side-by-side.
|
||||
|
||||
### 2. Debug Text Processing
|
||||
|
||||
```
|
||||
Text Processing → Display Text → Further Processing
|
||||
```
|
||||
View intermediate text processing results with proper formatting.
|
||||
|
||||
### 3. Show Long Descriptions
|
||||
|
||||
```
|
||||
Load Text → Display Text → Review
|
||||
```
|
||||
Display long text content with scrolling and word wrapping.
|
||||
|
||||
## Interactive Features
|
||||
|
||||
### Copy Button
|
||||
- Always visible in the top-right corner
|
||||
- Shows "✓ Copied!" feedback on click
|
||||
- In split view: separate buttons for each section
|
||||
- Strips prompt labels for clean copying
|
||||
|
||||
### Scrolling
|
||||
- Mouse wheel scrolling when hovering over text
|
||||
- Visual indicators appear when content is scrollable
|
||||
- Smooth scrolling with proper boundaries
|
||||
- Independent scrolling in split view mode
|
||||
|
||||
### Resizing
|
||||
- Text reflows when node width changes
|
||||
- Maintains readability at different sizes
|
||||
- Split view maintains 50/50 proportions
|
||||
- Minimum height ensures usability
|
||||
|
||||
## Smart Prompt Detection
|
||||
|
||||
The node intelligently detects prompt formats:
|
||||
|
||||
1. **SDXL Format**:
|
||||
- Looks for "Positive prompt:" and "Negative prompt:" markers
|
||||
- Case-insensitive detection
|
||||
- Handles various formatting styles
|
||||
|
||||
2. **Label Stripping**:
|
||||
- When copying from split view, labels are removed
|
||||
- "Positive prompt: beautiful sunset" → "beautiful sunset"
|
||||
- Clean prompts ready for direct use
|
||||
|
||||
## Styling
|
||||
|
||||
- **Font**: Monospace for consistent alignment
|
||||
- **Colors**:
|
||||
- Text: Light gray (#ddd) on dark background
|
||||
- Background: Semi-transparent dark (#1a1a1a)
|
||||
- Borders: Subtle gray (#333)
|
||||
- **Spacing**: Comfortable padding and line height
|
||||
- **Visual Feedback**: Hover effects on interactive elements
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Prompt Engineering Workflows
|
||||
- Display AI-generated prompts with proper formatting
|
||||
- Compare positive and negative prompts side-by-side
|
||||
- Copy refined prompts without manual cleanup
|
||||
|
||||
### Text Processing Pipelines
|
||||
- Debug text transformations at each step
|
||||
- View formatted outputs from text nodes
|
||||
- Monitor prompt construction workflows
|
||||
|
||||
### Documentation and Notes
|
||||
- Display workflow instructions
|
||||
- Show generation parameters
|
||||
- Present formatted metadata
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Text Processing**: Preserves original text while adding display formatting
|
||||
- **Responsive Design**: CSS-based layout adapts to node dimensions
|
||||
- **Event Handling**: Proper event propagation for ComfyUI compatibility
|
||||
- **Memory Efficient**: Only renders visible text portions
|
||||
|
||||
## Tips
|
||||
|
||||
1. **For Long Prompts**: The scrolling feature handles texts of any length efficiently
|
||||
2. **Quick Copy**: Use the copy buttons to quickly grab prompts for other nodes
|
||||
3. **Resizing**: Drag node edges to find optimal display width for your content
|
||||
4. **Split View**: Works best with SDXL-format prompts but handles any dual-section text
|
||||
|
||||
## Integration Example
|
||||
|
||||
```
|
||||
[Gemini Prompt Engineer] → [Display Text] → [Copy Button Click]
|
||||
↓ ↓ ↓
|
||||
SDXL Format Split View Display Clean Prompts
|
||||
```
|
||||
|
||||
This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction.
|
||||
@@ -219,4 +219,4 @@ Memory Usage: 262,144 × 4 bytes = 1.0 MB per batch
|
||||
- **Position Calculation**: Dynamic positioning based on node size
|
||||
- **State Management**: Visual feedback for button interactions
|
||||
- **Preset Intelligence**: Smart switching between compatible presets
|
||||
- **Fallback Logic**: Custom dimension swapping when preset not available
|
||||
- **Fallback Logic**: Custom dimension swapping when preset not available
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Gemini Prompt Engineer
|
||||
|
||||
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Model Support**: Generate prompts optimized for FLUX, SDXL, Danbooru, and Video generation
|
||||
- **Custom Prompts**: Override templates with your own system prompts
|
||||
- **Visual Feedback**: UI shows processing status and error states
|
||||
- **Flexible API Key Management**: Multiple ways to provide API credentials
|
||||
- **Dynamic Model Selection**: Fetch and use latest Gemini models with refresh button
|
||||
- **Model Caching**: Persistent storage of available models for offline access
|
||||
- **Help Integration**: Built-in setup guide accessible via help button
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Get API Key
|
||||
|
||||
Get your free Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey)
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install google-generativeai
|
||||
```
|
||||
|
||||
### 3. Configure API Key
|
||||
|
||||
Choose one of these methods:
|
||||
|
||||
1. **Environment Variable** (Recommended):
|
||||
```bash
|
||||
export GEMINI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
2. **Config File**:
|
||||
Create `gemini_config.json` in your ComfyUI root directory:
|
||||
```json
|
||||
{
|
||||
"api_key": "your-api-key-here"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Node Input**:
|
||||
Enter the API key directly in the node's `api_key` field
|
||||
|
||||
## Inputs
|
||||
|
||||
- **image** (IMAGE): The image to analyze
|
||||
- **prompt_type** (DROPDOWN): Type of prompt to generate
|
||||
- `flux`: Detailed artistic prompts with quality markers
|
||||
- `sdxl`: Positive/negative prompt pairs with weight emphasis
|
||||
- `danbooru`: Anime-style booru tags with underscores
|
||||
- `video`: Motion and temporal descriptions for video generation
|
||||
- **model** (DROPDOWN): Gemini model selection
|
||||
- Dynamically populated list of available models
|
||||
- Includes latest models like gemini-2.0-flash-exp
|
||||
- Click refresh button to update model list
|
||||
- **api_key** (STRING, optional): Gemini API key if not set elsewhere
|
||||
- **custom_prompt** (STRING, optional): Override template with custom system prompt
|
||||
|
||||
## Outputs
|
||||
|
||||
- **prompt** (STRING): Generated prompt text
|
||||
- **negative_prompt** (STRING): Negative prompt (only populated for SDXL format)
|
||||
|
||||
## Prompt Type Details
|
||||
|
||||
### FLUX Format
|
||||
Generates detailed prompts optimized for FLUX models:
|
||||
- Starts with main subject and action
|
||||
- Includes style and medium descriptors
|
||||
- Adds lighting and atmosphere details
|
||||
- Uses quality markers like "4K", "highly detailed", "award-winning"
|
||||
|
||||
Example output:
|
||||
```
|
||||
majestic mountain landscape at golden hour, oil painting style, dramatic lighting with sun rays piercing through clouds, wide angle composition, warm color palette with orange and purple hues, highly detailed, 4K resolution, trending on ArtStation, photorealistic rendering
|
||||
```
|
||||
|
||||
### SDXL Format
|
||||
Generates positive and negative prompt pairs with enhanced structure:
|
||||
- Layered positive prompts: main subject → style → composition → technical
|
||||
- Comprehensive negative prompts to avoid common issues
|
||||
- Uses parentheses for emphasis: `(detailed eyes:1.2)`
|
||||
- Includes quality boosters and technical specifications
|
||||
|
||||
Example output:
|
||||
```
|
||||
Positive prompt:
|
||||
beautiful woman with flowing red hair, elegant pose, (detailed eyes:1.2), serene expression
|
||||
oil painting style, renaissance art influence, classical portraiture
|
||||
golden hour lighting, warm color palette, soft shadows, dramatic chiaroscuro
|
||||
centered composition, rule of thirds, shallow depth of field, bokeh background
|
||||
masterpiece, best quality, highly detailed, 8k uhd, professional artwork
|
||||
|
||||
Negative prompt:
|
||||
low quality, worst quality, blurry, out of focus, pixelated, low resolution
|
||||
bad anatomy, deformed features, extra limbs, missing limbs, disconnected limbs
|
||||
poorly drawn face, poorly drawn hands, amateur drawing, bad proportions
|
||||
oversaturated, overexposed, underexposed, bad lighting, harsh shadows
|
||||
jpeg artifacts, watermark, signature, text, cropped, duplicate
|
||||
```
|
||||
|
||||
### Danbooru Format
|
||||
Generates booru-style tags for anime artwork:
|
||||
- Uses underscores for multi-word concepts
|
||||
- Includes character count descriptors (1girl, 2boys)
|
||||
- Orders tags from most to least important
|
||||
|
||||
Example output:
|
||||
```
|
||||
1girl, solo, long_hair, blue_eyes, blonde_hair, school_uniform, serafuku, pleated_skirt, thighhighs, smile, looking_at_viewer, classroom, sitting, desk, window, sunlight, highres, masterpiece
|
||||
```
|
||||
|
||||
### Video Format
|
||||
Generates prompts for video generation models:
|
||||
- Describes motion and camera movements
|
||||
- Includes temporal markers and transitions
|
||||
- Specifies technical details like fps and duration
|
||||
|
||||
Example output:
|
||||
```
|
||||
Aerial shot slowly descending toward a misty forest at dawn, camera smoothly transitions to tracking shot following a deer through the trees, photorealistic style, soft golden hour lighting with fog, 10 second duration, 4K resolution 24fps, ending with close-up of deer looking at camera
|
||||
```
|
||||
|
||||
## Custom System Prompts
|
||||
|
||||
You can override any template by providing your own system prompt. This is useful for:
|
||||
- Specialized use cases
|
||||
- Different language outputs
|
||||
- Custom formatting requirements
|
||||
- Integration with specific workflows
|
||||
|
||||
Example custom prompt:
|
||||
```
|
||||
You are an expert at analyzing images and creating simple, concise descriptions.
|
||||
Focus only on the main subject and primary colors.
|
||||
Keep your response under 50 words.
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The node provides clear error messages for common issues:
|
||||
- Missing API key
|
||||
- API request failures
|
||||
- Invalid image inputs
|
||||
- Rate limiting
|
||||
|
||||
Errors are displayed in the prompt output for easy debugging.
|
||||
|
||||
## Model Selection
|
||||
|
||||
### Dynamic Model List
|
||||
- Click the refresh button (🔄) next to the model dropdown to fetch latest models
|
||||
- Models are fetched from Google's API and include all available versions
|
||||
- Common models include:
|
||||
- `gemini-2.0-flash-exp`: Latest experimental flash model
|
||||
- `gemini-1.5-pro`: Advanced model with larger context
|
||||
- `gemini-1.5-flash`: Fast and efficient for most tasks
|
||||
|
||||
### Model Caching
|
||||
- Available models are cached locally for offline access
|
||||
- Cache persists across ComfyUI sessions
|
||||
- Refresh button updates the cache with latest models
|
||||
|
||||
## UI Features
|
||||
|
||||
### Help Button
|
||||
- Click the help button (?) for quick setup instructions
|
||||
- Shows API key setup methods
|
||||
- Links to Google AI Studio for key generation
|
||||
|
||||
### Status Indicators
|
||||
- Processing spinner during API calls
|
||||
- Error messages displayed in red
|
||||
- Success feedback when prompt is generated
|
||||
|
||||
## Tips
|
||||
|
||||
1. **API Usage**: Gemini has generous free tier limits, but be mindful of rate limits
|
||||
2. **Image Quality**: Higher resolution images provide better analysis results
|
||||
3. **Prompt Refinement**: You can chain multiple Gemini nodes with different custom prompts
|
||||
4. **Caching**: Results are not cached, so identical images will make new API calls
|
||||
5. **Model Selection**: Use flash models for faster responses, pro models for complex analysis
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. Load an image using Load Image node
|
||||
2. Connect to Gemini Prompt Engineer
|
||||
3. Select appropriate prompt_type for your target model
|
||||
4. Connect prompt output to your generation model
|
||||
5. For SDXL, connect both prompt and negative_prompt outputs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"API key not found" error**:
|
||||
- Check environment variable is set correctly
|
||||
- Verify config file path and JSON format
|
||||
- Try entering key directly in node
|
||||
|
||||
**"No response generated" error**:
|
||||
- Check internet connection
|
||||
- Verify API key is valid
|
||||
- Image might be too large (resize if needed)
|
||||
|
||||
**Import error for google-generativeai**:
|
||||
- Run `pip install google-generativeai` in your ComfyUI environment
|
||||
- Restart ComfyUI after installation
|
||||
@@ -0,0 +1,213 @@
|
||||
# Kiko Save Image
|
||||
|
||||
Enhanced image saving node with multiple format support, quality controls, and an interactive floating popup viewer for ComfyUI.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Format Support**: Save as PNG, JPEG, or WebP with format-specific optimizations
|
||||
- **Advanced Quality Controls**: Fine-tune compression settings per format
|
||||
- **Floating Popup Viewer**: Interactive window showing saved images immediately
|
||||
- **Batch Operations**: Multi-select images for bulk actions
|
||||
- **File Size Display**: Real-time feedback on compression effectiveness
|
||||
- **Smart UI**: Auto-hide, draggable, resizable popup window
|
||||
|
||||
## Inputs
|
||||
|
||||
- **images** (IMAGE): Batch of images to save
|
||||
- **filename_prefix** (STRING): Prefix for saved filenames
|
||||
- Default: "KikoSave"
|
||||
- Supports subfolder paths (e.g., "outputs/renders/final")
|
||||
- **format** (DROPDOWN): Output format selection
|
||||
- `PNG`: Lossless compression, best quality
|
||||
- `JPEG`: Lossy compression, smaller files
|
||||
- `WEBP`: Modern format, best compression ratio
|
||||
- **quality** (INT): JPEG/WebP quality level
|
||||
- Range: 1-100 (default: 90)
|
||||
- Higher values = better quality, larger files
|
||||
- **png_compress_level** (INT): PNG compression level
|
||||
- Range: 0-9 (default: 4)
|
||||
- Higher values = smaller files, slower saving
|
||||
- **webp_lossless** (BOOLEAN): Use lossless WebP compression
|
||||
- Default: False (lossy)
|
||||
- True: Lossless compression like PNG
|
||||
- **popup** (BOOLEAN): Enable popup viewer window
|
||||
- Default: True
|
||||
- Toggle per save operation
|
||||
|
||||
## Outputs
|
||||
|
||||
- **UI**: Enhanced preview data with interactive popup viewer
|
||||
|
||||
## Popup Viewer Features
|
||||
|
||||
### Window Controls
|
||||
- **Drag Handle**: Click and drag the header to move window
|
||||
- **Minimize Button**: Collapse to title bar only
|
||||
- **Maximize Button**: Expand to larger viewing size
|
||||
- **Roll-up Button**: Show/hide content area
|
||||
- **Close Button**: Hide the popup (can reopen with toggle)
|
||||
|
||||
### Image Grid
|
||||
- **Thumbnails**: Click any image to open full-size in new tab
|
||||
- **File Info**: Shows filename and size for each image
|
||||
- **Quality Indicators**:
|
||||
- PNG: Compression level (0-9)
|
||||
- JPEG/WebP: Quality percentage
|
||||
- **Batch Selection**: Checkboxes for multi-select operations
|
||||
|
||||
### Bulk Actions
|
||||
- **Open All Selected**: Opens selected images in new tabs
|
||||
- **Download All Selected**: Downloads selected images as a batch
|
||||
- **Individual Downloads**: Download button per image
|
||||
|
||||
### Smart Behavior
|
||||
- **Auto-positioning**: Appears in convenient screen location
|
||||
- **Persistence**: Stays open across multiple saves
|
||||
- **Auto-hide**: Can be minimized when not needed
|
||||
- **Responsive**: Adapts to different image counts
|
||||
|
||||
## Format Details
|
||||
|
||||
### PNG Format
|
||||
- **Pros**: Lossless quality, transparency support, wide compatibility
|
||||
- **Cons**: Larger file sizes
|
||||
- **Best for**: Final outputs, images with transparency, archival
|
||||
- **Compression**: 0 (none) to 9 (maximum)
|
||||
- Level 4 (default) balances size and speed
|
||||
- Level 9 for maximum compression (slow)
|
||||
|
||||
### JPEG Format
|
||||
- **Pros**: Smaller files, fast loading, universal support
|
||||
- **Cons**: Lossy compression, no transparency
|
||||
- **Best for**: Web images, previews, photos
|
||||
- **Quality**: 1-100%
|
||||
- 90% (default) excellent quality with good compression
|
||||
- 95%+ for near-lossless quality
|
||||
- 70-85% for web optimization
|
||||
|
||||
### WebP Format
|
||||
- **Pros**: Best compression ratios, supports transparency, modern
|
||||
- **Cons**: Limited software support
|
||||
- **Best for**: Web deployment, storage optimization
|
||||
- **Modes**:
|
||||
- Lossy (default): Excellent compression with quality control
|
||||
- Lossless: PNG-like quality with better compression
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### High-Quality Archive
|
||||
```
|
||||
Format: PNG
|
||||
Compression: 0-2
|
||||
Use Case: Final renders for portfolio or client delivery
|
||||
```
|
||||
|
||||
### Web Optimization
|
||||
```
|
||||
Format: JPEG or WebP
|
||||
Quality: 80-85
|
||||
Use Case: Website images, social media posts
|
||||
```
|
||||
|
||||
### Balanced Storage
|
||||
```
|
||||
Format: WebP
|
||||
Quality: 90
|
||||
Lossless: False
|
||||
Use Case: Large batches with storage constraints
|
||||
```
|
||||
|
||||
### Transparency Preservation
|
||||
```
|
||||
Format: PNG or WebP (lossless)
|
||||
Use Case: Logos, UI elements, cutout images
|
||||
```
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
### Basic Save
|
||||
```
|
||||
Generate → Kiko Save Image
|
||||
format: PNG
|
||||
popup: enabled
|
||||
```
|
||||
|
||||
### Format Comparison
|
||||
```
|
||||
Generate → Kiko Save Image (PNG) → Compare file sizes
|
||||
↘ Kiko Save Image (JPEG) ↗
|
||||
↘ Kiko Save Image (WebP) ↗
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
```
|
||||
Batch Generate → Kiko Save Image → Popup Viewer
|
||||
↓ ↓
|
||||
4 images Select best results
|
||||
```
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Format Selection**:
|
||||
- Use PNG for maximum quality and transparency
|
||||
- Use JPEG for photographs without transparency
|
||||
- Use WebP for modern web deployment
|
||||
|
||||
2. **Quality Settings**:
|
||||
- Start with defaults (90 for JPEG/WebP, 4 for PNG)
|
||||
- Adjust based on file size requirements
|
||||
- Preview results in popup before finalizing
|
||||
|
||||
3. **Popup Management**:
|
||||
- Drag to second monitor for larger workspace
|
||||
- Use roll-up to save screen space
|
||||
- Disable popup for automated workflows
|
||||
|
||||
4. **Batch Operations**:
|
||||
- Use checkboxes to select multiple images
|
||||
- Open all in tabs for side-by-side comparison
|
||||
- Download all for quick collection
|
||||
|
||||
5. **File Organization**:
|
||||
- Use subfolders in filename_prefix
|
||||
- Include descriptive prefixes
|
||||
- Let ComfyUI handle timestamp suffixes
|
||||
|
||||
## Advantages Over Standard Save Image
|
||||
|
||||
- **Immediate Preview**: No need to navigate file system
|
||||
- **Format Flexibility**: Choose optimal format per use case
|
||||
- **Quality Control**: Fine-tune compression settings
|
||||
- **Batch Management**: Handle multiple images efficiently
|
||||
- **Modern UI**: Floating interface doesn't interrupt workflow
|
||||
- **File Size Awareness**: See compression effectiveness immediately
|
||||
- **Quick Access**: One-click opening and downloading
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Image Processing**: Uses Pillow for format conversion
|
||||
- **Metadata**: Preserves ComfyUI metadata in saved files
|
||||
- **File Naming**: Automatic timestamp and counter suffixes
|
||||
- **Memory Efficiency**: Processes images individually
|
||||
- **Thread Safety**: Proper handling of concurrent saves
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Popup not appearing**:
|
||||
- Check that popup input is enabled
|
||||
- Look for minimized window
|
||||
- Try toggling the popup button in node
|
||||
|
||||
**WebP not working**:
|
||||
- Ensure Pillow has WebP support
|
||||
- Update Pillow: `pip install --upgrade pillow`
|
||||
|
||||
**Large file sizes**:
|
||||
- Increase compression (PNG) or reduce quality (JPEG/WebP)
|
||||
- Consider switching formats
|
||||
- Check image dimensions
|
||||
|
||||
**Can't see all images**:
|
||||
- Scroll within the popup grid
|
||||
- Maximize the popup window
|
||||
- Images are shown newest first
|
||||
@@ -98,4 +98,4 @@ The Resolution Calculator integrates seamlessly with:
|
||||
- Standard ComfyUI image loaders
|
||||
- VAE encode/decode operations
|
||||
- Upscaler nodes (ESRGAN, Real-ESRGAN, etc.)
|
||||
- Custom latent processing workflows
|
||||
- Custom latent processing workflows
|
||||
|
||||
@@ -40,7 +40,7 @@ The Sampler Combo is a unified ComfyUI node that combines sampler, scheduler, st
|
||||
|
||||
### Outputs
|
||||
- **sampler_name**: Selected sampler algorithm
|
||||
- **scheduler**: Selected scheduler algorithm
|
||||
- **scheduler**: Selected scheduler algorithm
|
||||
- **steps**: Number of sampling steps
|
||||
- **cfg**: CFG scale value
|
||||
|
||||
@@ -75,7 +75,7 @@ The Sampler Combo is a unified ComfyUI node that combines sampler, scheduler, st
|
||||
- **linear**: Basic linear distribution
|
||||
- **sgm_uniform**: Uniform distribution
|
||||
|
||||
### Advanced Schedulers
|
||||
### Advanced Schedulers
|
||||
- **karras**: Karras noise schedule (recommended)
|
||||
- **exponential**: Exponential decay
|
||||
- **polyexponential**: Polynomial exponential
|
||||
@@ -99,7 +99,7 @@ Steps: 15-25
|
||||
CFG: 6.0-8.0
|
||||
```
|
||||
|
||||
#### Quality Optimized
|
||||
#### Quality Optimized
|
||||
```
|
||||
Sampler: dpmpp_2m_sde or dpmpp_3m_sde
|
||||
Scheduler: karras
|
||||
@@ -138,7 +138,7 @@ CFG: 7.0-8.5
|
||||
### Basic Configuration
|
||||
```
|
||||
sampler_name: euler
|
||||
scheduler: normal
|
||||
scheduler: normal
|
||||
steps: 20
|
||||
cfg: 7.0
|
||||
```
|
||||
@@ -164,7 +164,7 @@ cfg: 6.5
|
||||
### Compatibility Analysis
|
||||
The node provides real-time analysis of parameter compatibility:
|
||||
- Scheduler compatibility with selected sampler
|
||||
- Steps optimization for sampler type
|
||||
- Steps optimization for sampler type
|
||||
- CFG scale recommendations
|
||||
- Performance impact assessment
|
||||
|
||||
@@ -200,9 +200,9 @@ The node provides real-time analysis of parameter compatibility:
|
||||
|
||||
The Sampler Combo node outputs are compatible with all standard ComfyUI sampling nodes:
|
||||
- KSampler
|
||||
- KSamplerAdvanced
|
||||
- KSamplerAdvanced
|
||||
- Custom sampling workflows
|
||||
- Upscaling pipelines
|
||||
- Img2img workflows
|
||||
|
||||
Connect the outputs directly to your sampling node inputs for streamlined configuration.
|
||||
Connect the outputs directly to your sampling node inputs for streamlined configuration.
|
||||
|
||||
@@ -164,4 +164,4 @@ See the `examples/workflows/` directory for complete workflow examples demonstra
|
||||
- Basic seed tracking workflow
|
||||
- Creative iteration with history
|
||||
- Technical reproducibility setup
|
||||
- Batch processing with seed management
|
||||
- Batch processing with seed management
|
||||
|
||||
@@ -147,7 +147,7 @@ Width Height Selector → EmptyLatentImage → Resolution Calculator → Upscale
|
||||
|
||||
### Aspect Ratio Considerations
|
||||
- **Portrait**: 3:4, 2:3, 13:19 work well for people
|
||||
- **Landscape**: 16:9, 19:13, 7:4 for scenes and objects
|
||||
- **Landscape**: 16:9, 19:13, 7:4 for scenes and objects
|
||||
- **Square**: 1:1 for centered compositions
|
||||
- **Ultra-wide**: 21:9+ for panoramic and cinematic shots
|
||||
|
||||
@@ -192,4 +192,4 @@ Width Height Selector → EmptyLatentImage → Resolution Calculator → Upscale
|
||||
### Preset Organization
|
||||
- Categorized by model optimization
|
||||
- Sorted by aspect ratio within categories
|
||||
- Comprehensive tooltips for each preset
|
||||
- Comprehensive tooltips for each preset
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"id": "display-any-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "DisplayAny",
|
||||
"pos": [
|
||||
400,
|
||||
270
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "display_text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": [
|
||||
"tensor shape"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
50,
|
||||
270
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"example.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "DisplayAny",
|
||||
"pos": [
|
||||
400,
|
||||
520
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
70
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "display_text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": [
|
||||
"raw value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAELoader",
|
||||
"pos": [
|
||||
-250,
|
||||
410
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": [
|
||||
"ae.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
430,
|
||||
390
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "VAEEncode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
640,
|
||||
270
|
||||
],
|
||||
"size": [
|
||||
450,
|
||||
278
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
640,
|
||||
610
|
||||
],
|
||||
"size": [
|
||||
590,
|
||||
278
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
-320,
|
||||
530
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Display Any Example\n\nUniversal debugging tool:\n- Accepts ANY input type\n- Two modes: raw value or tensor shape\n- Finds tensors in nested structures\n\nUse cases:\n- Debug tensor dimensions\n- Inspect latent data\n- View config objects\n- Track data flow\n\nConnect anything to see its contents!"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
7,
|
||||
8,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
8,
|
||||
1,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
9,
|
||||
4,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Display Any",
|
||||
"bounding": [
|
||||
-400,
|
||||
130,
|
||||
1740,
|
||||
850
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6115909044841477,
|
||||
"offset": [
|
||||
689.1392030323894,
|
||||
-27.435530779258137
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 466 KiB |
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "display-text-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 6,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
-160,
|
||||
70
|
||||
],
|
||||
"size": [
|
||||
500,
|
||||
400
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "easy positive",
|
||||
"pos": [
|
||||
-620,
|
||||
70
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-easy-use",
|
||||
"ver": "1.3.1",
|
||||
"Node name for S&R": "easy positive"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Positive prompt:\nbeautiful landscape, mountains in background, sunset lighting, golden hour, professional photography, high resolution, detailed textures, vibrant colors, masterpiece\n\nNegative prompt:\nlow quality, blurry, pixelated, bad composition, oversaturated, underexposed, amateur"
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
-620,
|
||||
330
|
||||
],
|
||||
"size": [
|
||||
360,
|
||||
250
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Display Text Example\n\nSmart text display with:\n- Auto-detection of SDXL prompt format\n- Split view for positive/negative prompts\n- Text wrapping and scrolling\n- Copy buttons (strips labels)\n- Responsive resizing\n\nTry editing the text to see:\n1. Single text mode (no prompt markers)\n2. Split view mode (with Positive/Negative prompts)\n\nPerfect for displaying Gemini-generated prompts!"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Display Text",
|
||||
"bounding": [
|
||||
-750,
|
||||
-60,
|
||||
1240,
|
||||
700
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.90909090909091,
|
||||
"offset": [
|
||||
720,
|
||||
30
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 272 KiB |
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"id": "empty-latent-batch-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 8,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "EmptyLatentBatch",
|
||||
"pos": [
|
||||
350,
|
||||
350
|
||||
],
|
||||
"size": [
|
||||
370,
|
||||
210
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "EmptyLatentBatch"
|
||||
},
|
||||
"widgets_values": [
|
||||
"896×1152 - 7:9 (1.0MP) - SDXL",
|
||||
896,
|
||||
1152,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
-50,
|
||||
320
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
250
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"text": "Empty Latent Batch Example\n\nThis node creates empty latent tensors with:\n- 26 preset resolutions for SDXL/FLUX/Ultra-wide\n- Batch size support (1-64)\n- Interactive swap button\n- Memory usage estimation\n\nConnect to:\n- KSampler for image generation\n- Other latent processing nodes\n\nOutputs sanitized dimensions divisible by 8."
|
||||
},
|
||||
"widgets_values": [
|
||||
"Empty Latent Batch Example\n\nThis node creates empty latent tensors with:\n- 26 preset resolutions for SDXL/FLUX/Ultra-wide\n- Batch size support (1-64)\n- Interactive swap button\n- Memory usage estimation\n\nConnect to:\n- KSampler for image generation\n- Other latent processing nodes\n\nOutputs sanitized dimensions divisible by 8."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "DisplayAny",
|
||||
"pos": [
|
||||
760,
|
||||
280
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "display_text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": [
|
||||
"raw value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "DisplayAny",
|
||||
"pos": [
|
||||
770,
|
||||
500
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "display_text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": [
|
||||
"raw value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
1000,
|
||||
280
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
138
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
1010,
|
||||
500
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
138
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Empty Latent",
|
||||
"bounding": [
|
||||
-110,
|
||||
120,
|
||||
1440,
|
||||
620
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8264462809917358,
|
||||
"offset": [
|
||||
156.41790912499965,
|
||||
-2.272547312500285
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 278 KiB |
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"id": "gemini-prompt-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 6,
|
||||
"last_link_id": 4,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
300,
|
||||
30
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"example.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
1070,
|
||||
30
|
||||
],
|
||||
"size": [
|
||||
500,
|
||||
400
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "GeminiPrompt",
|
||||
"pos": [
|
||||
640,
|
||||
30
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
276
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "negative_prompt",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "GeminiPrompt"
|
||||
},
|
||||
"widgets_values": [
|
||||
"sdxl",
|
||||
"gemini-2.0-flash",
|
||||
"1234-1234-1234-1234",
|
||||
"",
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
300,
|
||||
410
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
240
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Gemini Prompt Engineer Example\n\nAI-powered image analysis:\n- Analyzes image content and style\n- Generates optimized prompts for FLUX/SDXL/Danbooru/Video\n- Dynamic model selection with refresh button\n- Custom system prompt override\n\nSetup:\n1. Get free API key from Google AI Studio\n2. Set GEMINI_API_KEY env var or use config file\n3. Click refresh button to get latest models\n\nDisplayText automatically shows split view for SDXL prompts!"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Gemini Prompt Engineer",
|
||||
"bounding": [
|
||||
210,
|
||||
-110,
|
||||
1460,
|
||||
850
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7513148009015777,
|
||||
"offset": [
|
||||
97.5582232217803,
|
||||
133.12148846503663
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 404 KiB |
@@ -1,123 +1,552 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"id": "3cd0ac86-8d11-41e5-9d8b-6621bc810ee6",
|
||||
"revision": 0,
|
||||
"last_node_id": 21,
|
||||
"last_link_id": 21,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [100, 200],
|
||||
"size": [315, 314],
|
||||
"pos": [
|
||||
-80,
|
||||
850
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
314.0000305175781
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"label": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"shape": 3,
|
||||
"label": "IMAGE"
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
"widgets_values": [
|
||||
"example.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageToMultipleOf",
|
||||
"pos": [500, 200],
|
||||
"size": [315, 106],
|
||||
"id": 8,
|
||||
"type": "GetNode",
|
||||
"pos": [
|
||||
540,
|
||||
840
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
34
|
||||
],
|
||||
"flags": {
|
||||
"collapsed": true
|
||||
},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "UPSCALE_MODEL",
|
||||
"type": "UPSCALE_MODEL",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"title": "Get_upscale_skin_model",
|
||||
"properties": {
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"upscale_skin_model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "ImageUpscaleWithModel",
|
||||
"pos": [
|
||||
530,
|
||||
810
|
||||
],
|
||||
"size": [
|
||||
221.98202514648438,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "upscale_model",
|
||||
"type": "UPSCALE_MODEL",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10,
|
||||
21
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.28",
|
||||
"Node name for S&R": "ImageUpscaleWithModel",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "ImageToMultipleOf",
|
||||
"pos": [
|
||||
1000,
|
||||
810
|
||||
],
|
||||
"size": [
|
||||
230,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [2, 3],
|
||||
"shape": 3,
|
||||
"label": "image"
|
||||
"links": [
|
||||
18
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageToMultipleOf"
|
||||
"cnr_id": "comfyui-glifnodes",
|
||||
"ver": "1.0.0",
|
||||
"Node name for S&R": "ImageToMultipleOf",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [64, "center crop"]
|
||||
"widgets_values": [
|
||||
65,
|
||||
"center crop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 100],
|
||||
"size": [210, 246],
|
||||
"id": 13,
|
||||
"type": "ImageScaleBy",
|
||||
"pos": [
|
||||
760,
|
||||
810
|
||||
],
|
||||
"size": [
|
||||
220,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
6,
|
||||
19
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.28",
|
||||
"Node name for S&R": "ImageScaleBy",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"lanczos",
|
||||
0.5000000000000001
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "ImageUpscaleWithModel",
|
||||
"pos": [
|
||||
280,
|
||||
830
|
||||
],
|
||||
"size": [
|
||||
221.98202514648438,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "upscale_model",
|
||||
"type": "UPSCALE_MODEL",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
5,
|
||||
20
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.28",
|
||||
"Node name for S&R": "ImageUpscaleWithModel",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "UpscaleModelLoader",
|
||||
"pos": [
|
||||
-90,
|
||||
720
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "UPSCALE_MODEL",
|
||||
"type": "UPSCALE_MODEL",
|
||||
"links": [
|
||||
15
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.28",
|
||||
"Node name for S&R": "UpscaleModelLoader",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"4x-ClearRealityV1.pth"
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "UpscaleModelLoader",
|
||||
"pos": [
|
||||
140,
|
||||
610
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
60
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "UPSCALE_MODEL",
|
||||
"type": "UPSCALE_MODEL",
|
||||
"links": [
|
||||
16
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.28",
|
||||
"Node name for S&R": "UpscaleModelLoader",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"1xSkinContrast-High-SuperUltraCompact.pth"
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
1260,
|
||||
810
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
280
|
||||
],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
"link": 18
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "VAEEncode",
|
||||
"pos": [900, 400],
|
||||
"size": [210, 46],
|
||||
"id": 19,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
1010,
|
||||
940
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
258
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pixels",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
520,
|
||||
940
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
258
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 20
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
750,
|
||||
940
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
258
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 21
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"],
|
||||
[3, 2, 0, 4, 0, "IMAGE"]
|
||||
[
|
||||
5,
|
||||
14,
|
||||
0,
|
||||
9,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
6,
|
||||
13,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
9,
|
||||
0,
|
||||
13,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
13,
|
||||
1,
|
||||
0,
|
||||
14,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
15,
|
||||
15,
|
||||
0,
|
||||
14,
|
||||
0,
|
||||
"UPSCALE_MODEL"
|
||||
],
|
||||
[
|
||||
16,
|
||||
16,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"UPSCALE_MODEL"
|
||||
],
|
||||
[
|
||||
18,
|
||||
10,
|
||||
0,
|
||||
18,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
19,
|
||||
13,
|
||||
0,
|
||||
19,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
20,
|
||||
14,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
21,
|
||||
9,
|
||||
0,
|
||||
21,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Image to Multiple of",
|
||||
"bounding": [
|
||||
-180,
|
||||
420,
|
||||
1800,
|
||||
830
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6655312045216585,
|
||||
"offset": [
|
||||
292.51725658775155,
|
||||
-375.0766486499791
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 805 KiB |
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"id": "kiko-save-image-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KikoSaveImage",
|
||||
"pos": [
|
||||
390,
|
||||
80
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
390
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "KikoSaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"KikoSave",
|
||||
"PNG",
|
||||
90,
|
||||
4,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-10,
|
||||
80
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"example.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
-10,
|
||||
460
|
||||
],
|
||||
"size": [
|
||||
370,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Kiko Save Image Example\n\nEnhanced image saving with:\n- Format selection: PNG, JPEG, WebP\n- Quality controls per format\n- Floating popup viewer (draggable)\n- Batch operations support\n- File size display\n\nPopup Features:\n- Click images to open in new tab\n- Download individual or selected images\n- Minimize/maximize/roll-up controls\n- Persistent across saves\n\nTry different formats to compare file sizes!"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Kiko save Image",
|
||||
"bounding": [
|
||||
-110,
|
||||
-60,
|
||||
1020,
|
||||
870
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7513148009015777,
|
||||
"offset": [
|
||||
648.1018911400128,
|
||||
110.02838653698085
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 245 KiB |
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"id": "41469b2d-d616-479d-879a-95cdc6074a37",
|
||||
"revision": 0,
|
||||
"last_node_id": 6,
|
||||
"last_link_id": 5,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ResolutionCalculator",
|
||||
"pos": [
|
||||
60,
|
||||
430
|
||||
-30,
|
||||
210
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
@@ -38,8 +38,7 @@
|
||||
"type": "INT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
1,
|
||||
3
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -47,15 +46,15 @@
|
||||
"type": "INT",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
2,
|
||||
4
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "965ad60c74d7f25b1acce890d9c06518e46e6d0b",
|
||||
"Node name for S&R": "ResolutionCalculator",
|
||||
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
@@ -66,8 +65,8 @@
|
||||
"id": 6,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-250,
|
||||
430
|
||||
-340,
|
||||
210
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
@@ -94,8 +93,8 @@
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "LoadImage"
|
||||
"Node name for S&R": "LoadImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"image-2025-06-13-105737.jpg",
|
||||
@@ -103,80 +102,14 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "Display Int (rgthree)",
|
||||
"id": 7,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
420,
|
||||
360
|
||||
-350,
|
||||
610
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"dir": 3,
|
||||
"name": "input",
|
||||
"type": "INT",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "rgthree-comfy",
|
||||
"ver": "1.0.2506081210",
|
||||
"Node name for S&R": "Display Int (rgthree)",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "Display Int (rgthree)",
|
||||
"pos": [
|
||||
430,
|
||||
530
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"dir": 3,
|
||||
"name": "input",
|
||||
"type": "INT",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "rgthree-comfy",
|
||||
"ver": "1.0.2506081210",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "Display Int (rgthree)"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
-150,
|
||||
170
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
410,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
@@ -184,50 +117,167 @@
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"text": "Resolution Calculator Example\n\nThis node calculates upscaled dimensions from:\n- IMAGE tensors (connect from image loaders)\n- LATENT tensors (connect from VAE encode/generation)\n\nUse cases:\n- Calculate target dimensions for upscalers\n- Plan memory usage for large generations\n- Ensure dimensions are divisible by 8\n\nScale factors optimized for SDXL and FLUX models.",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Resolution Calculator Example\n\nThis node calculates upscaled dimensions from:\n- IMAGE tensors (connect from image loaders)\n- LATENT tensors (connect from VAE encode/generation)\n\nUse cases:\n- Calculate target dimensions for upscalers\n- Plan memory usage for large generations\n- Ensure dimensions are divisible by 8\n\nScale factors optimized for SDXL and FLUX models."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "DisplayAny",
|
||||
"pos": [
|
||||
330,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "display_text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": [
|
||||
"raw value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "DisplayAny",
|
||||
"pos": [
|
||||
330,
|
||||
310
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "display_text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": [
|
||||
"raw value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
670,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
138
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "DisplayText",
|
||||
"pos": [
|
||||
660,
|
||||
400
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
138
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
|
||||
"Node name for S&R": "DisplayText"
|
||||
},
|
||||
"widgets_values": [
|
||||
null
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"INT"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
"INT"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"INT"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
0,
|
||||
"INT"
|
||||
],
|
||||
[
|
||||
5,
|
||||
6,
|
||||
@@ -235,21 +285,67 @@
|
||||
1,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
7,
|
||||
1,
|
||||
1,
|
||||
9,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
8,
|
||||
8,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
9,
|
||||
9,
|
||||
0,
|
||||
11,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Resolution Calculator",
|
||||
"bounding": [
|
||||
-480,
|
||||
20,
|
||||
1480,
|
||||
880
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"ds": {
|
||||
"scale": 0.7972024500000015,
|
||||
"scale": 0.45000000000000145,
|
||||
"offset": [
|
||||
709.6391289161842,
|
||||
-90.0127389290854
|
||||
1248.1498802376432,
|
||||
256.0155843113725
|
||||
]
|
||||
},
|
||||
"links_added_by_ue": [],
|
||||
"frontendVersion": "1.21.7",
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
|
||||
|
After Width: | Height: | Size: 785 KiB |
@@ -534,4 +534,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,4 +641,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,4 +719,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,6 +10,11 @@ from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
from .tools.image_scale_down_by import ImageScaleDownByNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
from .tools.display_text import DisplayTextNode
|
||||
from .tools.xyz_grid import XYZPlotController, ImageGridCombiner, XYZPrompt
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -21,6 +26,13 @@ NODE_CLASS_MAPPINGS = {
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
"ImageScaleDownBy": ImageScaleDownByNode,
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
"DisplayText": DisplayTextNode,
|
||||
"XYZPlotController": XYZPlotController,
|
||||
"ImageGridCombiner": ImageGridCombiner,
|
||||
"XYZPrompt": XYZPrompt,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -32,6 +44,13 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"ImageScaleDownBy": "Image Scale Down By",
|
||||
"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,5 @@
|
||||
"""DisplayAny tool for ComfyUI."""
|
||||
|
||||
from .node import DisplayAnyNode
|
||||
|
||||
__all__ = ["DisplayAnyNode"]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Logic for DisplayAny node - displays any input value or tensor shape."""
|
||||
|
||||
from typing import Any, List, Union
|
||||
|
||||
|
||||
def get_tensor_shapes(input_value: Any) -> List[List[int]]:
|
||||
"""Extract tensor shapes from nested structures.
|
||||
|
||||
Args:
|
||||
input_value: Any input value that may contain tensors
|
||||
|
||||
Returns:
|
||||
List of tensor shapes found in the input
|
||||
"""
|
||||
shapes = []
|
||||
|
||||
def extract_shapes(value: Any) -> None:
|
||||
"""Recursively extract shapes from nested structures."""
|
||||
if isinstance(value, dict):
|
||||
for v in value.values():
|
||||
extract_shapes(v)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
extract_shapes(item)
|
||||
elif hasattr(value, "shape"):
|
||||
# Handle tensors (numpy arrays, torch tensors, etc.)
|
||||
shapes.append(list(value.shape))
|
||||
|
||||
extract_shapes(input_value)
|
||||
return shapes
|
||||
|
||||
|
||||
def format_display_value(input_value: Any, mode: str = "raw value") -> str:
|
||||
"""Format input value for display based on selected mode.
|
||||
|
||||
Args:
|
||||
input_value: Any input value to display
|
||||
mode: Display mode - "raw value" or "tensor shape"
|
||||
|
||||
Returns:
|
||||
Formatted string representation of the input
|
||||
"""
|
||||
if mode == "tensor shape":
|
||||
shapes = get_tensor_shapes(input_value)
|
||||
if shapes:
|
||||
return str(shapes)
|
||||
else:
|
||||
return "No tensors found in input"
|
||||
|
||||
# Default to raw value display
|
||||
return str(input_value)
|
||||
|
||||
|
||||
def validate_display_mode(mode: str) -> bool:
|
||||
"""Validate if the display mode is supported.
|
||||
|
||||
Args:
|
||||
mode: Display mode to validate
|
||||
|
||||
Returns:
|
||||
True if mode is valid, False otherwise
|
||||
"""
|
||||
valid_modes = ["raw value", "tensor shape"]
|
||||
return mode in valid_modes
|
||||
@@ -0,0 +1,66 @@
|
||||
"""DisplayAny node for ComfyUI - displays any input value or tensor information."""
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import format_display_value, validate_display_mode
|
||||
|
||||
|
||||
# Define AnyType for wildcard input matching
|
||||
class AnyType(str):
|
||||
"""A special type that matches any input type in ComfyUI."""
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
"""Display any input value or tensor shape information.
|
||||
|
||||
This node can display any type of input in two modes:
|
||||
- Raw value: Shows the string representation of the input
|
||||
- Tensor shape: Extracts and displays shapes of any tensors in the input
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {
|
||||
"input": (AnyType("*"), {}), # Accept any type of input
|
||||
"mode": (["raw value", "tensor shape"],),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs) -> bool:
|
||||
"""Validate inputs - always returns True as we accept any input."""
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("display_text",)
|
||||
FUNCTION = "display"
|
||||
OUTPUT_NODE = True # This node displays output in the UI
|
||||
|
||||
def display(self, input: Any, mode: str = "raw value") -> Dict[str, Any]:
|
||||
"""Display the input value according to the selected mode.
|
||||
|
||||
Args:
|
||||
input: Any input value to display
|
||||
mode: Display mode - "raw value" or "tensor shape"
|
||||
|
||||
Returns:
|
||||
Dictionary with UI display and result
|
||||
"""
|
||||
# Validate mode
|
||||
if not validate_display_mode(mode):
|
||||
mode = "raw value" # Default to raw value if invalid
|
||||
|
||||
# Format the display text
|
||||
display_text = format_display_value(input, mode)
|
||||
|
||||
# Return both UI display and result
|
||||
return {
|
||||
"ui": {"text": display_text},
|
||||
"result": (display_text,),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Display Text tool for ComfyUI."""
|
||||
|
||||
from .node import DisplayTextNode, NODE_DISPLAY_NAME
|
||||
|
||||
__all__ = ["DisplayTextNode", "NODE_DISPLAY_NAME"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Display Text node implementation."""
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
class DisplayTextNode(ComfyAssetsBaseNode):
|
||||
"""Displays text in the ComfyUI interface with copy-to-clipboard functionality."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("text",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "display_text"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
DESCRIPTION = """
|
||||
Displays text in the UI with a copy-to-clipboard feature.
|
||||
|
||||
Features:
|
||||
- Shows text content in a readable format
|
||||
- Copy button appears on hover
|
||||
- Passes text through for chaining
|
||||
"""
|
||||
|
||||
def display_text(self, text):
|
||||
"""Display the text and pass it through.
|
||||
|
||||
Args:
|
||||
text: Input text to display
|
||||
|
||||
Returns:
|
||||
Tuple containing the text
|
||||
"""
|
||||
# The actual display happens in the frontend
|
||||
# We just pass the text through
|
||||
return {"ui": {"text": [text]}, "result": (text,)}
|
||||
|
||||
|
||||
# Node display name
|
||||
NODE_DISPLAY_NAME = "Display Text"
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"models": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-pro-preview-03-25",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-pro-preview-06-05",
|
||||
"gemini-2.5-flash-lite-preview-06-17",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-001",
|
||||
"gemini-2.0-flash-lite-001",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-2.5-flash-preview-tts",
|
||||
"gemini-2.5-pro-preview-tts",
|
||||
"gemini-2.0-flash-preview-image-generation",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-exp-image-generation",
|
||||
"gemini-2.0-flash-lite-preview-02-05",
|
||||
"gemini-2.0-flash-lite-preview",
|
||||
"gemini-2.0-pro-exp",
|
||||
"gemini-2.0-pro-exp-02-05",
|
||||
"learnlm-2.0-flash-experimental",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-pro-002",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-002",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-1.5-flash-8b-001",
|
||||
"gemini-1.5-flash-8b-latest",
|
||||
"gemini-2.0-flash-thinking-exp-01-21",
|
||||
"gemini-2.0-flash-thinking-exp",
|
||||
"gemini-2.0-flash-thinking-exp-1219",
|
||||
"gemma-3-1b-it",
|
||||
"gemma-3-4b-it",
|
||||
"gemma-3-12b-it",
|
||||
"gemma-3-27b-it",
|
||||
"gemma-3n-e4b-it",
|
||||
"gemma-3n-e2b-it",
|
||||
"gemini-exp-1206"
|
||||
],
|
||||
"descriptions": {
|
||||
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
|
||||
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
|
||||
"gemini-1.5-pro": "Gemini 1.5 Pro",
|
||||
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
|
||||
"gemini-1.5-flash": "Gemini 1.5 Flash",
|
||||
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
|
||||
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
|
||||
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
|
||||
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
|
||||
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
|
||||
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.5-flash": "Gemini 2.5 Flash",
|
||||
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
|
||||
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
|
||||
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
|
||||
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
|
||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
||||
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
|
||||
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
|
||||
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
|
||||
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
|
||||
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
|
||||
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
|
||||
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
|
||||
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
|
||||
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
|
||||
"gemini-exp-1206": "Gemini Experimental 1206",
|
||||
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
|
||||
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
|
||||
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
|
||||
"gemma-3-1b-it": "Gemma 3 1B",
|
||||
"gemma-3-4b-it": "Gemma 3 4B",
|
||||
"gemma-3-12b-it": "Gemma 3 12B",
|
||||
"gemma-3-27b-it": "Gemma 3 27B",
|
||||
"gemma-3n-e4b-it": "Gemma 3n E4B",
|
||||
"gemma-3n-e2b-it": "Gemma 3n E2B",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
|
||||
},
|
||||
"timestamp": 1754142231.0568295
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Gemini Prompt Engineer node for ComfyUI."""
|
||||
|
||||
from .node import GeminiPromptNode
|
||||
|
||||
__all__ = ["GeminiPromptNode"]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Logic for Gemini API integration and prompt generation."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PROMPT_TEMPLATES
|
||||
|
||||
|
||||
def tensor_to_pil(tensor: np.ndarray) -> Image.Image:
|
||||
"""Convert ComfyUI tensor to PIL Image.
|
||||
|
||||
Args:
|
||||
tensor: Input tensor in ComfyUI format (B, H, W, C)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
# ComfyUI tensors are in [0, 1] range
|
||||
if tensor.ndim == 4:
|
||||
# Take first image from batch
|
||||
tensor = tensor[0]
|
||||
|
||||
# Convert to uint8
|
||||
image_array = (tensor * 255).astype(np.uint8)
|
||||
|
||||
# Convert to PIL
|
||||
return Image.fromarray(image_array, mode="RGB")
|
||||
|
||||
|
||||
def image_to_base64(image: Image.Image, format: str = "PNG") -> str:
|
||||
"""Convert PIL Image to base64 string.
|
||||
|
||||
Args:
|
||||
image: PIL Image object
|
||||
format: Image format (PNG or JPEG)
|
||||
|
||||
Returns:
|
||||
Base64 encoded string
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=format)
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.read()).decode("utf-8")
|
||||
|
||||
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""Get Gemini API key from environment or config.
|
||||
|
||||
Returns:
|
||||
API key string or None if not found
|
||||
"""
|
||||
# Check environment variable first
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
# Check for config file in ComfyUI directory
|
||||
try:
|
||||
config_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "gemini_config.json"
|
||||
)
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
api_key = config.get("api_key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
def analyze_image_with_gemini(
|
||||
image: np.ndarray,
|
||||
prompt_type: str,
|
||||
api_key: Optional[str] = None,
|
||||
custom_prompt: Optional[str] = None,
|
||||
model_name: str = "gemini-1.5-flash",
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""Analyze image using Gemini API and generate appropriate prompt.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
prompt_type: Type of prompt to generate (flux, sdxl, danbooru, video)
|
||||
api_key: Gemini API key (optional, will try to get from env/config)
|
||||
custom_prompt: Custom system prompt to use instead of templates
|
||||
model_name: Gemini model to use (default: gemini-1.5-flash)
|
||||
|
||||
Returns:
|
||||
Tuple of (generated_prompt, error_message)
|
||||
"""
|
||||
# Get API key
|
||||
if not api_key:
|
||||
api_key = get_api_key()
|
||||
|
||||
if not api_key:
|
||||
return (
|
||||
"",
|
||||
"Gemini API key not found. Please set GEMINI_API_KEY environment variable or provide it in the node.",
|
||||
)
|
||||
|
||||
# Convert tensor to PIL image
|
||||
try:
|
||||
pil_image = tensor_to_pil(image)
|
||||
except Exception as e:
|
||||
return "", f"Failed to convert image: {str(e)}"
|
||||
|
||||
# Get system prompt
|
||||
if custom_prompt:
|
||||
system_prompt = custom_prompt
|
||||
else:
|
||||
system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"])
|
||||
|
||||
# Here we would normally make the API call to Gemini
|
||||
# For now, we'll import the google-generativeai library
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError:
|
||||
return (
|
||||
"",
|
||||
"google-generativeai library not installed. Please run: pip install google-generativeai",
|
||||
)
|
||||
|
||||
try:
|
||||
# Configure Gemini
|
||||
genai.configure(api_key=api_key)
|
||||
|
||||
# Create model
|
||||
model = genai.GenerativeModel(model_name)
|
||||
|
||||
# Generate content
|
||||
response = model.generate_content(
|
||||
[
|
||||
system_prompt,
|
||||
pil_image,
|
||||
"Analyze this image and generate an appropriate prompt according to the instructions.",
|
||||
]
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
if response.text:
|
||||
return response.text.strip(), None
|
||||
else:
|
||||
return "", "No response generated from Gemini"
|
||||
|
||||
except Exception as e:
|
||||
return "", f"Gemini API error: {str(e)}"
|
||||
|
||||
|
||||
def validate_prompt_type(prompt_type: str) -> bool:
|
||||
"""Validate if prompt type is supported.
|
||||
|
||||
Args:
|
||||
prompt_type: Type of prompt to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return prompt_type in PROMPT_TEMPLATES
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Dynamic model fetching and caching for Gemini API."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache settings
|
||||
CACHE_DURATION = 3600 * 24 # 24 hours in seconds
|
||||
CACHE_FILE = os.path.join(os.path.dirname(__file__), ".gemini_models_cache.json")
|
||||
|
||||
|
||||
def get_available_models(
|
||||
api_key: Optional[str] = None, silent: bool = False
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""Fetch available Gemini models that support generateContent.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, will try to get from environment.
|
||||
silent: If True, suppress error logging (useful for initial load).
|
||||
|
||||
Returns:
|
||||
Tuple of (model_names_list, model_descriptions_dict)
|
||||
"""
|
||||
# Check cache first
|
||||
cached_data = _load_cache()
|
||||
if cached_data:
|
||||
return cached_data["models"], cached_data["descriptions"]
|
||||
|
||||
# Try to fetch from API
|
||||
try:
|
||||
models, descriptions = _fetch_models_from_api(api_key, silent=silent)
|
||||
if models:
|
||||
_save_cache(models, descriptions)
|
||||
return models, descriptions
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
logger.warning(f"Failed to fetch models from API: {e}")
|
||||
|
||||
# Fall back to defaults
|
||||
from .prompts import DEFAULT_GEMINI_MODELS
|
||||
|
||||
return DEFAULT_GEMINI_MODELS, {}
|
||||
|
||||
|
||||
def _fetch_models_from_api(
|
||||
api_key: Optional[str] = None, silent: bool = False
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""Fetch models from Gemini API.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key.
|
||||
silent: If True, suppress error logging.
|
||||
|
||||
Returns:
|
||||
Tuple of (model_names_list, model_descriptions_dict)
|
||||
"""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError:
|
||||
if not silent:
|
||||
logger.error("google-generativeai not installed")
|
||||
return [], {}
|
||||
|
||||
# Get API key
|
||||
if not api_key:
|
||||
from .logic import get_api_key
|
||||
|
||||
api_key = get_api_key()
|
||||
|
||||
if not api_key:
|
||||
if not silent:
|
||||
logger.debug("No API key available for fetching models")
|
||||
return [], {}
|
||||
|
||||
try:
|
||||
genai.configure(api_key=api_key)
|
||||
|
||||
models = []
|
||||
descriptions = {}
|
||||
|
||||
# Fetch all models
|
||||
for model in genai.list_models():
|
||||
# Only include models that support generateContent
|
||||
if "generateContent" in model.supported_generation_methods:
|
||||
# Remove "models/" prefix from name
|
||||
model_name = model.name.replace("models/", "")
|
||||
models.append(model_name)
|
||||
descriptions[model_name] = model.display_name
|
||||
|
||||
# Sort models by priority (newer versions first)
|
||||
models = _sort_models(models)
|
||||
|
||||
return models, descriptions
|
||||
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
logger.error(f"Error fetching models from API: {e}")
|
||||
return [], {}
|
||||
|
||||
|
||||
def _sort_models(models: List[str]) -> List[str]:
|
||||
"""Sort models by version and capability.
|
||||
|
||||
Prioritizes:
|
||||
1. Newer versions (2.5 > 2.0 > 1.5)
|
||||
2. Non-experimental models
|
||||
3. Flash models for general use
|
||||
"""
|
||||
|
||||
def sort_key(model: str):
|
||||
# Priority scoring
|
||||
score = 0
|
||||
|
||||
# Version priority
|
||||
if "2.5" in model:
|
||||
score += 1000
|
||||
elif "2.0" in model:
|
||||
score += 800
|
||||
elif "1.5" in model:
|
||||
score += 600
|
||||
|
||||
# Model type priority
|
||||
if "pro" in model and "preview" not in model and "exp" not in model:
|
||||
score += 100
|
||||
elif "flash" in model and "preview" not in model and "exp" not in model:
|
||||
score += 90
|
||||
|
||||
# Penalize experimental/preview models
|
||||
if "exp" in model or "experimental" in model:
|
||||
score -= 50
|
||||
if "preview" in model:
|
||||
score -= 30
|
||||
|
||||
# Penalize specific variants
|
||||
if "thinking" in model:
|
||||
score -= 100
|
||||
if "tts" in model:
|
||||
score -= 100
|
||||
if "lite" in model:
|
||||
score -= 20
|
||||
|
||||
return -score # Negative for descending sort
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
|
||||
|
||||
def _load_cache() -> Optional[Dict]:
|
||||
"""Load cached model data if available and not expired."""
|
||||
if not os.path.exists(CACHE_FILE):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if cache is expired
|
||||
if time.time() - data.get("timestamp", 0) > CACHE_DURATION:
|
||||
return None
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_cache(models: List[str], descriptions: Dict[str, str]) -> None:
|
||||
"""Save model data to cache."""
|
||||
try:
|
||||
data = {
|
||||
"models": models,
|
||||
"descriptions": descriptions,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
with open(CACHE_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save cache: {e}")
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear the model cache."""
|
||||
if os.path.exists(CACHE_FILE):
|
||||
try:
|
||||
os.remove(CACHE_FILE)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clear cache: {e}")
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Gemini Prompt Engineer node implementation."""
|
||||
|
||||
import torch
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
from .logic import analyze_image_with_gemini, validate_prompt_type
|
||||
from .prompts import PROMPT_OPTIONS, DEFAULT_GEMINI_MODELS
|
||||
from .models import get_available_models
|
||||
|
||||
|
||||
class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
# Get available models dynamically (silent mode for initial load)
|
||||
models, _ = get_available_models(silent=True)
|
||||
|
||||
# Use default if no models available
|
||||
if not models:
|
||||
models = DEFAULT_GEMINI_MODELS
|
||||
|
||||
# Find best default model
|
||||
default_model = models[0] if models else "gemini-2.5-flash"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"prompt_type": (PROMPT_OPTIONS, {"default": "flux"}),
|
||||
"model": (models, {"default": default_model}),
|
||||
},
|
||||
"optional": {
|
||||
"api_key": ("STRING", {"default": "", "multiline": False}),
|
||||
"custom_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Optional: Enter custom system prompt instead of using templates",
|
||||
},
|
||||
),
|
||||
"refresh_models": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "label_on": "Refresh", "label_off": "Skip"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("prompt", "negative_prompt")
|
||||
FUNCTION = "generate_prompt"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
DESCRIPTION = """
|
||||
Analyzes images using Google's Gemini AI to generate optimized prompts.
|
||||
|
||||
Supports multiple prompt formats:
|
||||
- FLUX: Detailed artistic prompts with quality markers
|
||||
- SDXL: Positive/negative prompt pairs with weight emphasis
|
||||
- Danbooru: Anime-style booru tags with underscores
|
||||
- Video: Motion and temporal descriptions for video generation
|
||||
|
||||
Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
|
||||
Install: pip install google-generativeai
|
||||
"""
|
||||
|
||||
def generate_prompt(
|
||||
self,
|
||||
image,
|
||||
prompt_type,
|
||||
model,
|
||||
api_key="",
|
||||
custom_prompt="",
|
||||
refresh_models=False,
|
||||
):
|
||||
"""Generate prompt from image using Gemini.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
prompt_type: Type of prompt to generate
|
||||
model: Gemini model to use
|
||||
api_key: Optional API key
|
||||
custom_prompt: Optional custom system prompt
|
||||
refresh_models: Whether to refresh the model list
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt, negative_prompt)
|
||||
"""
|
||||
# Refresh models if requested
|
||||
if refresh_models and api_key:
|
||||
try:
|
||||
from .models import clear_cache
|
||||
|
||||
# Clear cache to force refresh on next node creation
|
||||
clear_cache()
|
||||
print(
|
||||
"Model cache cleared. Please recreate the node to see updated models."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to clear model cache: {e}")
|
||||
|
||||
# Validate prompt type
|
||||
if not validate_prompt_type(prompt_type):
|
||||
raise ValueError(f"Invalid prompt type: {prompt_type}")
|
||||
|
||||
# Convert torch tensor to numpy if needed
|
||||
if isinstance(image, torch.Tensor):
|
||||
image_np = image.cpu().numpy()
|
||||
else:
|
||||
image_np = image
|
||||
|
||||
# If API key is provided, try to refresh model list in background
|
||||
if api_key:
|
||||
try:
|
||||
from .models import get_available_models
|
||||
|
||||
# Try to get fresh models with the provided API key
|
||||
fresh_models, _ = get_available_models(api_key=api_key, silent=True)
|
||||
if fresh_models and fresh_models != DEFAULT_GEMINI_MODELS:
|
||||
# Models were successfully fetched with this API key
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Analyze image with Gemini
|
||||
prompt, error = analyze_image_with_gemini(
|
||||
image_np,
|
||||
prompt_type,
|
||||
api_key=api_key or None,
|
||||
custom_prompt=custom_prompt or None,
|
||||
model_name=model,
|
||||
)
|
||||
|
||||
if error:
|
||||
# Return error as prompt for visibility
|
||||
return (f"Error: {error}", "")
|
||||
|
||||
# Handle different prompt types
|
||||
if prompt_type == "sdxl":
|
||||
# SDXL returns positive and negative prompts
|
||||
lines = prompt.split("\n")
|
||||
positive_prompt = ""
|
||||
negative_prompt = ""
|
||||
|
||||
for line in lines:
|
||||
if line.lower().startswith("positive:"):
|
||||
positive_prompt = (
|
||||
line.replace("Positive:", "").replace("positive:", "").strip()
|
||||
)
|
||||
elif line.lower().startswith("negative:"):
|
||||
negative_prompt = (
|
||||
line.replace("Negative:", "").replace("negative:", "").strip()
|
||||
)
|
||||
|
||||
# If format not found, assume entire response is positive prompt
|
||||
if not positive_prompt:
|
||||
positive_prompt = prompt
|
||||
|
||||
return (positive_prompt, negative_prompt)
|
||||
|
||||
else:
|
||||
# Other formats don't use negative prompts
|
||||
return (prompt, "")
|
||||
|
||||
|
||||
# Node display name
|
||||
NODE_DISPLAY_NAME = "Gemini Prompt Engineer"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""System prompts for different AI model types."""
|
||||
|
||||
FLUX_PROMPT = """You are an expert FLUX prompt engineer. Analyze the provided image and generate ONLY a FLUX prompt - no explanations, analysis, or additional text.
|
||||
|
||||
FLUX uses natural language descriptions, not comma-separated tags. Write a detailed, flowing description that reads like you're explaining the image to someone.
|
||||
|
||||
Include these elements in your description:
|
||||
- Main subject with specific details (appearance, clothing, expression, pose)
|
||||
- Environment and background details
|
||||
- Lighting conditions and atmosphere
|
||||
- Artistic style or photographic approach
|
||||
- Color palette and mood
|
||||
- Technical details if relevant (camera angle, focal length, etc.)
|
||||
- Textures and materials
|
||||
|
||||
Write in a natural, descriptive style. Use complete sentences that flow together. Be specific and detailed but maintain readability.
|
||||
|
||||
IMPORTANT: Return ONLY the prompt text. No analysis, headers, or additional commentary. Just the natural language description that can be directly used in FLUX.
|
||||
|
||||
Example of correct output:
|
||||
A close-up portrait of a middle-aged woman with curly red hair and green eyes, wearing a blue silk blouse. She has a warm smile and freckles across her cheeks. The lighting is soft and natural, coming from a window to her left, creating gentle shadows that accentuate her features. The background is softly blurred, showing hints of a cozy bookshelf. The overall mood is warm and inviting, captured in a photorealistic style with shallow depth of field."""
|
||||
|
||||
SDXL_PROMPT = """You are an expert prompt engineer specializing in SDXL (Stable Diffusion XL). Your task is to generate high-quality positive and negative prompts that conform to SDXL prompt formatting standards.
|
||||
|
||||
Your expertise includes:
|
||||
- Leveraging community-tested techniques (ComfyUI, A1111, InvokeAI)
|
||||
- Applying photographic theory for realism, composition, lighting
|
||||
- Following Civitai trend standards and style best practices
|
||||
- Mastering Pony Diffusion XL formatting for stylized and anime content
|
||||
|
||||
Structure prompts in this layered, modular format:
|
||||
[Main Subject], [Pose & Camera], [Lighting & Environment], [Style & Details], [Boost Terms], [Style References]
|
||||
|
||||
For SDXL specifically:
|
||||
- Use quality boosters: 8k, RAW photo, masterpiece, ultra detailed, cinematic lighting
|
||||
- Prioritize realism and artistry
|
||||
- Excellent for portraits, landscapes, or cinematic scenes
|
||||
|
||||
Instructions:
|
||||
|
||||
Only reply with two fields:
|
||||
Positive prompt: (Your positive prompt here)
|
||||
Negative prompt: (Your negative prompt here)
|
||||
|
||||
Do not include any commentary or explanation.
|
||||
|
||||
Use concise, highly descriptive language that maximizes visual richness.
|
||||
|
||||
Follow SDXL prompt conventions: prioritize subject clarity, camera perspective, lighting, mood, style tags, and composition.
|
||||
|
||||
Keep total token length efficient (ideally under 250 tokens).
|
||||
|
||||
Avoid redundancy and generic filler words.
|
||||
|
||||
Focus on crafting super high-quality prompts for stunning visual output.
|
||||
|
||||
Example Input:
|
||||
A futuristic cyberpunk samurai standing on a neon-lit rooftop in the rain.
|
||||
|
||||
Example Output:
|
||||
Positive prompt: cyberpunk samurai, neon-lit rooftop, dramatic rain, glowing katana, futuristic cityscape, night scene, cinematic lighting, intense expression, sleek cyber armor, atmospheric depth, ultra-detailed, masterpiece, 8k, sharp focus, trending on artstation
|
||||
Negative prompt: blurry, low quality, poorly drawn, extra limbs, bad anatomy, deformed hands, text, watermark, jpeg artifacts, duplicate, cropped, out of frame
|
||||
"""
|
||||
|
||||
DANBOORU_PROMPT = """You are a Danbooru tagging expert specializing in anime-style image tagging. Analyze the image and generate ONLY Danbooru-style tags - no explanations or analysis.
|
||||
|
||||
CRITICAL: Use strict Danbooru conventions:
|
||||
- Use underscores for multi-word tags (e.g., long_hair, school_uniform)
|
||||
- All tags must be lowercase
|
||||
- Character count comes first (1girl, 2boys, multiple_girls)
|
||||
- For anime models trained on Danbooru data, proper tagging is essential
|
||||
|
||||
Tag order and categories:
|
||||
1. Character count (1girl, solo, 2boys, etc.)
|
||||
2. Character features (hair_color, eye_color, hair_length)
|
||||
3. Expression/pose (smile, looking_at_viewer, sitting)
|
||||
4. Clothing (specific items with underscores)
|
||||
5. Background/setting (simple_background, outdoors, classroom)
|
||||
6. View/composition (upper_body, full_body, from_side)
|
||||
7. Quality tags (masterpiece, best_quality, highres)
|
||||
|
||||
Common quality prefix for anime models:
|
||||
"masterpiece, best_quality, very_aesthetic"
|
||||
|
||||
IMPORTANT: Return ONLY the comma-separated tags. Use underscores, not spaces. All lowercase.
|
||||
|
||||
Example of correct output:
|
||||
1girl, solo, long_hair, blue_eyes, blonde_hair, school_uniform, serafuku, pleated_skirt, smile, looking_at_viewer, classroom, sitting, desk, window, sunlight, upper_body, masterpiece, best_quality"""
|
||||
|
||||
VIDEO_PROMPT = """You are a WAN 2.2 video generation prompt specialist. Analyze the content and generate ONLY a video generation prompt optimized for WAN 2.2 - no explanations or analysis.
|
||||
|
||||
WAN 2.2 excels with rich, descriptive prompts that focus on:
|
||||
- Visual composition and scene elements
|
||||
- Specific movements and actions
|
||||
- Lighting and aesthetic details
|
||||
- Cinematographic elements
|
||||
|
||||
Write a single detailed paragraph describing the video scene. Focus on:
|
||||
- Main subjects and their actions
|
||||
- Visual style and atmosphere
|
||||
- Movement dynamics (use words like "intensely", "smoothly", "rapidly")
|
||||
- Environmental details and lighting
|
||||
- Specific visual elements and their interactions
|
||||
|
||||
Keep the prompt descriptive but concise. WAN 2.2 works best with natural language that paints a clear picture of the desired video.
|
||||
|
||||
IMPORTANT: Return ONLY the video prompt as a single descriptive paragraph. No analysis, headers, or additional text.
|
||||
|
||||
Example of correct output:
|
||||
Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage, their movements fluid and dynamic as they exchange rapid punches under dramatic theater lighting that casts long shadows across the ring, with the crowd visible as blurred silhouettes in the darkened background."""
|
||||
|
||||
PROMPT_TEMPLATES = {
|
||||
"flux": FLUX_PROMPT,
|
||||
"sdxl": SDXL_PROMPT,
|
||||
"danbooru": DANBOORU_PROMPT,
|
||||
"video": VIDEO_PROMPT,
|
||||
}
|
||||
|
||||
PROMPT_OPTIONS = ["flux", "sdxl", "danbooru", "video"]
|
||||
|
||||
# Default models list (fallback if API is unavailable)
|
||||
DEFAULT_GEMINI_MODELS = [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-pro",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Image Scale Down By tool for ComfyUI."""
|
||||
|
||||
from .node import ImageScaleDownByNode
|
||||
|
||||
__all__ = ["ImageScaleDownByNode"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Core logic for ImageScaleDownBy tool."""
|
||||
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def scale_down_image(image: Tensor, scale_by: float) -> Tensor:
|
||||
"""Scale down an image by a given factor.
|
||||
|
||||
Args:
|
||||
image: Input image tensor of shape (batch, height, width, channels)
|
||||
scale_by: Scale factor between 0.01 and 1.0
|
||||
|
||||
Returns:
|
||||
Scaled down image tensor
|
||||
"""
|
||||
batch, height, width, channels = image.shape
|
||||
|
||||
# Calculate new dimensions
|
||||
new_height = int(height * scale_by)
|
||||
new_width = int(width * scale_by)
|
||||
|
||||
# Ensure minimum size of 1x1
|
||||
new_height = max(1, new_height)
|
||||
new_width = max(1, new_width)
|
||||
|
||||
# Convert from BHWC to BCHW for interpolation
|
||||
image_chw = image.permute(0, 3, 1, 2)
|
||||
|
||||
# Scale down the image using bilinear interpolation
|
||||
scaled = F.interpolate(
|
||||
image_chw,
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
antialias=True,
|
||||
)
|
||||
|
||||
# Convert back to BHWC
|
||||
return scaled.permute(0, 2, 3, 1)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""ComfyUI node implementation for ImageScaleDownBy."""
|
||||
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from torch import Tensor
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import scale_down_image
|
||||
|
||||
|
||||
class ImageScaleDownByNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Scales down images by a specified factor.
|
||||
|
||||
Reduces image dimensions proportionally using bilinear interpolation
|
||||
with antialiasing for smooth downscaling.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"scale_by": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.5,
|
||||
"min": 0.01,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "scale_down"
|
||||
|
||||
def scale_down(self, images: Tensor, scale_by: float) -> Tuple[Tensor]:
|
||||
"""
|
||||
Scale down images by the specified factor.
|
||||
|
||||
Args:
|
||||
images: Input image tensor
|
||||
scale_by: Scale factor between 0.01 and 1.0
|
||||
|
||||
Returns:
|
||||
Tuple containing scaled down image tensor
|
||||
"""
|
||||
try:
|
||||
self.validate_inputs(images=images, scale_by=scale_by)
|
||||
|
||||
# Scale down the images
|
||||
scaled_images = scale_down_image(images, scale_by)
|
||||
|
||||
_, new_height, new_width, _ = scaled_images.shape
|
||||
_, orig_height, orig_width, _ = images.shape
|
||||
|
||||
self.log_info(
|
||||
f"Scaled down images from {orig_height}x{orig_width} "
|
||||
f"to {new_height}x{new_width} (scale factor: {scale_by})"
|
||||
)
|
||||
|
||||
return (scaled_images,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Failed to scale down images: {str(e)}", e)
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""Validate inputs for ImageScaleDownBy node."""
|
||||
images = kwargs.get("images")
|
||||
scale_by = kwargs.get("scale_by")
|
||||
|
||||
if images is None:
|
||||
raise ValueError("Images input is required")
|
||||
|
||||
if not isinstance(images, Tensor) or len(images.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected image tensor with shape (batch, height, width, channels), "
|
||||
f"got shape {images.shape if isinstance(images, Tensor) else 'non-tensor'}"
|
||||
)
|
||||
|
||||
if scale_by <= 0 or scale_by > 1.0:
|
||||
raise ValueError(f"scale_by must be between 0.01 and 1.0, got {scale_by}")
|
||||
@@ -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,23 @@
|
||||
[mypy]
|
||||
python_version = 3.10
|
||||
warn_return_any = True
|
||||
warn_unused_configs = True
|
||||
disallow_untyped_defs = False
|
||||
ignore_missing_imports = True
|
||||
no_strict_optional = True
|
||||
files = kikotools
|
||||
exclude = tests
|
||||
|
||||
# Ignore import errors from ComfyUI
|
||||
[mypy-comfy.*]
|
||||
ignore_errors = True
|
||||
|
||||
# Ignore errors for torch imports
|
||||
[mypy-torch.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-numpy.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-PIL.*]
|
||||
ignore_missing_imports = True
|
||||
@@ -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
|
||||
```
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.0.7"
|
||||
version = "1.0.10"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
@@ -40,3 +40,56 @@ PublisherId = "kiko9"
|
||||
DisplayName = "ComfyUI-KikoTools"
|
||||
Icon = "https://avatars.githubusercontent.com/u/213204677?s=200"
|
||||
includes = []
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py310']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
ignore_missing_imports = true
|
||||
no_strict_optional = true
|
||||
files = ["kikotools"]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra -q --strict-markers"
|
||||
markers = [
|
||||
"unit: Unit tests",
|
||||
"integration: Integration tests",
|
||||
"slow: Slow tests"
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["kikotools"]
|
||||
omit = ["*/tests/*", "*/__init__.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if False:"
|
||||
]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
testpaths = tests
|
||||
python_paths = .
|
||||
norecursedirs = venv .git __pycache__
|
||||
addopts = --ignore=__init__.py --ignore=venv
|
||||
addopts = --ignore=__init__.py --ignore=venv
|
||||
|
||||
@@ -16,4 +16,4 @@ pre-commit>=3.0.0
|
||||
# ComfyUI testing (mock dependencies for unit tests)
|
||||
torch>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pillow>=9.0.0
|
||||
pillow>=9.0.0
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
# Development dependencies for ComfyUI-KikoTools
|
||||
# Runtime dependencies for ComfyUI-KikoTools
|
||||
|
||||
# Testing framework
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.10.0
|
||||
|
||||
# Code quality
|
||||
black>=23.0.0
|
||||
flake8>=6.0.0
|
||||
mypy>=1.0.0
|
||||
|
||||
# Development utilities
|
||||
pre-commit>=3.0.0
|
||||
|
||||
# ComfyUI testing (mock dependencies for unit tests)
|
||||
torch>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pillow>=9.0.0
|
||||
# Gemini API integration (optional - only needed for Gemini Prompt node)
|
||||
google-generativeai>=0.3.0
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Run mypy type checking on kikotools package
|
||||
# This is used as an alternative to pre-commit due to package name issues
|
||||
|
||||
set -e
|
||||
|
||||
echo "Running mypy type checking..."
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Run mypy with the configuration
|
||||
python -m mypy kikotools/ --ignore-missing-imports --no-strict-optional || {
|
||||
echo "❌ Mypy type checking failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "✓ Mypy type checking passed"
|
||||