Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a8af833c31 | ||
|
|
005c3bdf65 | ||
|
|
67a59a0d3b | ||
|
|
bb5653fc0e | ||
|
|
ab23992c29 | ||
|
|
9007b10d42 | ||
|
|
b71bfa8d4e | ||
|
|
c1128addc7 | ||
|
|
a49071f824 | ||
|
|
bbdd27f498 | ||
|
|
22f62bf7b4 | ||
|
|
4ff6067dad | ||
|
|
ad13e66506 | ||
|
|
b16f6f40bd | ||
|
|
6dfa66963b | ||
|
|
ab016e0903 | ||
|
|
f4228a850c | ||
|
|
79042b78d2 | ||
|
|
0c4e59c4e9 | ||
|
|
4a0a206d61 | ||
|
|
8e0d4485bd | ||
|
|
92a3b1db4e | ||
|
|
ab628b1bf2 | ||
|
|
7e712a17d9 | ||
|
|
269fb2ba80 | ||
|
|
e17fdddcd7 | ||
|
|
5d1f01e6cb | ||
|
|
b3b8826044 | ||
|
|
9dbb1f749d | ||
|
|
682f2b0a47 | ||
|
|
1233cf693e | ||
|
|
32d44a282f | ||
|
|
bb79c7434f | ||
|
|
bd8c0a42bc | ||
|
|
5d9e71dc7b | ||
|
|
321d89dcc4 | ||
|
|
cd77d06ac9 | ||
|
|
d23ff34b27 | ||
|
|
cc725d27f6 | ||
|
|
4c3d3958d6 | ||
|
|
2c992b5c97 | ||
|
|
8628bc39bb | ||
|
|
3654867a21 | ||
|
|
85af1b38f9 | ||
|
|
03189afd85 | ||
|
|
69db6e12b4 | ||
|
|
fb9c313724 | ||
|
|
ffae4e9f21 | ||
|
|
24b257ea6d | ||
|
|
398cf27546 | ||
|
|
6c6c0e6abe | ||
|
|
bc1a34eef2 | ||
|
|
599981cd9a | ||
|
|
880f376e8e | ||
|
|
dcf2d679c1 | ||
|
|
965ad60c74 | ||
|
|
be0c70eab1 | ||
|
|
549d2dc014 | ||
|
|
f04020b728 | ||
|
|
c7e02a4565 | ||
|
|
5af7a56409 | ||
|
|
9833ccd694 | ||
|
|
2d6fef8fb4 | ||
|
|
ce8c36f309 | ||
|
|
bc30806fee | ||
|
|
36b861e778 | ||
|
|
5cf3977744 | ||
|
|
0b542eafbc | ||
|
|
214851f2ef | ||
|
|
bc7608f891 | ||
|
|
1ab839c58c | ||
|
|
0cf64ae411 | ||
|
|
da26d40d98 | ||
|
|
bc6e4938ef | ||
|
|
1c0e88435c | ||
|
|
43563ef33c | ||
|
|
7711cf31d8 | ||
|
|
bcfca9b8df | ||
|
|
ddc847a16d | ||
|
|
70e8322bd5 | ||
|
|
26da37be18 | ||
|
|
677bbc04ba | ||
|
|
602912f31d |
@@ -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,47 +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')
|
||||
"
|
||||
|
||||
@@ -117,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'
|
||||
|
||||
@@ -134,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"
|
||||
@@ -148,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'
|
||||
|
||||
@@ -163,61 +182,126 @@ 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'❌ Node missing required attribute: {attr}')
|
||||
print(f'❌ ResolutionCalculatorNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
print('✓ All architecture checks passed')
|
||||
|
||||
# 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')
|
||||
"
|
||||
|
||||
- name: Check test coverage expectations
|
||||
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')
|
||||
"
|
||||
"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -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"
|
||||
|
||||
@@ -17,12 +17,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') }}
|
||||
@@ -78,67 +78,318 @@ jobs:
|
||||
print('🎉 All tests passed!')
|
||||
"
|
||||
|
||||
- name: Test error handling
|
||||
- name: Test Width Height Selector
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
# Test Width Height Selector imports
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS, PRESET_METADATA
|
||||
from kikotools.tools.width_height_selector.logic import get_preset_dimensions
|
||||
|
||||
node = ResolutionCalculatorNode()
|
||||
print('✓ Width Height Selector imports successful')
|
||||
|
||||
# Test error handling
|
||||
try:
|
||||
node.calculate_resolution(2.0) # No input provided
|
||||
assert False, 'Should have raised ValueError'
|
||||
except ValueError:
|
||||
print('✓ Error handling test passed')
|
||||
# Test preset structure
|
||||
assert len(PRESET_OPTIONS) > 0
|
||||
assert 'custom' in PRESET_OPTIONS
|
||||
assert len(PRESET_METADATA) > 0
|
||||
print('✓ Preset structure tests passed')
|
||||
|
||||
# Test invalid scale factor
|
||||
try:
|
||||
node.calculate_resolution(0.0) # Invalid scale
|
||||
assert False, 'Should have raised ValueError'
|
||||
except ValueError:
|
||||
print('✓ Scale factor validation test passed')
|
||||
# Test node interface
|
||||
node = WidthHeightSelectorNode()
|
||||
input_types = node.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'preset' in input_types['required']
|
||||
assert 'width' in input_types['required']
|
||||
assert 'height' in input_types['required']
|
||||
print('✓ Node interface tests passed')
|
||||
|
||||
print('✓ All error handling tests passed')
|
||||
# Test formatted presets
|
||||
preset_options = input_types['required']['preset'][0]
|
||||
assert 'custom' in preset_options
|
||||
formatted_count = len([opt for opt in preset_options if ' - ' in opt and 'MP' in opt])
|
||||
assert formatted_count > 0
|
||||
print(f'✓ Found {formatted_count} formatted presets')
|
||||
|
||||
# Test dimension calculation
|
||||
result = node.get_dimensions('1024×1024', 512, 512)
|
||||
assert result == (1024, 1024)
|
||||
print('✓ Dimension calculation tests passed')
|
||||
|
||||
# Test formatted preset dimensions
|
||||
formatted_preset = '1024×1024 - 1:1 (1.1MP) - SDXL'
|
||||
result = node.get_dimensions(formatted_preset, 512, 512)
|
||||
assert result == (1024, 1024)
|
||||
print('✓ Formatted preset tests passed')
|
||||
|
||||
# Test preset extraction
|
||||
extracted = node._extract_preset_name(formatted_preset)
|
||||
assert extracted == '1024×1024'
|
||||
print('✓ Preset extraction tests passed')
|
||||
|
||||
print('🎉 All Width Height Selector tests passed!')
|
||||
"
|
||||
|
||||
- name: Test ComfyUI integration readiness
|
||||
- name: Test Sampler Combo
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Test Sampler Combo imports
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
from kikotools.tools.sampler_combo.logic import (
|
||||
get_sampler_combo, validate_sampler_settings, SAMPLERS, SCHEDULERS
|
||||
)
|
||||
|
||||
print('✓ Sampler Combo imports successful')
|
||||
|
||||
# Test node interface
|
||||
node = SamplerComboNode()
|
||||
input_types = node.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'sampler_name' in input_types['required']
|
||||
assert 'scheduler' in input_types['required']
|
||||
assert 'steps' in input_types['required']
|
||||
assert 'cfg' in input_types['required']
|
||||
print('✓ Sampler Combo interface tests passed')
|
||||
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == ('SAMPLER', SCHEDULERS, 'INT', 'FLOAT')
|
||||
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
|
||||
assert node.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Sampler Combo return types tests passed')
|
||||
|
||||
# Test sampler combo functionality
|
||||
result = node.get_sampler_combo('euler', 'normal', 20, 7.0)
|
||||
assert result == ('euler', 'normal', 20, 7.0)
|
||||
print('✓ Sampler combo functionality tests passed')
|
||||
|
||||
# Test validation
|
||||
assert validate_sampler_settings('euler', 'normal', 20, 7.0) == True
|
||||
print('✓ Sampler validation tests passed')
|
||||
|
||||
# Test available samplers and schedulers
|
||||
samplers = node.get_available_samplers()
|
||||
schedulers = node.get_available_schedulers()
|
||||
assert len(samplers) > 0
|
||||
assert len(schedulers) > 0
|
||||
assert 'euler' in samplers
|
||||
assert 'normal' in schedulers
|
||||
print(f'✓ Found {len(samplers)} samplers and {len(schedulers)} schedulers')
|
||||
|
||||
print('🎉 All Sampler Combo tests passed!')
|
||||
"
|
||||
|
||||
- name: Test Seed History
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Test Seed History imports
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
from kikotools.tools.seed_history.logic import (
|
||||
generate_random_seed, validate_seed_value, sanitize_seed_value
|
||||
)
|
||||
|
||||
print('✓ Seed History imports successful')
|
||||
|
||||
# Test node interface
|
||||
node = SeedHistoryNode()
|
||||
input_types = node.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'seed' in input_types['required']
|
||||
print('✓ Seed History interface tests passed')
|
||||
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == ('INT',)
|
||||
assert node.RETURN_NAMES == ('seed',)
|
||||
assert node.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Seed History return types tests passed')
|
||||
|
||||
# Test seed output functionality
|
||||
result = node.output_seed(12345)
|
||||
assert result == (12345,)
|
||||
print('✓ Seed output functionality tests passed')
|
||||
|
||||
# Test seed validation
|
||||
assert validate_seed_value(12345) == True
|
||||
assert validate_seed_value(-1) == False
|
||||
print('✓ Seed validation tests passed')
|
||||
|
||||
# Test seed generation
|
||||
new_seed = generate_random_seed()
|
||||
assert isinstance(new_seed, int)
|
||||
assert validate_seed_value(new_seed) == True
|
||||
print('✓ Seed generation tests passed')
|
||||
|
||||
# Test seed sanitization
|
||||
clean_seed = sanitize_seed_value(12345)
|
||||
assert clean_seed == 12345
|
||||
print('✓ Seed sanitization tests passed')
|
||||
|
||||
# Test node helper methods
|
||||
assert node.is_seed_in_range(12345) == True
|
||||
assert node.is_seed_in_range(-1) == False
|
||||
assert node.get_default_seed() == 12345
|
||||
print('✓ Seed helper methods tests passed')
|
||||
|
||||
print('🎉 All Seed History tests passed!')
|
||||
"
|
||||
|
||||
- name: Test error handling for all tools
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
print('=== Testing Error Handling for All Tools ===')
|
||||
|
||||
# Test Resolution Calculator error handling
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
res_node = ResolutionCalculatorNode()
|
||||
|
||||
# Test ComfyUI interface requirements
|
||||
node_class = ResolutionCalculatorNode
|
||||
try:
|
||||
res_node.calculate_resolution(2.0) # No input provided
|
||||
assert False, 'Should have raised ValueError'
|
||||
except ValueError:
|
||||
print('✓ Resolution Calculator error handling test passed')
|
||||
|
||||
# Check required class attributes
|
||||
assert hasattr(node_class, 'INPUT_TYPES')
|
||||
assert hasattr(node_class, 'RETURN_TYPES')
|
||||
assert hasattr(node_class, 'RETURN_NAMES')
|
||||
assert hasattr(node_class, 'FUNCTION')
|
||||
assert hasattr(node_class, 'CATEGORY')
|
||||
try:
|
||||
res_node.calculate_resolution(0.0) # Invalid scale
|
||||
assert False, 'Should have raised ValueError'
|
||||
except ValueError:
|
||||
print('✓ Resolution Calculator scale factor validation test passed')
|
||||
|
||||
# Check INPUT_TYPES structure
|
||||
input_types = node_class.INPUT_TYPES()
|
||||
# Test Width Height Selector error handling
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
wh_node = WidthHeightSelectorNode()
|
||||
|
||||
# Test invalid preset fallback
|
||||
result = wh_node.get_dimensions('invalid_preset', 800, 600)
|
||||
assert result == (800, 600) # Should fallback to custom dimensions
|
||||
print('✓ Width Height Selector invalid preset handling test passed')
|
||||
|
||||
# Test Sampler Combo error handling
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
sampler_node = SamplerComboNode()
|
||||
|
||||
# Test with invalid sampler (should use safe defaults)
|
||||
result = sampler_node.get_sampler_combo('invalid_sampler', 'normal', 20, 7.0)
|
||||
assert result == ('euler', 'normal', 20, 7.0) # Safe defaults
|
||||
print('✓ Sampler Combo invalid input handling test passed')
|
||||
|
||||
# Test Seed History error handling
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
seed_node = SeedHistoryNode()
|
||||
|
||||
# Test invalid seed value (should use fallback)
|
||||
result = seed_node.output_seed(-1) # Invalid negative seed
|
||||
assert result == (12345,) # Fallback seed
|
||||
print('✓ Seed History invalid seed handling test passed')
|
||||
|
||||
print('🎉 All error handling tests passed for all tools!')
|
||||
"
|
||||
|
||||
- name: Test ComfyUI integration readiness for all tools
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
print('=== Testing ComfyUI Integration for All Tools ===')
|
||||
|
||||
# Test Resolution Calculator
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
res_class = ResolutionCalculatorNode
|
||||
|
||||
assert hasattr(res_class, 'INPUT_TYPES')
|
||||
assert hasattr(res_class, 'RETURN_TYPES')
|
||||
assert hasattr(res_class, 'RETURN_NAMES')
|
||||
assert hasattr(res_class, 'FUNCTION')
|
||||
assert hasattr(res_class, 'CATEGORY')
|
||||
|
||||
input_types = res_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'optional' in input_types
|
||||
assert 'scale_factor' in input_types['required']
|
||||
assert 'image' in input_types['optional']
|
||||
assert 'latent' in input_types['optional']
|
||||
|
||||
# Check return types
|
||||
assert node_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert node_class.RETURN_NAMES == ('width', 'height')
|
||||
assert node_class.CATEGORY == 'ComfyAssets'
|
||||
assert res_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert res_class.RETURN_NAMES == ('width', 'height')
|
||||
assert res_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Resolution Calculator ComfyUI integration passed')
|
||||
|
||||
print('✓ ComfyUI integration readiness tests passed')
|
||||
# Test Width Height Selector
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
wh_class = WidthHeightSelectorNode
|
||||
|
||||
assert hasattr(wh_class, 'INPUT_TYPES')
|
||||
assert hasattr(wh_class, 'RETURN_TYPES')
|
||||
assert hasattr(wh_class, 'RETURN_NAMES')
|
||||
assert hasattr(wh_class, 'FUNCTION')
|
||||
assert hasattr(wh_class, 'CATEGORY')
|
||||
|
||||
input_types = wh_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'preset' in input_types['required']
|
||||
assert 'width' in input_types['required']
|
||||
assert 'height' in input_types['required']
|
||||
|
||||
assert wh_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert wh_class.RETURN_NAMES == ('width', 'height')
|
||||
assert wh_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Width Height Selector ComfyUI integration passed')
|
||||
|
||||
# Test Sampler Combo
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
sampler_class = SamplerComboNode
|
||||
|
||||
assert hasattr(sampler_class, 'INPUT_TYPES')
|
||||
assert hasattr(sampler_class, 'RETURN_TYPES')
|
||||
assert hasattr(sampler_class, 'RETURN_NAMES')
|
||||
assert hasattr(sampler_class, 'FUNCTION')
|
||||
assert hasattr(sampler_class, 'CATEGORY')
|
||||
|
||||
input_types = sampler_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'sampler_name' in input_types['required']
|
||||
assert 'scheduler' in input_types['required']
|
||||
assert 'steps' in input_types['required']
|
||||
assert 'cfg' in input_types['required']
|
||||
|
||||
assert sampler_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Sampler Combo ComfyUI integration passed')
|
||||
|
||||
# Test Seed History
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
seed_class = SeedHistoryNode
|
||||
|
||||
assert hasattr(seed_class, 'INPUT_TYPES')
|
||||
assert hasattr(seed_class, 'RETURN_TYPES')
|
||||
assert hasattr(seed_class, 'RETURN_NAMES')
|
||||
assert hasattr(seed_class, 'FUNCTION')
|
||||
assert hasattr(seed_class, 'CATEGORY')
|
||||
|
||||
input_types = seed_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'seed' in input_types['required']
|
||||
|
||||
assert seed_class.RETURN_TYPES == ('INT',)
|
||||
assert seed_class.RETURN_NAMES == ('seed',)
|
||||
assert seed_class.CATEGORY == 'ComfyAssets'
|
||||
print('✓ Seed History ComfyUI integration passed')
|
||||
|
||||
print('🎉 All tools ComfyUI integration readiness tests passed!')
|
||||
"
|
||||
|
||||
test-package-structure:
|
||||
@@ -147,7 +398,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"
|
||||
|
||||
@@ -163,14 +414,37 @@ jobs:
|
||||
test -d kikotools/base || (echo "kikotools/base directory missing" && exit 1)
|
||||
test -d kikotools/tools || (echo "kikotools/tools directory missing" && exit 1)
|
||||
test -d kikotools/tools/resolution_calculator || (echo "resolution_calculator directory missing" && exit 1)
|
||||
test -d kikotools/tools/width_height_selector || (echo "width_height_selector directory missing" && exit 1)
|
||||
test -d kikotools/tools/sampler_combo || (echo "sampler_combo directory missing" && exit 1)
|
||||
test -d kikotools/tools/seed_history || (echo "seed_history directory missing" && exit 1)
|
||||
test -d tests || (echo "tests directory missing" && exit 1)
|
||||
test -d examples || (echo "examples directory missing" && exit 1)
|
||||
test -d web || (echo "web directory missing" && exit 1)
|
||||
|
||||
# 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)
|
||||
test -f kikotools/tools/resolution_calculator/node.py || (echo "node.py missing" && exit 1)
|
||||
test -f kikotools/tools/resolution_calculator/logic.py || (echo "logic.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)
|
||||
|
||||
echo "✓ Package structure tests passed"
|
||||
|
||||
@@ -181,9 +455,15 @@ jobs:
|
||||
|
||||
- name: Test documentation completeness
|
||||
run: |
|
||||
# Check documentation files
|
||||
# Check documentation files for all tools
|
||||
test -f examples/documentation/resolution_calculator.md || (echo "Resolution calculator docs missing" && exit 1)
|
||||
test -f examples/workflows/resolution_calculator_example.json || (echo "Example workflow missing" && exit 1)
|
||||
test -f examples/workflows/resolution_calculator_example.json || (echo "Resolution calculator workflow missing" && exit 1)
|
||||
test -f examples/documentation/width_height_selector.md || (echo "Width height selector docs missing" && exit 1)
|
||||
test -f examples/workflows/width_height_selector_example.json || (echo "Width height selector workflow missing" && exit 1)
|
||||
test -f examples/documentation/sampler_combo.md || (echo "Sampler combo docs missing" && exit 1)
|
||||
test -f examples/workflows/sampler_combo_example.json || (echo "Sampler combo workflow missing" && exit 1)
|
||||
test -f examples/documentation/seed_history.md || (echo "Seed history docs missing" && exit 1)
|
||||
test -f examples/workflows/seed_history_example.json || (echo "Seed history workflow missing" && exit 1)
|
||||
|
||||
# Check README has key sections
|
||||
grep -q "Installation" README.md || (echo "README missing Installation section" && exit 1)
|
||||
|
||||
@@ -158,4 +158,7 @@ input/
|
||||
test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.claude/
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
|
||||
@@ -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,288 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ComfyUI-KikoTools is a planned modular collection of custom ComfyUI nodes that will provide essential tools missing from the standard ComfyUI release. All nodes will be grouped under "ComfyAssets" in the ComfyUI interface. The project is designed for extensibility, allowing new tools to be added easily while maintaining clean separation of concerns.
|
||||
|
||||
**Current Status**: Project is in initial planning phase. Only documentation and licensing files exist.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Principles
|
||||
- **Modular Design**: Each tool is a separate, self-contained module
|
||||
- **ComfyAssets Grouping**: All nodes appear under the "ComfyAssets" category
|
||||
- **Test-Driven Development**: Every tool includes comprehensive tests
|
||||
- **Clean Interfaces**: Standardized input/output patterns across tools
|
||||
|
||||
### Core Components
|
||||
- **Tool Registry**: Central registration system for all KikoTools nodes
|
||||
- **Base Classes**: Shared functionality for consistent tool behavior
|
||||
- **Individual Tools**: Self-contained modules for specific functionality
|
||||
|
||||
### Current Tools
|
||||
|
||||
#### 1. Resolution Calculator (First Tool)
|
||||
- **Purpose**: Calculate upscale resolution from image or latent inputs
|
||||
- **Inputs**:
|
||||
- Image or Latent tensor
|
||||
- Scale factor (1, 2, 3, 1.2, 1.5, 2.0)
|
||||
- **Outputs**:
|
||||
- Width (INT)
|
||||
- Height (INT)
|
||||
- **Target Models**: Flux and SDXL optimized
|
||||
- **Use Case**: Connect calculated dimensions to upscaler nodes
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Python with ComfyUI node patterns
|
||||
- **Node Framework**: ComfyUI INPUT_TYPES, RETURN_TYPES, execute() patterns
|
||||
- **Testing**: pytest with ComfyUI test fixtures
|
||||
- **Code Quality**: black, flake8, mypy
|
||||
- **Integration**: ComfyUI execution queue and tensor systems
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Note**: These commands are planned for when the project structure is implemented.
|
||||
|
||||
### Initial Setup
|
||||
```bash
|
||||
# Create basic project structure
|
||||
mkdir -p kikotools/{base,tools} tests/{unit,integration,fixtures} scripts examples
|
||||
|
||||
# Create entry point files
|
||||
touch __init__.py kikotools/__init__.py
|
||||
```
|
||||
|
||||
### Code Quality (Future)
|
||||
```bash
|
||||
# Format Python code
|
||||
black .
|
||||
|
||||
# Python linting
|
||||
flake8 .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
### Testing (Future TDD Workflow)
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/
|
||||
|
||||
# Run tests for specific tool
|
||||
pytest tests/unit/tools/test_{tool_name}.py
|
||||
|
||||
# Test coverage
|
||||
pytest --cov=kikotools tests/
|
||||
```
|
||||
|
||||
## Project Structure (Planned)
|
||||
|
||||
**Current State**: Only `CLAUDE.md` and `LICENSE` files exist.
|
||||
|
||||
**Planned Structure**:
|
||||
```
|
||||
├── __init__.py # ComfyUI node registration entry point
|
||||
├── kikotools/ # Main package
|
||||
│ ├── __init__.py # Package initialization and tool registry
|
||||
│ ├── base/ # Base classes and shared utilities
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_node.py # Base node class with ComfyAssets grouping
|
||||
│ │ └── utils.py # Shared utility functions
|
||||
│ ├── tools/ # Individual tool implementations
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── resolution_calculator/ # First planned tool
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── node.py # ResolutionCalculatorNode implementation
|
||||
│ │ │ └── logic.py # Core calculation logic
|
||||
│ │ └── template/ # Template for new tools
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── node.py
|
||||
│ │ └── logic.py
|
||||
├── tests/ # Comprehensive test suite (TDD approach)
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # pytest fixtures and ComfyUI test setup
|
||||
│ ├── unit/ # Unit tests for individual components
|
||||
│ │ ├── test_base_node.py
|
||||
│ │ └── tools/
|
||||
│ │ └── test_resolution_calculator.py
|
||||
│ ├── integration/ # ComfyUI integration tests
|
||||
│ │ ├── test_node_registration.py
|
||||
│ │ └── test_workflow_execution.py
|
||||
│ └── fixtures/ # Test data and workflow files
|
||||
│ ├── workflows/ # .json workflow files for testing
|
||||
│ ├── images/ # Test images
|
||||
│ └── latents/ # Test latent tensors
|
||||
├── scripts/ # Development automation
|
||||
│ ├── create_tool.py # Tool template generator
|
||||
│ ├── register_tool.py # Tool registration helper
|
||||
│ └── validate_nodes.py # Node validation script
|
||||
├── examples/ # Usage examples and demonstrations
|
||||
│ ├── workflows/ # Example workflow .json files
|
||||
│ └── documentation/ # Usage documentation per tool
|
||||
└── requirements-dev.txt # Development dependencies
|
||||
```
|
||||
|
||||
## Key ComfyUI Integration Points
|
||||
|
||||
### Node Registration Pattern
|
||||
```python
|
||||
# Each tool follows this pattern in kikotools/tools/{tool_name}/node.py
|
||||
class ResolutionCalculatorNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"scale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"latent": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
CATEGORY = "ComfyAssets" # All tools use this category
|
||||
|
||||
def calculate_resolution(self, scale_factor, image=None, latent=None):
|
||||
# Implementation here
|
||||
pass
|
||||
```
|
||||
|
||||
### Base Node Class
|
||||
- Provides consistent "ComfyAssets" categorization
|
||||
- Standardizes error handling and logging
|
||||
- Implements common validation patterns
|
||||
- Ensures consistent return type handling
|
||||
|
||||
### Tool Registry System
|
||||
- Automatic discovery of tools in `kikotools/tools/`
|
||||
- Dynamic node registration during ComfyUI startup
|
||||
- Version compatibility checking
|
||||
- Dependency validation
|
||||
|
||||
## Test-Driven Development (TDD) Workflow
|
||||
|
||||
### 1. Write Tests First
|
||||
```python
|
||||
# tests/unit/tools/test_resolution_calculator.py
|
||||
def test_resolution_calculator_with_image():
|
||||
"""Test resolution calculation with image input."""
|
||||
# Arrange
|
||||
node = ResolutionCalculatorNode()
|
||||
test_image = create_test_image(512, 512) # fixture
|
||||
scale_factor = 2.0
|
||||
|
||||
# Act
|
||||
width, height = node.calculate_resolution(scale_factor, image=test_image)
|
||||
|
||||
# Assert
|
||||
assert width == 1024
|
||||
assert height == 1024
|
||||
|
||||
def test_resolution_calculator_with_latent():
|
||||
"""Test resolution calculation with latent input."""
|
||||
# Similar pattern for latent inputs
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. Run Tests (Should Fail)
|
||||
```bash
|
||||
pytest tests/unit/tools/test_resolution_calculator.py -v
|
||||
```
|
||||
|
||||
### 3. Implement Minimal Code
|
||||
```python
|
||||
# kikotools/tools/resolution_calculator/logic.py
|
||||
def calculate_upscale_resolution(input_tensor, scale_factor):
|
||||
"""Calculate new resolution based on input and scale factor."""
|
||||
# Minimal implementation to pass tests
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. Refactor and Expand
|
||||
- Add error handling
|
||||
- Optimize for Flux/SDXL specific requirements
|
||||
- Add comprehensive validation
|
||||
- Implement edge case handling
|
||||
|
||||
### 5. Integration Testing
|
||||
```python
|
||||
# tests/integration/test_workflow_execution.py
|
||||
def test_resolution_calculator_in_workflow():
|
||||
"""Test resolution calculator in full ComfyUI workflow."""
|
||||
workflow = load_test_workflow("resolution_calculator_example.json")
|
||||
result = execute_comfyui_workflow(workflow)
|
||||
assert result.success
|
||||
```
|
||||
|
||||
## Tool-Specific Implementation Notes
|
||||
|
||||
### Resolution Calculator
|
||||
- **Input Validation**: Handle both image and latent tensors
|
||||
- **Scale Factors**: Support integer (1, 2, 3) and float (1.2, 1.5, 2.0) multipliers
|
||||
- **Model Optimization**: Consider Flux and SDXL specific resolution requirements
|
||||
- **Output Format**: Integer width/height suitable for upscaler node connections
|
||||
- **Error Handling**: Graceful handling of invalid inputs or edge cases
|
||||
|
||||
### Future Tools (Planned)
|
||||
- Batch Image Processor
|
||||
- Advanced Prompt Utilities
|
||||
- Model Management Tools
|
||||
- Custom Sampling Methods
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New Tool
|
||||
1. **Plan**: Define tool purpose, inputs, outputs, and test cases
|
||||
2. **Generate**: Use `python scripts/create_tool.py --name "NewTool"`
|
||||
3. **Test**: Write comprehensive tests following TDD principles
|
||||
4. **Implement**: Build tool logic with proper ComfyUI integration
|
||||
5. **Register**: Add tool to registry and validate registration
|
||||
6. **Document**: Update examples and documentation
|
||||
7. **Validate**: Test in real ComfyUI environment with actual workflows
|
||||
|
||||
### Code Quality Standards
|
||||
- **Type Hints**: Full type annotation for all functions
|
||||
- **Documentation**: Docstrings for all public methods and classes
|
||||
- **Testing**: Minimum 90% test coverage for all tools
|
||||
- **Linting**: Pass all flake8 and mypy checks
|
||||
- **Formatting**: Auto-formatted with black
|
||||
|
||||
### Release Process
|
||||
1. Run full test suite: `pytest tests/`
|
||||
2. Validate in ComfyUI: `python scripts/validate_nodes.py`
|
||||
3. Update version numbers and changelog
|
||||
4. Create example workflows demonstrating new features
|
||||
5. Update ComfyUI-Manager compatibility metadata
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
### ComfyUI Compatibility
|
||||
- Follow ComfyUI tensor format conventions
|
||||
- Implement proper memory management for large tensors
|
||||
- Handle ComfyUI execution context correctly
|
||||
- Ensure compatibility with ComfyUI's automatic typing system
|
||||
|
||||
### Performance Considerations
|
||||
- Optimize for real-time workflow execution
|
||||
- Minimize memory allocation during processing
|
||||
- Cache expensive computations when appropriate
|
||||
- Profile performance with typical Flux/SDXL workflows
|
||||
|
||||
### User Experience
|
||||
- Clear, descriptive node names and parameter labels
|
||||
- Helpful tooltips and parameter descriptions
|
||||
- Consistent visual styling within ComfyAssets group
|
||||
- Robust error messages with actionable guidance
|
||||
|
||||
### Extensibility
|
||||
- Plugin architecture for easy tool addition
|
||||
- Shared utilities for common operations
|
||||
- Consistent API patterns across all tools
|
||||
- Future-proof design for ComfyUI updates
|
||||
@@ -0,0 +1,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.
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
.PHONY: help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
|
||||
# Python and virtual environment setup
|
||||
PYTHON := python3
|
||||
VENV_DIR := venv
|
||||
VENV_BIN := $(VENV_DIR)/bin
|
||||
VENV_PYTHON := $(VENV_BIN)/python
|
||||
VENV_PIP := $(VENV_BIN)/pip
|
||||
|
||||
# Check if we're in a virtual environment, if not use venv
|
||||
ifeq ($(VIRTUAL_ENV),)
|
||||
PYTHON_CMD := $(VENV_PYTHON)
|
||||
PIP_CMD := $(VENV_PIP)
|
||||
else
|
||||
PYTHON_CMD := python
|
||||
PIP_CMD := pip
|
||||
endif
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "ComfyUI-KikoTools Development Commands"
|
||||
@@ -28,44 +44,48 @@ help:
|
||||
@echo " help - Show this help message"
|
||||
|
||||
# Setup and installation
|
||||
setup:
|
||||
@echo "Setting up ComfyUI-KikoTools development environment..."
|
||||
python -m venv venv
|
||||
@echo "Virtual environment created. Activate with:"
|
||||
@echo " source venv/bin/activate (Linux/Mac)"
|
||||
@echo " venv\\Scripts\\activate (Windows)"
|
||||
@echo "Then run: make install"
|
||||
setup: $(VENV_DIR)
|
||||
@echo "✅ ComfyUI-KikoTools development environment ready"
|
||||
@echo "Virtual environment created. Dependencies installed."
|
||||
|
||||
install:
|
||||
$(VENV_DIR):
|
||||
@echo "Creating virtual environment..."
|
||||
$(PYTHON) -m venv $(VENV_DIR)
|
||||
@echo "Installing development dependencies..."
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
$(VENV_PIP) install --upgrade pip
|
||||
$(VENV_PIP) install -r requirements-dev.txt
|
||||
@echo "✅ Virtual environment created and dependencies installed"
|
||||
|
||||
install: $(VENV_DIR)
|
||||
@echo "Installing/updating development dependencies..."
|
||||
$(PIP_CMD) install --upgrade pip
|
||||
$(PIP_CMD) install -r requirements-dev.txt
|
||||
@echo "✅ Dependencies installed"
|
||||
|
||||
# Code quality
|
||||
format:
|
||||
format: $(VENV_DIR)
|
||||
@echo "Formatting code with black..."
|
||||
black .
|
||||
$(PYTHON_CMD) -m black .
|
||||
@echo "✅ Code formatted"
|
||||
|
||||
lint:
|
||||
lint: $(VENV_DIR)
|
||||
@echo "Linting with flake8..."
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
$(PYTHON_CMD) -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=venv
|
||||
$(PYTHON_CMD) -m flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude=venv
|
||||
@echo "✅ Linting completed"
|
||||
|
||||
type-check:
|
||||
type-check: $(VENV_DIR)
|
||||
@echo "Type checking with mypy..."
|
||||
mypy kikotools/ --ignore-missing-imports --no-strict-optional || true
|
||||
$(PYTHON_CMD) -m mypy kikotools/ --ignore-missing-imports --no-strict-optional || true
|
||||
@echo "✅ Type checking completed"
|
||||
|
||||
quality-check: format lint type-check
|
||||
@echo "✅ All quality checks completed"
|
||||
|
||||
# Testing
|
||||
dev-test:
|
||||
dev-test: $(VENV_DIR)
|
||||
@echo "Running quick development test..."
|
||||
@python -c "\
|
||||
@$(PYTHON_CMD) -c "\
|
||||
import sys, os; \
|
||||
sys.path.insert(0, os.getcwd()); \
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode; \
|
||||
@@ -75,9 +95,9 @@ dev-test:
|
||||
print(f'✅ Development test passed! Result: {result[0]}x{result[1]}'); \
|
||||
"
|
||||
|
||||
test-fast:
|
||||
test-fast: $(VENV_DIR)
|
||||
@echo "Running core functionality tests..."
|
||||
@python -c "\
|
||||
@$(PYTHON_CMD) -c "\
|
||||
import sys, os; \
|
||||
sys.path.insert(0, os.getcwd()); \
|
||||
from kikotools.base import ComfyAssetsBaseNode; \
|
||||
@@ -103,7 +123,7 @@ test-fast:
|
||||
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!"
|
||||
@@ -146,13 +166,13 @@ ci: quality-check test
|
||||
@echo "✅ CI checks passed!"
|
||||
|
||||
# Tool-specific commands (can be extended for new tools)
|
||||
test-resolution-calculator:
|
||||
test-resolution-calculator: $(VENV_DIR)
|
||||
@echo "Testing Resolution Calculator specifically..."
|
||||
@python -c "import sys, os; sys.path.insert(0, os.getcwd()); from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode; import torch; node = ResolutionCalculatorNode(); scenarios = [('SDXL Portrait', torch.randn(1, 1216, 832, 3), 1.5), ('FLUX Square', torch.randn(1, 1024, 1024, 3), 2.0), ('User Scenario', torch.randn(1, 1216, 832, 3), 1.53)]; [print(f'✅ {name}: {image.shape[2]}×{image.shape[1]} → {node.calculate_resolution(scale, image=image)[0]}×{node.calculate_resolution(scale, image=image)[1]} ({scale}x)') for name, image, scale in scenarios]; print('🎉 Resolution Calculator tests completed!')"
|
||||
@$(PYTHON_CMD) -c "import sys, os; sys.path.insert(0, os.getcwd()); from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode; import torch; node = ResolutionCalculatorNode(); scenarios = [('SDXL Portrait', torch.randn(1, 1216, 832, 3), 1.5), ('FLUX Square', torch.randn(1, 1024, 1024, 3), 2.0), ('User Scenario', torch.randn(1, 1216, 832, 3), 1.53)]; [print(f'✅ {name}: {image.shape[2]}×{image.shape[1]} → {node.calculate_resolution(scale, image=image)[0]}×{node.calculate_resolution(scale, image=image)[1]} ({scale}x)') for name, image, scale in scenarios]; print('🎉 Resolution Calculator tests completed!')"
|
||||
|
||||
test-width-height-selector:
|
||||
test-width-height-selector: $(VENV_DIR)
|
||||
@echo "Testing Width Height Selector specifically..."
|
||||
@python -c "\
|
||||
@$(PYTHON_CMD) -c "\
|
||||
import sys, os; \
|
||||
sys.path.insert(0, os.getcwd()); \
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode; \
|
||||
@@ -176,4 +196,4 @@ test-width-height-selector:
|
||||
"
|
||||
|
||||
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.
|
||||
|
||||
@@ -44,6 +59,170 @@ Advanced preset-based dimension selection with visual swap button.
|
||||
- Mobile and ultra-wide format support
|
||||
- Integration with upscaling pipelines
|
||||
|
||||
#### 🎲 Seed History
|
||||
Advanced seed tracking with interactive history management and UI.
|
||||
|
||||
- **Automatic Tracking**: Monitors all seed changes with timestamps
|
||||
- **Interactive History**: Click any historical seed to reload instantly
|
||||
- **Smart Deduplication**: 500ms window prevents duplicate rapid additions
|
||||
- **Persistent Storage**: History survives browser sessions and ComfyUI restarts
|
||||
- **Auto-Hide UI**: Clean interface that hides after 2.5 seconds of inactivity
|
||||
- **Visual Feedback**: Toast notifications and selection highlighting
|
||||
|
||||
**Use Cases:**
|
||||
- Track promising seeds during creative exploration
|
||||
- Quickly return to successful generation parameters
|
||||
- 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.
|
||||
|
||||
- **All-in-One Interface**: Single node for complete sampling configuration
|
||||
- **Smart Recommendations**: Optimal settings suggestions per sampler type
|
||||
- **Compatibility Validation**: Ensures sampler/scheduler combinations work well
|
||||
- **Intelligent Defaults**: Context-aware parameter recommendations
|
||||
- **Range Validation**: Prevents invalid parameter combinations
|
||||
- **Comprehensive Tooltips**: Detailed guidance for each parameter
|
||||
|
||||
**Use Cases:**
|
||||
- Simplify complex sampling workflows
|
||||
- Ensure optimal sampler/scheduler combinations
|
||||
- Reduce node clutter in workflows
|
||||
- Quick sampling parameter experimentation
|
||||
|
||||
#### 📦 Empty Latent Batch
|
||||
Advanced empty latent creation with preset support and batch processing capabilities.
|
||||
|
||||
- **Preset Integration**: 26 curated resolution presets with model optimization
|
||||
- **Batch Processing**: Create multiple empty latents (1-64) in a single operation
|
||||
- **Visual Swap Button**: Interactive blue button for quick dimension swapping
|
||||
- **Smart Validation**: Automatic dimension sanitization for VAE compatibility
|
||||
- **Memory Estimation**: Built-in memory usage calculation and warnings
|
||||
- **Model-Aware Presets**: SDXL (~1MP), FLUX (high-res), and Ultra-wide options
|
||||
|
||||
**Use Cases:**
|
||||
- Initialize batch processing workflows efficiently
|
||||
- Create consistent latent dimensions across model types
|
||||
- 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.
|
||||
|
||||
- **Multiple Format Support**: Save as PNG, JPEG, or WebP with format-specific optimizations
|
||||
- **Advanced Quality Controls**: JPEG/WebP quality (1-100), PNG compression (0-9), WebP lossless mode
|
||||
- **Floating Popup Viewer**: Draggable, resizable window that shows saved images immediately
|
||||
- **Interactive Previews**: Click any image to open in new tab, download individual images
|
||||
- **Batch Selection**: Multi-select images for bulk actions (open all, download all)
|
||||
- **Format-Specific Settings**: Quality indicators, file size display, compression info
|
||||
- **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)
|
||||
- Batch download or open selected images efficiently
|
||||
- Monitor file sizes and compression effectiveness in real-time
|
||||
- Streamlined workflow for iterative image generation and saving
|
||||
|
||||
**Why Better Than Standard Save Image:**
|
||||
- **Immediate Visual Feedback**: See your saved images instantly without opening file explorer
|
||||
- **Multi-Format Flexibility**: Choose optimal format for your use case (PNG for quality, JPEG for size, WebP for modern efficiency)
|
||||
- **Advanced Compression Control**: Fine-tune file sizes with format-specific quality settings
|
||||
- **Batch Operations**: Handle multiple images efficiently with selection and bulk actions
|
||||
- **Modern UI**: Floating, draggable interface that doesn't interrupt your workflow
|
||||
- **Smart Memory Usage**: File size indicators help optimize storage and sharing
|
||||
- **One-Click Access**: Direct image opening in browser tabs for quick sharing or review
|
||||
|
||||
### 🔧 Architecture Highlights
|
||||
|
||||
- **Modular Design**: Each tool is self-contained and independently testable
|
||||
@@ -81,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
|
||||
@@ -93,10 +272,112 @@ 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
|
||||
|
||||
```
|
||||
Seed History → KSampler → VAE Decode → Save Image
|
||||
🎲 12345 ↘ seed ↗
|
||||
[History UI: 54321, 99999, 11111...]
|
||||
```
|
||||
|
||||
**Current Seed:** 12345
|
||||
**History:** Auto-tracked previous seeds with timestamps
|
||||
**Interaction:** Click any historical seed to reload instantly
|
||||
|
||||
### Sampler Combo Example
|
||||
|
||||
```
|
||||
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
|
||||
**Smart Features:** Recommendations and compatibility validation
|
||||
|
||||
### Empty Latent Batch Example
|
||||
|
||||
```
|
||||
Empty Latent Batch → KSampler → VAE Decode → Kiko Save Image
|
||||
📦 preset: "1024×1024" ↘ batch latents ↗ ↘ popup viewer ↗
|
||||
batch_size: 4
|
||||
[swap button]
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
📷 output ↘ format: WEBP ↘ draggable window ↗
|
||||
quality: 85
|
||||
[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
|
||||
**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>
|
||||
@@ -105,7 +386,7 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
```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
|
||||
@@ -120,7 +401,7 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -135,6 +416,14 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
|------|-------------|--------|---------------|
|
||||
| **Resolution Calculator** | Calculate upscaled dimensions with model optimization | ✅ Complete | [Docs](examples/documentation/resolution_calculator.md) |
|
||||
| **Width Height Selector** | Preset-based dimension selection with 26 curated options | ✅ Complete | [Docs](examples/documentation/width_height_selector.md) |
|
||||
| **Seed History** | Advanced seed tracking with interactive history management | ✅ Complete | [Docs](examples/documentation/seed_history.md) |
|
||||
| **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 |
|
||||
|
||||
@@ -144,7 +433,7 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
|
||||
**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:**
|
||||
@@ -166,7 +455,7 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
|
||||
**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
|
||||
@@ -178,6 +467,114 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
- FLUX Presets (8): 1920×1080 to 1152×1728 (high resolution)
|
||||
- Ultra-Wide (8): 2560×1080 to 768×2304 (modern ratios)
|
||||
|
||||
#### Seed History
|
||||
|
||||
**Inputs:**
|
||||
- `seed` (INT): 0 to 18,446,744,073,709,551,615, default 12345
|
||||
|
||||
**Outputs:**
|
||||
- `seed` (INT): Validated and processed seed value
|
||||
|
||||
**UI Features:**
|
||||
- Interactive history display with timestamps
|
||||
- Generate random seed button (🎲 Generate)
|
||||
- Clear history button (🗑️ Clear)
|
||||
- Auto-hide after 2.5 seconds of inactivity
|
||||
- Click-to-restore hidden history
|
||||
|
||||
**History Management:**
|
||||
- Maximum 10 entries for optimal performance
|
||||
- Smart deduplication with 500ms window
|
||||
- Persistent localStorage storage
|
||||
- Newest entries displayed first
|
||||
- Human-readable time formatting (5m ago, 2h ago)
|
||||
|
||||
#### Sampler Combo
|
||||
|
||||
**Inputs:**
|
||||
- `sampler_name` (DROPDOWN): Available ComfyUI samplers (euler, dpmpp_2m, etc.)
|
||||
- `scheduler` (DROPDOWN): Available schedulers (normal, karras, exponential, etc.)
|
||||
- `steps` (INT): 1-1000, default 20
|
||||
- `cfg` (FLOAT): 0.0-30.0, default 7.0
|
||||
|
||||
**Outputs:**
|
||||
- `sampler_name` (STRING): Selected sampler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `steps` (INT): Validated step count
|
||||
- `cfg` (FLOAT): Validated CFG scale
|
||||
|
||||
**Features:**
|
||||
- Smart parameter validation and sanitization
|
||||
- Sampler-specific recommendations for optimal settings
|
||||
- Compatibility checking between samplers and schedulers
|
||||
- Graceful error handling with safe defaults
|
||||
- Comprehensive tooltips for user guidance
|
||||
|
||||
#### Empty Latent Batch
|
||||
|
||||
**Inputs:**
|
||||
- `preset` (DROPDOWN): 26 preset options + custom with formatted metadata display
|
||||
- `width` (INT): 64-8192, step 8, default 1024
|
||||
- `height` (INT): 64-8192, step 8, default 1024
|
||||
- `batch_size` (INT): 1-64, default 1
|
||||
|
||||
**Outputs:**
|
||||
- `latent` (LATENT): Batch of empty latent tensors in ComfyUI format
|
||||
- `width` (INT): Final sanitized width (divisible by 8)
|
||||
- `height` (INT): Final sanitized height (divisible by 8)
|
||||
|
||||
**UI Features:**
|
||||
- Visual blue swap button with hover and click feedback
|
||||
- Intelligent preset switching when swapping dimensions
|
||||
- Memory usage estimation and warnings for large batches
|
||||
- Auto-update width/height widgets when presets change
|
||||
|
||||
**Batch Processing:**
|
||||
- Creates tensors with shape: [batch_size, 4, height//8, width//8]
|
||||
- Efficient memory allocation with torch.zeros
|
||||
- Validates batch size limits (1-64) with performance warnings
|
||||
- Compatible with all ComfyUI latent processing nodes
|
||||
|
||||
**Preset Integration:**
|
||||
- Full access to 26 curated resolution presets from Width Height Selector
|
||||
- Model-aware categorization (SDXL, FLUX, Ultra-wide)
|
||||
- Formatted display with aspect ratio and megapixel information
|
||||
- Intelligent fallback to custom dimensions for invalid presets
|
||||
|
||||
#### Kiko Save Image
|
||||
|
||||
**Inputs:**
|
||||
- `images` (IMAGE): Batch of images to save
|
||||
- `filename_prefix` (STRING): Prefix for saved filenames, default "KikoSave"
|
||||
- `format` (DROPDOWN): Output format (PNG, JPEG, WEBP), default PNG
|
||||
- `quality` (INT): JPEG/WebP quality (1-100), default 90
|
||||
- `png_compress_level` (INT): PNG compression level (0-9), default 4
|
||||
- `webp_lossless` (BOOLEAN): Use lossless WebP compression, default False
|
||||
- `popup` (BOOLEAN): Enable popup viewer window, default True
|
||||
|
||||
**Outputs:**
|
||||
- `UI`: Enhanced image preview data with popup viewer functionality
|
||||
|
||||
**UI Features:**
|
||||
- Floating, draggable popup window showing saved images immediately
|
||||
- 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
|
||||
- Auto-hide/show behavior with smart positioning
|
||||
|
||||
**Format Support:**
|
||||
- **PNG**: Lossless compression with metadata preservation, configurable compression levels
|
||||
- **JPEG**: Quality-controlled lossy compression with automatic transparency handling
|
||||
- **WebP**: Modern format with both lossy and lossless modes, superior compression ratios
|
||||
|
||||
**Advanced Features:**
|
||||
- File size monitoring and display for optimization feedback
|
||||
- Format-specific quality indicators (PNG compression level, JPEG/WebP quality percentage)
|
||||
- Smart filename sanitization with timestamp-based uniqueness
|
||||
- Persistent popup viewer across multiple save operations
|
||||
- Toggle button integration in node UI for manual viewer control
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -200,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
|
||||
@@ -216,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
|
||||
@@ -245,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
|
||||
```
|
||||
|
||||
@@ -298,12 +717,15 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 2 (Resolution Calculator, Width Height Selector)
|
||||
- **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
|
||||
- **Test Coverage**: 100%
|
||||
- **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)
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
|
||||
---
|
||||
|
||||
@@ -313,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!
|
||||
@@ -3,12 +3,39 @@ ComfyUI-KikoTools: Modular collection of custom ComfyUI nodes
|
||||
All nodes are grouped under the "ComfyAssets" category
|
||||
"""
|
||||
|
||||
from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
except ImportError:
|
||||
# Fallback for testing environment
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# Tell ComfyUI where to find our JavaScript extensions
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# Print startup message
|
||||
print("\033[94m[ComfyUI-KikoTools] Loaded with swap button support!\033[0m")
|
||||
|
||||
def get_version():
|
||||
"""Parse version from pyproject.toml"""
|
||||
try:
|
||||
pyproject_path = Path(__file__).parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
content = pyproject_path.read_text()
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Print startup message with loaded tools
|
||||
print()
|
||||
print(f"\033[94m[ComfyUI-KikoTools] Version:\033[0m {get_version()}")
|
||||
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
|
||||
print(f"🫶 \033[94mLoaded:\033[0m {display_name}")
|
||||
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
|
||||
print()
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Empty Latent Batch Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Empty Latent Batch is a ComfyUI node that creates empty latent tensors with batch support and preset integration. It combines the preset functionality of Width Height Selector with efficient batch processing capabilities, making it ideal for batch workflows and optimized generation pipelines.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 **Preset Integration**
|
||||
- **26 Curated Presets**: Full access to SDXL, FLUX, and Ultra-wide presets
|
||||
- **Formatted Display**: Shows aspect ratio, megapixels, and model group
|
||||
- **Smart Fallback**: Automatic fallback to custom dimensions for invalid presets
|
||||
- **Model Optimization**: Preset categories optimized for different model types
|
||||
|
||||
### 📦 **Batch Processing**
|
||||
- **Configurable Batch Size**: Create 1-64 empty latents in single operation
|
||||
- **Memory Efficient**: Uses torch.zeros for optimal memory allocation
|
||||
- **Batch Validation**: Prevents excessive memory usage with warnings
|
||||
- **ComfyUI Compatible**: Standard latent format for seamless integration
|
||||
|
||||
### 🔄 **Visual Swap Button**
|
||||
- **Interactive UI**: Blue swap button with hover and click feedback
|
||||
- **Preset-Aware Swapping**: Intelligent switching between matching presets
|
||||
- **Custom Dimension Support**: Simple value swapping for custom inputs
|
||||
- **Visual Feedback**: Button state changes during interaction
|
||||
|
||||
### ✅ **Smart Validation**
|
||||
- **Dimension Sanitization**: Automatic adjustment to divisible-by-8 constraint
|
||||
- **Memory Estimation**: Built-in memory usage calculation
|
||||
- **Error Handling**: Graceful handling of invalid inputs with helpful messages
|
||||
- **Logging**: Detailed operation logging for debugging
|
||||
|
||||
## Node Interface
|
||||
|
||||
### Inputs
|
||||
- **preset**: Dropdown with 26 formatted preset options + custom
|
||||
- **width**: Custom width (64-8192, step 8, default 1024)
|
||||
- **height**: Custom height (64-8192, step 8, default 1024)
|
||||
- **batch_size**: Number of latents to create (1-64, default 1)
|
||||
|
||||
### Outputs
|
||||
- **latent**: Dictionary containing batch of empty latent tensors
|
||||
- **width**: Final sanitized width (guaranteed divisible by 8)
|
||||
- **height**: Final sanitized height (guaranteed divisible by 8)
|
||||
|
||||
## Preset Reference
|
||||
|
||||
The Empty Latent Batch node uses the same 26 curated presets as the Width Height Selector:
|
||||
|
||||
### SDXL Presets (~1 Megapixel)
|
||||
Optimized for SDXL models with ~1MP resolution constraint.
|
||||
|
||||
### FLUX Presets (High Resolution)
|
||||
Higher resolution presets optimized for FLUX models with better quality/speed balance.
|
||||
|
||||
### Ultra-Wide Presets (Modern Ratios)
|
||||
Modern aspect ratios for ultra-wide and panoramic generation.
|
||||
|
||||
*For complete preset details, see [Width Height Selector Documentation](width_height_selector.md#preset-reference)*
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Empty Latent Creation
|
||||
1. **Select Preset**: Choose from dropdown (e.g., "1024×1024 - 1:1 (1.0MP) - SDXL")
|
||||
2. **Set Batch Size**: Enter desired number of latents (e.g., 4)
|
||||
3. **Connect Output**: Link latent output to KSampler or other processing nodes
|
||||
|
||||
### Custom Batch Creation
|
||||
1. **Set Preset**: Select "custom"
|
||||
2. **Enter Dimensions**: Input width and height manually
|
||||
3. **Set Batch Size**: Configure number of latents needed
|
||||
4. **Validation**: Automatic sanitization ensures compatibility
|
||||
|
||||
### Orientation Swapping
|
||||
1. **Choose Preset**: Any preset (e.g., "1920×1080")
|
||||
2. **Click Swap Button**: Blue button in bottom-right corner
|
||||
3. **Result**: Gets swapped preset if available, or custom dimensions with swapped values
|
||||
4. **Widget Update**: Width/height widgets automatically update
|
||||
|
||||
### Memory-Aware Batch Processing
|
||||
1. **Large Batch**: Set batch_size to 16 or higher
|
||||
2. **Memory Warning**: Node provides memory usage estimation
|
||||
3. **Optimization**: Choose appropriate resolution preset for available VRAM
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Batch Generation Pipeline
|
||||
```
|
||||
Empty Latent Batch → KSampler → VAE Decode → Save Image
|
||||
(batch_size: 4) ↓ ↓ ↓
|
||||
4 samples 4 images 4 files
|
||||
```
|
||||
- Create 4 empty latents at once
|
||||
- Process all through sampling
|
||||
- Generate 4 images in single operation
|
||||
- Efficient for parameter exploration
|
||||
|
||||
### Model Comparison Workflow
|
||||
```
|
||||
Empty Latent Batch → [Multiple KSamplers] → [Multiple VAE Decoders] → Compare Results
|
||||
(batch_size: 8) ↓ ↓ ↓
|
||||
Split batch Process variants Side-by-side
|
||||
```
|
||||
- Create consistent batch of empty latents
|
||||
- Split across different samplers/models
|
||||
- Compare results with identical starting conditions
|
||||
|
||||
### Upscaling Preparation
|
||||
```
|
||||
Empty Latent Batch → KSampler → VAE Decode → Resolution Calculator → Upscaler
|
||||
(832×1216, batch:4) ↓ ↓ ↓ ↓
|
||||
Sample Decode Calculate 2x Upscale batch
|
||||
```
|
||||
- Generate batch at base resolution
|
||||
- Calculate upscale dimensions
|
||||
- Process entire batch through upscaler
|
||||
|
||||
### Aspect Ratio Exploration
|
||||
```
|
||||
Empty Latent Batch → [Clone to multiple orientations] → Parallel Processing
|
||||
(1920×1080) ↓ ↓
|
||||
[Swap Button] → Portrait & Landscape versions Compare orientations
|
||||
```
|
||||
- Start with base preset
|
||||
- Use swap button to create orientation variants
|
||||
- Process both simultaneously
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Memory Estimation
|
||||
The node provides built-in memory estimation for batch operations:
|
||||
|
||||
```python
|
||||
# Example memory calculations
|
||||
Batch Size: 4, Resolution: 1024×1024
|
||||
Latent Tensor: 4 × 4 × 128 × 128 = 262,144 elements
|
||||
Memory Usage: 262,144 × 4 bytes = 1.0 MB per batch
|
||||
```
|
||||
|
||||
### Intelligent Preset Handling
|
||||
- **Formatted Display**: Shows full metadata in dropdown
|
||||
- **Original Extraction**: Extracts original preset name from formatted strings
|
||||
- **Validation**: Verifies preset exists before processing
|
||||
- **Fallback Logic**: Uses custom dimensions if preset is invalid
|
||||
|
||||
### Batch Size Optimization
|
||||
- **Performance Warnings**: Alerts for large batch sizes
|
||||
- **Memory Limits**: Prevents excessive memory allocation
|
||||
- **Hardware Awareness**: Considers available system resources
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
### Batch Size Selection
|
||||
- **Small Batches (1-4)**: Good for testing and development
|
||||
- **Medium Batches (5-16)**: Efficient for most production workflows
|
||||
- **Large Batches (17-64)**: Only for high-memory systems and specific use cases
|
||||
|
||||
### Preset Selection
|
||||
- **SDXL Projects**: Use SDXL presets for memory efficiency
|
||||
- **FLUX Projects**: Use FLUX presets for optimal quality
|
||||
- **Ultra-wide Projects**: Ensure sufficient VRAM for large resolutions
|
||||
- **Custom Projects**: Use custom dimensions for specific requirements
|
||||
|
||||
### Memory Management
|
||||
- Monitor memory usage with large batches
|
||||
- Use appropriate resolution presets for available VRAM
|
||||
- Consider splitting very large batches across multiple nodes
|
||||
- Clear GPU memory between large batch operations
|
||||
|
||||
### Workflow Integration
|
||||
- Always connect all three outputs (latent, width, height)
|
||||
- Use width/height outputs for downstream dimension calculations
|
||||
- Combine with Resolution Calculator for upscaling workflows
|
||||
- Leverage batch processing for efficient parameter exploration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
- **Out of Memory**: Reduce batch_size or use lower resolution presets
|
||||
- **Invalid Dimensions**: Node automatically sanitizes to valid values
|
||||
- **Preset Not Found**: Falls back to custom dimensions with warning
|
||||
- **Swap Button Not Working**: Ensure node is not collapsed and button is visible
|
||||
|
||||
### Performance Optimization
|
||||
- **Batch Size**: Start with smaller batches and increase as needed
|
||||
- **Resolution**: Use appropriate presets for your model and VRAM
|
||||
- **Memory Monitoring**: Watch for memory warnings and adjust accordingly
|
||||
- **Cleanup**: Clear unused tensors between large batch operations
|
||||
|
||||
### Error Handling
|
||||
- **Dimension Validation**: Automatic rounding to nearest valid values
|
||||
- **Batch Size Limits**: Clamped to 1-64 range with warnings
|
||||
- **Memory Allocation**: Graceful handling of insufficient memory
|
||||
- **Preset Fallbacks**: Automatic fallback to custom dimensions
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Latent Tensor Format
|
||||
- **Shape**: [batch_size, 4, height//8, width//8]
|
||||
- **Data Type**: torch.float32
|
||||
- **Initialization**: torch.zeros for clean empty state
|
||||
- **Memory Layout**: Contiguous tensor for optimal performance
|
||||
|
||||
### Validation Pipeline
|
||||
1. **Preset Extraction**: Parse formatted preset strings
|
||||
2. **Dimension Calculation**: Get base dimensions from preset or custom
|
||||
3. **Sanitization**: Ensure divisible-by-8 constraint
|
||||
4. **Batch Validation**: Check batch size limits
|
||||
5. **Memory Estimation**: Calculate expected memory usage
|
||||
6. **Tensor Creation**: Allocate and initialize latent tensor
|
||||
|
||||
### UI Integration
|
||||
- **JavaScript Extension**: Custom UI for swap button functionality
|
||||
- **Widget Synchronization**: Auto-update width/height when preset changes
|
||||
- **Visual Feedback**: Hover effects and click animations
|
||||
- **Event Handling**: Proper mouse event management
|
||||
|
||||
### Swap Button Implementation
|
||||
- **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
|
||||
@@ -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,82 @@
|
||||
# Image to Multiple Of
|
||||
|
||||
## Overview
|
||||
|
||||
The **Image to Multiple Of** node adjusts image dimensions to be multiples of a specified value. This is particularly useful for models that require input dimensions to be multiples of certain values (e.g., 8, 16, 32, 64) for optimal performance or compatibility.
|
||||
|
||||
## Purpose
|
||||
|
||||
Many AI models, especially diffusion models and VAEs, require input dimensions to be multiples of specific values due to their architecture (e.g., downsampling layers). This node ensures your images meet these requirements without manual calculation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **image** (IMAGE, required): The input image to process
|
||||
- **multiple_of** (INT, required): The value that dimensions should be multiple of
|
||||
- Default: 64
|
||||
- Range: 1-256
|
||||
- Step: 16
|
||||
- **method** (COMBO, required): Processing method
|
||||
- Options: "center crop", "rescale"
|
||||
|
||||
## Outputs
|
||||
|
||||
- **image** (IMAGE): Processed image with dimensions adjusted to multiples of the specified value
|
||||
|
||||
## Processing Methods
|
||||
|
||||
### Center Crop
|
||||
- Crops the image from the center to achieve the target dimensions
|
||||
- Preserves image quality but may lose edge content
|
||||
- Best for images where the important content is centered
|
||||
|
||||
### Rescale
|
||||
- Resizes the image to the target dimensions using bilinear interpolation
|
||||
- Keeps all content but may slightly affect image quality
|
||||
- Best when you need to preserve all image content
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Prepare for VAE Encoding
|
||||
```
|
||||
Load Image → Image to Multiple Of (multiple_of: 64) → VAE Encode
|
||||
```
|
||||
|
||||
### Example 2: Prepare for Specific Model Requirements
|
||||
```
|
||||
Load Image → Image to Multiple Of (multiple_of: 32) → Model Processing
|
||||
```
|
||||
|
||||
### Example 3: Batch Processing
|
||||
```
|
||||
Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batch Process
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Supports batch processing (processes all images in a batch)
|
||||
- Works with any number of channels (RGB, RGBA, grayscale, etc.)
|
||||
- Calculates the largest dimensions that are less than or equal to the original size
|
||||
- For center crop: crops equally from all sides to maintain centering
|
||||
- For rescale: uses bilinear interpolation with align_corners=False
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **VAE Preprocessing**: Ensure images are compatible with VAE encoders that require dimensions divisible by 64
|
||||
2. **Model Compatibility**: Adjust images for models with specific architectural requirements
|
||||
3. **Batch Uniformity**: Ensure all images in a batch have dimensions that meet model requirements
|
||||
4. **Performance Optimization**: Some models perform better with dimensions that are powers of 2
|
||||
|
||||
## Tips
|
||||
|
||||
- Use **center crop** when your subject is centered and you don't mind losing edge details
|
||||
- Use **rescale** when you need to preserve all image content
|
||||
- Common multiple_of values: 8, 16, 32, 64, 128
|
||||
- For Stable Diffusion models, 64 is typically recommended
|
||||
- For some upscaling models, 32 or 16 may be sufficient
|
||||
|
||||
## Error Handling
|
||||
|
||||
The node will raise an error if:
|
||||
- The image dimensions are smaller than the specified multiple_of value
|
||||
- Invalid input types are provided
|
||||
- The resulting dimensions would be 0 or negative
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Sampler Combo Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Sampler Combo is a unified ComfyUI node that combines sampler, scheduler, steps, and CFG settings into a single interface. It reduces workflow complexity while ensuring compatible parameter combinations and providing optimization recommendations.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 **Unified Configuration**
|
||||
- Single node for all sampling parameters
|
||||
- Compatible sampler + scheduler combinations
|
||||
- Optimized steps and CFG recommendations
|
||||
- Reduced workflow complexity
|
||||
|
||||
### 🧠 **Smart Recommendations**
|
||||
- Scheduler suggestions based on selected sampler
|
||||
- Optimal steps range for each sampler
|
||||
- CFG scale recommendations
|
||||
- Compatibility warnings and suggestions
|
||||
|
||||
### ✅ **Built-in Validation**
|
||||
- Parameter validation and sanitization
|
||||
- Error handling with safe defaults
|
||||
- Performance optimization hints
|
||||
- Real-time compatibility checking
|
||||
|
||||
### 📊 **Analysis Tools**
|
||||
- Combo configuration analysis
|
||||
- Performance assessment
|
||||
- Optimization recommendations
|
||||
- Compatibility scoring
|
||||
|
||||
## Node Interface
|
||||
|
||||
### Inputs
|
||||
- **sampler_name**: Dropdown with available sampling algorithms
|
||||
- **scheduler**: Dropdown with compatible schedulers
|
||||
- **steps**: Integer slider (1-100 steps)
|
||||
- **cfg**: Float slider (0.0-20.0 CFG scale)
|
||||
|
||||
### Outputs
|
||||
- **sampler_name**: Selected sampler algorithm
|
||||
- **scheduler**: Selected scheduler algorithm
|
||||
- **steps**: Number of sampling steps
|
||||
- **cfg**: CFG scale value
|
||||
|
||||
## Available Samplers
|
||||
|
||||
### Primary Samplers
|
||||
| Sampler | Type | Speed | Quality | Best For |
|
||||
|---------|------|-------|---------|----------|
|
||||
| euler | Deterministic | Fast | Good | General use |
|
||||
| euler_ancestral | Stochastic | Fast | Good | Creative variation |
|
||||
| heun | Higher-order | Medium | Better | Quality focus |
|
||||
| dpm_2 | Multi-step | Medium | Good | Balanced |
|
||||
| dpm_2_ancestral | Stochastic | Medium | Good | Creative quality |
|
||||
| lms | Linear | Fast | Good | Simple scenes |
|
||||
| dpm_fast | Optimized | Very Fast | Good | Speed priority |
|
||||
| dpm_adaptive | Adaptive | Variable | Best | Automatic tuning |
|
||||
|
||||
### Advanced Samplers
|
||||
| Sampler | Type | Speed | Quality | Best For |
|
||||
|---------|------|-------|---------|----------|
|
||||
| dpmpp_2s_ancestral | Advanced | Medium | Better | High quality |
|
||||
| dpmpp_2m | Optimized | Fast | Better | Speed + quality |
|
||||
| dpmpp_2m_sde | Stochastic | Medium | Best | Maximum quality |
|
||||
| dpmpp_3m_sde | Latest | Medium | Best | Cutting edge |
|
||||
| ddim | Classic | Fast | Good | Compatibility |
|
||||
| uni_pc | Unified | Fast | Better | Efficiency |
|
||||
|
||||
## Available Schedulers
|
||||
|
||||
### Linear Schedulers
|
||||
- **normal**: Standard linear schedule
|
||||
- **linear**: Basic linear distribution
|
||||
- **sgm_uniform**: Uniform distribution
|
||||
|
||||
### Advanced Schedulers
|
||||
- **karras**: Karras noise schedule (recommended)
|
||||
- **exponential**: Exponential decay
|
||||
- **polyexponential**: Polynomial exponential
|
||||
- **beta**: Beta distribution schedule
|
||||
|
||||
### Specialized Schedulers
|
||||
- **cosine**: Cosine annealing schedule
|
||||
- **simple**: Simplified schedule
|
||||
- **ddim_uniform**: DDIM uniform schedule
|
||||
- **laplace**: Laplace distribution
|
||||
|
||||
## Optimization Guidelines
|
||||
|
||||
### Recommended Combinations
|
||||
|
||||
#### Speed Optimized
|
||||
```
|
||||
Sampler: euler or dpm_fast
|
||||
Scheduler: normal or simple
|
||||
Steps: 15-25
|
||||
CFG: 6.0-8.0
|
||||
```
|
||||
|
||||
#### Quality Optimized
|
||||
```
|
||||
Sampler: dpmpp_2m_sde or dpmpp_3m_sde
|
||||
Scheduler: karras
|
||||
Steps: 25-35
|
||||
CFG: 7.0-9.0
|
||||
```
|
||||
|
||||
#### Balanced
|
||||
```
|
||||
Sampler: dpmpp_2m or heun
|
||||
Scheduler: karras or normal
|
||||
Steps: 20-30
|
||||
CFG: 7.0-8.5
|
||||
```
|
||||
|
||||
### Steps Recommendations
|
||||
|
||||
| Sampler Type | Min Steps | Optimal | Max Steps |
|
||||
|--------------|-----------|---------|-----------|
|
||||
| Fast (euler, dpm_fast) | 10 | 20 | 30 |
|
||||
| Standard (heun, dpm_2) | 15 | 25 | 40 |
|
||||
| Advanced (dpmpp_*) | 20 | 30 | 50 |
|
||||
| Adaptive | 10 | 25 | 100 |
|
||||
|
||||
### CFG Scale Guidelines
|
||||
|
||||
| Content Type | CFG Range | Recommended |
|
||||
|--------------|-----------|-------------|
|
||||
| Photorealistic | 5.0-8.0 | 7.0 |
|
||||
| Artistic/Stylized | 7.0-12.0 | 9.0 |
|
||||
| Abstract/Creative | 8.0-15.0 | 11.0 |
|
||||
| Text/Details | 10.0-20.0 | 13.0 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Configuration
|
||||
```
|
||||
sampler_name: euler
|
||||
scheduler: normal
|
||||
steps: 20
|
||||
cfg: 7.0
|
||||
```
|
||||
|
||||
### High Quality Setup
|
||||
```
|
||||
sampler_name: dpmpp_2m_sde
|
||||
scheduler: karras
|
||||
steps: 30
|
||||
cfg: 8.0
|
||||
```
|
||||
|
||||
### Speed Priority
|
||||
```
|
||||
sampler_name: dpm_fast
|
||||
scheduler: simple
|
||||
steps: 15
|
||||
cfg: 6.5
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Compatibility Analysis
|
||||
The node provides real-time analysis of parameter compatibility:
|
||||
- Scheduler compatibility with selected sampler
|
||||
- Steps optimization for sampler type
|
||||
- CFG scale recommendations
|
||||
- Performance impact assessment
|
||||
|
||||
### Error Handling
|
||||
- Invalid samplers default to 'euler'
|
||||
- Invalid schedulers default to 'normal'
|
||||
- Out-of-range steps clamped to valid range
|
||||
- Invalid CFG values sanitized to safe defaults
|
||||
|
||||
### Performance Tips
|
||||
1. **Use Karras scheduler** for most samplers (better quality)
|
||||
2. **Start with 20-30 steps** for most use cases
|
||||
3. **Keep CFG 6.0-9.0** for realistic images
|
||||
4. **Try dpmpp_2m** for best speed/quality balance
|
||||
5. **Use euler** for fastest generation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
- **Slow generation**: Try euler or dpm_fast samplers
|
||||
- **Poor quality**: Increase steps or try dpmpp_2m_sde
|
||||
- **Overcooked images**: Lower CFG scale
|
||||
- **Underdetailed**: Increase CFG or steps
|
||||
- **Artifacts**: Try karras scheduler or different sampler
|
||||
|
||||
### Performance Optimization
|
||||
- **GPU Memory**: Lower steps if running out of VRAM
|
||||
- **Speed**: Use euler + normal + 15-20 steps
|
||||
- **Quality**: Use dpmpp_2m_sde + karras + 25-30 steps
|
||||
- **Compatibility**: Stick to euler/heun for broad model support
|
||||
|
||||
## Integration
|
||||
|
||||
The Sampler Combo node outputs are compatible with all standard ComfyUI sampling nodes:
|
||||
- KSampler
|
||||
- KSamplerAdvanced
|
||||
- Custom sampling workflows
|
||||
- Upscaling pipelines
|
||||
- Img2img workflows
|
||||
|
||||
Connect the outputs directly to your sampling node inputs for streamlined configuration.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Seed History Tool
|
||||
|
||||
The Seed History tool provides advanced seed value tracking with an interactive UI for managing seed history, automatic deduplication, and convenient seed retrieval.
|
||||
|
||||
## Overview
|
||||
|
||||
The Seed History node functions as both a standard seed input and an intelligent tracking system that automatically monitors seed changes and maintains a searchable history.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎲 Core Functionality
|
||||
- **Seed Output**: Standard ComfyUI seed value output (0 to 18,446,744,073,709,551,615)
|
||||
- **History Tracking**: Automatic tracking of all seed changes with timestamps
|
||||
- **Deduplication**: Intelligent filtering to prevent duplicate entries within 500ms windows
|
||||
- **Persistent Storage**: History persists across ComfyUI sessions using localStorage
|
||||
|
||||
### 🎯 Interactive UI
|
||||
- **History Display**: Scrollable list showing recent seeds with timestamps
|
||||
- **Click to Load**: Click any history entry to instantly load that seed
|
||||
- **Generate Button**: Create new random seeds with one click
|
||||
- **Clear History**: Remove all tracked seeds when needed
|
||||
- **Auto-Hide**: History section automatically hides after 2.5 seconds of inactivity
|
||||
|
||||
### ⚡ Smart Features
|
||||
- **Real-time Updates**: Tracks seed changes from increment/decrement buttons
|
||||
- **Visual Feedback**: Selected seeds are highlighted in green
|
||||
- **Time Formatting**: Human-readable "time ago" display (e.g., "5m ago", "2h ago")
|
||||
- **Notifications**: Toast messages for actions like generate and clear
|
||||
- **Responsive Design**: Adapts to node resizing
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Setup
|
||||
|
||||
1. **Add Node**: Search for "Seed History" in the ComfyUI node browser
|
||||
2. **Connect Output**: Connect the seed output to any node requiring a seed input
|
||||
3. **Automatic Tracking**: The node automatically begins tracking seed changes
|
||||
|
||||
### Seed Management
|
||||
|
||||
```
|
||||
🎲 Seed History
|
||||
┌─────────────────┐
|
||||
│ 🎲 Generate │ 🗑️ Clear │
|
||||
├─────────────────┤
|
||||
│ 🎲 1,234,567 │ ← Click to load
|
||||
│ ⏰ 2m ago │
|
||||
├─────────────────┤
|
||||
│ 🎲 9,876,543 │
|
||||
│ ⏰ 5m ago │
|
||||
├─────────────────┤
|
||||
│ 🎲 5,555,555 │
|
||||
│ ⏰ 10m ago │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Workflow Integration
|
||||
|
||||
The Seed History node works seamlessly with any ComfyUI workflow:
|
||||
|
||||
```
|
||||
[Seed History] → [KSampler] → [Image Output]
|
||||
↓
|
||||
[VAE Decode] → [Save Image]
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### History Management
|
||||
- **Maximum Entries**: Keeps the 10 most recent seeds
|
||||
- **Smart Deduplication**: Prevents rapid duplicate additions
|
||||
- **Timestamp Tracking**: Full date/time information for each seed
|
||||
- **Persistent Storage**: History survives ComfyUI restarts
|
||||
|
||||
### UI Behavior
|
||||
- **Auto-Hide Timer**: History hides after 2.5 seconds of inactivity
|
||||
- **Mouse Interaction**: Hovering over history cancels auto-hide
|
||||
- **Restore Button**: Click to restore hidden history section
|
||||
- **Visual Feedback**: Hover effects and selection highlighting
|
||||
|
||||
### Seed Validation
|
||||
- **Range Checking**: Ensures seeds are within valid ComfyUI range
|
||||
- **Error Handling**: Graceful fallback to default seed (12345) on errors
|
||||
- **Sanitization**: Automatic clamping of out-of-range values
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Input Parameters
|
||||
- **seed** (INT): Seed value for generation processes
|
||||
- Range: 0 to 18,446,744,073,709,551,615
|
||||
- Default: 12345
|
||||
- Tooltip: "Seed value for generation processes. History UI tracks all changes automatically."
|
||||
|
||||
### Output
|
||||
- **seed** (INT): The processed seed value for use in other nodes
|
||||
|
||||
### Storage
|
||||
- **Key**: `comfyui_kikotools_seed_history`
|
||||
- **Format**: JSON array of history entries
|
||||
- **Location**: Browser localStorage
|
||||
- **Persistence**: Survives browser sessions and ComfyUI restarts
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 🎨 Creative Workflows
|
||||
- **Iteration Tracking**: Keep track of promising seeds during creative exploration
|
||||
- **Version Control**: Easily return to previous seeds that produced good results
|
||||
- **Experimentation**: Generate and track multiple seed variations
|
||||
|
||||
### 🔬 Technical Workflows
|
||||
- **Reproducibility**: Maintain exact seed records for reproducing specific outputs
|
||||
- **A/B Testing**: Compare results from different seeds with easy switching
|
||||
- **Documentation**: Export seed history for technical documentation
|
||||
|
||||
### 📊 Batch Processing
|
||||
- **Seed Management**: Track seeds across multiple batch runs
|
||||
- **Quality Control**: Quickly identify and reuse successful seeds
|
||||
- **Workflow Optimization**: Analyze seed performance patterns
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
### Efficient Usage
|
||||
1. **Let it Track**: The node automatically tracks all seed changes - no manual intervention needed
|
||||
2. **Use Generate**: The generate button is optimized for creating good random seeds
|
||||
3. **Regular Clearing**: Clear history periodically to maintain relevant seeds only
|
||||
|
||||
### Workflow Integration
|
||||
1. **Single Source**: Use one Seed History node per workflow for centralized tracking
|
||||
2. **Connect Early**: Place the node early in your workflow chain for complete tracking
|
||||
3. **Branch Connections**: Connect to multiple nodes that need the same seed
|
||||
|
||||
### History Management
|
||||
1. **Review Regularly**: Check history for seeds that produced good results
|
||||
2. **Document Success**: Note down particularly successful seeds externally
|
||||
3. **Clean Periodically**: Clear history when starting new creative projects
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**History Not Updating**
|
||||
- Ensure the node is properly connected to your workflow
|
||||
- Check that seed widget is visible and functional
|
||||
- Verify browser localStorage is enabled
|
||||
|
||||
**UI Not Appearing**
|
||||
- Check browser console for JavaScript errors
|
||||
- Ensure ComfyUI-KikoTools is properly installed
|
||||
- Verify web directory permissions
|
||||
|
||||
**Seeds Not Loading**
|
||||
- Confirm the seed is within valid range
|
||||
- Check that target widgets support the seed value
|
||||
- Verify node connections are intact
|
||||
|
||||
### Performance Notes
|
||||
- History is limited to 10 entries for optimal performance
|
||||
- Deduplication prevents excessive storage usage
|
||||
- Auto-hide reduces visual clutter during long workflows
|
||||
|
||||
## Examples
|
||||
|
||||
See the `examples/workflows/` directory for complete workflow examples demonstrating:
|
||||
- Basic seed tracking workflow
|
||||
- Creative iteration with history
|
||||
- Technical reproducibility setup
|
||||
- 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
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,552 @@
|
||||
{
|
||||
"id": "3cd0ac86-8d11-41e5-9d8b-6621bc810ee6",
|
||||
"revision": 0,
|
||||
"last_node_id": 21,
|
||||
"last_link_id": 21,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-80,
|
||||
850
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
314.0000305175781
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"label": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": 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": 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": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
18
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-glifnodes",
|
||||
"ver": "1.0.0",
|
||||
"Node name for S&R": "ImageToMultipleOf",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
65,
|
||||
"center crop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": 18
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.47",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
1010,
|
||||
940
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
258
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"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": [
|
||||
[
|
||||
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": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"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,47 +1,355 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"id": "41469b2d-d616-479d-879a-95cdc6074a37",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ResolutionCalculator",
|
||||
"pos": [100, 100],
|
||||
"size": {"0": 315, "1": 126},
|
||||
"pos": [
|
||||
-30,
|
||||
210
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
126
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "latent",
|
||||
"shape": 7,
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"ver": "965ad60c74d7f25b1acce890d9c06518e46e6d0b",
|
||||
"Node name for S&R": "ResolutionCalculator",
|
||||
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
2.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-340,
|
||||
210
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "image", "type": "IMAGE", "link": null},
|
||||
{"name": "latent", "type": "LATENT", "link": null}
|
||||
],
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "width", "type": "INT", "links": [1], "slot_index": 0},
|
||||
{"name": "height", "type": "INT", "links": [2], "slot_index": 1}
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [2.0],
|
||||
"category": "ComfyAssets"
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"image-2025-06-13-105737.jpg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [450, 100],
|
||||
"size": {"0": 400, "1": 200},
|
||||
"id": 7,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
-350,
|
||||
610
|
||||
],
|
||||
"size": [
|
||||
410,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"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."},
|
||||
"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."]
|
||||
"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"]
|
||||
[
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
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": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Resolution Calculator",
|
||||
"bounding": [
|
||||
-480,
|
||||
20,
|
||||
1480,
|
||||
880
|
||||
],
|
||||
"color": "#ffffff",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"ds": {
|
||||
"scale": 0.45000000000000145,
|
||||
"offset": [
|
||||
1248.1498802376432,
|
||||
256.0155843113725
|
||||
]
|
||||
},
|
||||
"links_added_by_ue": [],
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
After Width: | Height: | Size: 785 KiB |
@@ -0,0 +1,537 @@
|
||||
{
|
||||
"id": "972425bd-9910-484d-ad09-f142f534fc61",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 13,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
120,
|
||||
390
|
||||
],
|
||||
"size": [
|
||||
425.27801513671875,
|
||||
180.6060791015625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
180,
|
||||
610
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "EmptyLatentImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
863,
|
||||
186
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
571
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "KSampler",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
555950987049067,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1,
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1209,
|
||||
188
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1451,
|
||||
189
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
120,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
422.84503173828125,
|
||||
164.31304931640625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SamplerCombo",
|
||||
"pos": [
|
||||
550,
|
||||
290
|
||||
],
|
||||
"size": [
|
||||
300,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"slot_index": 3,
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"ver": "dcf2d679c1a1091c54c2aa8cc04724236723227d",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "SamplerCombo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"dpmpp_2m",
|
||||
"karras",
|
||||
25,
|
||||
7.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-220,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"sdxl_ckpt/realvisxlV40_v40LightningBakedvae.safetensors"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
9,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
0,
|
||||
3,
|
||||
6,
|
||||
"COMBO"
|
||||
],
|
||||
[
|
||||
11,
|
||||
10,
|
||||
1,
|
||||
3,
|
||||
7,
|
||||
"COMBO"
|
||||
],
|
||||
[
|
||||
12,
|
||||
10,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
"INT"
|
||||
],
|
||||
[
|
||||
13,
|
||||
10,
|
||||
3,
|
||||
3,
|
||||
5,
|
||||
"FLOAT"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"ds": {
|
||||
"scale": 0.9740024562304554,
|
||||
"offset": [
|
||||
75.10666660323399,
|
||||
-252.18018113111526
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.21.7",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
{
|
||||
"id": "972425bd-9910-484d-ad09-f142f534fc61",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
120,
|
||||
390
|
||||
],
|
||||
"size": [
|
||||
425.27801513671875,
|
||||
180.6060791015625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
180,
|
||||
610
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "EmptyLatentImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
863,
|
||||
186
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
571
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "KSampler",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
231413202715030,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1,
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1209,
|
||||
188
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1451,
|
||||
189
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
120,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
422.84503173828125,
|
||||
164.31304931640625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-220,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.40",
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"sdxl_ckpt/realvisxlV40_v40LightningBakedvae.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SamplerCombo",
|
||||
"pos": [
|
||||
550,
|
||||
360
|
||||
],
|
||||
"size": [
|
||||
300,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"slot_index": 3,
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"ver": "dcf2d679c1a1091c54c2aa8cc04724236723227d",
|
||||
"Node name for S&R": "SamplerCombo",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"dpmpp_2m",
|
||||
"karras",
|
||||
25,
|
||||
7.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SeedHistory",
|
||||
"pos": [
|
||||
550,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
290,
|
||||
220
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"links": [
|
||||
14
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
|
||||
"ver": "dcf2d679c1a1091c54c2aa8cc04724236723227d",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "SeedHistory"
|
||||
},
|
||||
"widgets_values": [
|
||||
893082183398485,
|
||||
"randomize",
|
||||
""
|
||||
],
|
||||
"color": "#2a363b",
|
||||
"bgcolor": "#3f5159",
|
||||
"hasBeenResized": true,
|
||||
"seedHistory": [
|
||||
{
|
||||
"seed": 893082183398485,
|
||||
"timestamp": 1750032481153,
|
||||
"dateString": "6/15/2025, 5:08:01 PM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236135,
|
||||
"timestamp": 1750014787315,
|
||||
"dateString": "6/15/2025, 12:13:07 PM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236134,
|
||||
"timestamp": 1750014721305,
|
||||
"dateString": "6/15/2025, 12:12:01 PM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236133,
|
||||
"timestamp": 1750012659402,
|
||||
"dateString": "6/15/2025, 11:37:39 AM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236132,
|
||||
"timestamp": 1750011284415,
|
||||
"dateString": "6/15/2025, 11:14:44 AM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236131,
|
||||
"timestamp": 1750011223416,
|
||||
"dateString": "6/15/2025, 11:13:43 AM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236130,
|
||||
"timestamp": 1750011181407,
|
||||
"dateString": "6/15/2025, 11:13:01 AM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236129,
|
||||
"timestamp": 1750005515384,
|
||||
"dateString": "6/15/2025, 9:38:35 AM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236128,
|
||||
"timestamp": 1750005462384,
|
||||
"dateString": "6/15/2025, 9:37:42 AM"
|
||||
},
|
||||
{
|
||||
"seed": 267914687236127,
|
||||
"timestamp": 1750005354713,
|
||||
"dateString": "6/15/2025, 9:35:54 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
9,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
0,
|
||||
3,
|
||||
7,
|
||||
"COMBO"
|
||||
],
|
||||
[
|
||||
11,
|
||||
10,
|
||||
1,
|
||||
3,
|
||||
8,
|
||||
"COMBO"
|
||||
],
|
||||
[
|
||||
12,
|
||||
10,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
"INT"
|
||||
],
|
||||
[
|
||||
13,
|
||||
10,
|
||||
3,
|
||||
3,
|
||||
6,
|
||||
"FLOAT"
|
||||
],
|
||||
[
|
||||
14,
|
||||
11,
|
||||
0,
|
||||
3,
|
||||
4,
|
||||
"INT"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"ds": {
|
||||
"scale": 0.9740024562304554,
|
||||
"offset": [
|
||||
2.9611945935278796,
|
||||
36.50023864466208
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.21.7",
|
||||
"VHS_latentpreview": true,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,16 +5,42 @@ Handles automatic discovery and registration of all ComfyAssets tools
|
||||
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.width_height_selector import WidthHeightSelectorNode
|
||||
from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
from .tools.display_text import DisplayTextNode
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ResolutionCalculator": ResolutionCalculatorNode,
|
||||
"WidthHeightSelector": WidthHeightSelectorNode,
|
||||
"SeedHistory": SeedHistoryNode,
|
||||
"SamplerCombo": SamplerComboNode,
|
||||
"SamplerComboCompact": SamplerComboCompactNode,
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
"DisplayText": DisplayTextNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ResolutionCalculator": "Resolution Calculator",
|
||||
"WidthHeightSelector": "Width Height Selector",
|
||||
"SeedHistory": "Seed History",
|
||||
"SamplerCombo": "Sampler Combo",
|
||||
"SamplerComboCompact": "Sampler Combo (Compact)",
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"GeminiPrompt": "Gemini Prompt Engineer",
|
||||
"DisplayAny": "Display Any",
|
||||
"DisplayText": "Display Text",
|
||||
}
|
||||
|
||||
__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,5 @@
|
||||
"""Empty Latent Batch tool for ComfyUI."""
|
||||
|
||||
from .node import EmptyLatentBatchNode
|
||||
|
||||
__all__ = ["EmptyLatentBatchNode"]
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Logic for creating empty latent tensors with batch support."""
|
||||
|
||||
import torch
|
||||
from typing import Dict, Tuple
|
||||
|
||||
|
||||
def create_empty_latent_batch(
|
||||
width: int, height: int, batch_size: int = 1
|
||||
) -> Dict[str, torch.Tensor]:
|
||||
"""
|
||||
Create empty latent tensor with batch support.
|
||||
|
||||
Args:
|
||||
width: Width in pixels (will be divided by 8 for latent space)
|
||||
height: Height in pixels (will be divided by 8 for latent space)
|
||||
batch_size: Number of latents in the batch
|
||||
|
||||
Returns:
|
||||
Dictionary containing the latent samples tensor
|
||||
|
||||
Raises:
|
||||
ValueError: If dimensions are invalid
|
||||
"""
|
||||
# Validate inputs
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"Width and height must be positive, got {width}x{height}")
|
||||
|
||||
if batch_size <= 0:
|
||||
raise ValueError(f"Batch size must be positive, got {batch_size}")
|
||||
|
||||
# Ensure dimensions are divisible by 8 (VAE requirement)
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
raise ValueError(
|
||||
f"Width and height must be divisible by 8, got {width}x{height}"
|
||||
)
|
||||
|
||||
# Convert pixel dimensions to latent space (divide by 8)
|
||||
latent_width = width // 8
|
||||
latent_height = height // 8
|
||||
|
||||
# Create empty latent tensor
|
||||
# ComfyUI latent format: [batch, channels, height, width]
|
||||
# Standard VAE uses 4 channels
|
||||
latent_tensor = torch.zeros(batch_size, 4, latent_height, latent_width)
|
||||
|
||||
return {"samples": latent_tensor}
|
||||
|
||||
|
||||
def validate_dimensions(width: int, height: int) -> bool:
|
||||
"""
|
||||
Validate that dimensions are suitable for latent creation.
|
||||
|
||||
Args:
|
||||
width: Width in pixels
|
||||
height: Height in pixels
|
||||
|
||||
Returns:
|
||||
True if dimensions are valid
|
||||
"""
|
||||
# Check basic constraints
|
||||
if width <= 0 or height <= 0:
|
||||
return False
|
||||
|
||||
# Check divisibility by 8
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
return False
|
||||
|
||||
# Check reasonable size limits (64x64 to 8192x8192)
|
||||
if width < 64 or height < 64:
|
||||
return False
|
||||
|
||||
if width > 8192 or height > 8192:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_dimensions(width: int, height: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Sanitize dimensions to ensure they meet latent requirements.
|
||||
|
||||
Args:
|
||||
width: Input width
|
||||
height: Input height
|
||||
|
||||
Returns:
|
||||
Tuple of (sanitized_width, sanitized_height)
|
||||
"""
|
||||
# Ensure minimum dimensions
|
||||
width = max(64, width)
|
||||
height = max(64, height)
|
||||
|
||||
# Ensure maximum dimensions
|
||||
width = min(8192, width)
|
||||
height = min(8192, height)
|
||||
|
||||
# Round to nearest multiple of 8
|
||||
width = (width + 7) // 8 * 8
|
||||
height = (height + 7) // 8 * 8
|
||||
|
||||
return width, height
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Empty Latent Batch node for ComfyUI."""
|
||||
|
||||
import torch
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
create_empty_latent_batch,
|
||||
validate_dimensions,
|
||||
sanitize_dimensions,
|
||||
)
|
||||
from ..width_height_selector.logic import get_preset_dimensions
|
||||
from ..width_height_selector.presets import (
|
||||
PRESET_OPTIONS,
|
||||
PRESET_METADATA,
|
||||
)
|
||||
|
||||
|
||||
class EmptyLatentBatchNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Empty Latent Batch node for creating empty latent tensors with batch support.
|
||||
|
||||
Creates empty latent tensors with specified dimensions and batch size,
|
||||
compatible with ComfyUI's latent format for use with VAE and diffusion models.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
# Create formatted preset options with metadata
|
||||
preset_options = ["custom"] # Custom first
|
||||
|
||||
# Add formatted presets with metadata
|
||||
for preset_name in PRESET_OPTIONS.keys():
|
||||
if preset_name != "custom":
|
||||
metadata = PRESET_METADATA.get(preset_name)
|
||||
if metadata:
|
||||
formatted_option = (
|
||||
f"{preset_name} - {metadata.aspect_ratio} "
|
||||
f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
|
||||
)
|
||||
preset_options.append(formatted_option)
|
||||
else:
|
||||
preset_options.append(preset_name)
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"preset": (
|
||||
preset_options,
|
||||
{
|
||||
"default": "custom",
|
||||
"tooltip": "Select from optimized resolution presets or use "
|
||||
"custom dimensions. SDXL presets are ~1MP, FLUX presets are "
|
||||
"higher resolution, Ultra-wide presets support modern "
|
||||
"aspect ratios.",
|
||||
},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 64,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "Custom width in pixels (must be multiple of 8). "
|
||||
"Used when preset is 'custom' or as fallback for invalid "
|
||||
"presets. This will be converted to latent space dimensions.",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 64,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "Custom height in pixels (must be multiple of 8). "
|
||||
"Used when preset is 'custom' or as fallback for invalid "
|
||||
"presets. This will be converted to latent space dimensions.",
|
||||
},
|
||||
),
|
||||
"batch_size": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 64,
|
||||
"step": 1,
|
||||
"tooltip": "Number of empty latents to create in the batch. "
|
||||
"Useful for batch processing workflows.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT", "INT")
|
||||
RETURN_NAMES = ("latent", "width", "height")
|
||||
FUNCTION = "create_empty_latent"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def create_empty_latent(
|
||||
self, preset: str, width: int, height: int, batch_size: int
|
||||
) -> Tuple[Dict[str, torch.Tensor], int, int]:
|
||||
"""
|
||||
Create empty latent tensor with specified dimensions and batch size.
|
||||
|
||||
Args:
|
||||
preset: Selected preset name or formatted preset string
|
||||
width: Custom width value
|
||||
height: Custom height value
|
||||
batch_size: Number of latents in the batch
|
||||
|
||||
Returns:
|
||||
Tuple containing (latent dictionary with 'samples' tensor, width, height)
|
||||
"""
|
||||
try:
|
||||
# Extract original preset name from formatted string if needed
|
||||
original_preset = self._extract_preset_name(preset)
|
||||
|
||||
# Get base dimensions from preset or custom input
|
||||
base_width, base_height = get_preset_dimensions(
|
||||
original_preset, width, height
|
||||
)
|
||||
|
||||
# Sanitize dimensions to ensure they meet requirements
|
||||
final_width, final_height = sanitize_dimensions(base_width, base_height)
|
||||
|
||||
# Log if dimensions were changed from the base dimensions
|
||||
if final_width != base_width or final_height != base_height:
|
||||
self.log_info(
|
||||
f"Dimensions adjusted from {base_width}×{base_height} to "
|
||||
f"{final_width}×{final_height} to meet VAE requirements"
|
||||
)
|
||||
|
||||
# Validate final dimensions
|
||||
if not validate_dimensions(final_width, final_height):
|
||||
self.handle_error(
|
||||
f"Invalid dimensions after sanitization: {final_width}×{final_height}"
|
||||
)
|
||||
|
||||
# Validate batch size
|
||||
if batch_size <= 0:
|
||||
self.handle_error(f"Batch size must be positive, got {batch_size}")
|
||||
|
||||
if batch_size > 64:
|
||||
self.log_info(
|
||||
f"Large batch size ({batch_size}) may use significant memory"
|
||||
)
|
||||
|
||||
# Create the empty latent batch
|
||||
latent_dict = create_empty_latent_batch(
|
||||
final_width, final_height, batch_size
|
||||
)
|
||||
|
||||
# Log the operation
|
||||
latent_height = final_height // 8
|
||||
latent_width = final_width // 8
|
||||
self.log_info(
|
||||
f"Created empty latent batch: {batch_size}×4×{latent_height}×{latent_width} "
|
||||
f"(pixel dims: {final_width}×{final_height})"
|
||||
)
|
||||
|
||||
return (latent_dict, final_width, final_height)
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
error_msg = f"Error creating empty latent batch: {str(e)}"
|
||||
self.handle_error(error_msg, e)
|
||||
|
||||
def _extract_preset_name(self, formatted_preset: str) -> str:
|
||||
"""
|
||||
Extract the original preset name from a formatted preset string.
|
||||
|
||||
Args:
|
||||
formatted_preset: Either original preset name or formatted string
|
||||
|
||||
Returns:
|
||||
Original preset name
|
||||
"""
|
||||
# If it's already "custom", return as-is
|
||||
if formatted_preset == "custom":
|
||||
return formatted_preset
|
||||
|
||||
# If it contains formatting metadata, extract the resolution part
|
||||
if " - " in formatted_preset:
|
||||
# Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
|
||||
# Extract the first part (resolution)
|
||||
resolution_part = formatted_preset.split(" - ")[0]
|
||||
|
||||
# Verify this is a valid preset name
|
||||
if resolution_part in PRESET_OPTIONS:
|
||||
return resolution_part
|
||||
|
||||
# If no formatting or not found, check if it's directly a valid preset
|
||||
if formatted_preset in PRESET_OPTIONS:
|
||||
return formatted_preset
|
||||
|
||||
# Default to "custom" if we can't parse it
|
||||
return "custom"
|
||||
|
||||
def validate_inputs(
|
||||
self, preset: str, width: int, height: int, batch_size: int
|
||||
) -> bool:
|
||||
"""
|
||||
Validate node inputs.
|
||||
|
||||
Args:
|
||||
preset: Preset name or formatted preset string
|
||||
width: Width value
|
||||
height: Height value
|
||||
batch_size: Batch size value
|
||||
|
||||
Returns:
|
||||
True if inputs are valid
|
||||
"""
|
||||
# Extract original preset name
|
||||
original_preset = self._extract_preset_name(preset)
|
||||
|
||||
# Check if preset exists or is custom
|
||||
if original_preset != "custom" and original_preset not in PRESET_OPTIONS:
|
||||
return False
|
||||
|
||||
# Get dimensions from preset or use custom
|
||||
base_width, base_height = get_preset_dimensions(original_preset, width, height)
|
||||
|
||||
# Check dimension validity (after sanitization)
|
||||
sanitized_width, sanitized_height = sanitize_dimensions(base_width, base_height)
|
||||
if not validate_dimensions(sanitized_width, sanitized_height):
|
||||
return False
|
||||
|
||||
# Check batch size
|
||||
if batch_size <= 0 or batch_size > 64:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_latent_info(self, width: int, height: int, batch_size: int) -> str:
|
||||
"""
|
||||
Get descriptive information about the latent that will be created.
|
||||
|
||||
Args:
|
||||
width: Width in pixels
|
||||
height: Height in pixels
|
||||
batch_size: Batch size
|
||||
|
||||
Returns:
|
||||
Description string for the latent
|
||||
"""
|
||||
sanitized_width, sanitized_height = sanitize_dimensions(width, height)
|
||||
latent_width = sanitized_width // 8
|
||||
latent_height = sanitized_height // 8
|
||||
|
||||
return (
|
||||
f"Empty latent batch: {batch_size} × 4 × {latent_height} × {latent_width} "
|
||||
f"(pixel dimensions: {sanitized_width}×{sanitized_height})"
|
||||
)
|
||||
|
||||
def get_memory_estimate(self, width: int, height: int, batch_size: int) -> str:
|
||||
"""
|
||||
Estimate memory usage for the latent batch.
|
||||
|
||||
Args:
|
||||
width: Width in pixels
|
||||
height: Height in pixels
|
||||
batch_size: Batch size
|
||||
|
||||
Returns:
|
||||
Memory estimate string
|
||||
"""
|
||||
sanitized_width, sanitized_height = sanitize_dimensions(width, height)
|
||||
latent_width = sanitized_width // 8
|
||||
latent_height = sanitized_height // 8
|
||||
|
||||
# Calculate tensor size in bytes (float32 = 4 bytes per element)
|
||||
elements = batch_size * 4 * latent_height * latent_width
|
||||
bytes_size = elements * 4 # 4 bytes per float32
|
||||
|
||||
# Convert to human-readable format
|
||||
if bytes_size < 1024:
|
||||
return f"{bytes_size} bytes"
|
||||
elif bytes_size < 1024 * 1024:
|
||||
return f"{bytes_size / 1024:.1f} KB"
|
||||
elif bytes_size < 1024 * 1024 * 1024:
|
||||
return f"{bytes_size / (1024 * 1024):.1f} MB"
|
||||
else:
|
||||
return f"{bytes_size / (1024 * 1024 * 1024):.1f} GB"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
return "EmptyLatentBatchNode"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Detailed string representation of the node."""
|
||||
return (
|
||||
f"EmptyLatentBatchNode("
|
||||
f"category='{self.CATEGORY}', "
|
||||
f"function='{self.FUNCTION}'"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
# Node class mappings for ComfyUI registration
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
}
|
||||
@@ -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 @@
|
||||
"""ImageToMultipleOf tool for ComfyUI-KikoTools."""
|
||||
|
||||
from .node import ImageToMultipleOfNode
|
||||
|
||||
__all__ = ["ImageToMultipleOfNode"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Core logic for ImageToMultipleOf tool."""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def calculate_dimensions_to_multiple(
|
||||
height: int, width: int, multiple_of: int
|
||||
) -> Tuple[int, int]:
|
||||
"""Calculate new dimensions that are multiples of the specified value.
|
||||
|
||||
Args:
|
||||
height: Original height
|
||||
width: Original width
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
|
||||
Returns:
|
||||
Tuple of (new_height, new_width)
|
||||
"""
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
return new_height, new_width
|
||||
|
||||
|
||||
def process_image_to_multiple_of(
|
||||
image: Tensor, multiple_of: int, method: str
|
||||
) -> Tensor:
|
||||
"""Process image to ensure dimensions are multiples of specified value.
|
||||
|
||||
Args:
|
||||
image: Input image tensor of shape (batch, height, width, channels)
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
method: Processing method - "center crop" or "rescale"
|
||||
|
||||
Returns:
|
||||
Processed image tensor
|
||||
"""
|
||||
_, height, width, _ = image.shape
|
||||
new_height, new_width = calculate_dimensions_to_multiple(height, width, multiple_of)
|
||||
|
||||
if method == "rescale":
|
||||
# Rescale the image to the new dimensions
|
||||
# Convert from BHWC to BCHW for interpolation
|
||||
image_chw = image.permute(0, 3, 1, 2)
|
||||
rescaled = F.interpolate(
|
||||
image_chw,
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
# Convert back to BHWC
|
||||
return rescaled.permute(0, 2, 3, 1)
|
||||
else: # center crop
|
||||
# Calculate crop offsets to center the crop
|
||||
top = (height - new_height) // 2
|
||||
left = (width - new_width) // 2
|
||||
bottom = top + new_height
|
||||
right = left + new_width
|
||||
return image[:, top:bottom, left:right, :]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""ComfyUI node implementation for ImageToMultipleOf."""
|
||||
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from torch import Tensor
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import process_image_to_multiple_of
|
||||
|
||||
|
||||
class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Adjusts image dimensions to be multiples of a specified value.
|
||||
|
||||
Useful for models that require specific dimension constraints.
|
||||
Supports both center cropping and rescaling methods.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
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 = "process"
|
||||
|
||||
def process(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
|
||||
"""
|
||||
Process image to ensure dimensions are multiples of specified value.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
method: Processing method - "center crop" or "rescale"
|
||||
|
||||
Returns:
|
||||
Tuple containing processed image tensor
|
||||
"""
|
||||
try:
|
||||
self.validate_inputs(image=image, multiple_of=multiple_of, method=method)
|
||||
|
||||
# Process the image
|
||||
processed_image = process_image_to_multiple_of(image, multiple_of, method)
|
||||
|
||||
_, new_height, new_width, _ = processed_image.shape
|
||||
self.log_info(
|
||||
f"Processed image from {image.shape[1]}x{image.shape[2]} "
|
||||
f"to {new_height}x{new_width} (multiple of {multiple_of}) "
|
||||
f"using {method}"
|
||||
)
|
||||
|
||||
return (processed_image,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Failed to process image: {str(e)}", e)
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""Validate inputs for ImageToMultipleOf node."""
|
||||
image = kwargs.get("image")
|
||||
multiple_of = kwargs.get("multiple_of")
|
||||
method = kwargs.get("method")
|
||||
|
||||
if image is None:
|
||||
raise ValueError("Image input is required")
|
||||
|
||||
if not isinstance(image, Tensor) or len(image.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected image tensor with shape (batch, height, width, channels), "
|
||||
f"got shape {image.shape if isinstance(image, Tensor) else 'non-tensor'}"
|
||||
)
|
||||
|
||||
if multiple_of <= 0:
|
||||
raise ValueError(f"multiple_of must be positive, got {multiple_of}")
|
||||
|
||||
if method not in ["center crop", "rescale"]:
|
||||
raise ValueError(f"Invalid method: {method}")
|
||||
|
||||
# Check if resulting dimensions would be too small
|
||||
_, height, width, _ = image.shape
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
|
||||
if new_height <= 0 or new_width <= 0:
|
||||
raise ValueError(
|
||||
f"Image dimensions ({height}x{width}) are too small "
|
||||
f"to be adjusted to multiple of {multiple_of}"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
KikoSaveImage tool module
|
||||
Enhanced image saving with format selection, quality control, and clickable previews
|
||||
"""
|
||||
|
||||
from .node import KikoSaveImageNode
|
||||
|
||||
__all__ = ["KikoSaveImageNode"]
|
||||
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
KikoSaveImage core logic
|
||||
Enhanced image saving functionality with multiple format support
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import torch
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import time
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
# Fallback for testing without ComfyUI
|
||||
class folder_paths:
|
||||
@staticmethod
|
||||
def get_output_directory():
|
||||
return "./output"
|
||||
|
||||
|
||||
def get_save_image_path(
|
||||
filename_prefix: str,
|
||||
batch_number: int,
|
||||
format_ext: str,
|
||||
output_dir: str,
|
||||
subfolder: str = "",
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate save path for image with proper filename handling
|
||||
|
||||
Args:
|
||||
filename_prefix: Base filename prefix
|
||||
batch_number: Batch index for multiple images
|
||||
format_ext: File extension (.png, .jpg, .webp)
|
||||
output_dir: Output directory path
|
||||
subfolder: Optional subfolder within output directory
|
||||
|
||||
Returns:
|
||||
Tuple of (full_path, relative_filename)
|
||||
"""
|
||||
# Split filename_prefix into directory path and actual filename prefix
|
||||
# This allows for directory structures like "kittybear/anime/images/kittybear"
|
||||
prefix_dir = os.path.dirname(filename_prefix)
|
||||
prefix_name = os.path.basename(filename_prefix)
|
||||
|
||||
# Sanitize only the filename part (not the directory path)
|
||||
safe_prefix = prefix_name.replace(
|
||||
":", "_"
|
||||
) # Only sanitize problematic chars for filenames
|
||||
safe_prefix = "".join(c for c in safe_prefix if c.isalnum() or c in "._-")
|
||||
|
||||
# Create unique filename with timestamp to avoid conflicts
|
||||
timestamp = int(time.time())
|
||||
filename = f"{safe_prefix}_{timestamp:010d}_{batch_number:05d}{format_ext}"
|
||||
|
||||
# Handle subfolder and prefix directory (but not the filename part)
|
||||
path_components = []
|
||||
path_components.append(output_dir)
|
||||
|
||||
if subfolder:
|
||||
path_components.append(subfolder)
|
||||
|
||||
# Only add prefix_dir if it exists (the directory part, not the filename part)
|
||||
if prefix_dir:
|
||||
path_components.append(prefix_dir)
|
||||
|
||||
full_output_folder = os.path.join(*path_components)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(full_output_folder, exist_ok=True)
|
||||
|
||||
full_path = os.path.join(full_output_folder, filename)
|
||||
|
||||
# For the preview, ComfyUI needs the filename and subfolder separately
|
||||
# The subfolder needs to be relative to the output directory root
|
||||
# Build the relative subfolder path including prefix directory (but not filename part)
|
||||
relative_path_components = []
|
||||
|
||||
if subfolder:
|
||||
relative_path_components.append(subfolder.strip("/\\"))
|
||||
|
||||
if prefix_dir:
|
||||
relative_path_components.append(prefix_dir.strip("/\\"))
|
||||
|
||||
if relative_path_components:
|
||||
relative_subfolder = os.path.join(*relative_path_components)
|
||||
else:
|
||||
relative_subfolder = ""
|
||||
|
||||
preview_filename = filename
|
||||
|
||||
return full_path, preview_filename, relative_subfolder
|
||||
|
||||
|
||||
def convert_tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image:
|
||||
"""
|
||||
Convert ComfyUI image tensor to PIL Image
|
||||
|
||||
Args:
|
||||
image_tensor: Tensor in format [height, width, channels] with values 0-1
|
||||
|
||||
Returns:
|
||||
PIL Image in RGB/RGBA format
|
||||
"""
|
||||
# Convert tensor (0-1 float) to 0-255 numpy array
|
||||
i = 255.0 * image_tensor.cpu().numpy()
|
||||
img_array = np.clip(i, 0, 255).astype(np.uint8)
|
||||
|
||||
# Create PIL image from numpy array
|
||||
img = Image.fromarray(img_array)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def create_png_metadata(
|
||||
prompt: Optional[Dict] = None, extra_pnginfo: Optional[Dict] = None
|
||||
) -> Optional[PngInfo]:
|
||||
"""
|
||||
Create PNG metadata with workflow information
|
||||
|
||||
Args:
|
||||
prompt: ComfyUI prompt data
|
||||
extra_pnginfo: Additional PNG metadata
|
||||
|
||||
Returns:
|
||||
PngInfo object or None if no metadata
|
||||
"""
|
||||
if prompt is None and extra_pnginfo is None:
|
||||
return None
|
||||
|
||||
metadata = PngInfo()
|
||||
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
|
||||
if extra_pnginfo is not None:
|
||||
for key, value in extra_pnginfo.items():
|
||||
metadata.add_text(key, json.dumps(value))
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def save_image_with_format(
|
||||
img: Image.Image,
|
||||
filepath: str,
|
||||
format_type: str,
|
||||
quality: int = 90,
|
||||
png_compress_level: int = 4,
|
||||
webp_lossless: bool = False,
|
||||
metadata: Optional[PngInfo] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Save PIL image with specified format and quality settings
|
||||
|
||||
Args:
|
||||
img: PIL Image to save
|
||||
filepath: Full path to save file
|
||||
format_type: Image format (PNG, JPEG, WEBP)
|
||||
quality: JPEG/WebP quality (1-100)
|
||||
png_compress_level: PNG compression level (0-9)
|
||||
webp_lossless: Use lossless WebP compression
|
||||
metadata: PNG metadata to embed
|
||||
|
||||
Returns:
|
||||
Dict with save information
|
||||
"""
|
||||
save_kwargs = {}
|
||||
|
||||
if format_type == "PNG":
|
||||
if metadata:
|
||||
save_kwargs["pnginfo"] = metadata
|
||||
save_kwargs["compress_level"] = png_compress_level
|
||||
|
||||
elif format_type == "JPEG":
|
||||
# Convert RGBA to RGB for JPEG (no transparency support)
|
||||
if img.mode == "RGBA":
|
||||
# Create white background
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
|
||||
img = background
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
save_kwargs["quality"] = quality
|
||||
save_kwargs["optimize"] = True
|
||||
|
||||
elif format_type == "WEBP":
|
||||
save_kwargs["quality"] = quality if not webp_lossless else 100
|
||||
save_kwargs["lossless"] = webp_lossless
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format_type}")
|
||||
|
||||
# Save the image
|
||||
img.save(filepath, **save_kwargs)
|
||||
|
||||
# Get file size for info
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
return {
|
||||
"filepath": filepath,
|
||||
"format": format_type,
|
||||
"file_size": file_size,
|
||||
"quality": quality if format_type != "PNG" else None,
|
||||
"compress_level": png_compress_level if format_type == "PNG" else None,
|
||||
"lossless": webp_lossless if format_type == "WEBP" else None,
|
||||
}
|
||||
|
||||
|
||||
def process_image_batch(
|
||||
images: torch.Tensor,
|
||||
filename_prefix: str = "KikoSave",
|
||||
format_type: str = "PNG",
|
||||
quality: int = 90,
|
||||
png_compress_level: int = 4,
|
||||
webp_lossless: bool = False,
|
||||
popup: bool = True,
|
||||
prompt: Optional[Dict] = None,
|
||||
extra_pnginfo: Optional[Dict] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process and save a batch of images with specified format settings
|
||||
|
||||
Args:
|
||||
images: Batch of image tensors [batch, height, width, channels]
|
||||
filename_prefix: Prefix for saved filenames
|
||||
format_type: Image format (PNG, JPEG, WEBP)
|
||||
quality: JPEG/WebP quality (1-100)
|
||||
png_compress_level: PNG compression level (0-9)
|
||||
webp_lossless: Use lossless WebP compression
|
||||
popup: Enable popup windows in UI
|
||||
prompt: ComfyUI prompt data for metadata
|
||||
extra_pnginfo: Additional PNG metadata
|
||||
|
||||
Returns:
|
||||
List of saved image information dicts
|
||||
"""
|
||||
# Get output directory
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
# Determine file extension
|
||||
format_extensions = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"}
|
||||
|
||||
if format_type not in format_extensions:
|
||||
raise ValueError(
|
||||
f"Unsupported format: {format_type}. "
|
||||
f"Supported: {list(format_extensions.keys())}"
|
||||
)
|
||||
|
||||
format_ext = format_extensions[format_type]
|
||||
|
||||
# Create metadata for PNG
|
||||
metadata = None
|
||||
if format_type == "PNG":
|
||||
metadata = create_png_metadata(prompt, extra_pnginfo)
|
||||
|
||||
# Process each image in the batch
|
||||
results = []
|
||||
enhanced_data = []
|
||||
|
||||
for batch_number, image_tensor in enumerate(images):
|
||||
# Convert tensor to PIL Image
|
||||
img = convert_tensor_to_pil(image_tensor)
|
||||
|
||||
# Generate save path
|
||||
filepath, preview_filename, relative_subfolder = get_save_image_path(
|
||||
filename_prefix, batch_number, format_ext, output_dir, ""
|
||||
)
|
||||
|
||||
# Save with format-specific settings
|
||||
save_info = save_image_with_format(
|
||||
img,
|
||||
filepath,
|
||||
format_type,
|
||||
quality,
|
||||
png_compress_level,
|
||||
webp_lossless,
|
||||
metadata,
|
||||
)
|
||||
|
||||
# Build result info for ComfyUI preview
|
||||
# ONLY the core fields that ComfyUI expects - no extra metadata
|
||||
result = {
|
||||
"filename": preview_filename,
|
||||
"subfolder": relative_subfolder,
|
||||
"type": "output",
|
||||
}
|
||||
|
||||
# Store enhanced data separately
|
||||
enhanced_info = {
|
||||
"filename": preview_filename,
|
||||
"subfolder": relative_subfolder,
|
||||
"popup": popup,
|
||||
"type": "output",
|
||||
"format": format_type,
|
||||
"file_size": save_info["file_size"],
|
||||
"dimensions": f"{img.width}x{img.height}",
|
||||
}
|
||||
|
||||
# Add format-specific info to enhanced data
|
||||
if format_type == "PNG":
|
||||
enhanced_info["compress_level"] = png_compress_level
|
||||
elif format_type in ["JPEG", "WEBP"]:
|
||||
enhanced_info["quality"] = quality
|
||||
if format_type == "WEBP":
|
||||
enhanced_info["lossless"] = webp_lossless
|
||||
|
||||
results.append(result)
|
||||
enhanced_data.append(enhanced_info)
|
||||
|
||||
return results, enhanced_data
|
||||
|
||||
|
||||
def validate_save_inputs(
|
||||
images: torch.Tensor, format_type: str, quality: int, png_compress_level: int
|
||||
) -> None:
|
||||
"""
|
||||
Validate inputs for image saving
|
||||
|
||||
Args:
|
||||
images: Image tensor batch to validate
|
||||
format_type: Image format to validate
|
||||
quality: Quality setting to validate
|
||||
png_compress_level: PNG compression level to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
# Validate images tensor
|
||||
if not isinstance(images, torch.Tensor):
|
||||
raise ValueError(f"images must be a torch.Tensor, got {type(images).__name__}")
|
||||
|
||||
if len(images.shape) != 4:
|
||||
raise ValueError(
|
||||
f"images tensor must have 4 dimensions [batch, height, width, channels], "
|
||||
f"got {len(images.shape)}"
|
||||
)
|
||||
|
||||
# Validate format
|
||||
supported_formats = ["PNG", "JPEG", "WEBP"]
|
||||
if format_type not in supported_formats:
|
||||
raise ValueError(
|
||||
f"format must be one of {supported_formats}, got {format_type}"
|
||||
)
|
||||
|
||||
# Validate quality (for JPEG/WebP)
|
||||
if format_type in ["JPEG", "WEBP"]:
|
||||
if not isinstance(quality, int) or not (1 <= quality <= 100):
|
||||
raise ValueError(
|
||||
f"quality must be an integer between 1 and 100, got {quality}"
|
||||
)
|
||||
|
||||
# Validate PNG compression level
|
||||
if format_type == "PNG":
|
||||
if not isinstance(png_compress_level, int) or not (
|
||||
0 <= png_compress_level <= 9
|
||||
):
|
||||
raise ValueError(
|
||||
f"png_compress_level must be an integer between 0 and 9, "
|
||||
f"got {png_compress_level}"
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
KikoSaveImage ComfyUI Node
|
||||
Enhanced image saving with format selection, quality control, and clickable previews
|
||||
"""
|
||||
|
||||
import torch
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import process_image_batch, validate_save_inputs
|
||||
|
||||
|
||||
class KikoSaveImageNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Enhanced ComfyUI image saving node with multiple format support
|
||||
|
||||
Features:
|
||||
- Multiple format support (PNG, JPEG, WebP)
|
||||
- Quality/compression controls
|
||||
- Clickable image previews
|
||||
- Metadata preservation
|
||||
- Batch processing
|
||||
|
||||
Inputs:
|
||||
- images (IMAGE): Images to save
|
||||
- filename_prefix (STRING): Prefix for saved filenames
|
||||
- format (COMBO): Output format (PNG, JPEG, WebP)
|
||||
- quality (INT): JPEG/WebP quality (1-100)
|
||||
- png_compress_level (INT): PNG compression level (0-9)
|
||||
- webp_lossless (BOOLEAN): Use lossless WebP compression
|
||||
- subfolder (STRING): Optional subfolder for organization
|
||||
|
||||
Outputs:
|
||||
- UI: Image preview data for ComfyUI interface
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
Define ComfyUI input interface with enhanced save options
|
||||
|
||||
Returns:
|
||||
Dict with required and optional input specifications
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", {"tooltip": "The images to save"}),
|
||||
"filename_prefix": (
|
||||
"STRING",
|
||||
{"default": "KikoSave", "tooltip": "Prefix for saved filenames"},
|
||||
),
|
||||
"format": (
|
||||
["PNG", "JPEG", "WEBP"],
|
||||
{"default": "PNG", "tooltip": "Output image format"},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"quality": (
|
||||
"INT",
|
||||
{
|
||||
"default": 90,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"tooltip": "JPEG/WebP quality (1-100, higher = better quality)",
|
||||
},
|
||||
),
|
||||
"png_compress_level": (
|
||||
"INT",
|
||||
{
|
||||
"default": 4,
|
||||
"min": 0,
|
||||
"max": 9,
|
||||
"step": 1,
|
||||
"tooltip": "PNG compression level (0-9, higher = smaller file)",
|
||||
},
|
||||
),
|
||||
"webp_lossless": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Use lossless WebP compression "
|
||||
"(ignores quality setting)",
|
||||
},
|
||||
),
|
||||
"popup": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Enable popup windows when clicking on images in the viewer",
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def save_images(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
filename_prefix: str = "KikoSave",
|
||||
format: str = "PNG",
|
||||
quality: int = 90,
|
||||
png_compress_level: int = 4,
|
||||
webp_lossless: bool = False,
|
||||
popup: bool = True,
|
||||
prompt: Optional[Dict] = None,
|
||||
extra_pnginfo: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Save images with enhanced format and quality options
|
||||
|
||||
Args:
|
||||
images: Batch of image tensors to save
|
||||
filename_prefix: Prefix for saved filenames
|
||||
format: Output format (PNG, JPEG, WebP)
|
||||
quality: JPEG/WebP quality setting
|
||||
png_compress_level: PNG compression level
|
||||
webp_lossless: Use lossless WebP compression
|
||||
popup: Enable popup windows when clicking on images
|
||||
prompt: ComfyUI prompt data for metadata
|
||||
extra_pnginfo: Additional PNG metadata
|
||||
|
||||
Returns:
|
||||
Dict with UI data for image previews
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
self.validate_inputs(
|
||||
images=images,
|
||||
format=format,
|
||||
quality=quality,
|
||||
png_compress_level=png_compress_level,
|
||||
webp_lossless=webp_lossless,
|
||||
popup=popup,
|
||||
)
|
||||
|
||||
# Log the save operation
|
||||
self.log_info(
|
||||
f"Saving {len(images)} images as {format} "
|
||||
f"(quality={quality if format != 'PNG' else 'N/A'}, "
|
||||
f"png_compress={png_compress_level if format == 'PNG' else 'N/A'})"
|
||||
)
|
||||
|
||||
# Process and save images
|
||||
results, enhanced_data = process_image_batch(
|
||||
images=images,
|
||||
filename_prefix=filename_prefix,
|
||||
format_type=format,
|
||||
quality=quality,
|
||||
png_compress_level=png_compress_level,
|
||||
webp_lossless=webp_lossless,
|
||||
popup=popup,
|
||||
prompt=prompt,
|
||||
extra_pnginfo=extra_pnginfo,
|
||||
)
|
||||
|
||||
# Log results
|
||||
total_size = sum(data["file_size"] for data in enhanced_data)
|
||||
self.log_info(
|
||||
f"Successfully saved {len(results)} images "
|
||||
f"(total size: {total_size / 1024:.1f} KB)"
|
||||
)
|
||||
|
||||
# Return UI data for ComfyUI preview (clean) + enhanced data for our JS
|
||||
return {
|
||||
"ui": {
|
||||
"images": results, # Clean data for ComfyUI
|
||||
"kiko_enhanced": enhanced_data, # Enhanced data for our JavaScript
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to save images: {str(e)}"
|
||||
self.handle_error(error_msg, e)
|
||||
|
||||
def validate_inputs(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
format: str,
|
||||
quality: int,
|
||||
png_compress_level: int,
|
||||
webp_lossless: bool,
|
||||
popup: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Validate inputs specific to KikoSaveImage
|
||||
|
||||
Args:
|
||||
images: Image tensor batch
|
||||
format: Image format
|
||||
quality: Quality setting
|
||||
png_compress_level: PNG compression level
|
||||
webp_lossless: WebP lossless setting
|
||||
popup: Enable popup windows
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
# Use logic module validation
|
||||
validate_save_inputs(images, format, quality, png_compress_level)
|
||||
|
||||
# Additional node-specific validation
|
||||
if not isinstance(webp_lossless, bool):
|
||||
raise ValueError(
|
||||
f"webp_lossless must be a boolean, got {type(webp_lossless).__name__}"
|
||||
)
|
||||
|
||||
if not isinstance(popup, bool):
|
||||
raise ValueError(f"popup must be a boolean, got {type(popup).__name__}")
|
||||
|
||||
|
||||
# Node class mappings for ComfyUI registration
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Pure functions for dimension extraction and scaling calculations
|
||||
"""
|
||||
|
||||
import torch
|
||||
from typing import Tuple, Optional, Union, Dict, Any
|
||||
from typing import Tuple, Optional, Dict
|
||||
|
||||
|
||||
def extract_dimensions(
|
||||
@@ -16,7 +16,8 @@ def extract_dimensions(
|
||||
|
||||
Args:
|
||||
image: Optional IMAGE tensor in ComfyUI format [batch, height, width, channels]
|
||||
latent: Optional LATENT dict with 'samples' tensor [batch, channels, height/8, width/8]
|
||||
latent: Optional LATENT dict with 'samples' tensor
|
||||
[batch, channels, height/8, width/8]
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) as integers
|
||||
@@ -42,7 +43,8 @@ def extract_dimensions(
|
||||
samples = latent["samples"]
|
||||
if len(samples.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected LATENT samples tensor with 4 dimensions, got {len(samples.shape)}"
|
||||
f"Expected LATENT samples tensor with 4 dimensions, "
|
||||
f"got {len(samples.shape)}"
|
||||
)
|
||||
|
||||
_, _, latent_height, latent_width = samples.shape
|
||||
|
||||
@@ -38,11 +38,12 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 2.0,
|
||||
"min": 1.0,
|
||||
"min": 0.1,
|
||||
"max": 8.0,
|
||||
"step": 0.1,
|
||||
"display": "slider",
|
||||
"tooltip": "Factor to scale the resolution by (e.g., 2.0 for 2x upscale)",
|
||||
"tooltip": "Factor to scale the resolution by "
|
||||
"(e.g., 2.0 for 2x, 0.5 for half scale)",
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -93,7 +94,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
else "LATENT" if latent is not None else "NONE"
|
||||
)
|
||||
self.log_info(
|
||||
f"Calculating resolution with scale_factor={scale_factor}, input_type={input_type}"
|
||||
f"Calculating resolution with scale_factor={scale_factor}, "
|
||||
f"input_type={input_type}"
|
||||
)
|
||||
|
||||
# Calculate the resolution
|
||||
@@ -138,35 +140,46 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
f"scale_factor must be a number, got {type(scale_factor).__name__}"
|
||||
)
|
||||
|
||||
# Additional tensor validation
|
||||
# Validate tensors using helper methods
|
||||
if image is not None:
|
||||
if not isinstance(image, torch.Tensor):
|
||||
raise ValueError(
|
||||
f"image must be a torch.Tensor, got {type(image).__name__}"
|
||||
)
|
||||
|
||||
if len(image.shape) != 4:
|
||||
raise ValueError(
|
||||
f"image tensor must have 4 dimensions [batch, height, width, channels], got {len(image.shape)}"
|
||||
)
|
||||
self._validate_image_tensor(image)
|
||||
|
||||
if latent is not None:
|
||||
if not isinstance(latent, dict):
|
||||
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
|
||||
self._validate_latent_dict(latent)
|
||||
|
||||
if "samples" not in latent:
|
||||
raise ValueError("latent dict must contain 'samples' key")
|
||||
def _validate_image_tensor(self, image: torch.Tensor) -> None:
|
||||
"""Validate image tensor format"""
|
||||
if not isinstance(image, torch.Tensor):
|
||||
raise ValueError(
|
||||
f"image must be a torch.Tensor, got {type(image).__name__}"
|
||||
)
|
||||
|
||||
samples = latent["samples"]
|
||||
if not isinstance(samples, torch.Tensor):
|
||||
raise ValueError(
|
||||
f"latent['samples'] must be a torch.Tensor, got {type(samples).__name__}"
|
||||
)
|
||||
if len(image.shape) != 4:
|
||||
raise ValueError(
|
||||
f"image tensor must have 4 dimensions "
|
||||
f"[batch, height, width, channels], got {len(image.shape)}"
|
||||
)
|
||||
|
||||
if len(samples.shape) != 4:
|
||||
raise ValueError(
|
||||
f"latent samples tensor must have 4 dimensions [batch, channels, height, width], got {len(samples.shape)}"
|
||||
)
|
||||
def _validate_latent_dict(self, latent: Dict[str, torch.Tensor]) -> None:
|
||||
"""Validate latent dictionary format"""
|
||||
if not isinstance(latent, dict):
|
||||
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
|
||||
|
||||
if "samples" not in latent:
|
||||
raise ValueError("latent dict must contain 'samples' key")
|
||||
|
||||
samples = latent["samples"]
|
||||
if not isinstance(samples, torch.Tensor):
|
||||
raise ValueError(
|
||||
f"latent['samples'] must be a torch.Tensor, "
|
||||
f"got {type(samples).__name__}"
|
||||
)
|
||||
|
||||
if len(samples.shape) != 4:
|
||||
raise ValueError(
|
||||
f"latent samples tensor must have 4 dimensions "
|
||||
f"[batch, channels, height, width], got {len(samples.shape)}"
|
||||
)
|
||||
|
||||
|
||||
# Node class mappings for ComfyUI registration
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Sampler Combo tool for ComfyUI."""
|
||||
|
||||
from .node import SamplerComboNode
|
||||
from .compact_node import SamplerComboCompactNode
|
||||
|
||||
__all__ = ["SamplerComboNode", "SamplerComboCompactNode"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Compact Sampler Combo node for ComfyUI with minimal interface."""
|
||||
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
get_sampler_combo,
|
||||
SAMPLERS,
|
||||
SCHEDULERS,
|
||||
)
|
||||
|
||||
|
||||
class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Compact Sampler Combo node with minimal interface.
|
||||
|
||||
Provides essential sampling parameters in a space-efficient layout
|
||||
with shorter parameter names and reduced visual footprint.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define compact input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"sampler": (
|
||||
SAMPLERS,
|
||||
{
|
||||
"default": "euler",
|
||||
"tooltip": "Sampler",
|
||||
},
|
||||
),
|
||||
"sched": (
|
||||
SCHEDULERS,
|
||||
{
|
||||
"default": "normal",
|
||||
"tooltip": "Scheduler",
|
||||
},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 20,
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"tooltip": "Steps",
|
||||
},
|
||||
),
|
||||
"cfg": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 7.0,
|
||||
"min": 1.0,
|
||||
"max": 15.0,
|
||||
"step": 0.5,
|
||||
"display": "slider",
|
||||
"tooltip": "CFG",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_combo"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def get_combo(
|
||||
self, sampler: str, sched: str, steps: int, cfg: float
|
||||
) -> Tuple[object, str, int, float]:
|
||||
"""
|
||||
Get compact sampler combo configuration.
|
||||
|
||||
Args:
|
||||
sampler: The sampler algorithm name
|
||||
sched: The scheduler algorithm name
|
||||
steps: Number of sampling steps
|
||||
cfg: CFG scale value
|
||||
|
||||
Returns:
|
||||
Tuple of (sampler_object, scheduler, steps, cfg)
|
||||
"""
|
||||
try:
|
||||
# Use the same validation logic but with compact interface
|
||||
result = get_sampler_combo(sampler, sched, steps, cfg)
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler_obj = comfy.samplers.sampler_object(result[0])
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler_obj = result[0]
|
||||
return (sampler_obj, result[1], result[2], result[3])
|
||||
|
||||
except Exception as e:
|
||||
# Graceful fallback
|
||||
self.handle_error(f"Error in compact combo: {str(e)}")
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler_obj = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler_obj = "euler"
|
||||
return (sampler_obj, "normal", 20, 7.0)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the compact node."""
|
||||
return "SamplerComboCompactNode"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Detailed string representation of the compact node."""
|
||||
return f"SamplerComboCompactNode(category='{self.CATEGORY}')"
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Logic module for Sampler Combo node."""
|
||||
|
||||
from typing import Tuple, Dict, Any, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import ComfyUI samplers - will be available when running in ComfyUI
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
SAMPLERS = comfy.samplers.KSampler.SAMPLERS
|
||||
SCHEDULERS = comfy.samplers.KSampler.SCHEDULERS
|
||||
except ImportError:
|
||||
# Fallback for testing/development environment
|
||||
SAMPLERS = [
|
||||
"euler",
|
||||
"euler_ancestral",
|
||||
"heun",
|
||||
"dpm_2",
|
||||
"dpm_2_ancestral",
|
||||
"lms",
|
||||
"dpm_fast",
|
||||
"dpm_adaptive",
|
||||
"dpmpp_2s_ancestral",
|
||||
"dpmpp_sde",
|
||||
"dpmpp_2m",
|
||||
"ddim",
|
||||
"uni_pc",
|
||||
"uni_pc_bh2",
|
||||
]
|
||||
SCHEDULERS = [
|
||||
"normal",
|
||||
"karras",
|
||||
"exponential",
|
||||
"sgm_uniform",
|
||||
"simple",
|
||||
"ddim_uniform",
|
||||
"beta",
|
||||
]
|
||||
|
||||
|
||||
def validate_sampler_settings(
|
||||
sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> bool:
|
||||
"""
|
||||
Validate sampler configuration settings.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
scheduler: The scheduler algorithm name
|
||||
steps: Number of sampling steps
|
||||
cfg: CFG (classifier-free guidance) scale value
|
||||
|
||||
Returns:
|
||||
True if all settings are valid
|
||||
"""
|
||||
try:
|
||||
# Validate sampler
|
||||
if sampler_name not in SAMPLERS:
|
||||
logger.error(f"Invalid sampler: {sampler_name}")
|
||||
return False
|
||||
|
||||
# Validate scheduler
|
||||
if scheduler not in SCHEDULERS:
|
||||
logger.error(f"Invalid scheduler: {scheduler}")
|
||||
return False
|
||||
|
||||
# Validate steps
|
||||
if not isinstance(steps, int) or steps < 1 or steps > 1000:
|
||||
logger.error(f"Invalid steps: {steps} (must be 1-1000)")
|
||||
return False
|
||||
|
||||
# Validate CFG
|
||||
if not isinstance(cfg, (int, float)) or cfg < 0 or cfg > 30:
|
||||
logger.error(f"Invalid CFG: {cfg} (must be 0-30)")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating sampler settings: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_sampler_combo(
|
||||
sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> Tuple[str, str, int, float]:
|
||||
"""
|
||||
Process and return sampler combo settings.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
scheduler: The scheduler algorithm name
|
||||
steps: Number of sampling steps
|
||||
cfg: CFG scale value
|
||||
|
||||
Returns:
|
||||
Tuple of (sampler_name, scheduler, steps, cfg)
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
if not validate_sampler_settings(sampler_name, scheduler, steps, cfg):
|
||||
# Return safe defaults if validation fails
|
||||
logger.warning("Invalid settings provided, using safe defaults")
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
# Sanitize values
|
||||
steps = max(1, min(1000, int(steps)))
|
||||
cfg = max(0.0, min(30.0, float(cfg)))
|
||||
|
||||
return (sampler_name, scheduler, steps, cfg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing sampler combo: {e}")
|
||||
# Return safe defaults on any error
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
|
||||
def get_compatible_scheduler_suggestions(sampler_name: str) -> List[str]:
|
||||
"""
|
||||
Get scheduler suggestions that work well with specific samplers.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
|
||||
Returns:
|
||||
List of recommended scheduler names
|
||||
"""
|
||||
# Scheduler compatibility recommendations
|
||||
compatibility_map = {
|
||||
"euler": ["normal", "simple", "sgm_uniform"],
|
||||
"euler_ancestral": ["normal", "karras", "exponential"],
|
||||
"heun": ["normal", "karras"],
|
||||
"dpm_2": ["normal", "karras"],
|
||||
"dpm_2_ancestral": ["normal", "karras", "exponential"],
|
||||
"dpmpp_2s_ancestral": ["normal", "karras", "exponential"],
|
||||
"dpmpp_sde": ["normal", "karras", "exponential"],
|
||||
"dpmpp_2m": ["normal", "karras", "sgm_uniform"],
|
||||
"ddim": ["ddim_uniform", "normal"],
|
||||
"uni_pc": ["normal", "sgm_uniform"],
|
||||
"uni_pc_bh2": ["normal", "sgm_uniform"],
|
||||
}
|
||||
|
||||
return compatibility_map.get(sampler_name, ["normal", "karras"])
|
||||
|
||||
|
||||
def get_recommended_steps_range(sampler_name: str) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Get recommended steps range for specific samplers.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
|
||||
Returns:
|
||||
Tuple of (min_steps, max_steps, default_steps)
|
||||
"""
|
||||
# Steps recommendations by sampler
|
||||
steps_map = {
|
||||
"euler": (10, 30, 20),
|
||||
"euler_ancestral": (15, 40, 25),
|
||||
"heun": (10, 25, 15),
|
||||
"dpm_2": (10, 30, 22),
|
||||
"dpm_2_ancestral": (15, 35, 25),
|
||||
"dpmpp_2s_ancestral": (15, 40, 28),
|
||||
"dpmpp_sde": (15, 35, 25),
|
||||
"dpmpp_2m": (15, 30, 20),
|
||||
"ddim": (20, 50, 30),
|
||||
"uni_pc": (10, 25, 15),
|
||||
"uni_pc_bh2": (10, 25, 15),
|
||||
}
|
||||
|
||||
return steps_map.get(sampler_name, (10, 50, 20))
|
||||
|
||||
|
||||
def get_recommended_cfg_range(sampler_name: str) -> Tuple[float, float, float]:
|
||||
"""
|
||||
Get recommended CFG range for specific samplers.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
|
||||
Returns:
|
||||
Tuple of (min_cfg, max_cfg, default_cfg)
|
||||
"""
|
||||
# CFG recommendations by sampler
|
||||
cfg_map = {
|
||||
"euler": (3.0, 15.0, 7.0),
|
||||
"euler_ancestral": (5.0, 20.0, 8.0),
|
||||
"heun": (3.0, 12.0, 6.0),
|
||||
"dpm_2": (4.0, 15.0, 7.5),
|
||||
"dpm_2_ancestral": (5.0, 18.0, 8.5),
|
||||
"dpmpp_2s_ancestral": (6.0, 20.0, 9.0),
|
||||
"dpmpp_sde": (5.0, 18.0, 8.0),
|
||||
"dpmpp_2m": (4.0, 15.0, 7.0),
|
||||
"ddim": (3.0, 12.0, 6.0),
|
||||
"uni_pc": (3.0, 12.0, 6.5),
|
||||
"uni_pc_bh2": (3.0, 12.0, 6.5),
|
||||
}
|
||||
|
||||
return cfg_map.get(sampler_name, (1.0, 20.0, 7.0))
|
||||
|
||||
|
||||
def get_sampler_info() -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about available samplers and schedulers.
|
||||
|
||||
Returns:
|
||||
Dictionary containing sampler/scheduler information
|
||||
"""
|
||||
return {
|
||||
"samplers": SAMPLERS,
|
||||
"schedulers": SCHEDULERS,
|
||||
"sampler_count": len(SAMPLERS),
|
||||
"scheduler_count": len(SCHEDULERS),
|
||||
"default_sampler": "euler",
|
||||
"default_scheduler": "normal",
|
||||
"default_steps": 20,
|
||||
"default_cfg": 7.0,
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Sampler Combo node for ComfyUI."""
|
||||
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
get_sampler_combo,
|
||||
validate_sampler_settings,
|
||||
get_compatible_scheduler_suggestions,
|
||||
get_recommended_steps_range,
|
||||
get_recommended_cfg_range,
|
||||
SAMPLERS,
|
||||
SCHEDULERS,
|
||||
)
|
||||
|
||||
|
||||
class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Sampler Combo node for selecting sampling configuration.
|
||||
|
||||
Provides a unified interface for selecting sampler, scheduler, steps,
|
||||
and CFG settings in a single node, reducing workflow complexity and
|
||||
ensuring compatible parameter combinations.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"sampler_name": (
|
||||
SAMPLERS,
|
||||
{
|
||||
"default": "euler",
|
||||
"tooltip": "Sampling algorithm",
|
||||
},
|
||||
),
|
||||
"scheduler": (
|
||||
SCHEDULERS,
|
||||
{
|
||||
"default": "normal",
|
||||
"tooltip": "Step distribution schedule",
|
||||
},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 20,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"tooltip": "Sampling steps (1-100)",
|
||||
},
|
||||
),
|
||||
"cfg": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 7.0,
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"display": "slider",
|
||||
"tooltip": "CFG scale (0-20)",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_sampler_combo"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def get_sampler_combo(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> Tuple[object, str, int, float]:
|
||||
"""
|
||||
Get sampler combo configuration.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
scheduler: The scheduler algorithm name
|
||||
steps: Number of sampling steps
|
||||
cfg: CFG scale value
|
||||
|
||||
Returns:
|
||||
Tuple of (sampler_object, scheduler, steps, cfg)
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
if not validate_sampler_settings(sampler_name, scheduler, steps, cfg):
|
||||
# Log the validation error but don't raise
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"{self.__class__.__name__}: Invalid sampler settings: "
|
||||
f"sampler={sampler_name}, scheduler={scheduler}, "
|
||||
f"steps={steps}, cfg={cfg}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return mock object for testing
|
||||
sampler = "euler"
|
||||
return (sampler, "normal", 20, 7.0)
|
||||
|
||||
# Process and return the combo
|
||||
result = get_sampler_combo(sampler_name, scheduler, steps, cfg)
|
||||
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object(result[0])
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler = result[0]
|
||||
|
||||
self.log_info(
|
||||
f"Configured sampler combo: {result[0]}, {result[1]}, "
|
||||
f"{result[2]} steps, CFG {result[3]}"
|
||||
)
|
||||
|
||||
return (sampler, result[1], result[2], result[3])
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"{self.__class__.__name__}: Error processing sampler combo: {str(e)}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return mock object for testing
|
||||
sampler = "euler"
|
||||
return (sampler, "normal", 20, 7.0)
|
||||
|
||||
def validate_inputs(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> None:
|
||||
"""
|
||||
Validate sampler combo inputs.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
scheduler: The scheduler algorithm name
|
||||
steps: Number of sampling steps
|
||||
cfg: CFG scale value
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
if not validate_sampler_settings(sampler_name, scheduler, steps, cfg):
|
||||
self.handle_error(
|
||||
f"Invalid sampler settings: sampler={sampler_name}, "
|
||||
f"scheduler={scheduler}, steps={steps}, cfg={cfg}"
|
||||
)
|
||||
|
||||
def get_scheduler_suggestions(self, sampler_name: str) -> list:
|
||||
"""
|
||||
Get scheduler suggestions compatible with the selected sampler.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
|
||||
Returns:
|
||||
List of recommended scheduler names
|
||||
"""
|
||||
return get_compatible_scheduler_suggestions(sampler_name)
|
||||
|
||||
def get_steps_recommendation(self, sampler_name: str) -> dict:
|
||||
"""
|
||||
Get steps recommendation for the selected sampler.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
|
||||
Returns:
|
||||
Dictionary with min, max, and default steps
|
||||
"""
|
||||
min_steps, max_steps, default_steps = get_recommended_steps_range(sampler_name)
|
||||
return {
|
||||
"min": min_steps,
|
||||
"max": max_steps,
|
||||
"default": default_steps,
|
||||
"recommendation": f"Range: {min_steps}-{max_steps} steps",
|
||||
}
|
||||
|
||||
def get_cfg_recommendation(self, sampler_name: str) -> dict:
|
||||
"""
|
||||
Get CFG recommendation for the selected sampler.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
|
||||
Returns:
|
||||
Dictionary with min, max, and default CFG values
|
||||
"""
|
||||
min_cfg, max_cfg, default_cfg = get_recommended_cfg_range(sampler_name)
|
||||
return {
|
||||
"min": min_cfg,
|
||||
"max": max_cfg,
|
||||
"default": default_cfg,
|
||||
"recommendation": f"Recommended range: {min_cfg}-{max_cfg} CFG",
|
||||
}
|
||||
|
||||
def get_combo_analysis(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze the sampler combo configuration and provide recommendations.
|
||||
|
||||
Args:
|
||||
sampler_name: The sampler algorithm name
|
||||
scheduler: The scheduler algorithm name
|
||||
steps: Number of sampling steps
|
||||
cfg: CFG scale value
|
||||
|
||||
Returns:
|
||||
Dictionary containing analysis and recommendations
|
||||
"""
|
||||
analysis = {
|
||||
"sampler": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"valid": validate_sampler_settings(sampler_name, scheduler, steps, cfg),
|
||||
"scheduler_suggestions": self.get_scheduler_suggestions(sampler_name),
|
||||
"steps_rec": self.get_steps_recommendation(sampler_name),
|
||||
"cfg_rec": self.get_cfg_recommendation(sampler_name),
|
||||
}
|
||||
|
||||
# Add compatibility assessment
|
||||
suggested_schedulers = self.get_scheduler_suggestions(sampler_name)
|
||||
analysis["scheduler_compatible"] = scheduler in suggested_schedulers
|
||||
|
||||
# Add performance assessment
|
||||
steps_rec = self.get_steps_recommendation(sampler_name)
|
||||
analysis["steps_optimal"] = steps_rec["min"] <= steps <= steps_rec["max"]
|
||||
|
||||
cfg_rec = self.get_cfg_recommendation(sampler_name)
|
||||
analysis["cfg_optimal"] = cfg_rec["min"] <= cfg <= cfg_rec["max"]
|
||||
|
||||
return analysis
|
||||
|
||||
@classmethod
|
||||
def get_available_samplers(cls) -> list:
|
||||
"""
|
||||
Get list of available samplers.
|
||||
|
||||
Returns:
|
||||
List of sampler names
|
||||
"""
|
||||
return list(SAMPLERS)
|
||||
|
||||
@classmethod
|
||||
def get_available_schedulers(cls) -> list:
|
||||
"""
|
||||
Get list of available schedulers.
|
||||
|
||||
Returns:
|
||||
List of scheduler names
|
||||
"""
|
||||
return list(SCHEDULERS)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
return (
|
||||
f"SamplerComboNode(samplers={len(SAMPLERS)}, "
|
||||
f"schedulers={len(SCHEDULERS)})"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Detailed string representation of the node."""
|
||||
return (
|
||||
f"SamplerComboNode("
|
||||
f"samplers={len(SAMPLERS)}, "
|
||||
f"schedulers={len(SCHEDULERS)}, "
|
||||
f"category='{self.CATEGORY}', "
|
||||
f"function='{self.FUNCTION}'"
|
||||
f")"
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Seed History tool for ComfyUI-KikoTools.
|
||||
|
||||
Provides seed value tracking with history management,
|
||||
automatic deduplication, and interactive UI.
|
||||
"""
|
||||
|
||||
from .node import SeedHistoryNode
|
||||
|
||||
__all__ = ["SeedHistoryNode"]
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Core logic for Seed History tool."""
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
|
||||
|
||||
def generate_random_seed() -> int:
|
||||
"""
|
||||
Generate a cryptographically strong random seed value.
|
||||
|
||||
Returns:
|
||||
Random integer in the valid ComfyUI seed range
|
||||
"""
|
||||
return random.randint(0, 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
|
||||
def validate_seed_value(seed: Any) -> bool:
|
||||
"""
|
||||
Validate that a seed value is within acceptable range.
|
||||
|
||||
Args:
|
||||
seed: Seed value to validate
|
||||
|
||||
Returns:
|
||||
True if seed is valid, False otherwise
|
||||
"""
|
||||
if seed is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
seed_int = int(seed)
|
||||
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_seed_value(seed: Any) -> int:
|
||||
"""
|
||||
Sanitize and convert seed value to valid integer.
|
||||
|
||||
Args:
|
||||
seed: Raw seed value
|
||||
|
||||
Returns:
|
||||
Valid seed integer
|
||||
|
||||
Raises:
|
||||
ValueError: If seed cannot be converted to valid range
|
||||
"""
|
||||
if seed is None:
|
||||
raise ValueError("Seed cannot be None")
|
||||
|
||||
try:
|
||||
seed_int = int(seed)
|
||||
|
||||
# Clamp to valid range
|
||||
if seed_int < 0:
|
||||
seed_int = 0
|
||||
elif seed_int > 0xFFFFFFFFFFFFFFFF:
|
||||
seed_int = 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
return seed_int
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid seed value: {seed}") from e
|
||||
|
||||
|
||||
def create_history_entry(
|
||||
seed: int, timestamp: Optional[float] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a standardized history entry for a seed.
|
||||
|
||||
Args:
|
||||
seed: Seed value
|
||||
timestamp: Optional timestamp (uses current time if None)
|
||||
|
||||
Returns:
|
||||
Dictionary containing seed history entry
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
return {
|
||||
"seed": seed,
|
||||
"timestamp": timestamp,
|
||||
"dateString": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)),
|
||||
}
|
||||
|
||||
|
||||
def filter_duplicate_seeds(
|
||||
history: List[Dict[str, Any]], new_seed: int, dedup_window_ms: int = 500
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a seed should be filtered as a duplicate.
|
||||
|
||||
Args:
|
||||
history: Current seed history
|
||||
new_seed: New seed to check
|
||||
dedup_window_ms: Deduplication window in milliseconds
|
||||
|
||||
Returns:
|
||||
True if seed should be filtered (is duplicate), False otherwise
|
||||
"""
|
||||
if not history:
|
||||
return False
|
||||
|
||||
current_time = time.time() * 1000 # Convert to milliseconds
|
||||
|
||||
# Check most recent entry for duplicates within window
|
||||
latest_entry = history[0]
|
||||
latest_timestamp_ms = latest_entry["timestamp"] * 1000
|
||||
|
||||
time_diff = current_time - latest_timestamp_ms
|
||||
is_same_seed = latest_entry["seed"] == new_seed
|
||||
is_within_window = time_diff < dedup_window_ms
|
||||
|
||||
return is_same_seed and is_within_window
|
||||
|
||||
|
||||
def add_seed_to_history(
|
||||
history: List[Dict[str, Any]],
|
||||
seed: int,
|
||||
max_history: int = 10,
|
||||
dedup_window_ms: int = 500,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""
|
||||
Add a seed to the history with deduplication and size management.
|
||||
|
||||
Args:
|
||||
history: Current seed history
|
||||
seed: Seed to add
|
||||
max_history: Maximum number of entries to keep
|
||||
dedup_window_ms: Deduplication window in milliseconds
|
||||
|
||||
Returns:
|
||||
Tuple of (updated_history, was_added)
|
||||
"""
|
||||
# Validate seed
|
||||
if not validate_seed_value(seed):
|
||||
return history, False
|
||||
|
||||
# Sanitize seed
|
||||
try:
|
||||
clean_seed = sanitize_seed_value(seed)
|
||||
except ValueError:
|
||||
return history, False
|
||||
|
||||
# Check for duplicates
|
||||
if filter_duplicate_seeds(history, clean_seed, dedup_window_ms):
|
||||
return history, False
|
||||
|
||||
# Create new history list (don't modify original)
|
||||
new_history = [entry for entry in history if entry["seed"] != clean_seed]
|
||||
|
||||
# Add new entry at the beginning
|
||||
new_entry = create_history_entry(clean_seed)
|
||||
new_history.insert(0, new_entry)
|
||||
|
||||
# Trim to max size
|
||||
if len(new_history) > max_history:
|
||||
new_history = new_history[:max_history]
|
||||
|
||||
return new_history, True
|
||||
|
||||
|
||||
def format_time_ago(timestamp: float) -> str:
|
||||
"""
|
||||
Format a timestamp as a human-readable time ago string.
|
||||
|
||||
Args:
|
||||
timestamp: Unix timestamp
|
||||
|
||||
Returns:
|
||||
Formatted time ago string
|
||||
"""
|
||||
now = time.time()
|
||||
diff = now - timestamp
|
||||
|
||||
days = int(diff // 86400)
|
||||
hours = int((diff % 86400) // 3600)
|
||||
minutes = int((diff % 3600) // 60)
|
||||
seconds = int(diff % 60)
|
||||
|
||||
if days > 0:
|
||||
return f"{days}d ago"
|
||||
elif hours > 0:
|
||||
return f"{hours}h ago"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}m ago"
|
||||
else:
|
||||
return f"{seconds}s ago"
|
||||
|
||||
|
||||
def search_history_by_seed(
|
||||
history: List[Dict[str, Any]], seed: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search history for a specific seed value.
|
||||
|
||||
Args:
|
||||
history: Seed history to search
|
||||
seed: Seed value to find
|
||||
|
||||
Returns:
|
||||
History entry if found, None otherwise
|
||||
"""
|
||||
for entry in history:
|
||||
if entry["seed"] == seed:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def get_history_statistics(history: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate statistics about the seed history.
|
||||
|
||||
Args:
|
||||
history: Seed history
|
||||
|
||||
Returns:
|
||||
Dictionary containing history statistics
|
||||
"""
|
||||
if not history:
|
||||
return {
|
||||
"total_seeds": 0,
|
||||
"oldest_timestamp": None,
|
||||
"newest_timestamp": None,
|
||||
"time_span_hours": 0,
|
||||
"unique_seeds": 0,
|
||||
}
|
||||
|
||||
timestamps = [entry["timestamp"] for entry in history]
|
||||
oldest = min(timestamps)
|
||||
newest = max(timestamps)
|
||||
time_span = (newest - oldest) / 3600 # Convert to hours
|
||||
|
||||
unique_seeds = len(set(entry["seed"] for entry in history))
|
||||
|
||||
return {
|
||||
"total_seeds": len(history),
|
||||
"oldest_timestamp": oldest,
|
||||
"newest_timestamp": newest,
|
||||
"time_span_hours": time_span,
|
||||
"unique_seeds": unique_seeds,
|
||||
}
|
||||
|
||||
|
||||
def export_history_to_text(history: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Export seed history to a formatted text string.
|
||||
|
||||
Args:
|
||||
history: Seed history to export
|
||||
|
||||
Returns:
|
||||
Formatted text representation
|
||||
"""
|
||||
if not history:
|
||||
return "# Seed History (Empty)\n\nNo seeds tracked yet."
|
||||
|
||||
lines = ["# ComfyUI Seed History", ""]
|
||||
lines.append(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"Total seeds: {len(history)}")
|
||||
lines.append("")
|
||||
|
||||
for i, entry in enumerate(history, 1):
|
||||
time_ago = format_time_ago(entry["timestamp"])
|
||||
lines.append(f"{i:2d}. {entry['seed']} ({time_ago})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def import_seeds_from_list(seed_list: List[int]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Import a list of seeds as history entries.
|
||||
|
||||
Args:
|
||||
seed_list: List of seed integers
|
||||
|
||||
Returns:
|
||||
List of history entries
|
||||
"""
|
||||
history = []
|
||||
current_time = time.time()
|
||||
|
||||
for i, seed in enumerate(seed_list):
|
||||
if validate_seed_value(seed):
|
||||
# Spread timestamps by 1 minute intervals (newest first)
|
||||
timestamp = current_time - (i * 60)
|
||||
entry = create_history_entry(seed, timestamp)
|
||||
history.append(entry)
|
||||
|
||||
return history
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Seed History node for ComfyUI."""
|
||||
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
generate_random_seed,
|
||||
validate_seed_value,
|
||||
sanitize_seed_value,
|
||||
)
|
||||
|
||||
|
||||
class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Seed History node for tracking and managing seed values.
|
||||
|
||||
Provides seed value output with integrated history tracking,
|
||||
deduplication, and interactive UI for seed management.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 12345,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"tooltip": "Seed value for generation processes. "
|
||||
"History UI tracks all changes automatically.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("seed",)
|
||||
FUNCTION = "output_seed"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
def output_seed(self, seed: int) -> Tuple[int]:
|
||||
"""
|
||||
Output the seed value for use in other nodes.
|
||||
|
||||
Args:
|
||||
seed: Input seed value
|
||||
|
||||
Returns:
|
||||
Tuple containing the seed value
|
||||
"""
|
||||
try:
|
||||
# Validate and sanitize the seed
|
||||
if not validate_seed_value(seed):
|
||||
# Log the validation error but don't raise
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"{self.__class__.__name__}: Invalid seed value: {seed}. "
|
||||
f"Using fallback seed 12345."
|
||||
)
|
||||
return (12345,)
|
||||
|
||||
clean_seed = sanitize_seed_value(seed)
|
||||
|
||||
return (clean_seed,)
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"{self.__class__.__name__}: Error processing seed: {str(e)}. "
|
||||
f"Using fallback seed 12345."
|
||||
)
|
||||
return (12345,)
|
||||
|
||||
def generate_new_seed(self) -> int:
|
||||
"""
|
||||
Generate a new random seed value.
|
||||
|
||||
Returns:
|
||||
New random seed integer
|
||||
"""
|
||||
try:
|
||||
new_seed = generate_random_seed()
|
||||
self.log_info(f"Generated new seed: {new_seed}")
|
||||
return new_seed
|
||||
except Exception as e:
|
||||
error_msg = f"Error generating seed: {str(e)}. Using fallback."
|
||||
self.handle_error(error_msg)
|
||||
return 12345
|
||||
|
||||
def validate_seed_input(self, seed: int) -> bool:
|
||||
"""
|
||||
Validate seed input value.
|
||||
|
||||
Args:
|
||||
seed: Seed value to validate
|
||||
|
||||
Returns:
|
||||
True if seed is valid
|
||||
"""
|
||||
return validate_seed_value(seed)
|
||||
|
||||
def get_seed_info(self, seed: int) -> str:
|
||||
"""
|
||||
Get descriptive information about a seed value.
|
||||
|
||||
Args:
|
||||
seed: Seed value
|
||||
|
||||
Returns:
|
||||
Information string about the seed
|
||||
"""
|
||||
if not validate_seed_value(seed):
|
||||
return f"Invalid seed: {seed} (outside valid range)"
|
||||
|
||||
# Convert to hex for additional info
|
||||
hex_value = hex(seed)
|
||||
|
||||
# Check if it's a "nice" number (power of 2, round number, etc.)
|
||||
seed_type = "standard"
|
||||
if seed == 0:
|
||||
seed_type = "zero"
|
||||
elif seed & (seed - 1) == 0: # Power of 2
|
||||
seed_type = "power of 2"
|
||||
elif str(seed).count("0") > len(str(seed)) // 2:
|
||||
seed_type = "round number"
|
||||
elif seed == 12345:
|
||||
seed_type = "default"
|
||||
|
||||
return f"Seed {seed} ({hex_value}) - {seed_type}"
|
||||
|
||||
def get_seed_range_info(self) -> str:
|
||||
"""
|
||||
Get information about the valid seed range.
|
||||
|
||||
Returns:
|
||||
Range information string
|
||||
"""
|
||||
max_seed = 0xFFFFFFFFFFFFFFFF
|
||||
return f"Valid range: 0 to {max_seed:,} ({hex(max_seed)})"
|
||||
|
||||
@classmethod
|
||||
def get_default_seed(cls) -> int:
|
||||
"""
|
||||
Get the default seed value.
|
||||
|
||||
Returns:
|
||||
Default seed integer
|
||||
"""
|
||||
return 12345
|
||||
|
||||
@classmethod
|
||||
def is_seed_in_range(cls, seed: int) -> bool:
|
||||
"""
|
||||
Check if seed is within valid ComfyUI range.
|
||||
|
||||
Args:
|
||||
seed: Seed value to check
|
||||
|
||||
Returns:
|
||||
True if seed is in valid range
|
||||
"""
|
||||
return 0 <= seed <= 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
return "SeedHistoryNode(with_ui_tracking)"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Detailed string representation of the node."""
|
||||
return (
|
||||
f"SeedHistoryNode("
|
||||
f"category='{self.CATEGORY}', "
|
||||
f"function='{self.FUNCTION}', "
|
||||
f"max_seed={hex(0xFFFFFFFFFFFFFFFF)}"
|
||||
f")"
|
||||
)
|
||||
@@ -4,14 +4,15 @@ from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
get_preset_dimensions,
|
||||
calculate_aspect_ratio,
|
||||
validate_dimensions,
|
||||
sanitize_dimensions,
|
||||
)
|
||||
from .presets import (
|
||||
PRESET_OPTIONS,
|
||||
PRESET_DESCRIPTIONS,
|
||||
PRESET_METADATA,
|
||||
get_model_recommendation,
|
||||
get_preset_metadata,
|
||||
get_presets_by_model_group,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,18 +27,32 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
# Get all preset options excluding the custom tuple
|
||||
preset_keys = [key for key in PRESET_OPTIONS.keys()]
|
||||
# Create formatted preset options with metadata
|
||||
preset_options = ["custom"] # Custom first
|
||||
|
||||
# Add formatted presets with metadata
|
||||
for preset_name in PRESET_OPTIONS.keys():
|
||||
if preset_name != "custom":
|
||||
metadata = PRESET_METADATA.get(preset_name)
|
||||
if metadata:
|
||||
formatted_option = (
|
||||
f"{preset_name} - {metadata.aspect_ratio} "
|
||||
f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
|
||||
)
|
||||
preset_options.append(formatted_option)
|
||||
else:
|
||||
preset_options.append(preset_name)
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"preset": (
|
||||
preset_keys,
|
||||
preset_options,
|
||||
{
|
||||
"default": "custom",
|
||||
"tooltip": "Select from optimized resolution presets or use custom dimensions. "
|
||||
"SDXL presets are ~1MP, FLUX presets are higher resolution, "
|
||||
"Ultra-wide presets support modern aspect ratios.",
|
||||
"tooltip": "Select from optimized resolution presets or use "
|
||||
"custom dimensions. SDXL presets are ~1MP, FLUX presets are "
|
||||
"higher resolution, Ultra-wide presets support modern "
|
||||
"aspect ratios.",
|
||||
},
|
||||
),
|
||||
"width": (
|
||||
@@ -48,7 +63,8 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "Custom width in pixels (must be multiple of 8). "
|
||||
"Used when preset is 'custom' or as fallback for invalid presets.",
|
||||
"Used when preset is 'custom' or as fallback for invalid "
|
||||
"presets.",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
@@ -59,7 +75,8 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "Custom height in pixels (must be multiple of 8). "
|
||||
"Used when preset is 'custom' or as fallback for invalid presets.",
|
||||
"Used when preset is 'custom' or as fallback for invalid "
|
||||
"presets.",
|
||||
},
|
||||
),
|
||||
}
|
||||
@@ -75,7 +92,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
Get width and height dimensions with preset and swap support.
|
||||
|
||||
Args:
|
||||
preset: Selected preset name or "custom"
|
||||
preset: Selected preset name or formatted preset string
|
||||
width: Custom width value
|
||||
height: Custom height value
|
||||
|
||||
@@ -83,8 +100,13 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
Tuple of (width, height)
|
||||
"""
|
||||
try:
|
||||
# Extract original preset name from formatted string if needed
|
||||
original_preset = self._extract_preset_name(preset)
|
||||
|
||||
# Get base dimensions from preset or custom input
|
||||
final_width, final_height = get_preset_dimensions(preset, width, height)
|
||||
final_width, final_height = get_preset_dimensions(
|
||||
original_preset, width, height
|
||||
)
|
||||
|
||||
# Sanitize dimensions to ensure they meet ComfyUI requirements
|
||||
final_width, final_height = sanitize_dimensions(final_width, final_height)
|
||||
@@ -108,6 +130,37 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
self.handle_error(error_msg)
|
||||
return (1024, 1024)
|
||||
|
||||
def _extract_preset_name(self, formatted_preset: str) -> str:
|
||||
"""
|
||||
Extract the original preset name from a formatted preset string.
|
||||
|
||||
Args:
|
||||
formatted_preset: Either original preset name or formatted string
|
||||
|
||||
Returns:
|
||||
Original preset name
|
||||
"""
|
||||
# If it's already "custom", return as-is
|
||||
if formatted_preset == "custom":
|
||||
return formatted_preset
|
||||
|
||||
# If it contains formatting metadata, extract the resolution part
|
||||
if " - " in formatted_preset:
|
||||
# Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
|
||||
# Extract the first part (resolution)
|
||||
resolution_part = formatted_preset.split(" - ")[0]
|
||||
|
||||
# Verify this is a valid preset name
|
||||
if resolution_part in PRESET_OPTIONS:
|
||||
return resolution_part
|
||||
|
||||
# If no formatting or not found, check if it's directly a valid preset
|
||||
if formatted_preset in PRESET_OPTIONS:
|
||||
return formatted_preset
|
||||
|
||||
# Default to "custom" if we can't parse it
|
||||
return "custom"
|
||||
|
||||
def get_preset_info(self, preset: str) -> str:
|
||||
"""
|
||||
Get descriptive information about a preset.
|
||||
@@ -121,14 +174,12 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
if preset == "custom":
|
||||
return "Custom dimensions - use the width and height inputs below"
|
||||
|
||||
if preset in PRESET_DESCRIPTIONS:
|
||||
return PRESET_DESCRIPTIONS[preset]
|
||||
|
||||
# Fallback for unknown presets
|
||||
if preset in PRESET_OPTIONS:
|
||||
width, height = PRESET_OPTIONS[preset]
|
||||
aspect_ratio = calculate_aspect_ratio(width, height)
|
||||
return f"{preset} - {aspect_ratio} aspect ratio"
|
||||
metadata = get_preset_metadata(preset)
|
||||
if metadata.width > 0: # Valid metadata
|
||||
return (
|
||||
f"{preset} - {metadata.aspect_ratio} ({metadata.megapixels:.1f}MP) - "
|
||||
f"{metadata.description}"
|
||||
)
|
||||
|
||||
return f"Unknown preset: {preset}"
|
||||
|
||||
@@ -149,19 +200,22 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
Validate node inputs.
|
||||
|
||||
Args:
|
||||
preset: Preset name
|
||||
preset: Preset name or formatted preset string
|
||||
width: Width value
|
||||
height: Height value
|
||||
|
||||
Returns:
|
||||
True if inputs are valid
|
||||
"""
|
||||
# Extract original preset name
|
||||
original_preset = self._extract_preset_name(preset)
|
||||
|
||||
# Check if preset exists or is custom
|
||||
if preset != "custom" and preset not in PRESET_OPTIONS:
|
||||
if original_preset != "custom" and original_preset not in PRESET_OPTIONS:
|
||||
return False
|
||||
|
||||
# For custom preset, validate dimensions
|
||||
if preset == "custom":
|
||||
if original_preset == "custom":
|
||||
if not validate_dimensions(width, height):
|
||||
return False
|
||||
|
||||
@@ -192,6 +246,52 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
return PRESET_OPTIONS[preset]
|
||||
return (0, 0)
|
||||
|
||||
@classmethod
|
||||
def get_presets_by_model(cls, model_group: str) -> dict:
|
||||
"""
|
||||
Get all presets for a specific model group with metadata.
|
||||
|
||||
Args:
|
||||
model_group: Model group name ("SDXL", "FLUX", "Ultra-Wide")
|
||||
|
||||
Returns:
|
||||
Dictionary of presets with metadata
|
||||
"""
|
||||
return get_presets_by_model_group(model_group)
|
||||
|
||||
@classmethod
|
||||
def get_preset_metadata_static(cls, preset: str) -> dict:
|
||||
"""
|
||||
Get metadata for a preset as a dictionary.
|
||||
|
||||
Args:
|
||||
preset: Preset name
|
||||
|
||||
Returns:
|
||||
Dictionary with metadata information
|
||||
"""
|
||||
metadata = get_preset_metadata(preset)
|
||||
return {
|
||||
"width": metadata.width,
|
||||
"height": metadata.height,
|
||||
"aspect_ratio": metadata.aspect_ratio,
|
||||
"aspect_decimal": metadata.aspect_decimal,
|
||||
"megapixels": metadata.megapixels,
|
||||
"model_group": metadata.model_group,
|
||||
"category": metadata.category,
|
||||
"description": metadata.description,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_model_groups(cls) -> list:
|
||||
"""
|
||||
Get list of available model groups.
|
||||
|
||||
Returns:
|
||||
List of model group names
|
||||
"""
|
||||
return list(set(metadata.model_group for metadata in PRESET_METADATA.values()))
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
return f"WidthHeightSelectorNode(presets={len(PRESET_OPTIONS)})"
|
||||
|
||||
@@ -1,114 +1,435 @@
|
||||
"""Preset definitions for Width Height Selector."""
|
||||
|
||||
from typing import Dict, Tuple
|
||||
from typing import Dict, Tuple, NamedTuple
|
||||
from fractions import Fraction
|
||||
|
||||
# SDXL optimized presets (~1 megapixel, dimensions divisible by 8)
|
||||
|
||||
class PresetMetadata(NamedTuple):
|
||||
"""Metadata for a resolution preset."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
aspect_ratio: str
|
||||
aspect_decimal: float
|
||||
megapixels: float
|
||||
model_group: str
|
||||
category: str
|
||||
description: str
|
||||
|
||||
|
||||
def calculate_aspect_ratio(width: int, height: int) -> Tuple[str, float]:
|
||||
"""Calculate aspect ratio as string and decimal."""
|
||||
fraction = Fraction(width, height)
|
||||
decimal = width / height
|
||||
return f"{fraction.numerator}:{fraction.denominator}", decimal
|
||||
|
||||
|
||||
# Enhanced preset definitions with full metadata
|
||||
PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
# SDXL Presets - Square
|
||||
"1024×1024": PresetMetadata(
|
||||
1024,
|
||||
1024,
|
||||
"1:1",
|
||||
1.0,
|
||||
1.05,
|
||||
"SDXL",
|
||||
"Square",
|
||||
"SDXL base resolution - perfect square",
|
||||
),
|
||||
# SDXL Presets - Portrait
|
||||
"896×1152": PresetMetadata(
|
||||
896,
|
||||
1152,
|
||||
"7:9",
|
||||
0.778,
|
||||
1.03,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 7:9 - moderate portrait",
|
||||
),
|
||||
"832×1216": PresetMetadata(
|
||||
832,
|
||||
1216,
|
||||
"13:19",
|
||||
0.684,
|
||||
1.01,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 13:19 - standard portrait",
|
||||
),
|
||||
"768×1344": PresetMetadata(
|
||||
768,
|
||||
1344,
|
||||
"4:7",
|
||||
0.571,
|
||||
1.03,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 4:7 - tall portrait",
|
||||
),
|
||||
"640×1536": PresetMetadata(
|
||||
640,
|
||||
1536,
|
||||
"5:12",
|
||||
0.417,
|
||||
0.98,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 5:12 - very tall portrait",
|
||||
),
|
||||
# SDXL Presets - Landscape
|
||||
"1152×896": PresetMetadata(
|
||||
1152,
|
||||
896,
|
||||
"9:7",
|
||||
1.286,
|
||||
1.03,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 9:7 - moderate landscape",
|
||||
),
|
||||
"1216×832": PresetMetadata(
|
||||
1216,
|
||||
832,
|
||||
"19:13",
|
||||
1.462,
|
||||
1.01,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 19:13 - standard landscape",
|
||||
),
|
||||
"1344×768": PresetMetadata(
|
||||
1344,
|
||||
768,
|
||||
"7:4",
|
||||
1.750,
|
||||
1.03,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 7:4 - wide landscape",
|
||||
),
|
||||
"1536×640": PresetMetadata(
|
||||
1536,
|
||||
640,
|
||||
"12:5",
|
||||
2.400,
|
||||
0.98,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 12:5 - very wide landscape",
|
||||
),
|
||||
# FLUX Presets - High Quality
|
||||
"1920×1080": PresetMetadata(
|
||||
1920,
|
||||
1080,
|
||||
"16:9",
|
||||
1.778,
|
||||
2.07,
|
||||
"FLUX",
|
||||
"Cinematic",
|
||||
"FLUX Full HD 16:9 - best quality/speed balance",
|
||||
),
|
||||
"1536×1536": PresetMetadata(
|
||||
1536,
|
||||
1536,
|
||||
"1:1",
|
||||
1.0,
|
||||
2.36,
|
||||
"FLUX",
|
||||
"Square",
|
||||
"FLUX high-res square - premium quality",
|
||||
),
|
||||
"1280×768": PresetMetadata(
|
||||
1280,
|
||||
768,
|
||||
"5:3",
|
||||
1.667,
|
||||
0.98,
|
||||
"FLUX",
|
||||
"Cinematic",
|
||||
"FLUX 5:3 landscape - cinematic wide",
|
||||
),
|
||||
"768×1280": PresetMetadata(
|
||||
768,
|
||||
1280,
|
||||
"3:5",
|
||||
0.600,
|
||||
0.98,
|
||||
"FLUX",
|
||||
"Portrait",
|
||||
"FLUX 3:5 portrait - mobile optimized",
|
||||
),
|
||||
# FLUX Presets - Alternative
|
||||
"1440×1080": PresetMetadata(
|
||||
1440,
|
||||
1080,
|
||||
"4:3",
|
||||
1.333,
|
||||
1.56,
|
||||
"FLUX",
|
||||
"Classic",
|
||||
"FLUX 4:3 classic - traditional aspect ratio",
|
||||
),
|
||||
"1080×1440": PresetMetadata(
|
||||
1080,
|
||||
1440,
|
||||
"3:4",
|
||||
0.750,
|
||||
1.56,
|
||||
"FLUX",
|
||||
"Portrait",
|
||||
"FLUX 3:4 portrait - classic portrait",
|
||||
),
|
||||
"1728×1152": PresetMetadata(
|
||||
1728,
|
||||
1152,
|
||||
"3:2",
|
||||
1.500,
|
||||
1.99,
|
||||
"FLUX",
|
||||
"Photography",
|
||||
"FLUX 3:2 photo - photography standard",
|
||||
),
|
||||
"1152×1728": PresetMetadata(
|
||||
1152,
|
||||
1728,
|
||||
"2:3",
|
||||
0.667,
|
||||
1.99,
|
||||
"FLUX",
|
||||
"Portrait",
|
||||
"FLUX 2:3 portrait - portrait photography",
|
||||
),
|
||||
# Ultra-Wide Presets - Landscape
|
||||
"2560×1080": PresetMetadata(
|
||||
2560,
|
||||
1080,
|
||||
"64:27",
|
||||
2.370,
|
||||
2.76,
|
||||
"Ultra-Wide",
|
||||
"Gaming",
|
||||
"Ultra-wide 64:27 - gaming/panoramic",
|
||||
),
|
||||
"2048×768": PresetMetadata(
|
||||
2048,
|
||||
768,
|
||||
"8:3",
|
||||
2.667,
|
||||
1.57,
|
||||
"Ultra-Wide",
|
||||
"Cinematic",
|
||||
"Wide cinematic 8:3 - movie aspect",
|
||||
),
|
||||
"1792×768": PresetMetadata(
|
||||
1792,
|
||||
768,
|
||||
"7:3",
|
||||
2.333,
|
||||
1.38,
|
||||
"Ultra-Wide",
|
||||
"Panoramic",
|
||||
"Panoramic 7:3 - landscape vista",
|
||||
),
|
||||
"2304×768": PresetMetadata(
|
||||
2304,
|
||||
768,
|
||||
"3:1",
|
||||
3.000,
|
||||
1.77,
|
||||
"Ultra-Wide",
|
||||
"Banner",
|
||||
"Banner 3:1 - extreme wide banner",
|
||||
),
|
||||
# Ultra-Wide Presets - Portrait
|
||||
"1080×2560": PresetMetadata(
|
||||
1080,
|
||||
2560,
|
||||
"27:64",
|
||||
0.422,
|
||||
2.76,
|
||||
"Ultra-Wide",
|
||||
"Mobile",
|
||||
"Mobile ultra-tall 27:64 - modern phones",
|
||||
),
|
||||
"768×2048": PresetMetadata(
|
||||
768,
|
||||
2048,
|
||||
"3:8",
|
||||
0.375,
|
||||
1.57,
|
||||
"Ultra-Wide",
|
||||
"Vertical",
|
||||
"Vertical cinematic 3:8 - portrait video",
|
||||
),
|
||||
"768×1792": PresetMetadata(
|
||||
768,
|
||||
1792,
|
||||
"3:7",
|
||||
0.429,
|
||||
1.38,
|
||||
"Ultra-Wide",
|
||||
"Vertical",
|
||||
"Vertical panoramic 3:7 - tall vista",
|
||||
),
|
||||
"768×2304": PresetMetadata(
|
||||
768,
|
||||
2304,
|
||||
"1:3",
|
||||
0.333,
|
||||
1.77,
|
||||
"Ultra-Wide",
|
||||
"Banner",
|
||||
"Vertical banner 1:3 - extreme tall banner",
|
||||
),
|
||||
}
|
||||
|
||||
# Legacy compatibility - maintain old preset dictionaries
|
||||
SDXL_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
# Square
|
||||
"1024×1024": (1024, 1024), # 1:1 - Base SDXL resolution
|
||||
# Portrait ratios
|
||||
"896×1152": (896, 1152), # 7:9 - Moderate portrait
|
||||
"832×1216": (832, 1216), # 13:19 - Standard portrait
|
||||
"768×1344": (768, 1344), # 4:7 - Tall portrait
|
||||
"640×1536": (640, 1536), # 5:12 - Very tall portrait
|
||||
# Landscape ratios
|
||||
"1152×896": (1152, 896), # 9:7 - Moderate landscape
|
||||
"1216×832": (1216, 832), # 19:13 - Standard landscape
|
||||
"1344×768": (1344, 768), # 7:4 - Wide landscape
|
||||
"1536×640": (1536, 640), # 12:5 - Very wide landscape
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "SDXL"
|
||||
}
|
||||
|
||||
# FLUX optimized presets (higher resolution, flexible ratios)
|
||||
FLUX_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
# Recommended high-quality resolutions
|
||||
"1920×1080": (1920, 1080), # 16:9 - Full HD landscape
|
||||
"1536×1536": (1536, 1536), # 1:1 - High-res square
|
||||
"1280×768": (1280, 768), # 5:3 - Wide landscape
|
||||
"768×1280": (768, 1280), # 3:5 - Tall portrait
|
||||
# Alternative quality resolutions
|
||||
"1440×1080": (1440, 1080), # 4:3 - Classic aspect ratio
|
||||
"1080×1440": (1080, 1440), # 3:4 - Classic portrait
|
||||
"1728×1152": (1728, 1152), # 3:2 - Photography standard
|
||||
"1152×1728": (1152, 1728), # 2:3 - Portrait photography
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX"
|
||||
}
|
||||
|
||||
# Ultra-wide and modern aspect ratios
|
||||
ULTRA_WIDE_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
# Ultra-wide landscape (21:9 and variants)
|
||||
"2560×1080": (2560, 1080), # 64:27 - Ultra-wide gaming
|
||||
"2048×768": (2048, 768), # 8:3 - Wide cinematic
|
||||
"1792×768": (1792, 768), # 7:3 - Panoramic
|
||||
# Ultra-wide portrait
|
||||
"1080×2560": (1080, 2560), # 27:64 - Mobile ultra-tall
|
||||
"768×2048": (768, 2048), # 3:8 - Vertical cinematic
|
||||
"768×1792": (768, 1792), # 3:7 - Vertical panoramic
|
||||
# Extreme ratios
|
||||
"2304×768": (2304, 768), # 3:1 - Banner landscape
|
||||
"768×2304": (768, 2304), # 1:3 - Banner portrait
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide"
|
||||
}
|
||||
|
||||
# Combined preset options for ComfyUI dropdown
|
||||
PRESET_OPTIONS: Dict[str, Tuple[int, int]] = {
|
||||
"custom": (0, 0), # Special case for custom dimensions
|
||||
**SDXL_PRESETS,
|
||||
**FLUX_PRESETS,
|
||||
**ULTRA_WIDE_PRESETS,
|
||||
**{k: (v.width, v.height) for k, v in PRESET_METADATA.items()},
|
||||
}
|
||||
|
||||
# Organized preset categories for better UX
|
||||
# Enhanced preset categories organized by model groups and aspect ratios
|
||||
PRESET_CATEGORIES = {
|
||||
"Custom": ["custom"],
|
||||
"SDXL Square": ["1024×1024"],
|
||||
"SDXL Portrait": ["896×1152", "832×1216", "768×1344", "640×1536"],
|
||||
"SDXL Landscape": ["1152×896", "1216×832", "1344×768", "1536×640"],
|
||||
"FLUX Recommended": ["1920×1080", "1536×1536", "1280×768", "768×1280"],
|
||||
"FLUX Alternative": ["1440×1080", "1080×1440", "1728×1152", "1152×1728"],
|
||||
"Ultra-Wide Landscape": ["2560×1080", "2048×768", "1792×768", "2304×768"],
|
||||
"Ultra-Wide Portrait": ["1080×2560", "768×2048", "768×1792", "768×2304"],
|
||||
# SDXL Categories
|
||||
"SDXL Square": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "SDXL" and v.category == "Square"
|
||||
],
|
||||
"SDXL Portrait": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "SDXL" and v.category == "Portrait"
|
||||
],
|
||||
"SDXL Landscape": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "SDXL" and v.category == "Landscape"
|
||||
],
|
||||
# FLUX Categories
|
||||
"FLUX Square": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX" and v.category == "Square"
|
||||
],
|
||||
"FLUX Portrait": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX" and v.category == "Portrait"
|
||||
],
|
||||
"FLUX Cinematic": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX" and v.category == "Cinematic"
|
||||
],
|
||||
"FLUX Classic": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX" and v.category == "Classic"
|
||||
],
|
||||
"FLUX Photography": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX" and v.category == "Photography"
|
||||
],
|
||||
# Ultra-Wide Categories
|
||||
"Ultra-Wide Gaming": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Gaming"
|
||||
],
|
||||
"Ultra-Wide Cinematic": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Cinematic"
|
||||
],
|
||||
"Ultra-Wide Panoramic": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Panoramic"
|
||||
],
|
||||
"Ultra-Wide Mobile": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Mobile"
|
||||
],
|
||||
"Ultra-Wide Vertical": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Vertical"
|
||||
],
|
||||
"Ultra-Wide Banner": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Banner"
|
||||
],
|
||||
}
|
||||
|
||||
# Preset descriptions for tooltips
|
||||
PRESET_DESCRIPTIONS = {
|
||||
# SDXL presets
|
||||
"1024×1024": "SDXL base resolution - perfect square",
|
||||
"896×1152": "SDXL portrait 7:9 - moderate portrait",
|
||||
"832×1216": "SDXL portrait 13:19 - standard portrait",
|
||||
"768×1344": "SDXL portrait 4:7 - tall portrait",
|
||||
"640×1536": "SDXL portrait 5:12 - very tall portrait",
|
||||
"1152×896": "SDXL landscape 9:7 - moderate landscape",
|
||||
"1216×832": "SDXL landscape 19:13 - standard landscape",
|
||||
"1344×768": "SDXL landscape 7:4 - wide landscape",
|
||||
"1536×640": "SDXL landscape 12:5 - very wide landscape",
|
||||
# FLUX presets
|
||||
"1920×1080": "FLUX Full HD 16:9 - best quality/speed balance",
|
||||
"1536×1536": "FLUX high-res square - premium quality",
|
||||
"1280×768": "FLUX 5:3 landscape - cinematic wide",
|
||||
"768×1280": "FLUX 3:5 portrait - mobile optimized",
|
||||
"1440×1080": "FLUX 4:3 classic - traditional aspect ratio",
|
||||
"1080×1440": "FLUX 3:4 portrait - classic portrait",
|
||||
"1728×1152": "FLUX 3:2 photo - photography standard",
|
||||
"1152×1728": "FLUX 2:3 portrait - portrait photography",
|
||||
# Ultra-wide presets
|
||||
"2560×1080": "Ultra-wide 64:27 - gaming/panoramic",
|
||||
"2048×768": "Wide cinematic 8:3 - movie aspect",
|
||||
"1792×768": "Panoramic 7:3 - landscape vista",
|
||||
"2304×768": "Banner 3:1 - extreme wide banner",
|
||||
"1080×2560": "Mobile ultra-tall 27:64 - modern phones",
|
||||
"768×2048": "Vertical cinematic 3:8 - portrait video",
|
||||
"768×1792": "Vertical panoramic 3:7 - tall vista",
|
||||
"768×2304": "Vertical banner 1:3 - extreme tall banner",
|
||||
}
|
||||
# Legacy compatibility - preset descriptions
|
||||
PRESET_DESCRIPTIONS = {k: v.description for k, v in PRESET_METADATA.items()}
|
||||
|
||||
# Model-specific recommendations
|
||||
# Model-specific recommendations with metadata
|
||||
MODEL_RECOMMENDATIONS = {
|
||||
"SDXL": list(SDXL_PRESETS.keys()),
|
||||
"FLUX": list(FLUX_PRESETS.keys()),
|
||||
"Ultra-Wide": list(ULTRA_WIDE_PRESETS.keys()),
|
||||
"SDXL": [k for k, v in PRESET_METADATA.items() if v.model_group == "SDXL"],
|
||||
"FLUX": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX"],
|
||||
"Ultra-Wide": [
|
||||
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# New metadata-aware helper functions
|
||||
def get_presets_by_model_group(model_group: str) -> Dict[str, PresetMetadata]:
|
||||
"""Get all presets for a specific model group."""
|
||||
return {k: v for k, v in PRESET_METADATA.items() if v.model_group == model_group}
|
||||
|
||||
|
||||
def get_presets_by_aspect_ratio(aspect_ratio: str) -> Dict[str, PresetMetadata]:
|
||||
"""Get all presets with a specific aspect ratio."""
|
||||
return {k: v for k, v in PRESET_METADATA.items() if v.aspect_ratio == aspect_ratio}
|
||||
|
||||
|
||||
def get_presets_by_category(category: str) -> Dict[str, PresetMetadata]:
|
||||
"""Get all presets in a specific category."""
|
||||
return {k: v for k, v in PRESET_METADATA.items() if v.category == category}
|
||||
|
||||
|
||||
def get_preset_metadata(preset_name: str) -> PresetMetadata:
|
||||
"""Get metadata for a specific preset."""
|
||||
return PRESET_METADATA.get(
|
||||
preset_name,
|
||||
PresetMetadata(0, 0, "1:1", 1.0, 0.0, "Custom", "Custom", "Custom dimensions"),
|
||||
)
|
||||
|
||||
|
||||
def get_preset_category(preset_name: str) -> str:
|
||||
"""Get the category for a given preset name."""
|
||||
metadata = PRESET_METADATA.get(preset_name)
|
||||
if metadata:
|
||||
return metadata.category
|
||||
for category, presets in PRESET_CATEGORIES.items():
|
||||
if preset_name in presets:
|
||||
return category
|
||||
@@ -117,21 +438,17 @@ def get_preset_category(preset_name: str) -> str:
|
||||
|
||||
def get_model_recommendation(preset_name: str) -> str:
|
||||
"""Get model recommendation for a given preset."""
|
||||
if preset_name in SDXL_PRESETS:
|
||||
return "Optimized for SDXL"
|
||||
elif preset_name in FLUX_PRESETS:
|
||||
return "Optimized for FLUX"
|
||||
elif preset_name in ULTRA_WIDE_PRESETS:
|
||||
return "Modern ultra-wide ratios"
|
||||
else:
|
||||
return "Custom dimensions"
|
||||
metadata = PRESET_METADATA.get(preset_name)
|
||||
if metadata:
|
||||
return f"Optimized for {metadata.model_group}"
|
||||
return "Custom dimensions"
|
||||
|
||||
|
||||
def validate_preset_dimensions() -> bool:
|
||||
"""Validate that all presets meet ComfyUI requirements."""
|
||||
all_presets = {**SDXL_PRESETS, **FLUX_PRESETS, **ULTRA_WIDE_PRESETS}
|
||||
for preset_name, metadata in PRESET_METADATA.items():
|
||||
width, height = metadata.width, metadata.height
|
||||
|
||||
for preset_name, (width, height) in all_presets.items():
|
||||
# Check divisible by 8
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
print(
|
||||
@@ -147,6 +464,36 @@ def validate_preset_dimensions() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# Additional validation for metadata consistency
|
||||
def validate_metadata_consistency() -> bool:
|
||||
"""Validate metadata consistency and completeness."""
|
||||
for preset_name, metadata in PRESET_METADATA.items():
|
||||
# Verify aspect ratio calculation
|
||||
expected_ratio, expected_decimal = calculate_aspect_ratio(
|
||||
metadata.width, metadata.height
|
||||
)
|
||||
if abs(metadata.aspect_decimal - expected_decimal) > 0.001:
|
||||
print(
|
||||
f"ERROR: {preset_name} aspect ratio mismatch: "
|
||||
f"expected {expected_decimal:.3f}, got {metadata.aspect_decimal}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Verify megapixel calculation
|
||||
expected_mp = (metadata.width * metadata.height) / 1_000_000
|
||||
if abs(metadata.megapixels - expected_mp) > 0.1:
|
||||
print(
|
||||
f"ERROR: {preset_name} megapixel mismatch: "
|
||||
f"expected {expected_mp:.2f}, got {metadata.megapixels}"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Validate presets on import
|
||||
if not validate_preset_dimensions():
|
||||
raise ValueError("Preset validation failed - check console for details")
|
||||
|
||||
if not validate_metadata_consistency():
|
||||
raise ValueError("Metadata validation failed - check console for details")
|
||||
|
||||
@@ -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,95 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.0.9"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# 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"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ComfyAssets/ComfyUI-KikoTools"
|
||||
# Used by Comfy Registry https://registry.comfy.org
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["kikotools*"]
|
||||
exclude = ["tests*", "web*"]
|
||||
|
||||
[tool.comfy]
|
||||
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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Runtime dependencies for ComfyUI-KikoTools
|
||||
|
||||
# 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"
|
||||
@@ -5,8 +5,6 @@ Provides mock ComfyUI environments and test data
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import numpy as np
|
||||
from typing import Dict, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ Tests the shared functionality for all ComfyAssets tools
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import logging
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Unit tests for DisplayAny node."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.display_any import DisplayAnyNode
|
||||
from kikotools.tools.display_any.logic import (
|
||||
format_display_value,
|
||||
get_tensor_shapes,
|
||||
validate_display_mode,
|
||||
)
|
||||
from kikotools.tools.display_any.node import AnyType
|
||||
|
||||
|
||||
class TestAnyType:
|
||||
"""Test cases for AnyType class."""
|
||||
|
||||
def test_anytype_not_equal(self):
|
||||
"""Test that AnyType is never equal to other types."""
|
||||
any_type = AnyType("*")
|
||||
|
||||
# Should not be equal to any other type
|
||||
assert not (any_type != "STRING")
|
||||
assert not (any_type != "IMAGE")
|
||||
assert not (any_type != "LATENT")
|
||||
assert not (any_type != 123)
|
||||
assert not (any_type != None)
|
||||
assert not (any_type != ["LIST"])
|
||||
|
||||
def test_anytype_string_representation(self):
|
||||
"""Test string representation of AnyType."""
|
||||
any_type = AnyType("*")
|
||||
assert str(any_type) == "*"
|
||||
|
||||
|
||||
class TestDisplayAnyNode:
|
||||
"""Test cases for DisplayAnyNode."""
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert DisplayAnyNode.CATEGORY == "ComfyAssets"
|
||||
assert DisplayAnyNode.FUNCTION == "display"
|
||||
assert DisplayAnyNode.RETURN_TYPES == ("STRING",)
|
||||
assert DisplayAnyNode.RETURN_NAMES == ("display_text",)
|
||||
assert DisplayAnyNode.OUTPUT_NODE is True
|
||||
|
||||
def test_input_types(self):
|
||||
"""Test INPUT_TYPES configuration."""
|
||||
input_types = DisplayAnyNode.INPUT_TYPES()
|
||||
|
||||
# Check required inputs
|
||||
assert "required" in input_types
|
||||
assert "input" in input_types["required"]
|
||||
# Check that input is AnyType with wildcard
|
||||
input_type = input_types["required"]["input"]
|
||||
assert len(input_type) == 2
|
||||
assert isinstance(input_type[0], AnyType)
|
||||
assert str(input_type[0]) == "*"
|
||||
assert input_type[1] == {}
|
||||
assert "mode" in input_types["required"]
|
||||
assert input_types["required"]["mode"] == (["raw value", "tensor shape"],)
|
||||
|
||||
def test_validate_inputs(self):
|
||||
"""Test VALIDATE_INPUTS always returns True."""
|
||||
assert DisplayAnyNode.VALIDATE_INPUTS() is True
|
||||
assert DisplayAnyNode.VALIDATE_INPUTS(input="test") is True
|
||||
assert DisplayAnyNode.VALIDATE_INPUTS(input=123, mode="raw value") is True
|
||||
|
||||
def test_display_raw_value_string(self):
|
||||
"""Test displaying raw string value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display("Hello, World!", "raw value")
|
||||
|
||||
assert "ui" in result
|
||||
assert "text" in result["ui"]
|
||||
assert result["ui"]["text"] == "Hello, World!"
|
||||
assert "result" in result
|
||||
assert result["result"] == ("Hello, World!",)
|
||||
|
||||
def test_display_raw_value_number(self):
|
||||
"""Test displaying raw number value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display(42, "raw value")
|
||||
|
||||
assert result["ui"]["text"] == "42"
|
||||
assert result["result"] == ("42",)
|
||||
|
||||
def test_display_raw_value_list(self):
|
||||
"""Test displaying raw list value."""
|
||||
node = DisplayAnyNode()
|
||||
test_list = [1, 2, 3, "test"]
|
||||
result = node.display(test_list, "raw value")
|
||||
|
||||
assert result["ui"]["text"] == str(test_list)
|
||||
assert result["result"] == (str(test_list),)
|
||||
|
||||
def test_display_raw_value_dict(self):
|
||||
"""Test displaying raw dictionary value."""
|
||||
node = DisplayAnyNode()
|
||||
test_dict = {"key": "value", "number": 123}
|
||||
result = node.display(test_dict, "raw value")
|
||||
|
||||
assert result["ui"]["text"] == str(test_dict)
|
||||
assert result["result"] == (str(test_dict),)
|
||||
|
||||
def test_display_tensor_shape_numpy(self):
|
||||
"""Test displaying numpy tensor shape."""
|
||||
node = DisplayAnyNode()
|
||||
tensor = np.random.rand(4, 3, 224, 224)
|
||||
result = node.display(tensor, "tensor shape")
|
||||
|
||||
assert result["ui"]["text"] == "[[4, 3, 224, 224]]"
|
||||
assert result["result"] == ("[[4, 3, 224, 224]]",)
|
||||
|
||||
@pytest.mark.skipif(not torch, reason="PyTorch not installed")
|
||||
def test_display_tensor_shape_torch(self):
|
||||
"""Test displaying PyTorch tensor shape."""
|
||||
node = DisplayAnyNode()
|
||||
tensor = torch.randn(2, 10, 512, 512)
|
||||
result = node.display(tensor, "tensor shape")
|
||||
|
||||
assert result["ui"]["text"] == "[[2, 10, 512, 512]]"
|
||||
assert result["result"] == ("[[2, 10, 512, 512]]",)
|
||||
|
||||
def test_display_nested_tensors(self):
|
||||
"""Test displaying shapes from nested structure with tensors."""
|
||||
node = DisplayAnyNode()
|
||||
nested_data = {
|
||||
"images": np.random.rand(1, 3, 256, 256),
|
||||
"masks": [
|
||||
np.random.rand(256, 256),
|
||||
np.random.rand(256, 256, 1),
|
||||
],
|
||||
"metadata": {"info": "test", "tensor": np.random.rand(10)},
|
||||
}
|
||||
result = node.display(nested_data, "tensor shape")
|
||||
|
||||
expected = "[[1, 3, 256, 256], [256, 256], [256, 256, 1], [10]]"
|
||||
assert result["ui"]["text"] == expected
|
||||
assert result["result"] == (expected,)
|
||||
|
||||
def test_display_no_tensors(self):
|
||||
"""Test displaying when no tensors are present."""
|
||||
node = DisplayAnyNode()
|
||||
data = {"text": "hello", "number": 42, "list": [1, 2, 3]}
|
||||
result = node.display(data, "tensor shape")
|
||||
|
||||
assert result["ui"]["text"] == "No tensors found in input"
|
||||
assert result["result"] == ("No tensors found in input",)
|
||||
|
||||
def test_invalid_mode_defaults_to_raw(self):
|
||||
"""Test that invalid mode defaults to raw value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display("test", "invalid_mode")
|
||||
|
||||
assert result["ui"]["text"] == "test"
|
||||
assert result["result"] == ("test",)
|
||||
|
||||
|
||||
class TestDisplayAnyLogic:
|
||||
"""Test cases for DisplayAny logic functions."""
|
||||
|
||||
def test_get_tensor_shapes_single(self):
|
||||
"""Test getting shape from single tensor."""
|
||||
tensor = np.random.rand(3, 224, 224)
|
||||
shapes = get_tensor_shapes(tensor)
|
||||
|
||||
assert len(shapes) == 1
|
||||
assert shapes[0] == [3, 224, 224]
|
||||
|
||||
def test_get_tensor_shapes_nested_dict(self):
|
||||
"""Test getting shapes from nested dictionary."""
|
||||
data = {
|
||||
"level1": {
|
||||
"tensor1": np.random.rand(10, 20),
|
||||
"level2": {"tensor2": np.random.rand(5, 5, 5)},
|
||||
}
|
||||
}
|
||||
shapes = get_tensor_shapes(data)
|
||||
|
||||
assert len(shapes) == 2
|
||||
assert [10, 20] in shapes
|
||||
assert [5, 5, 5] in shapes
|
||||
|
||||
def test_get_tensor_shapes_nested_list(self):
|
||||
"""Test getting shapes from nested list."""
|
||||
data = [
|
||||
np.random.rand(1, 2, 3),
|
||||
[np.random.rand(4, 5), np.random.rand(6, 7, 8)],
|
||||
"not a tensor",
|
||||
]
|
||||
shapes = get_tensor_shapes(data)
|
||||
|
||||
assert len(shapes) == 3
|
||||
assert [1, 2, 3] in shapes
|
||||
assert [4, 5] in shapes
|
||||
assert [6, 7, 8] in shapes
|
||||
|
||||
def test_get_tensor_shapes_tuple(self):
|
||||
"""Test getting shapes from tuple."""
|
||||
data = (np.random.rand(2, 2), np.random.rand(3, 3))
|
||||
shapes = get_tensor_shapes(data)
|
||||
|
||||
assert len(shapes) == 2
|
||||
assert [2, 2] in shapes
|
||||
assert [3, 3] in shapes
|
||||
|
||||
def test_format_display_value_raw(self):
|
||||
"""Test formatting for raw value display."""
|
||||
result = format_display_value({"key": "value"}, "raw value")
|
||||
assert result == "{'key': 'value'}"
|
||||
|
||||
def test_format_display_value_tensor_shape(self):
|
||||
"""Test formatting for tensor shape display."""
|
||||
tensor = np.random.rand(10, 10)
|
||||
result = format_display_value(tensor, "tensor shape")
|
||||
assert result == "[[10, 10]]"
|
||||
|
||||
def test_format_display_value_no_tensors(self):
|
||||
"""Test formatting when no tensors present."""
|
||||
result = format_display_value("just a string", "tensor shape")
|
||||
assert result == "No tensors found in input"
|
||||
|
||||
def test_validate_display_mode(self):
|
||||
"""Test display mode validation."""
|
||||
assert validate_display_mode("raw value") is True
|
||||
assert validate_display_mode("tensor shape") is True
|
||||
assert validate_display_mode("invalid") is False
|
||||
assert validate_display_mode("") is False
|
||||
assert validate_display_mode(None) is False
|
||||
|
||||
|
||||
class TestDisplayAnyEdgeCases:
|
||||
"""Test edge cases for DisplayAny."""
|
||||
|
||||
def test_display_none(self):
|
||||
"""Test displaying None value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display(None, "raw value")
|
||||
assert result["ui"]["text"] == "None"
|
||||
|
||||
def test_display_empty_list(self):
|
||||
"""Test displaying empty list."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display([], "raw value")
|
||||
assert result["ui"]["text"] == "[]"
|
||||
|
||||
def test_display_empty_dict(self):
|
||||
"""Test displaying empty dictionary."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display({}, "raw value")
|
||||
assert result["ui"]["text"] == "{}"
|
||||
|
||||
def test_display_complex_nested_structure(self):
|
||||
"""Test displaying complex nested structure."""
|
||||
node = DisplayAnyNode()
|
||||
complex_data = {
|
||||
"images": [np.random.rand(1, 3, 64, 64) for _ in range(3)],
|
||||
"config": {
|
||||
"steps": 20,
|
||||
"cfg": 7.5,
|
||||
"sampler": "euler",
|
||||
"latents": np.random.rand(1, 4, 32, 32),
|
||||
},
|
||||
"prompts": ["test1", "test2"],
|
||||
}
|
||||
result = node.display(complex_data, "tensor shape")
|
||||
|
||||
# Should find 4 tensors total (3 images + 1 latent)
|
||||
shapes_text = result["ui"]["text"]
|
||||
assert "[1, 3, 64, 64]" in shapes_text
|
||||
assert "[1, 4, 32, 32]" in shapes_text
|
||||
|
||||
def test_display_very_long_string(self):
|
||||
"""Test displaying very long string."""
|
||||
node = DisplayAnyNode()
|
||||
long_string = "x" * 10000
|
||||
result = node.display(long_string, "raw value")
|
||||
assert result["ui"]["text"] == long_string
|
||||
|
||||
def test_display_unicode(self):
|
||||
"""Test displaying unicode characters."""
|
||||
node = DisplayAnyNode()
|
||||
unicode_text = "Hello 世界 🌍"
|
||||
result = node.display(unicode_text, "raw value")
|
||||
assert result["ui"]["text"] == unicode_text
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Tests for Empty Latent Batch node and logic."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.empty_latent_batch.node import EmptyLatentBatchNode
|
||||
from kikotools.tools.empty_latent_batch.logic import (
|
||||
create_empty_latent_batch,
|
||||
validate_dimensions,
|
||||
sanitize_dimensions,
|
||||
)
|
||||
|
||||
|
||||
class TestEmptyLatentBatchLogic:
|
||||
"""Test the logic functions for empty latent batch creation."""
|
||||
|
||||
def test_create_empty_latent_batch_basic(self):
|
||||
"""Test basic empty latent creation."""
|
||||
result = create_empty_latent_batch(512, 512, 1)
|
||||
|
||||
assert "samples" in result
|
||||
samples = result["samples"]
|
||||
assert isinstance(samples, torch.Tensor)
|
||||
assert samples.shape == (1, 4, 64, 64) # 512/8 = 64
|
||||
assert torch.all(samples == 0) # Should be all zeros
|
||||
|
||||
def test_create_empty_latent_batch_with_batch_size(self):
|
||||
"""Test empty latent creation with larger batch size."""
|
||||
batch_size = 4
|
||||
result = create_empty_latent_batch(1024, 768, batch_size)
|
||||
|
||||
assert "samples" in result
|
||||
samples = result["samples"]
|
||||
assert isinstance(samples, torch.Tensor)
|
||||
assert samples.shape == (4, 4, 96, 128) # 768/8=96, 1024/8=128
|
||||
assert torch.all(samples == 0)
|
||||
|
||||
def test_create_empty_latent_batch_invalid_dimensions(self):
|
||||
"""Test error handling for invalid dimensions."""
|
||||
with pytest.raises(ValueError, match="Width and height must be positive"):
|
||||
create_empty_latent_batch(0, 512, 1)
|
||||
|
||||
with pytest.raises(ValueError, match="Width and height must be positive"):
|
||||
create_empty_latent_batch(512, -100, 1)
|
||||
|
||||
def test_create_empty_latent_batch_not_divisible_by_8(self):
|
||||
"""Test error handling for dimensions not divisible by 8."""
|
||||
with pytest.raises(ValueError, match="must be divisible by 8"):
|
||||
create_empty_latent_batch(513, 512, 1)
|
||||
|
||||
with pytest.raises(ValueError, match="must be divisible by 8"):
|
||||
create_empty_latent_batch(512, 515, 1)
|
||||
|
||||
def test_create_empty_latent_batch_invalid_batch_size(self):
|
||||
"""Test error handling for invalid batch size."""
|
||||
with pytest.raises(ValueError, match="Batch size must be positive"):
|
||||
create_empty_latent_batch(512, 512, 0)
|
||||
|
||||
with pytest.raises(ValueError, match="Batch size must be positive"):
|
||||
create_empty_latent_batch(512, 512, -1)
|
||||
|
||||
def test_validate_dimensions_valid(self):
|
||||
"""Test dimension validation with valid inputs."""
|
||||
assert validate_dimensions(512, 512) is True
|
||||
assert validate_dimensions(1024, 768) is True
|
||||
assert validate_dimensions(64, 64) is True # Minimum size
|
||||
assert validate_dimensions(8192, 8192) is True # Maximum size
|
||||
|
||||
def test_validate_dimensions_invalid(self):
|
||||
"""Test dimension validation with invalid inputs."""
|
||||
assert validate_dimensions(0, 512) is False # Zero dimension
|
||||
assert validate_dimensions(512, -100) is False # Negative dimension
|
||||
assert validate_dimensions(513, 512) is False # Not divisible by 8
|
||||
assert validate_dimensions(32, 32) is False # Too small
|
||||
assert validate_dimensions(8200, 8200) is False # Too large
|
||||
|
||||
def test_sanitize_dimensions_basic(self):
|
||||
"""Test basic dimension sanitization."""
|
||||
width, height = sanitize_dimensions(512, 512)
|
||||
assert width == 512
|
||||
assert height == 512
|
||||
|
||||
def test_sanitize_dimensions_not_divisible_by_8(self):
|
||||
"""Test sanitization of dimensions not divisible by 8."""
|
||||
width, height = sanitize_dimensions(513, 515)
|
||||
assert width == 512 # Rounds down to nearest multiple of 8
|
||||
assert height == 512
|
||||
|
||||
width, height = sanitize_dimensions(517, 519)
|
||||
assert width == 520 # Rounds up to nearest multiple of 8
|
||||
assert height == 520
|
||||
|
||||
def test_sanitize_dimensions_too_small(self):
|
||||
"""Test sanitization of dimensions that are too small."""
|
||||
width, height = sanitize_dimensions(32, 16)
|
||||
assert width == 64 # Minimum size
|
||||
assert height == 64
|
||||
|
||||
def test_sanitize_dimensions_too_large(self):
|
||||
"""Test sanitization of dimensions that are too large."""
|
||||
width, height = sanitize_dimensions(10000, 9000)
|
||||
assert width == 8192 # Maximum size
|
||||
assert height == 8192
|
||||
|
||||
|
||||
class TestEmptyLatentBatchNode:
|
||||
"""Test the EmptyLatentBatchNode ComfyUI node."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.node = EmptyLatentBatchNode()
|
||||
|
||||
def test_input_types_structure(self):
|
||||
"""Test that INPUT_TYPES returns proper structure."""
|
||||
input_types = EmptyLatentBatchNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
required = input_types["required"]
|
||||
|
||||
assert "width" in required
|
||||
assert "height" in required
|
||||
assert "batch_size" in required
|
||||
|
||||
# Check width parameter
|
||||
width_spec = required["width"]
|
||||
assert width_spec[0] == "INT"
|
||||
assert width_spec[1]["default"] == 1024
|
||||
assert width_spec[1]["min"] == 64
|
||||
assert width_spec[1]["max"] == 8192
|
||||
assert width_spec[1]["step"] == 8
|
||||
|
||||
def test_node_attributes(self):
|
||||
"""Test node class attributes."""
|
||||
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT",)
|
||||
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent",)
|
||||
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
|
||||
assert EmptyLatentBatchNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
def test_create_empty_latent_basic(self):
|
||||
"""Test basic empty latent creation through node."""
|
||||
result = self.node.create_empty_latent(512, 512, 1)
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
|
||||
latent_dict = result[0]
|
||||
assert isinstance(latent_dict, dict)
|
||||
assert "samples" in latent_dict
|
||||
|
||||
samples = latent_dict["samples"]
|
||||
assert isinstance(samples, torch.Tensor)
|
||||
assert samples.shape == (1, 4, 64, 64)
|
||||
|
||||
def test_create_empty_latent_with_batch(self):
|
||||
"""Test empty latent creation with batch size."""
|
||||
batch_size = 3
|
||||
result = self.node.create_empty_latent(1024, 768, batch_size)
|
||||
|
||||
latent_dict = result[0]
|
||||
samples = latent_dict["samples"]
|
||||
assert samples.shape == (3, 4, 96, 128) # batch=3, 768/8=96, 1024/8=128
|
||||
|
||||
def test_create_empty_latent_dimension_adjustment(self):
|
||||
"""Test that dimensions are adjusted when not divisible by 8."""
|
||||
# Input dimensions not divisible by 8
|
||||
result = self.node.create_empty_latent(513, 515, 1)
|
||||
|
||||
latent_dict = result[0]
|
||||
samples = latent_dict["samples"]
|
||||
# Should be adjusted to 512x512 -> 64x64 latent
|
||||
assert samples.shape == (1, 4, 64, 64)
|
||||
|
||||
def test_validate_inputs_valid(self):
|
||||
"""Test input validation with valid parameters."""
|
||||
assert self.node.validate_inputs(512, 512, 1) is True
|
||||
assert self.node.validate_inputs(1024, 768, 4) is True
|
||||
|
||||
def test_validate_inputs_invalid_batch_size(self):
|
||||
"""Test input validation with invalid batch size."""
|
||||
assert self.node.validate_inputs(512, 512, 0) is False
|
||||
assert self.node.validate_inputs(512, 512, 100) is False # Too large
|
||||
|
||||
def test_get_latent_info(self):
|
||||
"""Test latent info generation."""
|
||||
info = self.node.get_latent_info(512, 512, 2)
|
||||
assert "Empty latent batch" in info
|
||||
assert "2 × 4 × 64 × 64" in info
|
||||
assert "512×512" in info
|
||||
|
||||
def test_get_memory_estimate(self):
|
||||
"""Test memory estimation."""
|
||||
estimate = self.node.get_memory_estimate(512, 512, 1)
|
||||
assert "KB" in estimate or "MB" in estimate
|
||||
|
||||
# Larger batch should show larger estimate
|
||||
large_estimate = self.node.get_memory_estimate(1024, 1024, 8)
|
||||
assert "MB" in large_estimate
|
||||
|
||||
def test_node_registration_mappings(self):
|
||||
"""Test that node registration mappings are properly defined."""
|
||||
from kikotools.tools.empty_latent_batch.node import (
|
||||
NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS,
|
||||
)
|
||||
|
||||
assert "EmptyLatentBatch" in NODE_CLASS_MAPPINGS
|
||||
assert NODE_CLASS_MAPPINGS["EmptyLatentBatch"] == EmptyLatentBatchNode
|
||||
|
||||
assert "EmptyLatentBatch" in NODE_DISPLAY_NAME_MAPPINGS
|
||||
assert NODE_DISPLAY_NAME_MAPPINGS["EmptyLatentBatch"] == "Empty Latent Batch"
|
||||
|
||||
def test_node_inheritance(self):
|
||||
"""Test that node properly inherits from base class."""
|
||||
from kikotools.base.base_node import ComfyAssetsBaseNode
|
||||
|
||||
assert isinstance(self.node, ComfyAssetsBaseNode)
|
||||
assert hasattr(self.node, "handle_error")
|
||||
assert hasattr(self.node, "log_info")
|
||||
assert hasattr(self.node, "validate_inputs")
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Unit tests for Gemini Prompt Engineer node."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import patch, MagicMock
|
||||
from PIL import Image
|
||||
|
||||
from kikotools.tools.gemini_prompt import GeminiPromptNode
|
||||
from kikotools.tools.gemini_prompt.logic import (
|
||||
tensor_to_pil,
|
||||
image_to_base64,
|
||||
get_api_key,
|
||||
validate_prompt_type,
|
||||
analyze_image_with_gemini,
|
||||
)
|
||||
from kikotools.tools.gemini_prompt.prompts import (
|
||||
PROMPT_OPTIONS,
|
||||
PROMPT_TEMPLATES,
|
||||
GEMINI_MODELS,
|
||||
)
|
||||
|
||||
|
||||
class TestGeminiPromptNode:
|
||||
"""Test cases for GeminiPromptNode."""
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert GeminiPromptNode.CATEGORY == "ComfyAssets"
|
||||
assert GeminiPromptNode.FUNCTION == "generate_prompt"
|
||||
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
|
||||
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
|
||||
|
||||
def test_input_types(self):
|
||||
"""Test INPUT_TYPES configuration."""
|
||||
input_types = GeminiPromptNode.INPUT_TYPES()
|
||||
|
||||
# Check required inputs
|
||||
assert "required" in input_types
|
||||
assert "image" in input_types["required"]
|
||||
assert input_types["required"]["image"] == ("IMAGE",)
|
||||
assert "prompt_type" in input_types["required"]
|
||||
assert input_types["required"]["prompt_type"][0] == PROMPT_OPTIONS
|
||||
assert "model" in input_types["required"]
|
||||
assert input_types["required"]["model"][0] == GEMINI_MODELS
|
||||
|
||||
# Check optional inputs
|
||||
assert "optional" in input_types
|
||||
assert "api_key" in input_types["optional"]
|
||||
assert "custom_prompt" in input_types["optional"]
|
||||
|
||||
def test_gemini_models_available(self):
|
||||
"""Test that all expected Gemini models are available."""
|
||||
expected_models = [
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-pro-vision",
|
||||
"gemini-1.0-pro",
|
||||
]
|
||||
for model in expected_models:
|
||||
assert model in GEMINI_MODELS
|
||||
|
||||
@patch("kikotools.tools.gemini_prompt.node.analyze_image_with_gemini")
|
||||
def test_generate_prompt_success(self, mock_analyze):
|
||||
"""Test successful prompt generation."""
|
||||
# Setup
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
mock_analyze.return_value = ("A beautiful landscape with mountains", None)
|
||||
|
||||
# Execute
|
||||
result = node.generate_prompt(test_image, "flux")
|
||||
|
||||
# Assert
|
||||
assert result == ("A beautiful landscape with mountains", "")
|
||||
mock_analyze.assert_called_once()
|
||||
|
||||
@patch("kikotools.tools.gemini_prompt.node.analyze_image_with_gemini")
|
||||
def test_generate_prompt_sdxl_format(self, mock_analyze):
|
||||
"""Test SDXL format with positive and negative prompts."""
|
||||
# Setup
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
mock_analyze.return_value = (
|
||||
"Positive: beautiful landscape, mountains, sunset\nNegative: blurry, low quality",
|
||||
None,
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = node.generate_prompt(test_image, "sdxl")
|
||||
|
||||
# Assert
|
||||
assert result == (
|
||||
"beautiful landscape, mountains, sunset",
|
||||
"blurry, low quality",
|
||||
)
|
||||
|
||||
@patch("kikotools.tools.gemini_prompt.node.analyze_image_with_gemini")
|
||||
def test_generate_prompt_error(self, mock_analyze):
|
||||
"""Test error handling in prompt generation."""
|
||||
# Setup
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
mock_analyze.return_value = ("", "API key not found")
|
||||
|
||||
# Execute
|
||||
result = node.generate_prompt(test_image, "flux")
|
||||
|
||||
# Assert
|
||||
assert result[0].startswith("Error:")
|
||||
assert result[1] == ""
|
||||
|
||||
def test_invalid_prompt_type(self):
|
||||
"""Test handling of invalid prompt type."""
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid prompt type"):
|
||||
node.generate_prompt(test_image, "invalid_type")
|
||||
|
||||
|
||||
class TestGeminiLogic:
|
||||
"""Test cases for Gemini logic functions."""
|
||||
|
||||
def test_tensor_to_pil(self):
|
||||
"""Test tensor to PIL conversion."""
|
||||
# Test 4D tensor
|
||||
tensor_4d = np.random.rand(1, 64, 64, 3)
|
||||
result = tensor_to_pil(tensor_4d)
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.size == (64, 64)
|
||||
assert result.mode == "RGB"
|
||||
|
||||
# Test 3D tensor
|
||||
tensor_3d = np.random.rand(64, 64, 3)
|
||||
result = tensor_to_pil(tensor_3d)
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.size == (64, 64)
|
||||
|
||||
def test_image_to_base64(self):
|
||||
"""Test image to base64 conversion."""
|
||||
# Create test image
|
||||
image = Image.new("RGB", (64, 64), color="red")
|
||||
|
||||
# Convert to base64
|
||||
result = image_to_base64(image)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
# Test JPEG format
|
||||
result_jpeg = image_to_base64(image, format="JPEG")
|
||||
assert isinstance(result_jpeg, str)
|
||||
assert (
|
||||
result != result_jpeg
|
||||
) # Different formats should produce different results
|
||||
|
||||
@patch.dict("os.environ", {"GEMINI_API_KEY": "test_key_123"})
|
||||
def test_get_api_key_from_env(self):
|
||||
"""Test getting API key from environment."""
|
||||
result = get_api_key()
|
||||
assert result == "test_key_123"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
@patch("os.path.exists")
|
||||
@patch("builtins.open")
|
||||
def test_get_api_key_from_config(self, mock_open, mock_exists):
|
||||
"""Test getting API key from config file."""
|
||||
# Setup
|
||||
mock_exists.return_value = True
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = (
|
||||
'{"api_key": "config_key_456"}'
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = get_api_key()
|
||||
|
||||
# Assert
|
||||
assert result == "config_key_456"
|
||||
|
||||
def test_validate_prompt_type(self):
|
||||
"""Test prompt type validation."""
|
||||
# Valid types
|
||||
for prompt_type in PROMPT_OPTIONS:
|
||||
assert validate_prompt_type(prompt_type) is True
|
||||
|
||||
# Invalid types
|
||||
assert validate_prompt_type("invalid") is False
|
||||
assert validate_prompt_type("") is False
|
||||
assert validate_prompt_type(None) is False
|
||||
|
||||
@patch("google.generativeai.configure")
|
||||
@patch("google.generativeai.GenerativeModel")
|
||||
def test_analyze_image_with_gemini_success(self, mock_model_class, mock_configure):
|
||||
"""Test successful image analysis with Gemini."""
|
||||
# Setup
|
||||
mock_model = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "A beautiful sunset over mountains"
|
||||
mock_model.generate_content.return_value = mock_response
|
||||
mock_model_class.return_value = mock_model
|
||||
|
||||
test_image = np.random.rand(64, 64, 3)
|
||||
|
||||
# Execute
|
||||
result, error = analyze_image_with_gemini(
|
||||
test_image, "flux", api_key="test_key"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == "A beautiful sunset over mountains"
|
||||
assert error is None
|
||||
mock_configure.assert_called_once_with(api_key="test_key")
|
||||
mock_model.generate_content.assert_called_once()
|
||||
|
||||
def test_analyze_image_no_api_key(self):
|
||||
"""Test analysis without API key."""
|
||||
test_image = np.random.rand(64, 64, 3)
|
||||
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.logic.get_api_key", return_value=None
|
||||
):
|
||||
result, error = analyze_image_with_gemini(test_image, "flux")
|
||||
|
||||
assert result == ""
|
||||
assert "API key not found" in error
|
||||
|
||||
@patch("google.generativeai.configure")
|
||||
@patch("google.generativeai.GenerativeModel")
|
||||
def test_analyze_image_with_custom_prompt(self, mock_model_class, mock_configure):
|
||||
"""Test analysis with custom prompt."""
|
||||
# Setup
|
||||
mock_model = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Custom analysis result"
|
||||
mock_model.generate_content.return_value = mock_response
|
||||
mock_model_class.return_value = mock_model
|
||||
|
||||
test_image = np.random.rand(64, 64, 3)
|
||||
custom_prompt = "Analyze this image and describe the colors"
|
||||
|
||||
# Execute
|
||||
result, error = analyze_image_with_gemini(
|
||||
test_image, "flux", api_key="test_key", custom_prompt=custom_prompt
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == "Custom analysis result"
|
||||
assert error is None
|
||||
|
||||
# Check that custom prompt was used
|
||||
call_args = mock_model.generate_content.call_args[0][0]
|
||||
assert custom_prompt in call_args
|
||||
|
||||
|
||||
class TestPromptTemplates:
|
||||
"""Test prompt template configurations."""
|
||||
|
||||
def test_all_prompt_types_have_templates(self):
|
||||
"""Test that all prompt options have corresponding templates."""
|
||||
for prompt_type in PROMPT_OPTIONS:
|
||||
assert prompt_type in PROMPT_TEMPLATES
|
||||
assert isinstance(PROMPT_TEMPLATES[prompt_type], str)
|
||||
assert len(PROMPT_TEMPLATES[prompt_type]) > 0
|
||||
|
||||
def test_prompt_template_content(self):
|
||||
"""Test that prompt templates contain expected content."""
|
||||
# FLUX prompt should mention FLUX
|
||||
assert "FLUX" in PROMPT_TEMPLATES["flux"]
|
||||
|
||||
# SDXL prompt should mention positive and negative
|
||||
assert "Positive" in PROMPT_TEMPLATES["sdxl"]
|
||||
assert "Negative" in PROMPT_TEMPLATES["sdxl"]
|
||||
|
||||
# Danbooru should mention tags and underscores
|
||||
assert "tag" in PROMPT_TEMPLATES["danbooru"].lower()
|
||||
assert "underscore" in PROMPT_TEMPLATES["danbooru"].lower()
|
||||
|
||||
# Video should mention motion and temporal
|
||||
assert "motion" in PROMPT_TEMPLATES["video"].lower()
|
||||
assert "temporal" in PROMPT_TEMPLATES["video"].lower()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for ImageToMultipleOf tool."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to the Python path for tests
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from kikotools.tools.image_to_multiple_of.logic import (
|
||||
calculate_dimensions_to_multiple,
|
||||
process_image_to_multiple_of,
|
||||
)
|
||||
from kikotools.tools.image_to_multiple_of.node import ImageToMultipleOfNode
|
||||
|
||||
|
||||
class TestImageToMultipleOfLogic:
|
||||
"""Test core logic functions."""
|
||||
|
||||
def test_calculate_dimensions_to_multiple(self):
|
||||
"""Test dimension calculation for various inputs."""
|
||||
# Test exact multiples
|
||||
assert calculate_dimensions_to_multiple(256, 512, 64) == (256, 512)
|
||||
|
||||
# Test non-exact multiples
|
||||
assert calculate_dimensions_to_multiple(300, 400, 64) == (256, 384)
|
||||
assert calculate_dimensions_to_multiple(150, 200, 32) == (128, 192)
|
||||
|
||||
# Test small values
|
||||
assert calculate_dimensions_to_multiple(10, 20, 8) == (8, 16)
|
||||
|
||||
# Test with multiple_of = 1 (should return original)
|
||||
assert calculate_dimensions_to_multiple(123, 456, 1) == (123, 456)
|
||||
|
||||
def test_process_image_center_crop(self):
|
||||
"""Test center crop processing."""
|
||||
# Create test image (batch=1, height=300, width=400, channels=3)
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
# Process with center crop
|
||||
result = process_image_to_multiple_of(image, 64, "center crop")
|
||||
|
||||
# Check dimensions
|
||||
assert result.shape == (1, 256, 384, 3)
|
||||
|
||||
# Check that center portion is preserved
|
||||
# The crop should start at (22, 8) and end at (278, 392)
|
||||
# This is a rough check that values are from the center
|
||||
assert result.dtype == image.dtype
|
||||
|
||||
def test_process_image_rescale(self):
|
||||
"""Test rescale processing."""
|
||||
# Create test image
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
# Process with rescale
|
||||
result = process_image_to_multiple_of(image, 64, "rescale")
|
||||
|
||||
# Check dimensions
|
||||
assert result.shape == (1, 256, 384, 3)
|
||||
assert result.dtype == image.dtype
|
||||
|
||||
def test_process_image_batch(self):
|
||||
"""Test processing with batch of images."""
|
||||
# Create batch of images
|
||||
batch_size = 4
|
||||
image = torch.rand(batch_size, 300, 400, 3)
|
||||
|
||||
# Process with center crop
|
||||
result_crop = process_image_to_multiple_of(image, 32, "center crop")
|
||||
assert result_crop.shape == (batch_size, 288, 384, 3)
|
||||
|
||||
# Process with rescale
|
||||
result_rescale = process_image_to_multiple_of(image, 32, "rescale")
|
||||
assert result_rescale.shape == (batch_size, 288, 384, 3)
|
||||
|
||||
def test_process_image_different_channels(self):
|
||||
"""Test with different channel counts."""
|
||||
# Test with 1 channel (grayscale)
|
||||
image_gray = torch.rand(1, 256, 256, 1)
|
||||
result = process_image_to_multiple_of(image_gray, 64, "center crop")
|
||||
assert result.shape == (1, 256, 256, 1)
|
||||
|
||||
# Test with 4 channels (RGBA)
|
||||
image_rgba = torch.rand(1, 300, 400, 4)
|
||||
result = process_image_to_multiple_of(image_rgba, 64, "rescale")
|
||||
assert result.shape == (1, 256, 384, 4)
|
||||
|
||||
|
||||
class TestImageToMultipleOfNode:
|
||||
"""Test ComfyUI node implementation."""
|
||||
|
||||
def test_node_input_types(self):
|
||||
"""Test node input type definitions."""
|
||||
input_types = ImageToMultipleOfNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
assert "image" in input_types["required"]
|
||||
assert "multiple_of" in input_types["required"]
|
||||
assert "method" in input_types["required"]
|
||||
|
||||
# Check multiple_of configuration
|
||||
multiple_config = input_types["required"]["multiple_of"][1]
|
||||
assert multiple_config["default"] == 64
|
||||
assert multiple_config["min"] == 1
|
||||
assert multiple_config["max"] == 256
|
||||
assert multiple_config["step"] == 16
|
||||
|
||||
# Check method options
|
||||
methods = input_types["required"]["method"][0]
|
||||
assert "center crop" in methods
|
||||
assert "rescale" in methods
|
||||
|
||||
def test_node_metadata(self):
|
||||
"""Test node metadata."""
|
||||
assert ImageToMultipleOfNode.RETURN_TYPES == ("IMAGE",)
|
||||
assert ImageToMultipleOfNode.RETURN_NAMES == ("image",)
|
||||
assert ImageToMultipleOfNode.FUNCTION == "process"
|
||||
assert ImageToMultipleOfNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
def test_node_process_center_crop(self):
|
||||
"""Test node processing with center crop."""
|
||||
node = ImageToMultipleOfNode()
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
result = node.process(image, 64, "center crop")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 256, 384, 3)
|
||||
|
||||
def test_node_process_rescale(self):
|
||||
"""Test node processing with rescale."""
|
||||
node = ImageToMultipleOfNode()
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
result = node.process(image, 32, "rescale")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 288, 384, 3)
|
||||
|
||||
def test_node_validation_errors(self):
|
||||
"""Test input validation error handling."""
|
||||
node = ImageToMultipleOfNode()
|
||||
|
||||
# Test with None image
|
||||
with pytest.raises(ValueError, match="Image input is required"):
|
||||
node.validate_inputs(image=None, multiple_of=64, method="center crop")
|
||||
|
||||
# Test with invalid image shape
|
||||
invalid_image = torch.rand(300, 400, 3) # Missing batch dimension
|
||||
with pytest.raises(ValueError, match="Expected image tensor with shape"):
|
||||
node.validate_inputs(
|
||||
image=invalid_image, multiple_of=64, method="center crop"
|
||||
)
|
||||
|
||||
# Test with negative multiple_of
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
with pytest.raises(ValueError, match="multiple_of must be positive"):
|
||||
node.validate_inputs(image=image, multiple_of=-64, method="center crop")
|
||||
|
||||
# Test with invalid method
|
||||
with pytest.raises(ValueError, match="Invalid method"):
|
||||
node.validate_inputs(image=image, multiple_of=64, method="invalid")
|
||||
|
||||
# Test with image too small
|
||||
small_image = torch.rand(1, 30, 40, 3)
|
||||
with pytest.raises(ValueError, match="too small to be adjusted"):
|
||||
node.validate_inputs(
|
||||
image=small_image, multiple_of=64, method="center crop"
|
||||
)
|
||||
|
||||
def test_node_edge_cases(self):
|
||||
"""Test edge cases."""
|
||||
node = ImageToMultipleOfNode()
|
||||
|
||||
# Test with already multiple dimensions
|
||||
image = torch.rand(1, 256, 512, 3)
|
||||
result = node.process(image, 64, "center crop")
|
||||
assert result[0].shape == image.shape
|
||||
|
||||
# Test with multiple_of = 1
|
||||
image = torch.rand(1, 123, 456, 3)
|
||||
result = node.process(image, 1, "center crop")
|
||||
assert result[0].shape == image.shape
|
||||
|
||||
# Test with very large multiple_of
|
||||
image = torch.rand(1, 1024, 1024, 3)
|
||||
result = node.process(image, 256, "rescale")
|
||||
assert result[0].shape == (1, 1024, 1024, 3)
|
||||
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Unit tests for KikoSaveImage tool
|
||||
Tests image saving functionality with multiple formats and quality settings
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import tempfile
|
||||
import os
|
||||
from PIL import Image
|
||||
from unittest.mock import patch
|
||||
|
||||
from kikotools.tools.kiko_save_image.node import KikoSaveImageNode
|
||||
from kikotools.tools.kiko_save_image.logic import (
|
||||
convert_tensor_to_pil,
|
||||
process_image_batch,
|
||||
validate_save_inputs,
|
||||
save_image_with_format,
|
||||
get_save_image_path,
|
||||
create_png_metadata,
|
||||
)
|
||||
|
||||
|
||||
class TestKikoSaveImageLogic:
|
||||
"""Test core logic functions"""
|
||||
|
||||
def test_convert_tensor_to_pil(self):
|
||||
"""Test tensor to PIL conversion"""
|
||||
# Create test tensor [height, width, channels] with values 0-1
|
||||
tensor = torch.rand(64, 64, 3)
|
||||
|
||||
# Convert to PIL
|
||||
pil_image = convert_tensor_to_pil(tensor)
|
||||
|
||||
# Verify conversion
|
||||
assert isinstance(pil_image, Image.Image)
|
||||
assert pil_image.size == (64, 64) # PIL uses (width, height)
|
||||
assert pil_image.mode in ["RGB", "RGBA"]
|
||||
|
||||
def test_convert_tensor_to_pil_rgba(self):
|
||||
"""Test tensor to PIL conversion with alpha channel"""
|
||||
# Create RGBA tensor
|
||||
tensor = torch.rand(32, 32, 4)
|
||||
|
||||
pil_image = convert_tensor_to_pil(tensor)
|
||||
|
||||
assert isinstance(pil_image, Image.Image)
|
||||
assert pil_image.size == (32, 32)
|
||||
assert pil_image.mode == "RGBA"
|
||||
|
||||
def test_get_save_image_path(self):
|
||||
"""Test save path generation"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Test basic path generation
|
||||
full_path, filename = get_save_image_path(
|
||||
"test_prefix", 0, ".png", temp_dir
|
||||
)
|
||||
|
||||
assert full_path.startswith(temp_dir)
|
||||
assert filename.startswith("test_prefix_")
|
||||
assert filename.endswith("_00000.png")
|
||||
|
||||
# Test with empty subfolder (standard behavior)
|
||||
full_path, filename = get_save_image_path("test", 1, ".jpg", temp_dir, "")
|
||||
|
||||
assert full_path.startswith(temp_dir)
|
||||
assert filename.startswith("test_")
|
||||
assert filename.endswith("_00001.jpg")
|
||||
|
||||
def test_create_png_metadata(self):
|
||||
"""Test PNG metadata creation"""
|
||||
# Test with no metadata
|
||||
metadata = create_png_metadata()
|
||||
assert metadata is None
|
||||
|
||||
# Test with prompt data
|
||||
prompt_data = {"test": "value"}
|
||||
metadata = create_png_metadata(prompt=prompt_data)
|
||||
|
||||
assert metadata is not None
|
||||
# Check that metadata contains our data (implementation detail)
|
||||
assert hasattr(metadata, "text")
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_process_image_batch_png(self, mock_folder_paths):
|
||||
"""Test batch processing with PNG format"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
mock_folder_paths.get_output_directory.return_value = temp_dir
|
||||
|
||||
# Create test image batch [batch, height, width, channels]
|
||||
images = torch.rand(2, 32, 32, 3)
|
||||
|
||||
# Process batch
|
||||
results, enhanced_data = process_image_batch(
|
||||
images=images,
|
||||
filename_prefix="test_batch",
|
||||
format_type="PNG",
|
||||
png_compress_level=6,
|
||||
)
|
||||
|
||||
# Verify results (clean data)
|
||||
assert len(results) == 2
|
||||
for i, result in enumerate(results):
|
||||
assert "filename" in result
|
||||
assert "subfolder" in result
|
||||
assert "type" in result
|
||||
assert result["type"] == "output"
|
||||
|
||||
# Verify enhanced data
|
||||
assert len(enhanced_data) == 2
|
||||
for i, enhanced in enumerate(enhanced_data):
|
||||
assert enhanced["format"] == "PNG"
|
||||
assert enhanced["compress_level"] == 6
|
||||
assert enhanced["dimensions"] == "32x32"
|
||||
assert enhanced["popup"] is True # Default popup value
|
||||
assert "file_size" in enhanced
|
||||
|
||||
# Verify file was saved
|
||||
filepath = os.path.join(temp_dir, enhanced["filename"])
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
# Verify image can be loaded
|
||||
saved_img = Image.open(filepath)
|
||||
assert saved_img.size == (32, 32)
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_process_image_batch_jpeg(self, mock_folder_paths):
|
||||
"""Test batch processing with JPEG format"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
mock_folder_paths.get_output_directory.return_value = temp_dir
|
||||
|
||||
# Create test image batch
|
||||
images = torch.rand(1, 64, 64, 3)
|
||||
|
||||
# Process batch
|
||||
results, enhanced_data = process_image_batch(
|
||||
images=images,
|
||||
filename_prefix="test_jpeg",
|
||||
format_type="JPEG",
|
||||
quality=85,
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert len(results) == 1
|
||||
assert len(enhanced_data) == 1
|
||||
enhanced = enhanced_data[0]
|
||||
assert enhanced["format"] == "JPEG"
|
||||
assert enhanced["quality"] == 85
|
||||
assert enhanced["filename"].endswith(".jpg")
|
||||
|
||||
# Verify file exists and can be loaded
|
||||
filepath = os.path.join(temp_dir, results[0]["filename"])
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
saved_img = Image.open(filepath)
|
||||
assert saved_img.size == (64, 64)
|
||||
assert saved_img.mode == "RGB" # JPEG converts to RGB
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_process_image_batch_webp(self, mock_folder_paths):
|
||||
"""Test batch processing with WebP format"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
mock_folder_paths.get_output_directory.return_value = temp_dir
|
||||
|
||||
# Create test image batch
|
||||
images = torch.rand(1, 48, 48, 3)
|
||||
|
||||
# Test lossless WebP
|
||||
results = process_image_batch(
|
||||
images=images,
|
||||
filename_prefix="test_webp",
|
||||
format_type="WEBP",
|
||||
quality=90,
|
||||
webp_lossless=True,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert result["format"] == "WEBP"
|
||||
assert result["lossless"] is True
|
||||
assert result["filename"].endswith(".webp")
|
||||
|
||||
def test_validate_save_inputs_valid(self):
|
||||
"""Test input validation with valid inputs"""
|
||||
images = torch.rand(2, 64, 64, 3)
|
||||
|
||||
# Should not raise exception
|
||||
validate_save_inputs(images, "PNG", 90, 4)
|
||||
validate_save_inputs(images, "JPEG", 85, 4)
|
||||
validate_save_inputs(images, "WEBP", 95, 6)
|
||||
|
||||
def test_validate_save_inputs_invalid_tensor(self):
|
||||
"""Test validation with invalid tensor"""
|
||||
# Wrong tensor dimensions
|
||||
invalid_tensor = torch.rand(64, 64) # Missing batch and channel dims
|
||||
|
||||
with pytest.raises(ValueError, match="4 dimensions"):
|
||||
validate_save_inputs(invalid_tensor, "PNG", 90, 4)
|
||||
|
||||
# Non-tensor input
|
||||
with pytest.raises(ValueError, match="torch.Tensor"):
|
||||
validate_save_inputs("not_a_tensor", "PNG", 90, 4)
|
||||
|
||||
def test_validate_save_inputs_invalid_format(self):
|
||||
"""Test validation with invalid format"""
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
with pytest.raises(ValueError, match="format must be one of"):
|
||||
validate_save_inputs(images, "BMP", 90, 4)
|
||||
|
||||
def test_validate_save_inputs_invalid_quality(self):
|
||||
"""Test validation with invalid quality"""
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
# Quality out of range
|
||||
with pytest.raises(
|
||||
ValueError, match="quality must be an integer between 1 and 100"
|
||||
):
|
||||
validate_save_inputs(images, "JPEG", 0, 4)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="quality must be an integer between 1 and 100"
|
||||
):
|
||||
validate_save_inputs(images, "JPEG", 101, 4)
|
||||
|
||||
def test_validate_save_inputs_invalid_compress_level(self):
|
||||
"""Test validation with invalid PNG compression level"""
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="png_compress_level must be an integer between 0 and 9"
|
||||
):
|
||||
validate_save_inputs(images, "PNG", 90, -1)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="png_compress_level must be an integer between 0 and 9"
|
||||
):
|
||||
validate_save_inputs(images, "PNG", 90, 10)
|
||||
|
||||
def test_save_image_with_format_png(self):
|
||||
"""Test saving with PNG format"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Create test PIL image
|
||||
img = Image.new("RGB", (32, 32), color="red")
|
||||
|
||||
# Save with PNG format
|
||||
result = save_image_with_format(img, temp_path, "PNG", png_compress_level=8)
|
||||
|
||||
assert result["format"] == "PNG"
|
||||
assert result["compress_level"] == 8
|
||||
assert os.path.exists(temp_path)
|
||||
|
||||
# Verify saved image
|
||||
saved_img = Image.open(temp_path)
|
||||
assert saved_img.size == (32, 32)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_save_image_with_format_jpeg_rgba_conversion(self):
|
||||
"""Test JPEG saving with RGBA to RGB conversion"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Create RGBA image
|
||||
img = Image.new("RGBA", (32, 32), color=(255, 0, 0, 128))
|
||||
|
||||
# Save as JPEG (should convert to RGB)
|
||||
result = save_image_with_format(img, temp_path, "JPEG", quality=95)
|
||||
|
||||
assert result["format"] == "JPEG"
|
||||
assert result["quality"] == 95
|
||||
|
||||
# Verify saved image is RGB
|
||||
saved_img = Image.open(temp_path)
|
||||
assert saved_img.mode == "RGB"
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
class TestKikoSaveImageNode:
|
||||
"""Test KikoSaveImageNode class"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup test fixtures"""
|
||||
self.node = KikoSaveImageNode()
|
||||
|
||||
def test_input_types(self):
|
||||
"""Test INPUT_TYPES class method"""
|
||||
input_types = KikoSaveImageNode.INPUT_TYPES()
|
||||
|
||||
# Check required inputs
|
||||
required = input_types["required"]
|
||||
assert "images" in required
|
||||
assert "filename_prefix" in required
|
||||
assert "format" in required
|
||||
|
||||
# Check format options
|
||||
format_options = required["format"][0]
|
||||
assert "PNG" in format_options
|
||||
assert "JPEG" in format_options
|
||||
assert "WEBP" in format_options
|
||||
|
||||
# Check optional inputs
|
||||
optional = input_types["optional"]
|
||||
assert "quality" in optional
|
||||
assert "png_compress_level" in optional
|
||||
assert "webp_lossless" in optional
|
||||
assert "popup" in optional
|
||||
|
||||
# Check hidden inputs
|
||||
hidden = input_types["hidden"]
|
||||
assert "prompt" in hidden
|
||||
assert "extra_pnginfo" in hidden
|
||||
|
||||
def test_node_attributes(self):
|
||||
"""Test node class attributes"""
|
||||
assert KikoSaveImageNode.RETURN_TYPES == ()
|
||||
assert KikoSaveImageNode.FUNCTION == "save_images"
|
||||
assert KikoSaveImageNode.OUTPUT_NODE is True
|
||||
assert KikoSaveImageNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
|
||||
def test_save_images_success(self, mock_process):
|
||||
"""Test successful image saving"""
|
||||
# Setup mock - new return format (results, enhanced_data)
|
||||
mock_results = [
|
||||
{
|
||||
"filename": "test_00001_00000.png",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
mock_enhanced = [
|
||||
{
|
||||
"filename": "test_00001_00000.png",
|
||||
"popup": True,
|
||||
"type": "output",
|
||||
"format": "PNG",
|
||||
"file_size": 1024,
|
||||
"dimensions": "64x64",
|
||||
}
|
||||
]
|
||||
mock_process.return_value = (mock_results, mock_enhanced)
|
||||
|
||||
# Create test input
|
||||
images = torch.rand(1, 64, 64, 3)
|
||||
|
||||
# Call save_images
|
||||
result = self.node.save_images(
|
||||
images=images,
|
||||
filename_prefix="test",
|
||||
format="PNG",
|
||||
quality=90,
|
||||
png_compress_level=4,
|
||||
)
|
||||
|
||||
# Verify mock was called
|
||||
mock_process.assert_called_once()
|
||||
|
||||
# Verify result format
|
||||
assert "ui" in result
|
||||
assert "images" in result["ui"]
|
||||
assert "kiko_enhanced" in result["ui"]
|
||||
assert result["ui"]["images"] == mock_results
|
||||
assert result["ui"]["kiko_enhanced"] == mock_enhanced
|
||||
|
||||
def test_validate_inputs_success(self):
|
||||
"""Test input validation with valid inputs"""
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
# Should not raise exception
|
||||
self.node.validate_inputs(
|
||||
images=images,
|
||||
format="PNG",
|
||||
quality=90,
|
||||
png_compress_level=4,
|
||||
webp_lossless=False,
|
||||
popup=True,
|
||||
)
|
||||
|
||||
def test_validate_inputs_invalid_webp_lossless(self):
|
||||
"""Test validation with invalid webp_lossless type"""
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
with pytest.raises(ValueError, match="webp_lossless must be a boolean"):
|
||||
self.node.validate_inputs(
|
||||
images=images,
|
||||
format="PNG",
|
||||
quality=90,
|
||||
png_compress_level=4,
|
||||
webp_lossless="not_boolean",
|
||||
popup=True,
|
||||
)
|
||||
|
||||
def test_validate_inputs_invalid_popup(self):
|
||||
"""Test validation with invalid popup"""
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
# Non-boolean popup
|
||||
with pytest.raises(ValueError, match="popup must be a boolean"):
|
||||
self.node.validate_inputs(
|
||||
images=images,
|
||||
format="PNG",
|
||||
quality=90,
|
||||
png_compress_level=4,
|
||||
webp_lossless=False,
|
||||
popup="not_boolean",
|
||||
)
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
|
||||
def test_save_images_error_handling(self, mock_process):
|
||||
"""Test error handling in save_images method"""
|
||||
# Setup mock to raise exception
|
||||
mock_process.side_effect = Exception("Test error")
|
||||
|
||||
images = torch.rand(1, 32, 32, 3)
|
||||
|
||||
# Should handle error and re-raise with context
|
||||
with pytest.raises(ValueError, match="Failed to save images"):
|
||||
self.node.save_images(images=images)
|
||||
|
||||
def test_node_info(self):
|
||||
"""Test get_node_info method"""
|
||||
info = self.node.get_node_info()
|
||||
|
||||
assert info["class_name"] == "KikoSaveImageNode"
|
||||
assert info["category"] == "ComfyAssets"
|
||||
assert info["function"] == "save_images"
|
||||
|
||||
|
||||
class TestNodeRegistration:
|
||||
"""Test node registration mappings"""
|
||||
|
||||
def test_node_class_mappings(self):
|
||||
"""Test NODE_CLASS_MAPPINGS contains KikoSaveImage"""
|
||||
from kikotools.tools.kiko_save_image.node import NODE_CLASS_MAPPINGS
|
||||
|
||||
assert "KikoSaveImage" in NODE_CLASS_MAPPINGS
|
||||
assert NODE_CLASS_MAPPINGS["KikoSaveImage"] is KikoSaveImageNode
|
||||
|
||||
def test_node_display_name_mappings(self):
|
||||
"""Test NODE_DISPLAY_NAME_MAPPINGS contains KikoSaveImage"""
|
||||
from kikotools.tools.kiko_save_image.node import NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
assert "KikoSaveImage" in NODE_DISPLAY_NAME_MAPPINGS
|
||||
assert NODE_DISPLAY_NAME_MAPPINGS["KikoSaveImage"] == "Kiko Save Image"
|
||||
|
||||
|
||||
# Integration test fixtures
|
||||
@pytest.fixture
|
||||
def sample_image_tensor():
|
||||
"""Create sample image tensor for testing"""
|
||||
# Create a colorful test image [batch, height, width, channels]
|
||||
batch_size, height, width, channels = 2, 64, 64, 3
|
||||
|
||||
# Create gradient pattern
|
||||
tensor = torch.zeros(batch_size, height, width, channels)
|
||||
for b in range(batch_size):
|
||||
for h in range(height):
|
||||
for w in range(width):
|
||||
# Create RGB gradient pattern
|
||||
tensor[b, h, w, 0] = h / height # Red gradient
|
||||
tensor[b, h, w, 1] = w / width # Green gradient
|
||||
tensor[b, h, w, 2] = (b + 1) * 0.5 # Blue varies by batch
|
||||
|
||||
return tensor
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests using sample data"""
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_full_pipeline_png(self, mock_folder_paths, sample_image_tensor):
|
||||
"""Test complete pipeline with PNG format"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
mock_folder_paths.get_output_directory.return_value = temp_dir
|
||||
|
||||
node = KikoSaveImageNode()
|
||||
|
||||
# Save images
|
||||
result = node.save_images(
|
||||
images=sample_image_tensor,
|
||||
filename_prefix="integration_test",
|
||||
format="PNG",
|
||||
png_compress_level=6,
|
||||
)
|
||||
|
||||
# Verify result structure
|
||||
assert "ui" in result
|
||||
assert "images" in result["ui"]
|
||||
assert len(result["ui"]["images"]) == 2
|
||||
|
||||
# Verify files were created
|
||||
for image_info in result["ui"]["images"]:
|
||||
filepath = os.path.join(temp_dir, image_info["filename"])
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
# Verify image properties
|
||||
img = Image.open(filepath)
|
||||
assert img.size == (64, 64)
|
||||
assert img.format == "PNG"
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_full_pipeline_all_formats(self, mock_folder_paths, sample_image_tensor):
|
||||
"""Test complete pipeline with all supported formats"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
mock_folder_paths.get_output_directory.return_value = temp_dir
|
||||
|
||||
node = KikoSaveImageNode()
|
||||
|
||||
# Test each format
|
||||
formats_to_test = [
|
||||
("PNG", {"png_compress_level": 8}),
|
||||
("JPEG", {"quality": 85}),
|
||||
("WEBP", {"quality": 90, "webp_lossless": False}),
|
||||
("WEBP", {"quality": 100, "webp_lossless": True}),
|
||||
]
|
||||
|
||||
for format_type, kwargs in formats_to_test:
|
||||
result = node.save_images(
|
||||
images=sample_image_tensor,
|
||||
filename_prefix=f"test_{format_type.lower()}",
|
||||
format=format_type,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert len(result["ui"]["images"]) == 2
|
||||
|
||||
for image_info in result["ui"]["images"]:
|
||||
assert image_info["format"] == format_type
|
||||
|
||||
# Verify file exists and can be opened
|
||||
filepath = os.path.join(temp_dir, image_info["filename"])
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
img = Image.open(filepath)
|
||||
assert img.size == (64, 64)
|
||||
@@ -5,7 +5,6 @@ Following TDD principles - these tests define the expected behavior
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Import the modules we're going to test (they don't exist yet - TDD!)
|
||||
from kikotools.tools.resolution_calculator.logic import (
|
||||
@@ -172,8 +171,6 @@ class TestResolutionCalculatorNode:
|
||||
|
||||
def test_node_has_correct_comfyui_attributes(self):
|
||||
"""Test node has all required ComfyUI attributes"""
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
# Check class attributes exist
|
||||
assert hasattr(ResolutionCalculatorNode, "INPUT_TYPES")
|
||||
assert hasattr(ResolutionCalculatorNode, "RETURN_TYPES")
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Tests for Sampler Combo node."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
from kikotools.tools.sampler_combo.logic import (
|
||||
validate_sampler_settings,
|
||||
get_sampler_combo,
|
||||
get_compatible_scheduler_suggestions,
|
||||
get_recommended_steps_range,
|
||||
get_recommended_cfg_range,
|
||||
get_sampler_info,
|
||||
SAMPLERS,
|
||||
SCHEDULERS,
|
||||
)
|
||||
|
||||
|
||||
class TestSamplerComboLogic:
|
||||
"""Test cases for sampler combo logic functions."""
|
||||
|
||||
def test_validate_sampler_settings_valid(self):
|
||||
"""Test validation with valid settings."""
|
||||
assert validate_sampler_settings("euler", "normal", 20, 7.0) is True
|
||||
assert validate_sampler_settings("dpmpp_2m", "karras", 15, 8.5) is True
|
||||
assert validate_sampler_settings("ddim", "ddim_uniform", 30, 6.0) is True
|
||||
|
||||
def test_validate_sampler_settings_invalid_sampler(self):
|
||||
"""Test validation with invalid sampler."""
|
||||
assert validate_sampler_settings("invalid_sampler", "normal", 20, 7.0) is False
|
||||
|
||||
def test_validate_sampler_settings_invalid_scheduler(self):
|
||||
"""Test validation with invalid scheduler."""
|
||||
assert validate_sampler_settings("euler", "invalid_scheduler", 20, 7.0) is False
|
||||
|
||||
def test_validate_sampler_settings_invalid_steps(self):
|
||||
"""Test validation with invalid steps."""
|
||||
assert validate_sampler_settings("euler", "normal", 0, 7.0) is False
|
||||
assert validate_sampler_settings("euler", "normal", 1001, 7.0) is False
|
||||
assert validate_sampler_settings("euler", "normal", -5, 7.0) is False
|
||||
|
||||
def test_validate_sampler_settings_invalid_cfg(self):
|
||||
"""Test validation with invalid CFG."""
|
||||
assert validate_sampler_settings("euler", "normal", 20, -1.0) is False
|
||||
assert validate_sampler_settings("euler", "normal", 20, 31.0) is False
|
||||
|
||||
def test_get_sampler_combo_valid(self):
|
||||
"""Test getting sampler combo with valid inputs."""
|
||||
result = get_sampler_combo("euler", "normal", 20, 7.0)
|
||||
assert result == ("euler", "normal", 20, 7.0)
|
||||
|
||||
result = get_sampler_combo("dpmpp_2m", "karras", 25, 8.5)
|
||||
assert result == ("dpmpp_2m", "karras", 25, 8.5)
|
||||
|
||||
def test_get_sampler_combo_invalid_returns_defaults(self):
|
||||
"""Test that invalid inputs return safe defaults."""
|
||||
result = get_sampler_combo("invalid", "normal", 20, 7.0)
|
||||
assert result == ("euler", "normal", 20, 7.0)
|
||||
|
||||
result = get_sampler_combo("euler", "invalid", 20, 7.0)
|
||||
assert result == ("euler", "normal", 20, 7.0)
|
||||
|
||||
def test_get_sampler_combo_sanitizes_values(self):
|
||||
"""Test that values are sanitized to valid ranges."""
|
||||
# Test steps clamping
|
||||
result = get_sampler_combo("euler", "normal", 0, 7.0)
|
||||
assert result[2] >= 1 # steps should be at least 1
|
||||
|
||||
result = get_sampler_combo("euler", "normal", 1500, 7.0)
|
||||
assert result[2] <= 1000 # steps should be at most 1000
|
||||
|
||||
# Test CFG clamping
|
||||
result = get_sampler_combo("euler", "normal", 20, -5.0)
|
||||
assert result[3] >= 0.0 # CFG should be at least 0
|
||||
|
||||
result = get_sampler_combo("euler", "normal", 20, 50.0)
|
||||
assert result[3] <= 30.0 # CFG should be at most 30
|
||||
|
||||
def test_get_compatible_scheduler_suggestions(self):
|
||||
"""Test getting scheduler suggestions for different samplers."""
|
||||
suggestions = get_compatible_scheduler_suggestions("euler")
|
||||
assert isinstance(suggestions, list)
|
||||
assert len(suggestions) > 0
|
||||
assert "normal" in suggestions
|
||||
|
||||
suggestions = get_compatible_scheduler_suggestions("ddim")
|
||||
assert "ddim_uniform" in suggestions
|
||||
|
||||
# Test unknown sampler returns defaults
|
||||
suggestions = get_compatible_scheduler_suggestions("unknown_sampler")
|
||||
assert "normal" in suggestions
|
||||
assert "karras" in suggestions
|
||||
|
||||
def test_get_recommended_steps_range(self):
|
||||
"""Test getting recommended steps range for samplers."""
|
||||
min_steps, max_steps, default_steps = get_recommended_steps_range("euler")
|
||||
assert isinstance(min_steps, int)
|
||||
assert isinstance(max_steps, int)
|
||||
assert isinstance(default_steps, int)
|
||||
assert min_steps <= default_steps <= max_steps
|
||||
assert min_steps > 0
|
||||
|
||||
# Test unknown sampler returns defaults
|
||||
min_steps, max_steps, default_steps = get_recommended_steps_range("unknown")
|
||||
assert min_steps == 10
|
||||
assert max_steps == 50
|
||||
assert default_steps == 20
|
||||
|
||||
def test_get_recommended_cfg_range(self):
|
||||
"""Test getting recommended CFG range for samplers."""
|
||||
min_cfg, max_cfg, default_cfg = get_recommended_cfg_range("euler")
|
||||
assert isinstance(min_cfg, float)
|
||||
assert isinstance(max_cfg, float)
|
||||
assert isinstance(default_cfg, float)
|
||||
assert min_cfg <= default_cfg <= max_cfg
|
||||
assert min_cfg >= 0.0
|
||||
|
||||
# Test unknown sampler returns defaults
|
||||
min_cfg, max_cfg, default_cfg = get_recommended_cfg_range("unknown")
|
||||
assert min_cfg == 1.0
|
||||
assert max_cfg == 20.0
|
||||
assert default_cfg == 7.0
|
||||
|
||||
def test_get_sampler_info(self):
|
||||
"""Test getting sampler information."""
|
||||
info = get_sampler_info()
|
||||
assert isinstance(info, dict)
|
||||
assert "samplers" in info
|
||||
assert "schedulers" in info
|
||||
assert "sampler_count" in info
|
||||
assert "scheduler_count" in info
|
||||
assert info["sampler_count"] == len(SAMPLERS)
|
||||
assert info["scheduler_count"] == len(SCHEDULERS)
|
||||
|
||||
|
||||
class TestSamplerComboNode:
|
||||
"""Test cases for SamplerComboNode."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.node = SamplerComboNode()
|
||||
|
||||
def test_input_types_structure(self):
|
||||
"""Test that INPUT_TYPES returns correct structure."""
|
||||
input_types = SamplerComboNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
required = input_types["required"]
|
||||
|
||||
# Check all required inputs are present
|
||||
assert "sampler_name" in required
|
||||
assert "scheduler" in required
|
||||
assert "steps" in required
|
||||
assert "cfg" in required
|
||||
|
||||
# Check sampler input structure
|
||||
sampler_input = required["sampler_name"]
|
||||
assert sampler_input[0] == SAMPLERS
|
||||
assert isinstance(sampler_input[1], dict)
|
||||
assert "default" in sampler_input[1]
|
||||
assert "tooltip" in sampler_input[1]
|
||||
|
||||
# Check scheduler input structure
|
||||
scheduler_input = required["scheduler"]
|
||||
assert scheduler_input[0] == SCHEDULERS
|
||||
assert isinstance(scheduler_input[1], dict)
|
||||
|
||||
# Check steps input structure
|
||||
steps_input = required["steps"]
|
||||
assert steps_input[0] == "INT"
|
||||
assert steps_input[1]["min"] == 1
|
||||
assert steps_input[1]["max"] == 100
|
||||
|
||||
# Check CFG input structure
|
||||
cfg_input = required["cfg"]
|
||||
assert cfg_input[0] == "FLOAT"
|
||||
assert cfg_input[1]["min"] == 0.0
|
||||
assert cfg_input[1]["max"] == 20.0
|
||||
|
||||
def test_return_types_structure(self):
|
||||
"""Test that return types are correctly defined."""
|
||||
assert SamplerComboNode.RETURN_TYPES == ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
assert SamplerComboNode.RETURN_NAMES == (
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"steps",
|
||||
"cfg",
|
||||
)
|
||||
assert SamplerComboNode.FUNCTION == "get_sampler_combo"
|
||||
assert SamplerComboNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
def test_get_sampler_combo_valid_inputs(self):
|
||||
"""Test get_sampler_combo with valid inputs."""
|
||||
result = self.node.get_sampler_combo("euler", "normal", 20, 7.0)
|
||||
assert result == ("euler", "normal", 20, 7.0)
|
||||
|
||||
result = self.node.get_sampler_combo("dpmpp_2m", "karras", 15, 8.5)
|
||||
assert result == ("dpmpp_2m", "karras", 15, 8.5)
|
||||
|
||||
def test_get_sampler_combo_invalid_inputs_returns_defaults(self):
|
||||
"""Test that invalid inputs return safe defaults."""
|
||||
with patch.object(self.node, "handle_error") as mock_error:
|
||||
mock_error.side_effect = ValueError("Invalid settings")
|
||||
|
||||
try:
|
||||
result = self.node.get_sampler_combo("invalid", "normal", 20, 7.0)
|
||||
except ValueError:
|
||||
pass # Expected when handle_error raises
|
||||
|
||||
# Test with exception handling bypassed
|
||||
with patch(
|
||||
"kikotools.tools.sampler_combo.node.validate_sampler_settings",
|
||||
return_value=False,
|
||||
):
|
||||
result = self.node.get_sampler_combo("invalid", "normal", 20, 7.0)
|
||||
assert result == ("euler", "normal", 20, 7.0)
|
||||
|
||||
def test_validate_inputs_valid(self):
|
||||
"""Test input validation with valid inputs."""
|
||||
# Should not raise any exception
|
||||
self.node.validate_inputs("euler", "normal", 20, 7.0)
|
||||
|
||||
def test_validate_inputs_invalid(self):
|
||||
"""Test input validation with invalid inputs."""
|
||||
with pytest.raises(ValueError):
|
||||
self.node.validate_inputs("invalid", "normal", 20, 7.0)
|
||||
|
||||
def test_get_scheduler_suggestions(self):
|
||||
"""Test getting scheduler suggestions."""
|
||||
suggestions = self.node.get_scheduler_suggestions("euler")
|
||||
assert isinstance(suggestions, list)
|
||||
assert len(suggestions) > 0
|
||||
|
||||
suggestions = self.node.get_scheduler_suggestions("ddim")
|
||||
assert "ddim_uniform" in suggestions
|
||||
|
||||
def test_get_steps_recommendation(self):
|
||||
"""Test getting steps recommendations."""
|
||||
rec = self.node.get_steps_recommendation("euler")
|
||||
assert isinstance(rec, dict)
|
||||
assert "min" in rec
|
||||
assert "max" in rec
|
||||
assert "default" in rec
|
||||
assert "recommendation" in rec
|
||||
|
||||
def test_get_cfg_recommendation(self):
|
||||
"""Test getting CFG recommendations."""
|
||||
rec = self.node.get_cfg_recommendation("euler")
|
||||
assert isinstance(rec, dict)
|
||||
assert "min" in rec
|
||||
assert "max" in rec
|
||||
assert "default" in rec
|
||||
assert "recommendation" in rec
|
||||
|
||||
def test_get_combo_analysis(self):
|
||||
"""Test getting combo analysis."""
|
||||
analysis = self.node.get_combo_analysis("euler", "normal", 20, 7.0)
|
||||
assert isinstance(analysis, dict)
|
||||
assert "sampler" in analysis
|
||||
assert "scheduler" in analysis
|
||||
assert "steps" in analysis
|
||||
assert "cfg" in analysis
|
||||
assert "valid" in analysis
|
||||
assert "scheduler_suggestions" in analysis
|
||||
assert "scheduler_compatible" in analysis
|
||||
assert "steps_optimal" in analysis
|
||||
assert "cfg_optimal" in analysis
|
||||
|
||||
def test_get_available_samplers(self):
|
||||
"""Test getting available samplers."""
|
||||
samplers = SamplerComboNode.get_available_samplers()
|
||||
assert isinstance(samplers, list)
|
||||
assert len(samplers) > 0
|
||||
assert "euler" in samplers
|
||||
|
||||
def test_get_available_schedulers(self):
|
||||
"""Test getting available schedulers."""
|
||||
schedulers = SamplerComboNode.get_available_schedulers()
|
||||
assert isinstance(schedulers, list)
|
||||
assert len(schedulers) > 0
|
||||
assert "normal" in schedulers
|
||||
|
||||
def test_string_representations(self):
|
||||
"""Test string representations of the node."""
|
||||
str_repr = str(self.node)
|
||||
assert "SamplerComboNode" in str_repr
|
||||
assert "samplers=" in str_repr
|
||||
assert "schedulers=" in str_repr
|
||||
|
||||
repr_str = repr(self.node)
|
||||
assert "SamplerComboNode" in repr_str
|
||||
assert "category=" in repr_str
|
||||
assert "function=" in repr_str
|
||||
|
||||
def test_node_inheritance(self):
|
||||
"""Test that node properly inherits from base class."""
|
||||
from kikotools.base.base_node import ComfyAssetsBaseNode
|
||||
|
||||
assert isinstance(self.node, ComfyAssetsBaseNode)
|
||||
assert hasattr(self.node, "validate_inputs")
|
||||
assert hasattr(self.node, "handle_error")
|
||||
assert hasattr(self.node, "log_info")
|
||||
|
||||
|
||||
class TestSamplerComboIntegration:
|
||||
"""Integration tests for Sampler Combo functionality."""
|
||||
|
||||
def test_full_workflow_valid_settings(self):
|
||||
"""Test complete workflow with valid settings."""
|
||||
node = SamplerComboNode()
|
||||
|
||||
# Test with different sampler/scheduler combinations
|
||||
test_cases = [
|
||||
("euler", "normal", 20, 7.0),
|
||||
("dpmpp_2m", "karras", 15, 8.0),
|
||||
("euler_ancestral", "exponential", 25, 9.0),
|
||||
("ddim", "ddim_uniform", 30, 6.0),
|
||||
]
|
||||
|
||||
for sampler, scheduler, steps, cfg in test_cases:
|
||||
result = node.get_sampler_combo(sampler, scheduler, steps, cfg)
|
||||
assert result == (sampler, scheduler, steps, cfg)
|
||||
|
||||
def test_recommendation_compatibility(self):
|
||||
"""Test that recommendations are compatible with actual functionality."""
|
||||
node = SamplerComboNode()
|
||||
|
||||
for sampler in SAMPLERS[:5]: # Test first 5 samplers
|
||||
suggestions = node.get_scheduler_suggestions(sampler)
|
||||
steps_rec = node.get_steps_recommendation(sampler)
|
||||
cfg_rec = node.get_cfg_recommendation(sampler)
|
||||
|
||||
# Test that recommendations work with the node
|
||||
for scheduler in suggestions[:2]: # Test first 2 suggestions
|
||||
result = node.get_sampler_combo(
|
||||
sampler, scheduler, steps_rec["default"], cfg_rec["default"]
|
||||
)
|
||||
assert result[0] == sampler
|
||||
assert result[1] == scheduler
|
||||
assert result[2] == steps_rec["default"]
|
||||
assert result[3] == cfg_rec["default"]
|
||||
|
||||
def test_error_recovery(self):
|
||||
"""Test error recovery with malformed inputs."""
|
||||
node = SamplerComboNode()
|
||||
|
||||
# These should all return safe defaults due to error handling
|
||||
with patch(
|
||||
"kikotools.tools.sampler_combo.logic.validate_sampler_settings",
|
||||
side_effect=Exception("Simulated error"),
|
||||
):
|
||||
result = node.get_sampler_combo("euler", "normal", 20, 7.0)
|
||||
assert result == ("euler", "normal", 20, 7.0) # Safe defaults
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Tests for Seed History tool."""
|
||||
|
||||
import time
|
||||
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
from kikotools.tools.seed_history.logic import (
|
||||
generate_random_seed,
|
||||
validate_seed_value,
|
||||
sanitize_seed_value,
|
||||
create_history_entry,
|
||||
filter_duplicate_seeds,
|
||||
add_seed_to_history,
|
||||
format_time_ago,
|
||||
search_history_by_seed,
|
||||
get_history_statistics,
|
||||
export_history_to_text,
|
||||
import_seeds_from_list,
|
||||
)
|
||||
|
||||
|
||||
class TestSeedHistoryNode:
|
||||
"""Test SeedHistoryNode functionality."""
|
||||
|
||||
def test_node_structure(self):
|
||||
"""Test that node has correct ComfyUI structure."""
|
||||
# Test class attributes
|
||||
assert hasattr(SeedHistoryNode, "INPUT_TYPES")
|
||||
assert hasattr(SeedHistoryNode, "RETURN_TYPES")
|
||||
assert hasattr(SeedHistoryNode, "RETURN_NAMES")
|
||||
assert hasattr(SeedHistoryNode, "FUNCTION")
|
||||
assert hasattr(SeedHistoryNode, "CATEGORY")
|
||||
|
||||
# Test input types structure
|
||||
input_types = SeedHistoryNode.INPUT_TYPES()
|
||||
assert "required" in input_types
|
||||
assert "seed" in input_types["required"]
|
||||
|
||||
# Test seed input configuration
|
||||
seed_config = input_types["required"]["seed"]
|
||||
assert seed_config[0] == "INT"
|
||||
assert isinstance(seed_config[1], dict)
|
||||
assert "default" in seed_config[1]
|
||||
assert "min" in seed_config[1]
|
||||
assert "max" in seed_config[1]
|
||||
assert seed_config[1]["min"] == 0
|
||||
assert seed_config[1]["max"] == 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
# Test return types
|
||||
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
|
||||
assert SeedHistoryNode.RETURN_NAMES == ("seed",)
|
||||
assert SeedHistoryNode.FUNCTION == "output_seed"
|
||||
assert SeedHistoryNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
def test_output_seed_valid_input(self):
|
||||
"""Test seed output with valid input."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Test various valid seeds
|
||||
test_seeds = [0, 12345, 999999, 0xFFFFFFFFFFFFFFFF]
|
||||
|
||||
for seed in test_seeds:
|
||||
result = node.output_seed(seed)
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0] == seed
|
||||
|
||||
def test_output_seed_invalid_input(self):
|
||||
"""Test seed output with invalid input."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Test invalid seeds (negative values)
|
||||
result = node.output_seed(-1)
|
||||
assert result == (12345,) # Fallback
|
||||
|
||||
# Test seeds too large
|
||||
result = node.output_seed(0xFFFFFFFFFFFFFFFF + 1)
|
||||
assert result == (12345,) # Fallback
|
||||
|
||||
def test_generate_new_seed(self):
|
||||
"""Test random seed generation."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Generate multiple seeds
|
||||
seeds = []
|
||||
for _ in range(10):
|
||||
seed = node.generate_new_seed()
|
||||
seeds.append(seed)
|
||||
|
||||
# Test all seeds are valid
|
||||
for seed in seeds:
|
||||
assert validate_seed_value(seed)
|
||||
|
||||
# Test seeds are different (probabilistically)
|
||||
assert len(set(seeds)) > 5 # Should have some variety
|
||||
|
||||
def test_validate_seed_input(self):
|
||||
"""Test seed validation."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Valid seeds
|
||||
assert node.validate_seed_input(0)
|
||||
assert node.validate_seed_input(12345)
|
||||
assert node.validate_seed_input(0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
# Invalid seeds
|
||||
assert not node.validate_seed_input(-1)
|
||||
assert not node.validate_seed_input(0xFFFFFFFFFFFFFFFF + 1)
|
||||
assert not node.validate_seed_input(None)
|
||||
|
||||
def test_get_seed_info(self):
|
||||
"""Test seed information generation."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Test various seed types
|
||||
info_zero = node.get_seed_info(0)
|
||||
assert "zero" in info_zero.lower()
|
||||
|
||||
info_default = node.get_seed_info(12345)
|
||||
assert "default" in info_default.lower()
|
||||
|
||||
info_power_of_2 = node.get_seed_info(1024)
|
||||
assert "power of 2" in info_power_of_2.lower()
|
||||
|
||||
# Test invalid seed
|
||||
info_invalid = node.get_seed_info(-1)
|
||||
assert "invalid" in info_invalid.lower()
|
||||
|
||||
def test_seed_range_info(self):
|
||||
"""Test seed range information."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
range_info = node.get_seed_range_info()
|
||||
assert "Valid range" in range_info
|
||||
assert str(0xFFFFFFFFFFFFFFFF) in range_info
|
||||
|
||||
def test_class_methods(self):
|
||||
"""Test class methods."""
|
||||
# Test default seed
|
||||
default_seed = SeedHistoryNode.get_default_seed()
|
||||
assert default_seed == 12345
|
||||
|
||||
# Test range checking
|
||||
assert SeedHistoryNode.is_seed_in_range(0)
|
||||
assert SeedHistoryNode.is_seed_in_range(12345)
|
||||
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF)
|
||||
assert not SeedHistoryNode.is_seed_in_range(-1)
|
||||
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF + 1)
|
||||
|
||||
|
||||
class TestSeedHistoryLogic:
|
||||
"""Test seed history logic functions."""
|
||||
|
||||
def test_generate_random_seed(self):
|
||||
"""Test random seed generation."""
|
||||
# Generate multiple seeds
|
||||
seeds = [generate_random_seed() for _ in range(100)]
|
||||
|
||||
# Test all seeds are valid
|
||||
for seed in seeds:
|
||||
assert validate_seed_value(seed)
|
||||
|
||||
# Test seeds have variety
|
||||
assert len(set(seeds)) > 50 # Should have good variety
|
||||
|
||||
def test_validate_seed_value(self):
|
||||
"""Test seed validation logic."""
|
||||
# Valid seeds
|
||||
assert validate_seed_value(0)
|
||||
assert validate_seed_value(12345)
|
||||
assert validate_seed_value(0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
# Invalid seeds
|
||||
assert not validate_seed_value(-1)
|
||||
assert not validate_seed_value(0xFFFFFFFFFFFFFFFF + 1)
|
||||
assert not validate_seed_value(None)
|
||||
assert not validate_seed_value("invalid")
|
||||
assert not validate_seed_value([])
|
||||
|
||||
def test_sanitize_seed_value(self):
|
||||
"""Test seed sanitization."""
|
||||
# Valid seeds should pass through
|
||||
assert sanitize_seed_value(12345) == 12345
|
||||
assert sanitize_seed_value(0) == 0
|
||||
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF) == 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
# String numbers should convert
|
||||
assert sanitize_seed_value("12345") == 12345
|
||||
assert sanitize_seed_value("0") == 0
|
||||
|
||||
# Out of range should clamp
|
||||
assert sanitize_seed_value(-100) == 0
|
||||
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF + 100) == 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
# Invalid should raise
|
||||
try:
|
||||
sanitize_seed_value(None)
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
sanitize_seed_value("invalid")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_create_history_entry(self):
|
||||
"""Test history entry creation."""
|
||||
seed = 12345
|
||||
timestamp = time.time()
|
||||
|
||||
# With explicit timestamp
|
||||
entry = create_history_entry(seed, timestamp)
|
||||
assert entry["seed"] == seed
|
||||
assert entry["timestamp"] == timestamp
|
||||
assert "dateString" in entry
|
||||
|
||||
# With auto timestamp
|
||||
entry_auto = create_history_entry(seed)
|
||||
assert entry_auto["seed"] == seed
|
||||
assert "timestamp" in entry_auto
|
||||
assert "dateString" in entry_auto
|
||||
|
||||
def test_filter_duplicate_seeds(self):
|
||||
"""Test duplicate seed filtering."""
|
||||
seed = 12345
|
||||
current_time = time.time()
|
||||
|
||||
# Empty history should not filter
|
||||
assert not filter_duplicate_seeds([], seed, 500)
|
||||
|
||||
# Recent duplicate should filter
|
||||
recent_entry = create_history_entry(seed, current_time - 0.1)
|
||||
history = [recent_entry]
|
||||
assert filter_duplicate_seeds(history, seed, 500)
|
||||
|
||||
# Old duplicate should not filter
|
||||
old_entry = create_history_entry(seed, current_time - 1.0)
|
||||
history = [old_entry]
|
||||
assert not filter_duplicate_seeds(history, seed, 500)
|
||||
|
||||
# Different seed should not filter
|
||||
different_entry = create_history_entry(54321, current_time - 0.1)
|
||||
history = [different_entry]
|
||||
assert not filter_duplicate_seeds(history, seed, 500)
|
||||
|
||||
def test_add_seed_to_history(self):
|
||||
"""Test adding seeds to history."""
|
||||
history = []
|
||||
|
||||
# Add first seed
|
||||
new_history, was_added = add_seed_to_history(history, 12345)
|
||||
assert was_added
|
||||
assert len(new_history) == 1
|
||||
assert new_history[0]["seed"] == 12345
|
||||
|
||||
# Add different seed
|
||||
new_history2, was_added2 = add_seed_to_history(new_history, 54321)
|
||||
assert was_added2
|
||||
assert len(new_history2) == 2
|
||||
assert new_history2[0]["seed"] == 54321 # Most recent first
|
||||
|
||||
# Add duplicate (should remove old and add new)
|
||||
time.sleep(0.6) # Wait past dedup window
|
||||
new_history3, was_added3 = add_seed_to_history(new_history2, 12345)
|
||||
assert was_added3
|
||||
assert len(new_history3) == 2
|
||||
assert new_history3[0]["seed"] == 12345 # Most recent first
|
||||
|
||||
# Test max history limit
|
||||
history_long = []
|
||||
for i in range(15):
|
||||
history_long, _ = add_seed_to_history(history_long, i, max_history=10)
|
||||
time.sleep(0.001) # Small delay to avoid dedup
|
||||
|
||||
assert len(history_long) == 10
|
||||
|
||||
def test_format_time_ago(self):
|
||||
"""Test time ago formatting."""
|
||||
now = time.time()
|
||||
|
||||
# Recent times
|
||||
assert "s ago" in format_time_ago(now - 30)
|
||||
assert "m ago" in format_time_ago(now - 300)
|
||||
assert "h ago" in format_time_ago(now - 7200)
|
||||
assert "d ago" in format_time_ago(now - 86400)
|
||||
|
||||
def test_search_history_by_seed(self):
|
||||
"""Test history search."""
|
||||
history = [
|
||||
create_history_entry(12345),
|
||||
create_history_entry(54321),
|
||||
create_history_entry(99999),
|
||||
]
|
||||
|
||||
# Found seed
|
||||
result = search_history_by_seed(history, 54321)
|
||||
assert result is not None
|
||||
assert result["seed"] == 54321
|
||||
|
||||
# Not found seed
|
||||
result = search_history_by_seed(history, 11111)
|
||||
assert result is None
|
||||
|
||||
def test_get_history_statistics(self):
|
||||
"""Test history statistics."""
|
||||
# Empty history
|
||||
stats = get_history_statistics([])
|
||||
assert stats["total_seeds"] == 0
|
||||
assert stats["unique_seeds"] == 0
|
||||
|
||||
# History with data
|
||||
now = time.time()
|
||||
history = [
|
||||
create_history_entry(12345, now - 3600),
|
||||
create_history_entry(54321, now - 1800),
|
||||
create_history_entry(12345, now), # Duplicate
|
||||
]
|
||||
|
||||
stats = get_history_statistics(history)
|
||||
assert stats["total_seeds"] == 3
|
||||
assert stats["unique_seeds"] == 2
|
||||
assert stats["time_span_hours"] == 1.0
|
||||
|
||||
def test_export_history_to_text(self):
|
||||
"""Test history export."""
|
||||
# Empty history
|
||||
text = export_history_to_text([])
|
||||
assert "Empty" in text
|
||||
|
||||
# History with data
|
||||
history = [create_history_entry(12345), create_history_entry(54321)]
|
||||
|
||||
text = export_history_to_text(history)
|
||||
assert "12345" in text
|
||||
assert "54321" in text
|
||||
assert "Total seeds: 2" in text
|
||||
|
||||
def test_import_seeds_from_list(self):
|
||||
"""Test importing seeds from list."""
|
||||
seed_list = [12345, 54321, 99999]
|
||||
|
||||
history = import_seeds_from_list(seed_list)
|
||||
assert len(history) == 3
|
||||
|
||||
# Check seeds are in correct order (newest first)
|
||||
assert history[0]["seed"] == 12345
|
||||
assert history[1]["seed"] == 54321
|
||||
assert history[2]["seed"] == 99999
|
||||
|
||||
# Check timestamps are spaced
|
||||
assert history[0]["timestamp"] > history[1]["timestamp"]
|
||||
assert history[1]["timestamp"] > history[2]["timestamp"]
|
||||
|
||||
|
||||
class TestSeedHistoryIntegration:
|
||||
"""Test integration scenarios."""
|
||||
|
||||
def test_complete_workflow(self):
|
||||
"""Test complete seed history workflow."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Test basic seed output
|
||||
result = node.output_seed(12345)
|
||||
assert result == (12345,)
|
||||
|
||||
# Test seed generation
|
||||
new_seed = node.generate_new_seed()
|
||||
assert validate_seed_value(new_seed)
|
||||
|
||||
# Test seed info
|
||||
info = node.get_seed_info(new_seed)
|
||||
assert str(new_seed) in info
|
||||
|
||||
def test_history_management(self):
|
||||
"""Test history management operations."""
|
||||
history = []
|
||||
|
||||
# Add seeds over time
|
||||
seeds = [12345, 54321, 99999, 11111, 22222]
|
||||
for seed in seeds:
|
||||
history, was_added = add_seed_to_history(history, seed)
|
||||
assert was_added
|
||||
time.sleep(0.001) # Avoid dedup
|
||||
|
||||
# Check history order (newest first)
|
||||
assert history[0]["seed"] == 22222
|
||||
assert history[-1]["seed"] == 12345
|
||||
|
||||
# Test search
|
||||
found = search_history_by_seed(history, 99999)
|
||||
assert found is not None
|
||||
|
||||
# Test statistics
|
||||
stats = get_history_statistics(history)
|
||||
assert stats["total_seeds"] == 5
|
||||
assert stats["unique_seeds"] == 5
|
||||
|
||||
def test_error_handling(self):
|
||||
"""Test error handling scenarios."""
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Test with invalid seeds
|
||||
result = node.output_seed(-1)
|
||||
assert result == (12345,) # Fallback
|
||||
|
||||
# Test validation
|
||||
assert not node.validate_seed_input(None)
|
||||
assert not node.validate_seed_input("invalid")
|
||||
|
||||
# Test history with invalid seeds
|
||||
history = []
|
||||
history, was_added = add_seed_to_history(history, -1)
|
||||
assert not was_added
|
||||
assert len(history) == 0
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for Width Height Selector tool."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
from kikotools.tools.width_height_selector.logic import (
|
||||
get_preset_dimensions,
|
||||
@@ -10,9 +8,12 @@ from kikotools.tools.width_height_selector.logic import (
|
||||
)
|
||||
from kikotools.tools.width_height_selector.presets import (
|
||||
PRESET_OPTIONS,
|
||||
PRESET_METADATA,
|
||||
SDXL_PRESETS,
|
||||
FLUX_PRESETS,
|
||||
ULTRA_WIDE_PRESETS,
|
||||
get_preset_metadata,
|
||||
get_presets_by_model_group,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,7 +45,8 @@ class TestWidthHeightSelectorNode:
|
||||
assert result == (1920, 1080)
|
||||
|
||||
def test_sdxl_square_preset(self):
|
||||
"""Test SDXL square preset."""
|
||||
"""Test SDXL square preset (supports both raw and formatted)."""
|
||||
# Test raw preset
|
||||
result = self.node.get_dimensions(
|
||||
preset="1024×1024",
|
||||
width=512, # Should be ignored
|
||||
@@ -52,38 +54,91 @@ class TestWidthHeightSelectorNode:
|
||||
)
|
||||
assert result == (1024, 1024)
|
||||
|
||||
# Test formatted preset
|
||||
result = self.node.get_dimensions(
|
||||
preset="1024×1024 - 1:1 (1.1MP) - SDXL",
|
||||
width=512, # Should be ignored
|
||||
height=512, # Should be ignored
|
||||
)
|
||||
assert result == (1024, 1024)
|
||||
|
||||
def test_sdxl_portrait_preset(self):
|
||||
"""Test SDXL portrait preset."""
|
||||
"""Test SDXL portrait preset (supports both raw and formatted)."""
|
||||
# Test raw preset
|
||||
result = self.node.get_dimensions(preset="832×1216", width=512, height=512)
|
||||
assert result == (832, 1216)
|
||||
|
||||
# Test formatted preset if available
|
||||
formatted_preset = "832×1216 - 13:19 (1.0MP) - SDXL"
|
||||
result = self.node.get_dimensions(
|
||||
preset=formatted_preset, width=512, height=512
|
||||
)
|
||||
assert result == (832, 1216)
|
||||
|
||||
def test_sdxl_landscape_preset(self):
|
||||
"""Test SDXL landscape preset."""
|
||||
"""Test SDXL landscape preset (supports both raw and formatted)."""
|
||||
# Test raw preset
|
||||
result = self.node.get_dimensions(preset="1216×832", width=512, height=512)
|
||||
assert result == (1216, 832)
|
||||
|
||||
# Test formatted preset if available
|
||||
formatted_preset = "1216×832 - 19:13 (1.0MP) - SDXL"
|
||||
result = self.node.get_dimensions(
|
||||
preset=formatted_preset, width=512, height=512
|
||||
)
|
||||
assert result == (1216, 832)
|
||||
|
||||
def test_flux_preset(self):
|
||||
"""Test FLUX preset."""
|
||||
"""Test FLUX preset (supports both raw and formatted)."""
|
||||
# Test raw preset
|
||||
result = self.node.get_dimensions(preset="1920×1080", width=512, height=512)
|
||||
assert result == (1920, 1080)
|
||||
|
||||
# Test formatted preset
|
||||
formatted_preset = "1920×1080 - 16:9 (2.1MP) - FLUX"
|
||||
result = self.node.get_dimensions(
|
||||
preset=formatted_preset, width=512, height=512
|
||||
)
|
||||
assert result == (1920, 1080)
|
||||
|
||||
def test_ultra_wide_preset(self):
|
||||
"""Test ultra-wide preset."""
|
||||
"""Test ultra-wide preset (supports both raw and formatted)."""
|
||||
# Test raw preset
|
||||
result = self.node.get_dimensions(preset="2560×1080", width=512, height=512)
|
||||
assert result == (2560, 1080)
|
||||
|
||||
# Test formatted preset if available
|
||||
formatted_preset = "2560×1080 - 64:27 (2.8MP) - Ultra-Wide"
|
||||
result = self.node.get_dimensions(
|
||||
preset=formatted_preset, width=512, height=512
|
||||
)
|
||||
assert result == (2560, 1080)
|
||||
|
||||
def test_all_presets_available(self):
|
||||
"""Test that all presets are available in INPUT_TYPES."""
|
||||
input_types = self.node.INPUT_TYPES()
|
||||
available_presets = input_types["required"]["preset"][0]
|
||||
|
||||
# Check that all major preset categories are available
|
||||
# Check that custom is available
|
||||
assert "custom" in available_presets
|
||||
assert "1024×1024" in available_presets # SDXL square
|
||||
assert "832×1216" in available_presets # SDXL portrait
|
||||
assert "1216×832" in available_presets # SDXL landscape
|
||||
assert "1920×1080" in available_presets # FLUX
|
||||
assert "2560×1080" in available_presets # Ultra-wide
|
||||
|
||||
# Check that formatted presets are available (with metadata)
|
||||
# Extract raw preset names from formatted options
|
||||
raw_presets = []
|
||||
for option in available_presets:
|
||||
if option == "custom":
|
||||
raw_presets.append(option)
|
||||
elif " - " in option:
|
||||
raw_presets.append(option.split(" - ")[0])
|
||||
else:
|
||||
raw_presets.append(option)
|
||||
|
||||
# Check that all major preset categories are available
|
||||
assert "1024×1024" in raw_presets # SDXL square
|
||||
assert "832×1216" in raw_presets # SDXL portrait
|
||||
assert "1216×832" in raw_presets # SDXL landscape
|
||||
assert "1920×1080" in raw_presets # FLUX
|
||||
assert "2560×1080" in raw_presets # Ultra-wide
|
||||
|
||||
def test_invalid_preset_fallback(self):
|
||||
"""Test handling of invalid preset."""
|
||||
@@ -261,3 +316,243 @@ class TestEdgeCases:
|
||||
# Prime number dimensions
|
||||
ratio = calculate_aspect_ratio(1920, 1080)
|
||||
assert ratio == "16:9"
|
||||
|
||||
|
||||
class TestPresetMetadata:
|
||||
"""Test preset metadata functionality."""
|
||||
|
||||
def test_preset_metadata_structure(self):
|
||||
"""Test that metadata has correct structure."""
|
||||
for preset_name, metadata in PRESET_METADATA.items():
|
||||
assert hasattr(metadata, "width")
|
||||
assert hasattr(metadata, "height")
|
||||
assert hasattr(metadata, "aspect_ratio")
|
||||
assert hasattr(metadata, "aspect_decimal")
|
||||
assert hasattr(metadata, "megapixels")
|
||||
assert hasattr(metadata, "model_group")
|
||||
assert hasattr(metadata, "category")
|
||||
assert hasattr(metadata, "description")
|
||||
|
||||
def test_metadata_aspect_ratios(self):
|
||||
"""Test that aspect ratios are correctly calculated."""
|
||||
for preset_name, metadata in PRESET_METADATA.items():
|
||||
expected_decimal = metadata.width / metadata.height
|
||||
assert abs(metadata.aspect_decimal - expected_decimal) < 0.001
|
||||
|
||||
# Common aspect ratios should match expected values
|
||||
if preset_name == "1024×1024":
|
||||
assert metadata.aspect_ratio == "1:1"
|
||||
assert metadata.aspect_decimal == 1.0
|
||||
elif preset_name == "1920×1080":
|
||||
assert metadata.aspect_ratio == "16:9"
|
||||
assert abs(metadata.aspect_decimal - 1.778) < 0.01
|
||||
|
||||
def test_metadata_megapixels(self):
|
||||
"""Test that megapixel calculations are correct."""
|
||||
for preset_name, metadata in PRESET_METADATA.items():
|
||||
expected_mp = (metadata.width * metadata.height) / 1_000_000
|
||||
assert abs(metadata.megapixels - expected_mp) < 0.1
|
||||
|
||||
def test_model_groups(self):
|
||||
"""Test that model groups are properly assigned."""
|
||||
sdxl_presets = get_presets_by_model_group("SDXL")
|
||||
flux_presets = get_presets_by_model_group("FLUX")
|
||||
ultra_wide_presets = get_presets_by_model_group("Ultra-Wide")
|
||||
|
||||
assert len(sdxl_presets) > 0
|
||||
assert len(flux_presets) > 0
|
||||
assert len(ultra_wide_presets) > 0
|
||||
|
||||
# Check specific presets are in correct groups
|
||||
assert "1024×1024" in [k for k, v in sdxl_presets.items()]
|
||||
assert "1920×1080" in [k for k, v in flux_presets.items()]
|
||||
assert "2560×1080" in [k for k, v in ultra_wide_presets.items()]
|
||||
|
||||
def test_get_preset_metadata_function(self):
|
||||
"""Test get_preset_metadata function."""
|
||||
# Valid preset
|
||||
metadata = get_preset_metadata("1024×1024")
|
||||
assert metadata.width == 1024
|
||||
assert metadata.height == 1024
|
||||
assert metadata.model_group == "SDXL"
|
||||
|
||||
# Invalid preset returns default
|
||||
metadata = get_preset_metadata("invalid_preset")
|
||||
assert metadata.width == 0
|
||||
assert metadata.height == 0
|
||||
assert metadata.model_group == "Custom"
|
||||
|
||||
|
||||
class TestNodeMetadataIntegration:
|
||||
"""Test node integration with metadata."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.node = WidthHeightSelectorNode()
|
||||
|
||||
def test_get_preset_info_with_metadata(self):
|
||||
"""Test that preset info includes metadata."""
|
||||
info = self.node.get_preset_info("1024×1024")
|
||||
assert "1:1" in info # Aspect ratio
|
||||
assert "1.0MP" in info or "1.1MP" in info # Megapixels
|
||||
assert "SDXL" in info # Description
|
||||
|
||||
def test_get_presets_by_model_static(self):
|
||||
"""Test static method for getting presets by model."""
|
||||
sdxl_presets = self.node.get_presets_by_model("SDXL")
|
||||
assert isinstance(sdxl_presets, dict)
|
||||
assert len(sdxl_presets) > 0
|
||||
|
||||
# Check that returned values are metadata objects
|
||||
for preset_name, metadata in sdxl_presets.items():
|
||||
assert metadata.model_group == "SDXL"
|
||||
|
||||
def test_get_preset_metadata_static(self):
|
||||
"""Test static method for getting preset metadata."""
|
||||
metadata_dict = self.node.get_preset_metadata_static("1920×1080")
|
||||
|
||||
assert metadata_dict["width"] == 1920
|
||||
assert metadata_dict["height"] == 1080
|
||||
assert metadata_dict["aspect_ratio"] == "16:9"
|
||||
assert metadata_dict["model_group"] == "FLUX"
|
||||
|
||||
def test_get_model_groups(self):
|
||||
"""Test static method for getting model groups."""
|
||||
groups = self.node.get_model_groups()
|
||||
assert "SDXL" in groups
|
||||
assert "FLUX" in groups
|
||||
assert "Ultra-Wide" in groups
|
||||
|
||||
|
||||
class TestMetadataValidation:
|
||||
"""Test metadata validation functions."""
|
||||
|
||||
def test_dimensions_validation(self):
|
||||
"""Test dimensions validation from metadata."""
|
||||
from kikotools.tools.width_height_selector.presets import (
|
||||
validate_preset_dimensions,
|
||||
)
|
||||
|
||||
assert validate_preset_dimensions() is True
|
||||
|
||||
def test_metadata_consistency_validation(self):
|
||||
"""Test metadata consistency validation."""
|
||||
from kikotools.tools.width_height_selector.presets import (
|
||||
validate_metadata_consistency,
|
||||
)
|
||||
|
||||
assert validate_metadata_consistency() is True
|
||||
|
||||
|
||||
class TestFormattedPresets:
|
||||
"""Test formatted preset functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.node = WidthHeightSelectorNode()
|
||||
|
||||
def test_formatted_preset_generation(self):
|
||||
"""Test that INPUT_TYPES generates formatted presets."""
|
||||
input_types = self.node.INPUT_TYPES()
|
||||
available_presets = input_types["required"]["preset"][0]
|
||||
|
||||
# Should have custom first
|
||||
assert available_presets[0] == "custom"
|
||||
|
||||
# Should have formatted presets with metadata
|
||||
formatted_count = 0
|
||||
for option in available_presets[1:]: # Skip custom
|
||||
if " - " in option and "MP" in option:
|
||||
formatted_count += 1
|
||||
|
||||
assert formatted_count > 0, "No formatted presets found"
|
||||
assert formatted_count == len(PRESET_METADATA), "Not all presets are formatted"
|
||||
|
||||
def test_preset_name_extraction(self):
|
||||
"""Test extraction of raw preset names from formatted strings."""
|
||||
test_cases = [
|
||||
("custom", "custom"),
|
||||
("1024×1024 - 1:1 (1.1MP) - SDXL", "1024×1024"),
|
||||
("1920×1080 - 16:9 (2.1MP) - FLUX", "1920×1080"),
|
||||
("832×1216 - 13:19 (1.0MP) - SDXL", "832×1216"),
|
||||
("1024×1024", "1024×1024"), # Raw preset name
|
||||
("invalid_preset", "custom"), # Invalid fallback
|
||||
]
|
||||
|
||||
for formatted_preset, expected in test_cases:
|
||||
result = self.node._extract_preset_name(formatted_preset)
|
||||
assert (
|
||||
result == expected
|
||||
), f"Expected {expected}, got {result} for input {formatted_preset}"
|
||||
|
||||
def test_formatted_preset_dimensions(self):
|
||||
"""Test that formatted presets return correct dimensions."""
|
||||
# Test with formatted preset string
|
||||
formatted_preset = "1024×1024 - 1:1 (1.1MP) - SDXL"
|
||||
result = self.node.get_dimensions(formatted_preset, 512, 512)
|
||||
assert result == (1024, 1024)
|
||||
|
||||
# Test with FLUX formatted preset
|
||||
formatted_preset = "1920×1080 - 16:9 (2.1MP) - FLUX"
|
||||
result = self.node.get_dimensions(formatted_preset, 512, 512)
|
||||
assert result == (1920, 1080)
|
||||
|
||||
def test_formatted_preset_validation(self):
|
||||
"""Test validation of formatted presets."""
|
||||
# Valid formatted preset
|
||||
assert self.node.validate_inputs("1024×1024 - 1:1 (1.1MP) - SDXL", 1024, 1024)
|
||||
|
||||
# Valid raw preset
|
||||
assert self.node.validate_inputs("1024×1024", 1024, 1024)
|
||||
|
||||
# Custom preset
|
||||
assert self.node.validate_inputs("custom", 1024, 1024)
|
||||
|
||||
# Invalid formatted preset should still work (fallback to custom)
|
||||
assert self.node.validate_inputs("invalid - formatted", 1024, 1024)
|
||||
|
||||
def test_backwards_compatibility(self):
|
||||
"""Test that raw preset names still work."""
|
||||
# Raw preset names should still work for backwards compatibility
|
||||
raw_presets = ["1024×1024", "1920×1080", "832×1216"]
|
||||
|
||||
for raw_preset in raw_presets:
|
||||
if raw_preset in PRESET_OPTIONS:
|
||||
result = self.node.get_dimensions(raw_preset, 512, 512)
|
||||
expected = PRESET_OPTIONS[raw_preset]
|
||||
assert result == expected, f"Raw preset {raw_preset} failed"
|
||||
|
||||
def test_formatted_preset_metadata_accuracy(self):
|
||||
"""Test that formatted presets contain accurate metadata."""
|
||||
input_types = self.node.INPUT_TYPES()
|
||||
formatted_presets = [
|
||||
opt for opt in input_types["required"]["preset"][0] if " - " in opt
|
||||
]
|
||||
|
||||
for formatted_preset in formatted_presets:
|
||||
# Extract components
|
||||
parts = formatted_preset.split(" - ")
|
||||
assert (
|
||||
len(parts) == 3
|
||||
), f"Formatted preset should have 3 parts: {formatted_preset}"
|
||||
|
||||
resolution = parts[0]
|
||||
aspect_and_mp = parts[1]
|
||||
model_group = parts[2]
|
||||
|
||||
# Verify resolution exists in metadata
|
||||
assert (
|
||||
resolution in PRESET_METADATA
|
||||
), f"Resolution {resolution} not in metadata"
|
||||
|
||||
# Verify metadata matches format
|
||||
metadata = PRESET_METADATA[resolution]
|
||||
assert (
|
||||
metadata.model_group == model_group
|
||||
), f"Model group mismatch for {resolution}"
|
||||
assert (
|
||||
metadata.aspect_ratio in aspect_and_mp
|
||||
), f"Aspect ratio not in {aspect_and_mp}"
|
||||
assert (
|
||||
f"{metadata.megapixels:.1f}MP" in aspect_and_mp
|
||||
), f"Megapixels not in {aspect_and_mp}"
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.DisplayText",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "DisplayText") {
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (message?.text) {
|
||||
// Create or update the text widget
|
||||
this.updateTextDisplay(message.text[0]);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.updateTextDisplay = function(text) {
|
||||
// Remove existing text widget if any
|
||||
const existingWidget = this.widgets?.find(w => w.name === "displayed_text");
|
||||
if (existingWidget) {
|
||||
const index = this.widgets.indexOf(existingWidget);
|
||||
this.widgets.splice(index, 1);
|
||||
}
|
||||
|
||||
// Parse the text to detect positive/negative prompt format
|
||||
function parsePrompts(text) {
|
||||
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
|
||||
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
|
||||
|
||||
if (posMatch && negMatch) {
|
||||
// Extract just the prompt content, stopping at the first ** marker
|
||||
let positiveText = posMatch[1].trim();
|
||||
let negativeText = negMatch[1].trim();
|
||||
|
||||
// Remove any trailing ** markers and everything after them
|
||||
const posEndIndex = positiveText.indexOf('**');
|
||||
if (posEndIndex > 0) {
|
||||
positiveText = positiveText.substring(0, posEndIndex).trim();
|
||||
}
|
||||
|
||||
const negEndIndex = negativeText.indexOf('**');
|
||||
if (negEndIndex > 0) {
|
||||
negativeText = negativeText.substring(0, negEndIndex).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'prompts',
|
||||
positive: positiveText,
|
||||
negative: negativeText
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
content: text
|
||||
};
|
||||
}
|
||||
|
||||
const parsedContent = parsePrompts(text);
|
||||
|
||||
// Create custom widget for text display
|
||||
const widget = {
|
||||
type: "custom_text_display",
|
||||
name: "displayed_text",
|
||||
size: [this.size[0] - 20, this.size[1] - 60], // Adjust for node chrome
|
||||
parsedContent: parsedContent,
|
||||
scrollOffset: 0,
|
||||
posScrollOffset: 0,
|
||||
negScrollOffset: 0,
|
||||
|
||||
draw: function(ctx, node, widget_width, y, H) {
|
||||
const margin = 10;
|
||||
const padding = 10;
|
||||
const lineHeight = 20;
|
||||
const buttonHeight = 30;
|
||||
const buttonWidth = 90;
|
||||
|
||||
// Use the actual widget height from node size
|
||||
const availableHeight = node.size[1] - 60; // Account for node header and margins
|
||||
this.size[1] = Math.max(100, availableHeight);
|
||||
|
||||
// Calculate available width for text
|
||||
const availableWidth = widget_width - margin * 2 - padding * 2;
|
||||
|
||||
// Word wrap function with better performance
|
||||
function wrapText(text, maxWidth) {
|
||||
const words = text.split(' ');
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
|
||||
ctx.font = "14px monospace";
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLine + (currentLine ? ' ' : '') + word;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && currentLine) {
|
||||
lines.push(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines : [''];
|
||||
}
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = "#2a2a2a";
|
||||
ctx.fillRect(margin, y, widget_width - margin * 2, this.size[1]);
|
||||
|
||||
// Draw border
|
||||
ctx.strokeStyle = "#444";
|
||||
ctx.strokeRect(margin, y, widget_width - margin * 2, this.size[1]);
|
||||
|
||||
if (this.parsedContent.type === 'prompts') {
|
||||
// Simple split view for positive/negative prompts
|
||||
const headerHeight = 25;
|
||||
const buttonAreaHeight = buttonHeight + padding;
|
||||
const totalTextHeight = this.size[1] - buttonAreaHeight;
|
||||
const halfHeight = totalTextHeight / 2;
|
||||
|
||||
// Draw positive prompt header
|
||||
ctx.fillStyle = "#3a3a3a";
|
||||
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, headerHeight);
|
||||
ctx.fillStyle = "#8f8";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.fillText("✓ Positive Prompt", margin + padding, y + headerHeight - 7);
|
||||
|
||||
// Positive prompt text area
|
||||
const posTextY = y + headerHeight;
|
||||
const posTextHeight = halfHeight - headerHeight;
|
||||
ctx.fillStyle = "#1e1e1e";
|
||||
ctx.fillRect(margin + 1, posTextY, widget_width - margin * 2 - 2, posTextHeight);
|
||||
|
||||
// Draw separator
|
||||
const separatorY = y + halfHeight;
|
||||
ctx.strokeStyle = "#555";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin, separatorY);
|
||||
ctx.lineTo(widget_width - margin, separatorY);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw negative prompt header
|
||||
ctx.fillStyle = "#3a3a3a";
|
||||
ctx.fillRect(margin + 1, separatorY + 1, widget_width - margin * 2 - 2, headerHeight);
|
||||
ctx.fillStyle = "#f88";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.fillText("✗ Negative Prompt", margin + padding, separatorY + headerHeight - 7);
|
||||
|
||||
// Negative prompt text area
|
||||
const negTextY = separatorY + headerHeight;
|
||||
const negTextHeight = halfHeight - headerHeight;
|
||||
ctx.fillStyle = "#1e1e1e";
|
||||
ctx.fillRect(margin + 1, negTextY, widget_width - margin * 2 - 2, negTextHeight);
|
||||
|
||||
// Draw text for both sections
|
||||
ctx.font = "14px monospace";
|
||||
ctx.fillStyle = "#ddd";
|
||||
|
||||
// Wrap text for both prompts
|
||||
const posLines = [];
|
||||
const posParagraphs = this.parsedContent.positive.split('\n');
|
||||
for (const para of posParagraphs) {
|
||||
if (para.trim() === '') {
|
||||
posLines.push('');
|
||||
} else {
|
||||
posLines.push(...wrapText(para, availableWidth - 10));
|
||||
}
|
||||
}
|
||||
|
||||
const negLines = [];
|
||||
const negParagraphs = this.parsedContent.negative.split('\n');
|
||||
for (const para of negParagraphs) {
|
||||
if (para.trim() === '') {
|
||||
negLines.push('');
|
||||
} else {
|
||||
negLines.push(...wrapText(para, availableWidth - 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Draw positive prompt text with clipping
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(margin + padding, posTextY + padding, availableWidth - 10, posTextHeight - padding * 2);
|
||||
ctx.clip();
|
||||
|
||||
let currentY = posTextY + padding + lineHeight - 5;
|
||||
const posVisibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
|
||||
const posStartLine = Math.floor(this.posScrollOffset);
|
||||
const posEndLine = Math.min(posStartLine + posVisibleLines, posLines.length);
|
||||
|
||||
for (let i = posStartLine; i < posEndLine; i++) {
|
||||
ctx.fillText(posLines[i], margin + padding, currentY);
|
||||
currentY += lineHeight;
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Draw positive scroll indicator if needed
|
||||
if (posLines.length > posVisibleLines) {
|
||||
const scrollBarWidth = 6;
|
||||
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
|
||||
const scrollBarHeight = posTextHeight - padding * 2;
|
||||
const maxScroll = posLines.length - posVisibleLines;
|
||||
const scrollRatio = this.posScrollOffset / maxScroll;
|
||||
const thumbHeight = Math.max(20, (posVisibleLines / posLines.length) * scrollBarHeight);
|
||||
const thumbY = posTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
|
||||
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.fillRect(scrollBarX, posTextY + padding, scrollBarWidth, scrollBarHeight);
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// Draw negative prompt text with clipping
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(margin + padding, negTextY + padding, availableWidth - 10, negTextHeight - padding * 2);
|
||||
ctx.clip();
|
||||
|
||||
ctx.fillStyle = "#ddd";
|
||||
currentY = negTextY + padding + lineHeight - 5;
|
||||
const negVisibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
|
||||
const negStartLine = Math.floor(this.negScrollOffset);
|
||||
const negEndLine = Math.min(negStartLine + negVisibleLines, negLines.length);
|
||||
|
||||
for (let i = negStartLine; i < negEndLine; i++) {
|
||||
ctx.fillText(negLines[i], margin + padding, currentY);
|
||||
currentY += lineHeight;
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Draw negative scroll indicator if needed
|
||||
if (negLines.length > negVisibleLines) {
|
||||
const scrollBarWidth = 6;
|
||||
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
|
||||
const scrollBarHeight = negTextHeight - padding * 2;
|
||||
const maxScroll = negLines.length - negVisibleLines;
|
||||
const scrollRatio = this.negScrollOffset / maxScroll;
|
||||
const thumbHeight = Math.max(20, (negVisibleLines / negLines.length) * scrollBarHeight);
|
||||
const thumbY = negTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
|
||||
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.fillRect(scrollBarX, negTextY + padding, scrollBarWidth, scrollBarHeight);
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// Draw copy buttons
|
||||
const buttonY = y + this.size[1] - buttonHeight - padding / 2;
|
||||
const halfWidth = (widget_width - margin * 2) / 2;
|
||||
|
||||
// Positive copy button
|
||||
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
|
||||
ctx.fillStyle = this.posCopyHovered ? "#5a5a5a" : "#4a4a4a";
|
||||
ctx.fillRect(posButtonX, buttonY, buttonWidth, buttonHeight);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.strokeRect(posButtonX, buttonY, buttonWidth, buttonHeight);
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.posCopySuccess ? "✓ Copied!" : "📋 Positive", posButtonX + buttonWidth/2, buttonY + buttonHeight/2);
|
||||
|
||||
// Negative copy button
|
||||
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
|
||||
ctx.fillStyle = this.negCopyHovered ? "#5a5a5a" : "#4a4a4a";
|
||||
ctx.fillRect(negButtonX, buttonY, buttonWidth, buttonHeight);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.strokeRect(negButtonX, buttonY, buttonWidth, buttonHeight);
|
||||
|
||||
// Ensure text color and alignment are set
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.negCopySuccess ? "✓ Copied!" : "📋 Negative", negButtonX + buttonWidth/2, buttonY + buttonHeight/2);
|
||||
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
|
||||
} else {
|
||||
// Regular text display
|
||||
const textAreaHeight = this.size[1] - buttonHeight - padding;
|
||||
|
||||
// Draw text area background
|
||||
ctx.fillStyle = "#1e1e1e";
|
||||
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, textAreaHeight);
|
||||
|
||||
// Process text
|
||||
ctx.font = "14px monospace";
|
||||
const paragraphs = this.parsedContent.content.split('\n');
|
||||
const allLines = [];
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.trim() === '') {
|
||||
allLines.push('');
|
||||
} else {
|
||||
const wrappedLines = wrapText(paragraph, availableWidth);
|
||||
allLines.push(...wrappedLines);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw text with clipping
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(margin + padding, y + padding, availableWidth, textAreaHeight - padding * 2);
|
||||
ctx.clip();
|
||||
|
||||
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
|
||||
const maxScroll = Math.max(0, allLines.length - visibleLines);
|
||||
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
|
||||
|
||||
ctx.fillStyle = "#ddd";
|
||||
let currentY = y + padding + lineHeight - 5 - (this.scrollOffset * lineHeight);
|
||||
|
||||
for (let i = 0; i < allLines.length; i++) {
|
||||
if (currentY > y && currentY < y + textAreaHeight) {
|
||||
ctx.fillText(allLines[i], margin + padding, currentY);
|
||||
}
|
||||
currentY += lineHeight;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Draw scroll indicator if needed
|
||||
if (allLines.length > visibleLines) {
|
||||
const scrollBarWidth = 6;
|
||||
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
|
||||
const scrollBarHeight = textAreaHeight - 4;
|
||||
const thumbHeight = Math.max(20, (visibleLines / allLines.length) * scrollBarHeight);
|
||||
const thumbY = y + 2 + (this.scrollOffset / maxScroll) * (scrollBarHeight - thumbHeight);
|
||||
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.fillRect(scrollBarX, y + 2, scrollBarWidth, scrollBarHeight);
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// Draw copy button
|
||||
const buttonX = widget_width - margin - buttonWidth - padding;
|
||||
const buttonY = y + textAreaHeight + padding / 2;
|
||||
|
||||
ctx.fillStyle = this.copyButtonHovered ? "#5a5a5a" : "#4a4a4a";
|
||||
ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.copySuccess ? "✓ Copied!" : "📋 Copy", buttonX + buttonWidth/2, buttonY + buttonHeight/2);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
return this.size[1];
|
||||
},
|
||||
|
||||
mouse: function(event, pos, node) {
|
||||
const margin = 10;
|
||||
const padding = 10;
|
||||
const buttonWidth = 90;
|
||||
const buttonHeight = 30;
|
||||
const lineHeight = 20;
|
||||
|
||||
// Check if mouse is over the widget
|
||||
const isOver = pos[1] > this.last_y && pos[1] < this.last_y + this.size[1];
|
||||
if (!isOver) return false;
|
||||
|
||||
if (this.parsedContent.type === 'prompts') {
|
||||
// Handle split view
|
||||
const headerHeight = 25;
|
||||
const buttonAreaHeight = buttonHeight + padding;
|
||||
const totalTextHeight = this.size[1] - buttonAreaHeight;
|
||||
const halfHeight = totalTextHeight / 2;
|
||||
|
||||
const posTextY = this.last_y + headerHeight;
|
||||
const posTextHeight = halfHeight - headerHeight;
|
||||
const negTextY = this.last_y + halfHeight + headerHeight;
|
||||
const negTextHeight = halfHeight - headerHeight;
|
||||
|
||||
const buttonY = this.last_y + this.size[1] - buttonHeight - padding / 2;
|
||||
const halfWidth = (node.size[0] - margin * 2) / 2;
|
||||
|
||||
// Check which section for scrolling
|
||||
const inPosSection = pos[1] > posTextY && pos[1] < posTextY + posTextHeight;
|
||||
const inNegSection = pos[1] > negTextY && pos[1] < negTextY + negTextHeight;
|
||||
|
||||
// Handle scrolling
|
||||
if (event.type === "wheel") {
|
||||
if (inPosSection) {
|
||||
const delta = event.deltaY > 0 ? 1 : -1;
|
||||
this.posScrollOffset = (this.posScrollOffset || 0) + delta;
|
||||
|
||||
// Calculate max scroll
|
||||
const visibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
|
||||
const totalLines = this.parsedContent.positive.split('\n').length * 2; // Estimate
|
||||
const maxScroll = Math.max(0, totalLines - visibleLines);
|
||||
this.posScrollOffset = Math.max(0, Math.min(this.posScrollOffset, maxScroll));
|
||||
|
||||
node.setDirtyCanvas(true);
|
||||
return true;
|
||||
} else if (inNegSection) {
|
||||
const delta = event.deltaY > 0 ? 1 : -1;
|
||||
this.negScrollOffset = (this.negScrollOffset || 0) + delta;
|
||||
|
||||
// Calculate max scroll
|
||||
const visibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
|
||||
const totalLines = this.parsedContent.negative.split('\n').length * 2; // Estimate
|
||||
const maxScroll = Math.max(0, totalLines - visibleLines);
|
||||
this.negScrollOffset = Math.max(0, Math.min(this.negScrollOffset, maxScroll));
|
||||
|
||||
node.setDirtyCanvas(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check button hovers
|
||||
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
|
||||
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
|
||||
|
||||
const oldPosHover = this.posCopyHovered;
|
||||
const oldNegHover = this.negCopyHovered;
|
||||
|
||||
this.posCopyHovered = pos[0] > posButtonX && pos[0] < posButtonX + buttonWidth &&
|
||||
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
|
||||
this.negCopyHovered = pos[0] > negButtonX && pos[0] < negButtonX + buttonWidth &&
|
||||
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
|
||||
|
||||
if (oldPosHover !== this.posCopyHovered || oldNegHover !== this.negCopyHovered) {
|
||||
node.setDirtyCanvas(true);
|
||||
}
|
||||
|
||||
// Handle button clicks
|
||||
if (event.type === "pointerdown") {
|
||||
if (this.posCopyHovered) {
|
||||
this.copyToClipboard(this.parsedContent.positive, 'positive');
|
||||
return true;
|
||||
} else if (this.negCopyHovered) {
|
||||
this.copyToClipboard(this.parsedContent.negative, 'negative');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular text handling
|
||||
const textAreaHeight = this.size[1] - buttonHeight - padding;
|
||||
const buttonX = node.size[0] - margin - buttonWidth - padding;
|
||||
const buttonY = this.last_y + textAreaHeight + padding / 2;
|
||||
|
||||
// Handle scrolling
|
||||
if (event.type === "wheel" && pos[1] < this.last_y + textAreaHeight) {
|
||||
const delta = event.deltaY > 0 ? 1 : -1;
|
||||
this.scrollOffset = (this.scrollOffset || 0) + delta;
|
||||
|
||||
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
|
||||
const totalLines = this.parsedContent.content.split('\n').length * 2; // Estimate
|
||||
const maxScroll = Math.max(0, totalLines - visibleLines);
|
||||
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
|
||||
|
||||
node.setDirtyCanvas(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check button hover
|
||||
const oldHover = this.copyButtonHovered;
|
||||
this.copyButtonHovered = pos[0] > buttonX && pos[0] < buttonX + buttonWidth &&
|
||||
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
|
||||
|
||||
if (oldHover !== this.copyButtonHovered) {
|
||||
node.setDirtyCanvas(true);
|
||||
}
|
||||
|
||||
// Handle button click
|
||||
if (event.type === "pointerdown" && this.copyButtonHovered) {
|
||||
this.copyToClipboard(this.parsedContent.content, 'regular');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
copyToClipboard: function(text, type) {
|
||||
const node = this._node;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (type === 'positive') {
|
||||
this.posCopySuccess = true;
|
||||
} else if (type === 'negative') {
|
||||
this.negCopySuccess = true;
|
||||
} else {
|
||||
this.copySuccess = true;
|
||||
}
|
||||
node.setDirtyCanvas(true);
|
||||
|
||||
setTimeout(() => {
|
||||
this.posCopySuccess = false;
|
||||
this.negCopySuccess = false;
|
||||
this.copySuccess = false;
|
||||
node.setDirtyCanvas(true);
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
// Fallback copy method
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
if (type === 'positive') {
|
||||
this.posCopySuccess = true;
|
||||
} else if (type === 'negative') {
|
||||
this.negCopySuccess = true;
|
||||
} else {
|
||||
this.copySuccess = true;
|
||||
}
|
||||
node.setDirtyCanvas(true);
|
||||
setTimeout(() => {
|
||||
this.posCopySuccess = false;
|
||||
this.negCopySuccess = false;
|
||||
this.copySuccess = false;
|
||||
node.setDirtyCanvas(true);
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
},
|
||||
|
||||
computeSize: function(width) {
|
||||
return [width, this.size[1]];
|
||||
}
|
||||
};
|
||||
|
||||
// Store reference to node for callbacks
|
||||
widget._node = this;
|
||||
|
||||
// Store the last y position for mouse detection
|
||||
const originalDraw = widget.draw;
|
||||
widget.draw = function(ctx, node, widget_width, y, H) {
|
||||
this.last_y = y;
|
||||
return originalDraw.call(this, ctx, node, widget_width, y, H);
|
||||
};
|
||||
|
||||
// Add the widget
|
||||
if (!this.widgets) {
|
||||
this.widgets = [];
|
||||
}
|
||||
this.widgets.push(widget);
|
||||
|
||||
// Adjust node size to accommodate the widget
|
||||
this.computeSize();
|
||||
};
|
||||
|
||||
// Handle node resizing
|
||||
const onResize = nodeType.prototype.onResize;
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
onResize?.apply(this, arguments);
|
||||
|
||||
// Update widget size when node is resized
|
||||
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
|
||||
if (textWidget) {
|
||||
textWidget.size[0] = size[0] - 20;
|
||||
textWidget.size[1] = size[1] - 60;
|
||||
this.setDirtyCanvas(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on node creation
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Set default size
|
||||
this.size[0] = Math.max(this.size[0], 350);
|
||||
this.size[1] = Math.max(this.size[1], 300);
|
||||
|
||||
// Add placeholder text
|
||||
this.updateTextDisplay("Text will appear here after execution...");
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
// ComfyUI-KikoTools - Empty Latent Batch with Swap Button
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyassets.EmptyLatentBatch",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
if (nodeData.name === "EmptyLatentBatch") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) onNodeCreated.apply(this, []);
|
||||
|
||||
// Track button click state for visual feedback
|
||||
this.swapButtonPressed = false;
|
||||
|
||||
// Helper function to extract resolution from formatted preset string
|
||||
this.extractResolutionFromPreset = function (presetValue) {
|
||||
if (presetValue === "custom") return null;
|
||||
|
||||
// If it contains formatting metadata, extract the resolution part
|
||||
if (presetValue.includes(" - ")) {
|
||||
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
|
||||
return presetValue.split(" - ")[0];
|
||||
}
|
||||
|
||||
// Otherwise assume it's already a raw resolution
|
||||
return presetValue;
|
||||
};
|
||||
|
||||
// Override preset callback to update width/height widgets when preset changes
|
||||
const presetWidget = this.widgets.find((w) => w.name === "preset");
|
||||
if (presetWidget) {
|
||||
const originalCallback = presetWidget.callback;
|
||||
presetWidget.callback = function (
|
||||
value,
|
||||
graphcanvas,
|
||||
node,
|
||||
pos,
|
||||
event,
|
||||
) {
|
||||
// Call original callback first
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value, graphcanvas, node, pos, event);
|
||||
}
|
||||
|
||||
// Update width/height widgets based on preset
|
||||
const widthWidget = node.widgets.find((w) => w.name === "width");
|
||||
const heightWidget = node.widgets.find((w) => w.name === "height");
|
||||
|
||||
if (widthWidget && heightWidget && value !== "custom") {
|
||||
// Extract raw resolution from formatted preset
|
||||
const rawResolution = node.extractResolutionFromPreset(value);
|
||||
|
||||
// Define all available presets from our preset system
|
||||
const presetDimensions = {
|
||||
// SDXL Presets
|
||||
"1024×1024": [1024, 1024],
|
||||
"896×1152": [896, 1152],
|
||||
"832×1216": [832, 1216],
|
||||
"768×1344": [768, 1344],
|
||||
"640×1536": [640, 1536],
|
||||
"1152×896": [1152, 896],
|
||||
"1216×832": [1216, 832],
|
||||
"1344×768": [1344, 768],
|
||||
"1536×640": [1536, 640],
|
||||
// FLUX Presets
|
||||
"1920×1080": [1920, 1080],
|
||||
"1536×1536": [1536, 1536],
|
||||
"1280×768": [1280, 768],
|
||||
"768×1280": [768, 1280],
|
||||
"1440×1080": [1440, 1080],
|
||||
"1080×1440": [1080, 1440],
|
||||
"1728×1152": [1728, 1152],
|
||||
"1152×1728": [1152, 1728],
|
||||
// Ultra-Wide Presets
|
||||
"2560×1080": [2560, 1080],
|
||||
"2048×768": [2048, 768],
|
||||
"1792×768": [1792, 768],
|
||||
"2304×768": [2304, 768],
|
||||
"1080×2560": [1080, 2560],
|
||||
"768×2048": [768, 2048],
|
||||
"768×1792": [768, 1792],
|
||||
"768×2304": [768, 2304],
|
||||
};
|
||||
|
||||
if (rawResolution && presetDimensions[rawResolution]) {
|
||||
const [w, h] = presetDimensions[rawResolution];
|
||||
widthWidget.value = w;
|
||||
heightWidget.value = h;
|
||||
|
||||
// Trigger widget callbacks to update the UI
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(w, graphcanvas, node, pos, event);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(h, graphcanvas, node, pos, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add swap functionality
|
||||
this.swapDimensions = function () {
|
||||
const widthWidget = this.widgets.find((w) => w.name === "width");
|
||||
const heightWidget = this.widgets.find((w) => w.name === "height");
|
||||
const presetWidget = this.widgets.find((w) => w.name === "preset");
|
||||
|
||||
if (widthWidget && heightWidget && presetWidget) {
|
||||
// Handle preset swapping first
|
||||
if (presetWidget.value !== "custom") {
|
||||
const currentPreset = presetWidget.value;
|
||||
|
||||
// Extract raw resolution from formatted preset
|
||||
const rawResolution =
|
||||
this.extractResolutionFromPreset(currentPreset);
|
||||
if (!rawResolution) return;
|
||||
|
||||
// Parse current preset dimensions (handle both × and x separators)
|
||||
let w, h;
|
||||
if (rawResolution.includes("×")) {
|
||||
[w, h] = rawResolution.split("×").map((v) => parseInt(v));
|
||||
} else if (rawResolution.includes("x")) {
|
||||
[w, h] = rawResolution.split("x").map((v) => parseInt(v));
|
||||
} else {
|
||||
return; // Invalid preset format
|
||||
}
|
||||
|
||||
const swappedRawPreset = `${h}×${w}`;
|
||||
|
||||
// Find the formatted version of the swapped preset from available options
|
||||
const availablePresets =
|
||||
presetWidget.options.values || presetWidget.options;
|
||||
let swappedFormattedPreset = null;
|
||||
|
||||
for (const option of availablePresets) {
|
||||
if (option === "custom") continue;
|
||||
const extractedRes = this.extractResolutionFromPreset(option);
|
||||
if (extractedRes === swappedRawPreset) {
|
||||
swappedFormattedPreset = option;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (swappedFormattedPreset) {
|
||||
// Swapped preset exists, use the formatted version
|
||||
presetWidget.value = swappedFormattedPreset;
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback(
|
||||
swappedFormattedPreset,
|
||||
this,
|
||||
presetWidget,
|
||||
);
|
||||
}
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(h, this, widthWidget);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(w, this, heightWidget);
|
||||
}
|
||||
} else {
|
||||
// Swapped preset doesn't exist, switch to custom and swap manual values
|
||||
presetWidget.value = "custom";
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback("custom", this, presetWidget);
|
||||
}
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(h, this, widthWidget);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(w, this, heightWidget);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Custom preset - just swap the width and height values
|
||||
const tempWidth = widthWidget.value;
|
||||
widthWidget.value = heightWidget.value;
|
||||
heightWidget.value = tempWidth;
|
||||
|
||||
// Trigger widget change events
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(widthWidget.value, this, widthWidget);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(heightWidget.value, this, heightWidget);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the graph as changed
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Override onResize to refresh button position
|
||||
const originalOnResize = this.onResize;
|
||||
this.onResize = function (size) {
|
||||
if (originalOnResize) {
|
||||
originalOnResize.call(this, size);
|
||||
}
|
||||
// Force redraw to update button position
|
||||
this.setDirtyCanvas(true, true);
|
||||
// Also mark the graph as dirty
|
||||
if (this.graph) {
|
||||
this.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Override onBounding to ensure proper updates
|
||||
const originalOnBounding = this.onBounding;
|
||||
this.onBounding = function (out) {
|
||||
if (originalOnBounding) {
|
||||
originalOnBounding.call(this, out);
|
||||
}
|
||||
// Force redraw when bounds change
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
};
|
||||
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
if (onDrawForeground) {
|
||||
onDrawForeground.apply(this, arguments);
|
||||
}
|
||||
|
||||
if (this.flags.collapsed) return;
|
||||
|
||||
// Draw swap button with consistent spacing from widgets
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX = this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Calculate button position based on widget spacing rather than bottom margin
|
||||
// Estimate widget area height and add consistent spacing
|
||||
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
|
||||
const topMargin = 35; // Space from top to first widget
|
||||
const buttonSpacing = 40; // Space between last widget and button (moved down 5)
|
||||
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
// Button background - change color based on pressed state
|
||||
if (this.swapButtonPressed) {
|
||||
// Darker when pressed
|
||||
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
|
||||
} else {
|
||||
// Normal state
|
||||
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
swapButtonX,
|
||||
swapButtonY,
|
||||
swapButtonSize,
|
||||
swapButtonSize,
|
||||
4,
|
||||
);
|
||||
ctx.fill();
|
||||
|
||||
// Button border with subtle highlight
|
||||
ctx.strokeStyle = this.swapButtonPressed
|
||||
? "rgba(20, 100, 180, 1.0)"
|
||||
: "rgba(33, 150, 243, 0.9)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw swap icon - modern double arrow design
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
|
||||
const centerX = swapButtonX + 12;
|
||||
const centerY = swapButtonY + 12;
|
||||
|
||||
// Top arrow (pointing right) - width to height
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 7, centerY - 3);
|
||||
ctx.lineTo(centerX + 5, centerY - 3);
|
||||
ctx.stroke();
|
||||
|
||||
// Top arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + 5, centerY - 3);
|
||||
ctx.lineTo(centerX + 2, centerY - 5);
|
||||
ctx.moveTo(centerX + 5, centerY - 3);
|
||||
ctx.lineTo(centerX + 2, centerY - 1);
|
||||
ctx.stroke();
|
||||
|
||||
// Bottom arrow (pointing left) - height to width
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + 5, centerY + 3);
|
||||
ctx.lineTo(centerX - 7, centerY + 3);
|
||||
ctx.stroke();
|
||||
|
||||
// Bottom arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 1);
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 5);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const onMouseDown = nodeType.prototype.onMouseDown;
|
||||
nodeType.prototype.onMouseDown = function (e) {
|
||||
// Check if click is on swap button
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX =
|
||||
this.pos[0] + this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Use same positioning logic as drawing
|
||||
const estimatedWidgetHeight = 90;
|
||||
const topMargin = 35;
|
||||
const buttonSpacing = 40;
|
||||
const swapButtonY =
|
||||
this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
if (
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
e.canvasY >= swapButtonY &&
|
||||
e.canvasY <= swapButtonY + swapButtonSize
|
||||
) {
|
||||
// Visual feedback - set button as pressed
|
||||
this.swapButtonPressed = true;
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
// Execute swap
|
||||
this.swapDimensions();
|
||||
|
||||
// Reset button state after a short delay for visual feedback
|
||||
setTimeout(() => {
|
||||
this.swapButtonPressed = false;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 150);
|
||||
|
||||
return true; // Consume the event
|
||||
}
|
||||
|
||||
// Call original onMouseDown if not clicking swap button
|
||||
if (onMouseDown) {
|
||||
return onMouseDown.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
// Optional: Add hover effect for better user feedback
|
||||
const onMouseMove = nodeType.prototype.onMouseMove;
|
||||
nodeType.prototype.onMouseMove = function (e) {
|
||||
// Check if hovering over swap button
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX =
|
||||
this.pos[0] + this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Use same positioning logic as drawing
|
||||
const estimatedWidgetHeight = 90;
|
||||
const topMargin = 35;
|
||||
const buttonSpacing = 40;
|
||||
const swapButtonY =
|
||||
this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
const isHovering =
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
e.canvasY >= swapButtonY &&
|
||||
e.canvasY <= swapButtonY + swapButtonSize;
|
||||
|
||||
// Update cursor style for better UX (safely)
|
||||
if (
|
||||
isHovering &&
|
||||
this.graph &&
|
||||
this.graph.canvas &&
|
||||
this.graph.canvas.canvas
|
||||
) {
|
||||
this.graph.canvas.canvas.style.cursor = "pointer";
|
||||
} else if (
|
||||
this.graph &&
|
||||
this.graph.canvas &&
|
||||
this.graph.canvas.canvas
|
||||
) {
|
||||
this.graph.canvas.canvas.style.cursor = "default";
|
||||
}
|
||||
|
||||
// Call original onMouseMove
|
||||
if (onMouseMove) {
|
||||
return onMouseMove.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.GeminiPrompt",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeminiPrompt") {
|
||||
// Add visual enhancements to the node
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Store reference to widgets
|
||||
this.promptTypeWidget = this.widgets.find(w => w.name === "prompt_type");
|
||||
this.modelWidget = this.widgets.find(w => w.name === "model");
|
||||
this.apiKeyWidget = this.widgets.find(w => w.name === "api_key");
|
||||
this.customPromptWidget = this.widgets.find(w => w.name === "custom_prompt");
|
||||
|
||||
// Add helper text button
|
||||
const helpButton = this.addWidget("button", "Help / API Setup", null, () => {
|
||||
this.showHelpDialog();
|
||||
});
|
||||
|
||||
// Style the button
|
||||
helpButton.serialize = false;
|
||||
|
||||
// Add refresh models button
|
||||
const refreshButton = this.addWidget("button", "Refresh Model List", null, () => {
|
||||
this.refreshModelList();
|
||||
});
|
||||
refreshButton.serialize = false;
|
||||
|
||||
// Add status indicator
|
||||
this.status = this.addWidget("text", "status", "Ready", () => {}, {
|
||||
serialize: false
|
||||
});
|
||||
this.status.disabled = true;
|
||||
|
||||
// Update custom prompt visibility based on selection
|
||||
if (this.promptTypeWidget && this.customPromptWidget) {
|
||||
const originalCallback = this.promptTypeWidget.callback;
|
||||
this.promptTypeWidget.callback = (value) => {
|
||||
if (originalCallback) originalCallback.call(this.promptTypeWidget, value);
|
||||
this.updateCustomPromptVisibility();
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Add method to show help dialog
|
||||
nodeType.prototype.showHelpDialog = function() {
|
||||
const helpContent = `
|
||||
<div style="padding: 20px; max-width: 600px;">
|
||||
<h2>Gemini Prompt Engineer Setup</h2>
|
||||
|
||||
<h3>1. Get API Key</h3>
|
||||
<p>Get your free API key from: <a href="https://makersuite.google.com/app/apikey" target="_blank">Google AI Studio</a></p>
|
||||
|
||||
<h3>2. Set API Key</h3>
|
||||
<p>Choose one of these methods:</p>
|
||||
<ul>
|
||||
<li><strong>Environment Variable:</strong> Set GEMINI_API_KEY in your system</li>
|
||||
<li><strong>Config File:</strong> Create gemini_config.json in ComfyUI root with {"api_key": "your-key"}</li>
|
||||
<li><strong>Node Input:</strong> Enter directly in the api_key field</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Install Dependencies</h3>
|
||||
<code>pip install google-generativeai</code>
|
||||
|
||||
<h3>Prompt Types</h3>
|
||||
<ul>
|
||||
<li><strong>FLUX:</strong> Detailed artistic prompts with quality markers</li>
|
||||
<li><strong>SDXL:</strong> Positive/negative prompt pairs with weights</li>
|
||||
<li><strong>Danbooru:</strong> Anime-style booru tags</li>
|
||||
<li><strong>Video:</strong> Motion and temporal descriptions</li>
|
||||
</ul>
|
||||
|
||||
<h3>Gemini Models</h3>
|
||||
<ul>
|
||||
<li><strong>gemini-1.5-flash:</strong> Fast and efficient (recommended for most uses)</li>
|
||||
<li><strong>gemini-1.5-flash-8b:</strong> Smaller and faster, good for simple prompts</li>
|
||||
<li><strong>gemini-1.5-pro:</strong> Most capable, best quality results</li>
|
||||
<li><strong>gemini-1.0-pro:</strong> Previous generation, stable option</li>
|
||||
</ul>
|
||||
|
||||
<h3>Custom Prompts</h3>
|
||||
<p>You can override any template by entering your own system prompt in the custom_prompt field.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.ui.dialog.show(helpContent);
|
||||
};
|
||||
|
||||
// Add method to update custom prompt visibility
|
||||
nodeType.prototype.updateCustomPromptVisibility = function() {
|
||||
// You could implement logic here to show/hide custom prompt based on selection
|
||||
// For now, it's always visible but this method provides extensibility
|
||||
};
|
||||
|
||||
// Add method to refresh model list
|
||||
nodeType.prototype.refreshModelList = function() {
|
||||
if (this.status) {
|
||||
this.status.value = "Refreshing models...";
|
||||
}
|
||||
|
||||
// Set the refresh_models flag
|
||||
const refreshWidget = this.widgets.find(w => w.name === "refresh_models");
|
||||
if (refreshWidget) {
|
||||
refreshWidget.value = true;
|
||||
}
|
||||
|
||||
// Show message
|
||||
alert("Model list will refresh on next execution. Make sure API key is set and run the node.");
|
||||
|
||||
if (this.status) {
|
||||
setTimeout(() => {
|
||||
this.status.value = "Ready - Run node to refresh";
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Override execute to show status
|
||||
const onExecute = nodeType.prototype.onExecute;
|
||||
nodeType.prototype.onExecute = function() {
|
||||
if (this.status) {
|
||||
this.status.value = "Processing...";
|
||||
}
|
||||
const result = onExecute?.apply(this, arguments);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Handle execution feedback
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
const result = onExecuted?.apply(this, arguments);
|
||||
|
||||
if (this.status) {
|
||||
// Check if there was an error in the output
|
||||
const outputs = message.output;
|
||||
if (outputs && outputs.prompt && outputs.prompt[0] && outputs.prompt[0].startsWith("Error:")) {
|
||||
this.status.value = "Error - Check output";
|
||||
this.bgcolor = "#552222";
|
||||
} else {
|
||||
this.status.value = "Success!";
|
||||
this.bgcolor = "#225522";
|
||||
}
|
||||
|
||||
// Reset color after delay
|
||||
setTimeout(() => {
|
||||
this.bgcolor = "";
|
||||
if (this.status) {
|
||||
this.status.value = "Ready";
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Add custom styling
|
||||
async setup() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.gemini-prompt-help {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gemini-prompt-help h2 {
|
||||
color: #4285f4;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gemini-prompt-help h3 {
|
||||
color: #8ab4f8;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.gemini-prompt-help code {
|
||||
background: #333;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.gemini-prompt-help a {
|
||||
color: #8ab4f8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gemini-prompt-help a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
// ComfyUI-KikoTools - Seed History with Tracking UI
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyassets.SeedHistory",
|
||||
|
||||
async setup() {
|
||||
// Store reference to all SeedHistory nodes
|
||||
window.seedHistoryNodes = window.seedHistoryNodes || [];
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "SeedHistory") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Initialize seed history
|
||||
this.seedHistory = this.loadSeedHistory();
|
||||
this.hideTimer = null;
|
||||
this.mouseOverHistory = false;
|
||||
|
||||
// Register this node in global registry
|
||||
window.seedHistoryNodes = window.seedHistoryNodes || [];
|
||||
window.seedHistoryNodes.push(this);
|
||||
|
||||
// Create UI container
|
||||
const uiContainer = document.createElement("div");
|
||||
uiContainer.style.padding = "8px";
|
||||
uiContainer.style.backgroundColor = "#1e1e1e";
|
||||
uiContainer.style.borderRadius = "6px";
|
||||
uiContainer.style.marginTop = "6px";
|
||||
uiContainer.style.border = "1px solid #404040";
|
||||
|
||||
this.buildSeedInterface(uiContainer);
|
||||
|
||||
// Add as widget
|
||||
this.seedWidget = this.addDOMWidget(
|
||||
"seed_history_ui",
|
||||
"div",
|
||||
uiContainer,
|
||||
);
|
||||
|
||||
// Set node size
|
||||
if (!this.hasBeenResized) {
|
||||
this.size = [280, 320];
|
||||
}
|
||||
|
||||
// Track manual resizing
|
||||
const originalResize = this.onResize;
|
||||
this.onResize = function (size) {
|
||||
this.hasBeenResized = true;
|
||||
if (originalResize) {
|
||||
originalResize.call(this, size);
|
||||
}
|
||||
};
|
||||
|
||||
// Hook into seed widget callbacks to track all changes
|
||||
setTimeout(() => {
|
||||
this.setupSeedWidgetCallbacks();
|
||||
}, 100);
|
||||
|
||||
// Hook directly into widget value changes
|
||||
const originalOnWidgetChange = this.onWidgetChange;
|
||||
this.onWidgetChange = function(name, value, oldValue, widget) {
|
||||
if (name === "seed" && value !== oldValue) {
|
||||
this.addSeedToHistory(value);
|
||||
}
|
||||
|
||||
if (originalOnWidgetChange) {
|
||||
return originalOnWidgetChange.call(this, name, value, oldValue, widget);
|
||||
}
|
||||
};
|
||||
|
||||
// Save/load data
|
||||
const originalSerialize = this.serialize;
|
||||
this.serialize = function () {
|
||||
const data = originalSerialize ? originalSerialize.call(this) : {};
|
||||
data.hasBeenResized = this.hasBeenResized;
|
||||
data.seedHistory = this.seedHistory;
|
||||
return data;
|
||||
};
|
||||
|
||||
const originalConfigure = this.configure;
|
||||
this.configure = function (data) {
|
||||
if (originalConfigure) {
|
||||
originalConfigure.call(this, data);
|
||||
}
|
||||
if (data.hasBeenResized) {
|
||||
this.hasBeenResized = data.hasBeenResized;
|
||||
}
|
||||
if (data.seedHistory) {
|
||||
this.seedHistory = data.seedHistory;
|
||||
this.refreshHistoryDisplay();
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup interval on node removal
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function () {
|
||||
if (this.seedValueWatcher) {
|
||||
clearInterval(this.seedValueWatcher);
|
||||
this.seedValueWatcher = null;
|
||||
}
|
||||
|
||||
// Clean up deduplication tracking
|
||||
if (this.lastAddedSeed) {
|
||||
this.lastAddedSeed = null;
|
||||
}
|
||||
|
||||
// Remove from global registry
|
||||
if (window.seedHistoryNodes) {
|
||||
const index = window.seedHistoryNodes.indexOf(this);
|
||||
if (index !== -1) {
|
||||
window.seedHistoryNodes.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalOnRemoved) {
|
||||
originalOnRemoved.call(this);
|
||||
}
|
||||
};
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// Setup seed widget callbacks to track increment/decrement/randomize
|
||||
nodeType.prototype.setupSeedWidgetCallbacks = function () {
|
||||
// Find the seed widget
|
||||
const seedWidget = this.widgets?.find(w => w.name === "seed");
|
||||
if (!seedWidget) {
|
||||
setTimeout(() => this.setupSeedWidgetCallbacks(), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the last known seed value to detect changes
|
||||
this.lastSeedValue = seedWidget.value;
|
||||
|
||||
// Monitor for value changes that might not trigger callback
|
||||
this.seedValueWatcher = setInterval(() => {
|
||||
if (seedWidget.value !== this.lastSeedValue) {
|
||||
this.lastSeedValue = seedWidget.value;
|
||||
this.addSeedToHistory(seedWidget.value);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Build the seed interface
|
||||
nodeType.prototype.buildSeedInterface = function (container) {
|
||||
container.innerHTML = "";
|
||||
|
||||
// Header
|
||||
const headerDiv = document.createElement("div");
|
||||
headerDiv.style.marginBottom = "8px";
|
||||
|
||||
const titleDiv = document.createElement("div");
|
||||
titleDiv.style.fontWeight = "bold";
|
||||
titleDiv.style.color = "#00d4ff";
|
||||
titleDiv.style.textAlign = "center";
|
||||
titleDiv.style.padding = "4px";
|
||||
titleDiv.style.backgroundColor = "rgba(0, 212, 255, 0.1)";
|
||||
titleDiv.style.borderRadius = "4px";
|
||||
titleDiv.style.fontSize = "11px";
|
||||
titleDiv.innerHTML = "🎲 Seed History";
|
||||
headerDiv.appendChild(titleDiv);
|
||||
|
||||
// Action buttons
|
||||
const buttonDiv = document.createElement("div");
|
||||
buttonDiv.style.display = "flex";
|
||||
buttonDiv.style.gap = "4px";
|
||||
buttonDiv.style.marginTop = "6px";
|
||||
|
||||
// Generate button
|
||||
const generateBtn = document.createElement("button");
|
||||
generateBtn.textContent = "🎲 Generate";
|
||||
generateBtn.style.flex = "1";
|
||||
generateBtn.style.padding = "3px 6px";
|
||||
generateBtn.style.backgroundColor = "#0088cc";
|
||||
generateBtn.style.color = "white";
|
||||
generateBtn.style.border = "none";
|
||||
generateBtn.style.borderRadius = "3px";
|
||||
generateBtn.style.cursor = "pointer";
|
||||
generateBtn.style.fontSize = "10px";
|
||||
generateBtn.addEventListener("click", () => this.generateRandomSeed());
|
||||
buttonDiv.appendChild(generateBtn);
|
||||
|
||||
// Clear button
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.textContent = "🗑️ Clear";
|
||||
clearBtn.style.flex = "1";
|
||||
clearBtn.style.padding = "3px 6px";
|
||||
clearBtn.style.backgroundColor = "#cc4444";
|
||||
clearBtn.style.color = "white";
|
||||
clearBtn.style.border = "none";
|
||||
clearBtn.style.borderRadius = "3px";
|
||||
clearBtn.style.cursor = "pointer";
|
||||
clearBtn.style.fontSize = "10px";
|
||||
clearBtn.addEventListener("click", () => this.clearSeedHistory());
|
||||
buttonDiv.appendChild(clearBtn);
|
||||
|
||||
headerDiv.appendChild(buttonDiv);
|
||||
container.appendChild(headerDiv);
|
||||
|
||||
// History display
|
||||
const historyDiv = document.createElement("div");
|
||||
historyDiv.style.maxHeight = "180px";
|
||||
historyDiv.style.overflowY = "auto";
|
||||
historyDiv.style.border = "1px solid #333";
|
||||
historyDiv.style.borderRadius = "4px";
|
||||
historyDiv.style.backgroundColor = "#2a2a2a";
|
||||
historyDiv.style.padding = "6px";
|
||||
historyDiv.style.fontSize = "10px";
|
||||
historyDiv.style.fontFamily = "monospace";
|
||||
|
||||
// Mouse events for auto-hide
|
||||
historyDiv.addEventListener("mouseenter", () => {
|
||||
this.mouseOverHistory = true;
|
||||
this.cancelAutoHide();
|
||||
});
|
||||
|
||||
historyDiv.addEventListener("mouseleave", () => {
|
||||
this.mouseOverHistory = false;
|
||||
this.startAutoHide();
|
||||
});
|
||||
|
||||
this.historyDisplay = historyDiv;
|
||||
container.appendChild(historyDiv);
|
||||
|
||||
this.refreshHistoryDisplay();
|
||||
};
|
||||
|
||||
// Load history from storage
|
||||
nodeType.prototype.loadSeedHistory = function () {
|
||||
try {
|
||||
const stored = localStorage.getItem('comfyui_kikotools_seed_history');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Save history to storage
|
||||
nodeType.prototype.saveSeedHistory = function () {
|
||||
try {
|
||||
localStorage.setItem('comfyui_kikotools_seed_history', JSON.stringify(this.seedHistory));
|
||||
} catch (error) {
|
||||
// Silently handle storage errors
|
||||
}
|
||||
};
|
||||
|
||||
// Add seed to history
|
||||
nodeType.prototype.addSeedToHistory = function (seed) {
|
||||
if (!seed || seed === 0) return;
|
||||
|
||||
const numSeed = typeof seed === 'string' ? parseInt(seed) : seed;
|
||||
const now = Date.now();
|
||||
|
||||
// Deduplication: prevent adding the same seed within 500ms window
|
||||
if (!this.lastAddedSeed) {
|
||||
this.lastAddedSeed = { seed: null, timestamp: 0 };
|
||||
}
|
||||
|
||||
const timeSinceLastAdd = now - this.lastAddedSeed.timestamp;
|
||||
const isSameSeed = this.lastAddedSeed.seed === numSeed;
|
||||
const isWithinDupeWindow = timeSinceLastAdd < 500; // 500ms window
|
||||
|
||||
if (isSameSeed && isWithinDupeWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update deduplication tracking
|
||||
this.lastAddedSeed = { seed: numSeed, timestamp: now };
|
||||
|
||||
// Remove if already exists in history
|
||||
this.seedHistory = this.seedHistory.filter(item => item.seed !== numSeed);
|
||||
|
||||
// Add to front
|
||||
this.seedHistory.unshift({
|
||||
seed: numSeed,
|
||||
timestamp: now,
|
||||
dateString: new Date().toLocaleString()
|
||||
});
|
||||
|
||||
// Keep only last 10
|
||||
if (this.seedHistory.length > 10) {
|
||||
this.seedHistory = this.seedHistory.slice(0, 10);
|
||||
}
|
||||
|
||||
this.saveSeedHistory();
|
||||
this.refreshHistoryDisplay();
|
||||
this.startAutoHide();
|
||||
};
|
||||
|
||||
// Generate new random seed
|
||||
nodeType.prototype.generateRandomSeed = function () {
|
||||
const newSeed = Math.floor(Math.random() * 0xFFFFFFFFFFFFFFFF);
|
||||
|
||||
const seedWidget = this.widgets?.find(w => w.name === "seed");
|
||||
if (seedWidget) {
|
||||
seedWidget.value = newSeed;
|
||||
if (seedWidget.callback) {
|
||||
seedWidget.callback(newSeed, this, seedWidget);
|
||||
}
|
||||
}
|
||||
|
||||
this.addSeedToHistory(newSeed);
|
||||
this.setDirtyCanvas(true, true);
|
||||
this.showMessage(`Generated: ${newSeed}`, "success");
|
||||
};
|
||||
|
||||
// Use seed from history
|
||||
nodeType.prototype.useSeedFromHistory = function (historyItem, index) {
|
||||
const seedWidget = this.widgets?.find(w => w.name === "seed");
|
||||
if (seedWidget) {
|
||||
seedWidget.value = historyItem.seed;
|
||||
if (seedWidget.callback) {
|
||||
seedWidget.callback(historyItem.seed, this, seedWidget);
|
||||
}
|
||||
}
|
||||
|
||||
this.highlightHistoryEntry(index);
|
||||
this.setDirtyCanvas(true, true);
|
||||
this.startAutoHide();
|
||||
this.showMessage(`Loaded: ${historyItem.seed}`, "info");
|
||||
};
|
||||
|
||||
// Clear history
|
||||
nodeType.prototype.clearSeedHistory = function () {
|
||||
this.seedHistory = [];
|
||||
this.saveSeedHistory();
|
||||
this.refreshHistoryDisplay();
|
||||
this.showMessage("History cleared", "info");
|
||||
};
|
||||
|
||||
// Refresh history display
|
||||
nodeType.prototype.refreshHistoryDisplay = function () {
|
||||
if (!this.historyDisplay) return;
|
||||
|
||||
if (!this.seedHistory || this.seedHistory.length === 0) {
|
||||
this.historyDisplay.innerHTML =
|
||||
'<div style="color: #888; text-align: center; padding: 15px;">No seeds tracked<br><small>Generate seeds to build history</small></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyDisplay.innerHTML = "";
|
||||
|
||||
this.seedHistory.forEach((item, index) => {
|
||||
const entryDiv = document.createElement("div");
|
||||
entryDiv.style.padding = "4px";
|
||||
entryDiv.style.marginBottom = "3px";
|
||||
entryDiv.style.backgroundColor = "#333";
|
||||
entryDiv.style.borderRadius = "2px";
|
||||
entryDiv.style.cursor = "pointer";
|
||||
entryDiv.style.border = "1px solid transparent";
|
||||
entryDiv.style.lineHeight = "1.2";
|
||||
|
||||
entryDiv.addEventListener("mouseenter", () => {
|
||||
entryDiv.style.backgroundColor = "#444";
|
||||
entryDiv.style.border = "1px solid #555";
|
||||
});
|
||||
entryDiv.addEventListener("mouseleave", () => {
|
||||
entryDiv.style.backgroundColor = "#333";
|
||||
entryDiv.style.border = "1px solid transparent";
|
||||
});
|
||||
|
||||
entryDiv.addEventListener("click", () => {
|
||||
this.useSeedFromHistory(item, index);
|
||||
});
|
||||
|
||||
const timeAgo = this.formatTimeAgo(item.timestamp);
|
||||
entryDiv.innerHTML = `
|
||||
<div style="color: #fff; font-weight: bold; margin-bottom: 1px;">
|
||||
🎲 ${item.seed}
|
||||
</div>
|
||||
<div style="color: #999; font-size: 8px;">
|
||||
⏰ ${timeAgo}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.historyDisplay.appendChild(entryDiv);
|
||||
});
|
||||
|
||||
this.startAutoHide();
|
||||
};
|
||||
|
||||
// Highlight selected entry
|
||||
nodeType.prototype.highlightHistoryEntry = function (index) {
|
||||
const entries = this.historyDisplay.querySelectorAll('div[style*="cursor: pointer"]');
|
||||
entries.forEach((entry, i) => {
|
||||
if (i === index) {
|
||||
entry.style.backgroundColor = "#006600";
|
||||
entry.style.border = "1px solid #00aa00";
|
||||
} else {
|
||||
entry.style.backgroundColor = "#333";
|
||||
entry.style.border = "1px solid transparent";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-hide functionality
|
||||
nodeType.prototype.startAutoHide = function () {
|
||||
this.cancelAutoHide();
|
||||
if (!this.mouseOverHistory) {
|
||||
this.hideTimer = setTimeout(() => {
|
||||
this.hideHistorySection();
|
||||
}, 2500);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.cancelAutoHide = function () {
|
||||
if (this.hideTimer) {
|
||||
clearTimeout(this.hideTimer);
|
||||
this.hideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.hideHistorySection = function () {
|
||||
if (this.historyDisplay && !this.mouseOverHistory) {
|
||||
this.historyDisplay.style.display = "none";
|
||||
|
||||
if (!this.restoreButton) {
|
||||
const restoreDiv = document.createElement("div");
|
||||
restoreDiv.style.padding = "10px";
|
||||
restoreDiv.style.backgroundColor = "#2a2a2a";
|
||||
restoreDiv.style.border = "1px solid #333";
|
||||
restoreDiv.style.borderRadius = "4px";
|
||||
restoreDiv.style.textAlign = "center";
|
||||
restoreDiv.style.cursor = "pointer";
|
||||
restoreDiv.style.color = "#888";
|
||||
restoreDiv.style.fontSize = "10px";
|
||||
restoreDiv.innerHTML = "🎲 History auto-hidden<br><small>Click to show</small>";
|
||||
|
||||
restoreDiv.addEventListener("mouseenter", () => {
|
||||
restoreDiv.style.backgroundColor = "#333";
|
||||
restoreDiv.style.color = "#bbb";
|
||||
});
|
||||
restoreDiv.addEventListener("mouseleave", () => {
|
||||
restoreDiv.style.backgroundColor = "#2a2a2a";
|
||||
restoreDiv.style.color = "#888";
|
||||
});
|
||||
|
||||
restoreDiv.addEventListener("click", () => {
|
||||
this.showHistorySection();
|
||||
});
|
||||
|
||||
this.restoreButton = restoreDiv;
|
||||
this.historyDisplay.parentNode.insertBefore(
|
||||
restoreDiv,
|
||||
this.historyDisplay.nextSibling
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.showHistorySection = function () {
|
||||
if (this.historyDisplay) {
|
||||
this.historyDisplay.style.display = "block";
|
||||
|
||||
if (this.restoreButton && this.restoreButton.parentNode) {
|
||||
this.restoreButton.parentNode.removeChild(this.restoreButton);
|
||||
this.restoreButton = null;
|
||||
}
|
||||
|
||||
this.startAutoHide();
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
nodeType.prototype.formatTimeAgo = function (timestamp) {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return `${seconds}s ago`;
|
||||
};
|
||||
|
||||
// Show messages
|
||||
nodeType.prototype.showMessage = function (message, type = "info") {
|
||||
const notification = document.createElement("div");
|
||||
notification.style.position = "fixed";
|
||||
notification.style.top = "15px";
|
||||
notification.style.right = "15px";
|
||||
notification.style.padding = "6px 10px";
|
||||
notification.style.borderRadius = "3px";
|
||||
notification.style.color = "white";
|
||||
notification.style.fontSize = "10px";
|
||||
notification.style.zIndex = "10000";
|
||||
notification.style.maxWidth = "200px";
|
||||
notification.textContent = message;
|
||||
|
||||
switch (type) {
|
||||
case "success":
|
||||
notification.style.backgroundColor = "#28a745";
|
||||
break;
|
||||
case "error":
|
||||
notification.style.backgroundColor = "#dc3545";
|
||||
break;
|
||||
case "warning":
|
||||
notification.style.backgroundColor = "#ffc107";
|
||||
break;
|
||||
default:
|
||||
notification.style.backgroundColor = "#17a2b8";
|
||||
}
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
document.body.removeChild(notification);
|
||||
}
|
||||
}, 1800);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||