Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,31 @@
|
||||
[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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -59,7 +59,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,60 +67,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')
|
||||
"
|
||||
|
||||
@@ -147,7 +151,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"
|
||||
@@ -176,82 +180,103 @@ jobs:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
|
||||
print('Checking architecture compliance...')
|
||||
|
||||
|
||||
# Test separation of concerns
|
||||
from kikotools.tools.resolution_calculator import logic, node
|
||||
|
||||
|
||||
# Logic module should not import node-specific things
|
||||
import inspect
|
||||
logic_source = inspect.getsource(logic)
|
||||
|
||||
|
||||
if 'ComfyUI' in logic_source and 'INPUT_TYPES' not in logic_source:
|
||||
print('⚠️ Warning: Logic module contains ComfyUI-specific code')
|
||||
else:
|
||||
print('✓ Logic module properly separated')
|
||||
|
||||
|
||||
# Node module should inherit from base
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
if issubclass(ResolutionCalculatorNode, ComfyAssetsBaseNode):
|
||||
print('✓ Node properly inherits from base class')
|
||||
else:
|
||||
print('❌ Node does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Check that nodes have proper ComfyUI interface
|
||||
required_attrs = ['INPUT_TYPES', 'RETURN_TYPES', 'RETURN_NAMES', 'FUNCTION', 'CATEGORY']
|
||||
|
||||
|
||||
# Test Resolution Calculator Node
|
||||
for attr in required_attrs:
|
||||
if not hasattr(ResolutionCalculatorNode, attr):
|
||||
print(f'❌ ResolutionCalculatorNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Width Height Selector Node
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
|
||||
|
||||
if issubclass(WidthHeightSelectorNode, ComfyAssetsBaseNode):
|
||||
print('✓ WidthHeightSelectorNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ WidthHeightSelectorNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(WidthHeightSelectorNode, attr):
|
||||
print(f'❌ WidthHeightSelectorNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Sampler Combo Node
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
|
||||
|
||||
if issubclass(SamplerComboNode, ComfyAssetsBaseNode):
|
||||
print('✓ SamplerComboNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ SamplerComboNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(SamplerComboNode, attr):
|
||||
print(f'❌ SamplerComboNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Seed History Node
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
|
||||
|
||||
if issubclass(SeedHistoryNode, ComfyAssetsBaseNode):
|
||||
print('✓ SeedHistoryNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ SeedHistoryNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(SeedHistoryNode, attr):
|
||||
print(f'❌ SeedHistoryNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Test Kiko Save Image Node
|
||||
from kikotools.tools.kiko_save_image.node import KikoSaveImageNode
|
||||
|
||||
if issubclass(KikoSaveImageNode, ComfyAssetsBaseNode):
|
||||
print('✓ KikoSaveImageNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ KikoSaveImageNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
# KikoSaveImage is an output node, so it doesn't have RETURN_TYPES/RETURN_NAMES
|
||||
save_required_attrs = ['INPUT_TYPES', 'FUNCTION', 'CATEGORY']
|
||||
for attr in save_required_attrs:
|
||||
if not hasattr(KikoSaveImageNode, attr):
|
||||
print(f'❌ KikoSaveImageNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
# Check that it's properly marked as an output node
|
||||
if not hasattr(KikoSaveImageNode, 'OUTPUT_NODE') or not KikoSaveImageNode.OUTPUT_NODE:
|
||||
print('❌ KikoSaveImageNode missing OUTPUT_NODE = True')
|
||||
sys.exit(1)
|
||||
|
||||
print('✓ All architecture checks passed for all tools')
|
||||
"
|
||||
|
||||
@@ -259,22 +284,22 @@ jobs:
|
||||
run: |
|
||||
python -c "
|
||||
import os
|
||||
|
||||
|
||||
# Count test files vs implementation files
|
||||
test_files = 0
|
||||
impl_files = 0
|
||||
|
||||
|
||||
for root, dirs, files in os.walk('tests'):
|
||||
test_files += len([f for f in files if f.startswith('test_') and f.endswith('.py')])
|
||||
|
||||
|
||||
for root, dirs, files in os.walk('kikotools'):
|
||||
impl_files += len([f for f in files if f.endswith('.py') and not f.startswith('__')])
|
||||
|
||||
|
||||
print(f'Implementation files: {impl_files}')
|
||||
print(f'Test files: {test_files}')
|
||||
|
||||
|
||||
if test_files >= impl_files * 0.5: # At least 50% test coverage by file count
|
||||
print('✓ Adequate test file coverage')
|
||||
else:
|
||||
print('⚠️ Warning: Low test file coverage')
|
||||
"
|
||||
"
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -28,30 +28,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 +64,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,24 +87,24 @@ 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
|
||||
|
||||
@@ -128,18 +128,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"
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
print('✓ Sampler Combo interface tests passed')
|
||||
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == (SAMPLERS, SCHEDULERS, 'INT', 'FLOAT')
|
||||
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')
|
||||
@@ -424,24 +424,24 @@ jobs:
|
||||
# Check key files
|
||||
test -f kikotools/__init__.py || (echo "kikotools/__init__.py missing" && exit 1)
|
||||
test -f kikotools/base/base_node.py || (echo "base_node.py missing" && exit 1)
|
||||
|
||||
|
||||
# Resolution Calculator files
|
||||
test -f kikotools/tools/resolution_calculator/node.py || (echo "resolution_calculator node.py missing" && exit 1)
|
||||
test -f kikotools/tools/resolution_calculator/logic.py || (echo "resolution_calculator logic.py missing" && exit 1)
|
||||
|
||||
|
||||
# Width Height Selector files
|
||||
test -f kikotools/tools/width_height_selector/node.py || (echo "width_height_selector node.py missing" && exit 1)
|
||||
test -f kikotools/tools/width_height_selector/logic.py || (echo "width_height_selector logic.py missing" && exit 1)
|
||||
test -f kikotools/tools/width_height_selector/presets.py || (echo "width_height_selector presets.py missing" && exit 1)
|
||||
|
||||
|
||||
# Sampler Combo files
|
||||
test -f kikotools/tools/sampler_combo/node.py || (echo "sampler_combo node.py missing" && exit 1)
|
||||
test -f kikotools/tools/sampler_combo/logic.py || (echo "sampler_combo logic.py missing" && exit 1)
|
||||
|
||||
|
||||
# Seed History files
|
||||
test -f kikotools/tools/seed_history/node.py || (echo "seed_history node.py missing" && exit 1)
|
||||
test -f kikotools/tools/seed_history/logic.py || (echo "seed_history logic.py missing" && exit 1)
|
||||
|
||||
|
||||
# Web files
|
||||
test -f web/width_height_swap.js || (echo "width_height_swap.js missing" && exit 1)
|
||||
test -f web/seed_history_ui.js || (echo "seed_history_ui.js missing" && exit 1)
|
||||
|
||||
+1
-1
@@ -158,4 +158,4 @@ input/
|
||||
test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.claude/
|
||||
|
||||
@@ -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=127'] # Match CI configuration
|
||||
|
||||
# Python linting with flake8
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.3.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ['--max-line-length=127', '--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
|
||||
@@ -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!"
|
||||
|
||||
@@ -25,7 +25,7 @@ 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
|
||||
|
||||
@@ -76,6 +76,70 @@ Unified sampling configuration interface combining sampler, scheduler, steps, an
|
||||
- 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
|
||||
|
||||
#### 🤖 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
|
||||
|
||||
**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
|
||||
|
||||
### 💾 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
|
||||
@@ -113,8 +177,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
|
||||
@@ -125,8 +189,8 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|
||||
[swap button]
|
||||
```
|
||||
|
||||
**Preset:** FLUX HD (1920×1080)
|
||||
**Output:** 1920×1080 (16:9 cinematic)
|
||||
**Preset:** FLUX HD (1920×1080)
|
||||
**Output:** 1920×1080 (16:9 cinematic)
|
||||
**Swap Button:** Click to get 1080×1920 (9:16 portrait)
|
||||
|
||||
### Seed History Example
|
||||
@@ -137,8 +201,8 @@ Seed History → KSampler → VAE Decode → Save Image
|
||||
[History UI: 54321, 99999, 11111...]
|
||||
```
|
||||
|
||||
**Current Seed:** 12345
|
||||
**History:** Auto-tracked previous seeds with timestamps
|
||||
**Current Seed:** 12345
|
||||
**History:** Auto-tracked previous seeds with timestamps
|
||||
**Interaction:** Click any historical seed to reload instantly
|
||||
|
||||
### Sampler Combo Example
|
||||
@@ -148,10 +212,52 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
⚙️ All Settings ↘ sampler/scheduler/steps/cfg ↗
|
||||
```
|
||||
|
||||
**Configuration:** euler, normal, 20 steps, CFG 7.0
|
||||
**Output:** Complete sampling configuration in one node
|
||||
**Configuration:** euler, normal, 20 steps, CFG 7.0
|
||||
**Output:** Complete sampling configuration in one node
|
||||
**Smart Features:** Recommendations and compatibility validation
|
||||
|
||||
### Empty Latent Batch Example
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
### Gemini Prompt Engineer Example
|
||||
```
|
||||
Load Image → Gemini Prompt → Text Generation Model
|
||||
🖼️ reference ↘ type: FLUX ↘ "majestic landscape..."
|
||||
[API key] → FLUX model
|
||||
```
|
||||
|
||||
**Input:** Reference image for style analysis
|
||||
**Prompt Type:** FLUX (detailed artistic prompts)
|
||||
**Output:** Optimized prompt with style, lighting, composition details
|
||||
**API:** Requires Gemini API key (free tier available)
|
||||
**Use Case:** Recreate similar style/mood from reference images
|
||||
|
||||
### Common Workflows
|
||||
|
||||
<details>
|
||||
@@ -160,7 +266,7 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
```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
|
||||
@@ -175,7 +281,7 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -192,6 +298,8 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
| **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) |
|
||||
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
|
||||
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
|
||||
|
||||
@@ -201,7 +309,7 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
|
||||
**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:**
|
||||
@@ -223,7 +331,7 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
|
||||
**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
|
||||
@@ -267,7 +375,7 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
|
||||
**Outputs:**
|
||||
- `sampler_name` (STRING): Selected sampler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `steps` (INT): Validated step count
|
||||
- `cfg` (FLOAT): Validated CFG scale
|
||||
|
||||
@@ -278,6 +386,71 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
- 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
|
||||
@@ -300,6 +473,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
|
||||
@@ -316,13 +492,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
|
||||
@@ -345,7 +540,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
|
||||
```
|
||||
|
||||
@@ -398,13 +593,14 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 4 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo)
|
||||
- **Nodes**: 6 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image)
|
||||
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
|
||||
- **Presets**: 26 curated resolution presets
|
||||
- **Interactive Features**: 2 (Swap Button, History UI)
|
||||
- **Test Coverage**: 100% (180+ comprehensive tests)
|
||||
- **Interactive Features**: 4 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer)
|
||||
- **Test Coverage**: 100% (200+ comprehensive tests)
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy)
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow)
|
||||
|
||||
---
|
||||
|
||||
@@ -414,4 +610,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>
|
||||
|
||||
+27
-3
@@ -3,15 +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"
|
||||
|
||||
|
||||
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("\033[94m[ComfyUI-KikoTools]\033[0m")
|
||||
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[94m[Loaded:\033[0m {display_name}")
|
||||
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"]
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
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,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,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
|
||||
@@ -98,4 +98,4 @@ The Resolution Calculator integrates seamlessly with:
|
||||
- Standard ComfyUI image loaders
|
||||
- VAE encode/decode operations
|
||||
- Upscaler nodes (ESRGAN, Real-ESRGAN, etc.)
|
||||
- Custom latent processing workflows
|
||||
- Custom latent processing workflows
|
||||
|
||||
@@ -40,7 +40,7 @@ The Sampler Combo is a unified ComfyUI node that combines sampler, scheduler, st
|
||||
|
||||
### Outputs
|
||||
- **sampler_name**: Selected sampler algorithm
|
||||
- **scheduler**: Selected scheduler algorithm
|
||||
- **scheduler**: Selected scheduler algorithm
|
||||
- **steps**: Number of sampling steps
|
||||
- **cfg**: CFG scale value
|
||||
|
||||
@@ -75,7 +75,7 @@ The Sampler Combo is a unified ComfyUI node that combines sampler, scheduler, st
|
||||
- **linear**: Basic linear distribution
|
||||
- **sgm_uniform**: Uniform distribution
|
||||
|
||||
### Advanced Schedulers
|
||||
### Advanced Schedulers
|
||||
- **karras**: Karras noise schedule (recommended)
|
||||
- **exponential**: Exponential decay
|
||||
- **polyexponential**: Polynomial exponential
|
||||
@@ -99,7 +99,7 @@ Steps: 15-25
|
||||
CFG: 6.0-8.0
|
||||
```
|
||||
|
||||
#### Quality Optimized
|
||||
#### Quality Optimized
|
||||
```
|
||||
Sampler: dpmpp_2m_sde or dpmpp_3m_sde
|
||||
Scheduler: karras
|
||||
@@ -138,7 +138,7 @@ CFG: 7.0-8.5
|
||||
### Basic Configuration
|
||||
```
|
||||
sampler_name: euler
|
||||
scheduler: normal
|
||||
scheduler: normal
|
||||
steps: 20
|
||||
cfg: 7.0
|
||||
```
|
||||
@@ -164,7 +164,7 @@ cfg: 6.5
|
||||
### Compatibility Analysis
|
||||
The node provides real-time analysis of parameter compatibility:
|
||||
- Scheduler compatibility with selected sampler
|
||||
- Steps optimization for sampler type
|
||||
- Steps optimization for sampler type
|
||||
- CFG scale recommendations
|
||||
- Performance impact assessment
|
||||
|
||||
@@ -200,9 +200,9 @@ The node provides real-time analysis of parameter compatibility:
|
||||
|
||||
The Sampler Combo node outputs are compatible with all standard ComfyUI sampling nodes:
|
||||
- KSampler
|
||||
- KSamplerAdvanced
|
||||
- KSamplerAdvanced
|
||||
- Custom sampling workflows
|
||||
- Upscaling pipelines
|
||||
- Img2img workflows
|
||||
|
||||
Connect the outputs directly to your sampling node inputs for streamlined configuration.
|
||||
Connect the outputs directly to your sampling node inputs for streamlined configuration.
|
||||
|
||||
@@ -164,4 +164,4 @@ See the `examples/workflows/` directory for complete workflow examples demonstra
|
||||
- Basic seed tracking workflow
|
||||
- Creative iteration with history
|
||||
- Technical reproducibility setup
|
||||
- Batch processing with seed management
|
||||
- Batch processing with seed management
|
||||
|
||||
@@ -147,7 +147,7 @@ Width Height Selector → EmptyLatentImage → Resolution Calculator → Upscale
|
||||
|
||||
### Aspect Ratio Considerations
|
||||
- **Portrait**: 3:4, 2:3, 13:19 work well for people
|
||||
- **Landscape**: 16:9, 19:13, 7:4 for scenes and objects
|
||||
- **Landscape**: 16:9, 19:13, 7:4 for scenes and objects
|
||||
- **Square**: 1:1 for centered compositions
|
||||
- **Ultra-wide**: 21:9+ for panoramic and cinematic shots
|
||||
|
||||
@@ -192,4 +192,4 @@ Width Height Selector → EmptyLatentImage → Resolution Calculator → Upscale
|
||||
### Preset Organization
|
||||
- Categorized by model optimization
|
||||
- Sorted by aspect ratio within categories
|
||||
- Comprehensive tooltips for each preset
|
||||
- Comprehensive tooltips for each preset
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [100, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"shape": 3,
|
||||
"label": "IMAGE"
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageToMultipleOf",
|
||||
"pos": [500, 200],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [2, 3],
|
||||
"shape": 3,
|
||||
"label": "image"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageToMultipleOf"
|
||||
},
|
||||
"widgets_values": [64, "center crop"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 100],
|
||||
"size": [210, 246],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "VAEEncode",
|
||||
"pos": [900, 400],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"],
|
||||
[3, 2, 0, 4, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -256,4 +256,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,4 +534,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,4 +641,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,4 +719,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ 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
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -15,6 +18,9 @@ NODE_CLASS_MAPPINGS = {
|
||||
"SeedHistory": SeedHistoryNode,
|
||||
"SamplerCombo": SamplerComboNode,
|
||||
"SamplerComboCompact": SamplerComboCompactNode,
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -23,6 +29,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SeedHistory": "Seed History",
|
||||
"SamplerCombo": "Sampler Combo",
|
||||
"SamplerComboCompact": "Sampler Combo (Compact)",
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -35,9 +35,7 @@ class ComfyAssetsBaseNode:
|
||||
"""
|
||||
pass
|
||||
|
||||
def handle_error(
|
||||
self, error_msg: str, exception: Optional[Exception] = None
|
||||
) -> None:
|
||||
def handle_error(self, error_msg: str, exception: Optional[Exception] = None) -> None:
|
||||
"""
|
||||
Standardized error handling with logging
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Empty Latent Batch tool for ComfyUI."""
|
||||
|
||||
from .node import EmptyLatentBatchNode
|
||||
|
||||
__all__ = ["EmptyLatentBatchNode"]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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,292 @@
|
||||
"""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,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,344 @@
|
||||
"""
|
||||
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,220 @@
|
||||
"""
|
||||
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",
|
||||
}
|
||||
@@ -28,9 +28,7 @@ def extract_dimensions(
|
||||
if image is not None:
|
||||
# IMAGE tensor format: [batch, height, width, channels]
|
||||
if len(image.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected IMAGE tensor with 4 dimensions, got {len(image.shape)}"
|
||||
)
|
||||
raise ValueError(f"Expected IMAGE tensor with 4 dimensions, got {len(image.shape)}")
|
||||
|
||||
_, height, width, _ = image.shape
|
||||
return int(width), int(height)
|
||||
@@ -42,10 +40,7 @@ def extract_dimensions(
|
||||
|
||||
samples = latent["samples"]
|
||||
if len(samples.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected LATENT samples tensor with 4 dimensions, "
|
||||
f"got {len(samples.shape)}"
|
||||
)
|
||||
raise ValueError(f"Expected LATENT samples tensor with 4 dimensions, " f"got {len(samples.shape)}")
|
||||
|
||||
_, _, latent_height, latent_width = samples.shape
|
||||
|
||||
@@ -79,9 +74,7 @@ def ensure_divisible_by_8(width: int, height: int) -> Tuple[int, int]:
|
||||
return int(new_width), int(new_height)
|
||||
|
||||
|
||||
def calculate_scaled_dimensions(
|
||||
width: int, height: int, scale_factor: float
|
||||
) -> Tuple[int, int]:
|
||||
def calculate_scaled_dimensions(width: int, height: int, scale_factor: float) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate new dimensions with scale factor and ensure divisible by 8
|
||||
|
||||
@@ -104,9 +97,7 @@ def calculate_scaled_dimensions(
|
||||
return ensure_divisible_by_8(new_width, new_height)
|
||||
|
||||
|
||||
def validate_scale_factor(
|
||||
scale_factor: float, min_scale: float = 0.1, max_scale: float = 8.0
|
||||
) -> None:
|
||||
def validate_scale_factor(scale_factor: float, min_scale: float = 0.1, max_scale: float = 8.0) -> None:
|
||||
"""
|
||||
Validate scale factor is within reasonable bounds
|
||||
|
||||
@@ -119,19 +110,13 @@ def validate_scale_factor(
|
||||
ValueError: If scale factor is out of bounds
|
||||
"""
|
||||
if not isinstance(scale_factor, (int, float)):
|
||||
raise ValueError(
|
||||
f"Scale factor must be a number, got {type(scale_factor).__name__}"
|
||||
)
|
||||
raise ValueError(f"Scale factor must be a number, got {type(scale_factor).__name__}")
|
||||
|
||||
if scale_factor < min_scale:
|
||||
raise ValueError(
|
||||
f"Scale factor {scale_factor} is too small (minimum: {min_scale})"
|
||||
)
|
||||
raise ValueError(f"Scale factor {scale_factor} is too small (minimum: {min_scale})")
|
||||
|
||||
if scale_factor > max_scale:
|
||||
raise ValueError(
|
||||
f"Scale factor {scale_factor} is too large (maximum: {max_scale})"
|
||||
)
|
||||
raise ValueError(f"Scale factor {scale_factor} is too large (maximum: {max_scale})")
|
||||
|
||||
|
||||
def calculate_resolution_from_input(
|
||||
@@ -161,8 +146,6 @@ def calculate_resolution_from_input(
|
||||
original_width, original_height = extract_dimensions(image=image, latent=latent)
|
||||
|
||||
# Calculate scaled dimensions
|
||||
new_width, new_height = calculate_scaled_dimensions(
|
||||
original_width, original_height, scale_factor
|
||||
)
|
||||
new_width, new_height = calculate_scaled_dimensions(original_width, original_height, scale_factor)
|
||||
|
||||
return new_width, new_height
|
||||
|
||||
@@ -38,12 +38,11 @@ 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)",
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -88,20 +87,11 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
self.validate_inputs(scale_factor=scale_factor, image=image, latent=latent)
|
||||
|
||||
# Log the operation
|
||||
input_type = (
|
||||
"IMAGE"
|
||||
if image is not None
|
||||
else "LATENT" if latent is not None else "NONE"
|
||||
)
|
||||
self.log_info(
|
||||
f"Calculating resolution with scale_factor={scale_factor}, "
|
||||
f"input_type={input_type}"
|
||||
)
|
||||
input_type = "IMAGE" if image is not None else "LATENT" if latent is not None else "NONE"
|
||||
self.log_info(f"Calculating resolution with scale_factor={scale_factor}, " f"input_type={input_type}")
|
||||
|
||||
# Calculate the resolution
|
||||
width, height = calculate_resolution_from_input(
|
||||
scale_factor=scale_factor, image=image, latent=latent
|
||||
)
|
||||
width, height = calculate_resolution_from_input(scale_factor=scale_factor, image=image, latent=latent)
|
||||
|
||||
# Log the result
|
||||
self.log_info(f"Calculated resolution: {width}x{height}")
|
||||
@@ -136,42 +126,41 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
|
||||
# Validate scale factor type
|
||||
if not isinstance(scale_factor, (int, float)):
|
||||
raise ValueError(
|
||||
f"scale_factor must be a number, got {type(scale_factor).__name__}"
|
||||
)
|
||||
raise ValueError(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 "
|
||||
f"[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, "
|
||||
f"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 "
|
||||
f"[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
|
||||
|
||||
@@ -60,14 +60,12 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
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[str, str, int, float]:
|
||||
def get_combo(self, sampler: str, sched: str, steps: int, cfg: float) -> Tuple[object, str, int, float]:
|
||||
"""
|
||||
Get compact sampler combo configuration.
|
||||
|
||||
@@ -78,17 +76,32 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
cfg: CFG scale value
|
||||
|
||||
Returns:
|
||||
Tuple of (sampler, scheduler, steps, cfg)
|
||||
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)
|
||||
return result
|
||||
# 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)}")
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
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."""
|
||||
|
||||
@@ -40,9 +40,7 @@ except ImportError:
|
||||
]
|
||||
|
||||
|
||||
def validate_sampler_settings(
|
||||
sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> bool:
|
||||
def validate_sampler_settings(sampler_name: str, scheduler: str, steps: int, cfg: float) -> bool:
|
||||
"""
|
||||
Validate sampler configuration settings.
|
||||
|
||||
@@ -83,9 +81,7 @@ def validate_sampler_settings(
|
||||
return False
|
||||
|
||||
|
||||
def get_sampler_combo(
|
||||
sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> Tuple[str, str, int, float]:
|
||||
def get_sampler_combo(sampler_name: str, scheduler: str, steps: int, cfg: float) -> Tuple[str, str, int, float]:
|
||||
"""
|
||||
Process and return sampler combo settings.
|
||||
|
||||
|
||||
@@ -65,14 +65,12 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
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[str, str, int, float]:
|
||||
def get_sampler_combo(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> Tuple[object, str, int, float]:
|
||||
"""
|
||||
Get sampler combo configuration.
|
||||
|
||||
@@ -83,7 +81,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
cfg: CFG scale value
|
||||
|
||||
Returns:
|
||||
Tuple of (sampler_name, scheduler, steps, cfg)
|
||||
Tuple of (sampler_object, scheduler, steps, cfg)
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
@@ -98,17 +96,30 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
f"steps={steps}, cfg={cfg}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
return ("euler", "normal", 20, 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)
|
||||
|
||||
self.log_info(
|
||||
f"Configured sampler combo: {result[0]}, {result[1]}, "
|
||||
f"{result[2]} steps, CFG {result[3]}"
|
||||
)
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
return result
|
||||
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
|
||||
@@ -119,11 +130,16 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
f"{self.__class__.__name__}: Error processing sampler combo: {str(e)}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
def validate_inputs(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> None:
|
||||
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.
|
||||
|
||||
@@ -138,8 +154,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
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}"
|
||||
f"Invalid sampler settings: sampler={sampler_name}, " f"scheduler={scheduler}, steps={steps}, cfg={cfg}"
|
||||
)
|
||||
|
||||
def get_scheduler_suggestions(self, sampler_name: str) -> list:
|
||||
@@ -190,9 +205,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
"recommendation": f"Recommended range: {min_cfg}-{max_cfg} CFG",
|
||||
}
|
||||
|
||||
def get_combo_analysis(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
) -> dict:
|
||||
def get_combo_analysis(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> dict:
|
||||
"""
|
||||
Analyze the sampler combo configuration and provide recommendations.
|
||||
|
||||
@@ -251,10 +264,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
return (
|
||||
f"SamplerComboNode(samplers={len(SAMPLERS)}, "
|
||||
f"schedulers={len(SCHEDULERS)})"
|
||||
)
|
||||
return f"SamplerComboNode(samplers={len(SAMPLERS)}, " f"schedulers={len(SCHEDULERS)})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Detailed string representation of the node."""
|
||||
|
||||
@@ -66,9 +66,7 @@ def sanitize_seed_value(seed: Any) -> int:
|
||||
raise ValueError(f"Invalid seed value: {seed}") from e
|
||||
|
||||
|
||||
def create_history_entry(
|
||||
seed: int, timestamp: Optional[float] = None
|
||||
) -> Dict[str, Any]:
|
||||
def create_history_entry(seed: int, timestamp: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a standardized history entry for a seed.
|
||||
|
||||
@@ -89,9 +87,7 @@ def create_history_entry(
|
||||
}
|
||||
|
||||
|
||||
def filter_duplicate_seeds(
|
||||
history: List[Dict[str, Any]], new_seed: int, dedup_window_ms: int = 500
|
||||
) -> bool:
|
||||
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.
|
||||
|
||||
@@ -193,9 +189,7 @@ def format_time_ago(timestamp: float) -> str:
|
||||
return f"{seconds}s ago"
|
||||
|
||||
|
||||
def search_history_by_seed(
|
||||
history: List[Dict[str, Any]], seed: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def search_history_by_seed(history: List[Dict[str, Any]], seed: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search history for a specific seed value.
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
"default": 12345,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"tooltip": "Seed value for generation processes. "
|
||||
"History UI tracks all changes automatically.",
|
||||
"tooltip": "Seed value for generation processes. " "History UI tracks all changes automatically.",
|
||||
},
|
||||
),
|
||||
}
|
||||
@@ -57,10 +56,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"{self.__class__.__name__}: Invalid seed value: {seed}. "
|
||||
f"Using fallback seed 12345."
|
||||
)
|
||||
logger.error(f"{self.__class__.__name__}: Invalid seed value: {seed}. " f"Using fallback seed 12345.")
|
||||
return (12345,)
|
||||
|
||||
clean_seed = sanitize_seed_value(seed)
|
||||
@@ -72,10 +68,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"{self.__class__.__name__}: Error processing seed: {str(e)}. "
|
||||
f"Using fallback seed 12345."
|
||||
)
|
||||
logger.error(f"{self.__class__.__name__}: Error processing seed: {str(e)}. " f"Using fallback seed 12345.")
|
||||
return (12345,)
|
||||
|
||||
def generate_new_seed(self) -> int:
|
||||
|
||||
@@ -5,9 +5,7 @@ from math import gcd
|
||||
from .presets import PRESET_OPTIONS
|
||||
|
||||
|
||||
def get_preset_dimensions(
|
||||
preset: str, custom_width: int, custom_height: int
|
||||
) -> Tuple[int, int]:
|
||||
def get_preset_dimensions(preset: str, custom_width: int, custom_height: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Get dimensions from preset name or use custom dimensions.
|
||||
|
||||
@@ -114,9 +112,7 @@ def sanitize_dimensions(width: int, height: int) -> Tuple[int, int]:
|
||||
return width, height
|
||||
|
||||
|
||||
def get_dimension_info(
|
||||
preset: str, width: int, height: int, swap_enabled: bool
|
||||
) -> dict:
|
||||
def get_dimension_info(preset: str, width: int, height: int, swap_enabled: bool) -> dict:
|
||||
"""
|
||||
Get comprehensive dimension information including metadata.
|
||||
|
||||
@@ -205,9 +201,7 @@ def parse_dimension_string(dimension_str: str) -> Tuple[int, int]:
|
||||
raise ValueError(f"Could not parse dimensions from {dimension_str}: {e}")
|
||||
|
||||
|
||||
def get_optimal_scale_factor(
|
||||
current_width: int, current_height: int, target_width: int, target_height: int
|
||||
) -> float:
|
||||
def get_optimal_scale_factor(current_width: int, current_height: int, target_width: int, target_height: int) -> float:
|
||||
"""
|
||||
Calculate optimal scale factor to get from current to target dimensions.
|
||||
|
||||
|
||||
@@ -36,8 +36,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
metadata = PRESET_METADATA.get(preset_name)
|
||||
if metadata:
|
||||
formatted_option = (
|
||||
f"{preset_name} - {metadata.aspect_ratio} "
|
||||
f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
|
||||
f"{preset_name} - {metadata.aspect_ratio} " f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
|
||||
)
|
||||
preset_options.append(formatted_option)
|
||||
else:
|
||||
@@ -104,9 +103,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
original_preset = self._extract_preset_name(preset)
|
||||
|
||||
# Get base dimensions from preset or custom input
|
||||
final_width, final_height = get_preset_dimensions(
|
||||
original_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)
|
||||
@@ -115,8 +112,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
if not validate_dimensions(final_width, final_height):
|
||||
# This should not happen after sanitization, but handle gracefully
|
||||
self.handle_error(
|
||||
f"Generated invalid dimensions: {final_width}×{final_height}. "
|
||||
f"Using fallback dimensions 1024×1024."
|
||||
f"Generated invalid dimensions: {final_width}×{final_height}. " f"Using fallback dimensions 1024×1024."
|
||||
)
|
||||
final_width, final_height = 1024, 1024
|
||||
|
||||
@@ -124,9 +120,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
error_msg = (
|
||||
f"Error processing dimensions: {str(e)}. Using fallback 1024×1024."
|
||||
)
|
||||
error_msg = f"Error processing dimensions: {str(e)}. Using fallback 1024×1024."
|
||||
self.handle_error(error_msg)
|
||||
return (1024, 1024)
|
||||
|
||||
@@ -176,10 +170,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
|
||||
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"{preset} - {metadata.aspect_ratio} ({metadata.megapixels:.1f}MP) - " f"{metadata.description}"
|
||||
|
||||
return f"Unknown preset: {preset}"
|
||||
|
||||
|
||||
@@ -287,21 +287,15 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
|
||||
# Legacy compatibility - maintain old preset dictionaries
|
||||
SDXL_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "SDXL"
|
||||
k: (v.width, v.height) for k, v in PRESET_METADATA.items() if v.model_group == "SDXL"
|
||||
}
|
||||
|
||||
FLUX_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "FLUX"
|
||||
k: (v.width, v.height) for k, v in PRESET_METADATA.items() if v.model_group == "FLUX"
|
||||
}
|
||||
|
||||
ULTRA_WIDE_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide"
|
||||
k: (v.width, v.height) for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"
|
||||
}
|
||||
|
||||
# Combined preset options for ComfyUI dropdown
|
||||
@@ -314,78 +308,28 @@ PRESET_OPTIONS: Dict[str, Tuple[int, int]] = {
|
||||
PRESET_CATEGORIES = {
|
||||
"Custom": ["custom"],
|
||||
# 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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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 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"
|
||||
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"
|
||||
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"
|
||||
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"],
|
||||
}
|
||||
|
||||
# Legacy compatibility - preset descriptions
|
||||
@@ -395,9 +339,7 @@ PRESET_DESCRIPTIONS = {k: v.description for k, v in PRESET_METADATA.items()}
|
||||
MODEL_RECOMMENDATIONS = {
|
||||
"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"
|
||||
],
|
||||
"Ultra-Wide": [k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"],
|
||||
}
|
||||
|
||||
|
||||
@@ -451,9 +393,7 @@ def validate_preset_dimensions() -> bool:
|
||||
|
||||
# Check divisible by 8
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
print(
|
||||
f"ERROR: {preset_name} dimensions not divisible by 8: {width}×{height}"
|
||||
)
|
||||
print(f"ERROR: {preset_name} dimensions not divisible by 8: {width}×{height}")
|
||||
return False
|
||||
|
||||
# Check reasonable bounds
|
||||
@@ -469,9 +409,7 @@ 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
|
||||
)
|
||||
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: "
|
||||
@@ -482,10 +420,7 @@ def validate_metadata_consistency() -> bool:
|
||||
# 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}"
|
||||
)
|
||||
print(f"ERROR: {preset_name} megapixel mismatch: " f"expected {expected_mp:.2f}, got {metadata.megapixels}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -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
|
||||
+82
-3
@@ -1,16 +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.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["# Development dependencies for ComfyUI-KikoTools", "# Testing framework", "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "# Code quality", "black>=23.0.0", "flake8>=6.0.0", "mypy>=1.0.0", "# Development utilities", "pre-commit>=3.0.0", "# ComfyUI testing (mock dependencies for unit tests)", "torch>=2.0.0", "numpy>=1.24.0", "pillow>=9.0.0"]
|
||||
version = "1.0.7"
|
||||
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:"
|
||||
]
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-18
@@ -1,19 +1,4 @@
|
||||
# Development dependencies for ComfyUI-KikoTools
|
||||
# Runtime dependencies for ComfyUI-KikoTools
|
||||
|
||||
# Testing framework
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.10.0
|
||||
|
||||
# Code quality
|
||||
black>=23.0.0
|
||||
flake8>=6.0.0
|
||||
mypy>=1.0.0
|
||||
|
||||
# Development utilities
|
||||
pre-commit>=3.0.0
|
||||
|
||||
# ComfyUI testing (mock dependencies for unit tests)
|
||||
torch>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pillow>=9.0.0
|
||||
# Gemini API integration (optional - only needed for Gemini Prompt node)
|
||||
google-generativeai>=0.3.0
|
||||
|
||||
Executable
+16
@@ -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"
|
||||
+3
-9
@@ -102,18 +102,12 @@ def assert_divisible_by_8(width: int, height: int) -> None:
|
||||
assert height % 8 == 0, f"Height {height} must be divisible by 8"
|
||||
|
||||
|
||||
def assert_reasonable_dimensions(
|
||||
width: int, height: int, min_size: int = 64, max_size: int = 8192
|
||||
) -> None:
|
||||
def assert_reasonable_dimensions(width: int, height: int, min_size: int = 64, max_size: int = 8192) -> None:
|
||||
"""
|
||||
Helper function to assert dimensions are within reasonable bounds
|
||||
"""
|
||||
assert (
|
||||
min_size <= width <= max_size
|
||||
), f"Width {width} out of reasonable range [{min_size}, {max_size}]"
|
||||
assert (
|
||||
min_size <= height <= max_size
|
||||
), f"Height {height} out of reasonable range [{min_size}, {max_size}]"
|
||||
assert min_size <= width <= max_size, f"Width {width} out of reasonable range [{min_size}, {max_size}]"
|
||||
assert min_size <= height <= max_size, f"Height {height} out of reasonable range [{min_size}, {max_size}]"
|
||||
|
||||
|
||||
# Make helper functions available as pytest fixtures
|
||||
|
||||
@@ -32,10 +32,7 @@ class TestComfyAssetsBaseNode:
|
||||
node.handle_error("Test error message")
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
assert (
|
||||
"ComfyAssetsBaseNode: Test error message"
|
||||
in mock_logger.error.call_args[0][0]
|
||||
)
|
||||
assert "ComfyAssetsBaseNode: Test error message" in mock_logger.error.call_args[0][0]
|
||||
|
||||
def test_handle_error_with_exception_logs_exception(self):
|
||||
"""Test error handling with original exception logs both messages"""
|
||||
@@ -58,10 +55,7 @@ class TestComfyAssetsBaseNode:
|
||||
node.log_info("Test information")
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
assert (
|
||||
"ComfyAssetsBaseNode: Test information"
|
||||
in mock_logger.info.call_args[0][0]
|
||||
)
|
||||
assert "ComfyAssetsBaseNode: Test information" in mock_logger.info.call_args[0][0]
|
||||
|
||||
def test_get_node_info_returns_metadata(self):
|
||||
"""Test get_node_info returns correct metadata"""
|
||||
|
||||
@@ -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,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,536 @@
|
||||
"""
|
||||
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)
|
||||
@@ -56,13 +56,9 @@ class TestDimensionExtraction:
|
||||
with pytest.raises(ValueError, match="Either image or latent must be provided"):
|
||||
extract_dimensions()
|
||||
|
||||
def test_extract_dimensions_both_inputs_prefers_image(
|
||||
self, mock_image_tensor, mock_latent_tensor
|
||||
):
|
||||
def test_extract_dimensions_both_inputs_prefers_image(self, mock_image_tensor, mock_latent_tensor):
|
||||
"""Test that when both inputs provided, image takes precedence"""
|
||||
width, height = extract_dimensions(
|
||||
image=mock_image_tensor, latent=mock_latent_tensor
|
||||
)
|
||||
width, height = extract_dimensions(image=mock_image_tensor, latent=mock_latent_tensor)
|
||||
|
||||
# Should return image dimensions, not latent
|
||||
assert width == 832
|
||||
@@ -93,9 +89,7 @@ class TestScaledDimensionsCalculation:
|
||||
original_width, original_height = 832, 1216
|
||||
scale_factor = 1.5
|
||||
|
||||
new_width, new_height = calculate_scaled_dimensions(
|
||||
original_width, original_height, scale_factor
|
||||
)
|
||||
new_width, new_height = calculate_scaled_dimensions(original_width, original_height, scale_factor)
|
||||
|
||||
# Check aspect ratio is preserved (within floating point precision)
|
||||
original_ratio = original_width / original_height
|
||||
@@ -107,9 +101,7 @@ class TestScaledDimensionsCalculation:
|
||||
base_width, base_height = 1024, 1024
|
||||
|
||||
for scale_factor in sample_scale_factors:
|
||||
width, height = calculate_scaled_dimensions(
|
||||
base_width, base_height, scale_factor
|
||||
)
|
||||
width, height = calculate_scaled_dimensions(base_width, base_height, scale_factor)
|
||||
|
||||
expected_width = int(base_width * scale_factor)
|
||||
expected_height = int(base_height * scale_factor)
|
||||
@@ -213,9 +205,7 @@ class TestResolutionCalculatorNode:
|
||||
"""Test node calculation with IMAGE input"""
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
width, height = node.calculate_resolution(
|
||||
scale_factor=2.0, image=mock_image_tensor
|
||||
)
|
||||
width, height = node.calculate_resolution(scale_factor=2.0, image=mock_image_tensor)
|
||||
|
||||
# Original: 832x1216, 2x scale = 1664x2432
|
||||
assert isinstance(width, int)
|
||||
@@ -230,9 +220,7 @@ class TestResolutionCalculatorNode:
|
||||
"""Test node calculation with LATENT input"""
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
width, height = node.calculate_resolution(
|
||||
scale_factor=1.5, latent=mock_latent_tensor
|
||||
)
|
||||
width, height = node.calculate_resolution(scale_factor=1.5, latent=mock_latent_tensor)
|
||||
|
||||
# Original: 832x1216, 1.5x scale = 1248x1824
|
||||
assert isinstance(width, int)
|
||||
@@ -250,16 +238,12 @@ class TestResolutionCalculatorNode:
|
||||
with pytest.raises(ValueError):
|
||||
node.calculate_resolution(scale_factor=2.0)
|
||||
|
||||
def test_calculate_resolution_with_various_scale_factors(
|
||||
self, mock_image_tensor_square, sample_scale_factors
|
||||
):
|
||||
def test_calculate_resolution_with_various_scale_factors(self, mock_image_tensor_square, sample_scale_factors):
|
||||
"""Test calculation with various scale factors"""
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
for scale_factor in sample_scale_factors:
|
||||
width, height = node.calculate_resolution(
|
||||
scale_factor=scale_factor, image=mock_image_tensor_square
|
||||
)
|
||||
width, height = node.calculate_resolution(scale_factor=scale_factor, image=mock_image_tensor_square)
|
||||
|
||||
# All results should be integers divisible by 8
|
||||
assert isinstance(width, int)
|
||||
|
||||
@@ -168,17 +168,17 @@ class TestSamplerComboNode:
|
||||
steps_input = required["steps"]
|
||||
assert steps_input[0] == "INT"
|
||||
assert steps_input[1]["min"] == 1
|
||||
assert steps_input[1]["max"] == 1000
|
||||
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"] == 30.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 == (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
assert SamplerComboNode.RETURN_TYPES == ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
assert SamplerComboNode.RETURN_NAMES == (
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
@@ -331,9 +331,7 @@ class TestSamplerComboIntegration:
|
||||
|
||||
# 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"]
|
||||
)
|
||||
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"]
|
||||
|
||||
@@ -70,9 +70,7 @@ class TestWidthHeightSelectorNode:
|
||||
|
||||
# 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
|
||||
)
|
||||
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
|
||||
assert result == (832, 1216)
|
||||
|
||||
def test_sdxl_landscape_preset(self):
|
||||
@@ -83,9 +81,7 @@ class TestWidthHeightSelectorNode:
|
||||
|
||||
# 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
|
||||
)
|
||||
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
|
||||
assert result == (1216, 832)
|
||||
|
||||
def test_flux_preset(self):
|
||||
@@ -96,9 +92,7 @@ class TestWidthHeightSelectorNode:
|
||||
|
||||
# Test formatted preset
|
||||
formatted_preset = "1920×1080 - 16:9 (2.1MP) - FLUX"
|
||||
result = self.node.get_dimensions(
|
||||
preset=formatted_preset, width=512, height=512
|
||||
)
|
||||
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
|
||||
assert result == (1920, 1080)
|
||||
|
||||
def test_ultra_wide_preset(self):
|
||||
@@ -109,9 +103,7 @@ class TestWidthHeightSelectorNode:
|
||||
|
||||
# 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
|
||||
)
|
||||
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
|
||||
assert result == (2560, 1080)
|
||||
|
||||
def test_all_presets_available(self):
|
||||
@@ -143,9 +135,7 @@ class TestWidthHeightSelectorNode:
|
||||
def test_invalid_preset_fallback(self):
|
||||
"""Test handling of invalid preset."""
|
||||
# Should fall back to custom dimensions
|
||||
result = self.node.get_dimensions(
|
||||
preset="invalid_preset", width=800, height=600
|
||||
)
|
||||
result = self.node.get_dimensions(preset="invalid_preset", width=800, height=600)
|
||||
assert result == (800, 600)
|
||||
|
||||
|
||||
@@ -268,18 +258,14 @@ class TestPresetDefinitions:
|
||||
for preset_dict in [SDXL_PRESETS, FLUX_PRESETS, ULTRA_WIDE_PRESETS]:
|
||||
for preset_name, (width, height) in preset_dict.items():
|
||||
assert width % 8 == 0, f"{preset_name} width {width} not divisible by 8"
|
||||
assert (
|
||||
height % 8 == 0
|
||||
), f"{preset_name} height {height} not divisible by 8"
|
||||
assert height % 8 == 0, f"{preset_name} height {height} not divisible by 8"
|
||||
|
||||
def test_preset_dimensions_within_limits(self):
|
||||
"""Test that all preset dimensions are within acceptable limits."""
|
||||
for preset_dict in [SDXL_PRESETS, FLUX_PRESETS, ULTRA_WIDE_PRESETS]:
|
||||
for preset_name, (width, height) in preset_dict.items():
|
||||
assert 64 <= width <= 8192, f"{preset_name} width {width} out of range"
|
||||
assert (
|
||||
64 <= height <= 8192
|
||||
), f"{preset_name} height {height} out of range"
|
||||
assert 64 <= height <= 8192, f"{preset_name} height {height} out of range"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
@@ -481,9 +467,7 @@ class TestFormattedPresets:
|
||||
|
||||
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}"
|
||||
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."""
|
||||
@@ -525,34 +509,22 @@ class TestFormattedPresets:
|
||||
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
|
||||
]
|
||||
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}"
|
||||
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"
|
||||
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}"
|
||||
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,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);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+25
-25
@@ -22,11 +22,11 @@ app.registerExtension({
|
||||
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";
|
||||
@@ -62,14 +62,14 @@ app.registerExtension({
|
||||
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);
|
||||
}
|
||||
@@ -105,12 +105,12 @@ app.registerExtension({
|
||||
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);
|
||||
@@ -118,7 +118,7 @@ app.registerExtension({
|
||||
window.seedHistoryNodes.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (originalOnRemoved) {
|
||||
originalOnRemoved.call(this);
|
||||
}
|
||||
@@ -220,7 +220,7 @@ app.registerExtension({
|
||||
this.mouseOverHistory = true;
|
||||
this.cancelAutoHide();
|
||||
});
|
||||
|
||||
|
||||
historyDiv.addEventListener("mouseleave", () => {
|
||||
this.mouseOverHistory = false;
|
||||
this.startAutoHide();
|
||||
@@ -257,38 +257,38 @@ app.registerExtension({
|
||||
|
||||
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();
|
||||
@@ -297,7 +297,7 @@ app.registerExtension({
|
||||
// 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;
|
||||
@@ -305,7 +305,7 @@ app.registerExtension({
|
||||
seedWidget.callback(newSeed, this, seedWidget);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.addSeedToHistory(newSeed);
|
||||
this.setDirtyCanvas(true, true);
|
||||
this.showMessage(`Generated: ${newSeed}`, "success");
|
||||
@@ -320,7 +320,7 @@ app.registerExtension({
|
||||
seedWidget.callback(historyItem.seed, this, seedWidget);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.highlightHistoryEntry(index);
|
||||
this.setDirtyCanvas(true, true);
|
||||
this.startAutoHide();
|
||||
@@ -340,7 +340,7 @@ app.registerExtension({
|
||||
if (!this.historyDisplay) return;
|
||||
|
||||
if (!this.seedHistory || this.seedHistory.length === 0) {
|
||||
this.historyDisplay.innerHTML =
|
||||
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;
|
||||
}
|
||||
@@ -382,7 +382,7 @@ app.registerExtension({
|
||||
|
||||
this.historyDisplay.appendChild(entryDiv);
|
||||
});
|
||||
|
||||
|
||||
this.startAutoHide();
|
||||
};
|
||||
|
||||
@@ -420,7 +420,7 @@ app.registerExtension({
|
||||
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";
|
||||
@@ -458,12 +458,12 @@ app.registerExtension({
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -521,4 +521,4 @@ app.registerExtension({
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+52
-52
@@ -2,30 +2,30 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyassets.WidthHeightSelector",
|
||||
name: "comfyassets.WidthHeightSelector",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
if (nodeData.name === "WidthHeightSelector") {
|
||||
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) {
|
||||
@@ -35,36 +35,36 @@ app.registerExtension({
|
||||
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],
|
||||
"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],
|
||||
// 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],
|
||||
"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);
|
||||
@@ -76,22 +76,22 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// 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('×')) {
|
||||
@@ -101,13 +101,13 @@ app.registerExtension({
|
||||
} 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);
|
||||
@@ -116,7 +116,7 @@ app.registerExtension({
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (swappedFormattedPreset) {
|
||||
// Swapped preset exists, use the formatted version
|
||||
presetWidget.value = swappedFormattedPreset;
|
||||
@@ -136,7 +136,7 @@ app.registerExtension({
|
||||
presetWidget.value = "custom";
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
|
||||
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback("custom", this, presetWidget);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ app.registerExtension({
|
||||
const tempWidth = widthWidget.value;
|
||||
widthWidget.value = heightWidget.value;
|
||||
heightWidget.value = tempWidth;
|
||||
|
||||
|
||||
// Trigger widget change events
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(widthWidget.value, this, widthWidget);
|
||||
@@ -161,7 +161,7 @@ app.registerExtension({
|
||||
heightWidget.callback(heightWidget.value, this, heightWidget);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Mark the graph as changed
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
@@ -173,21 +173,21 @@ app.registerExtension({
|
||||
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 = 10; // Space between last widget and button
|
||||
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
|
||||
// Button background - change color based on pressed state
|
||||
if (this.swapButtonPressed) {
|
||||
// Darker when pressed
|
||||
@@ -199,26 +199,26 @@ app.registerExtension({
|
||||
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);
|
||||
@@ -226,13 +226,13 @@ app.registerExtension({
|
||||
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);
|
||||
@@ -240,7 +240,7 @@ app.registerExtension({
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 5);
|
||||
ctx.stroke();
|
||||
|
||||
|
||||
// Add subtle tooltip text when hovering (if we had hover state)
|
||||
// This could be extended with hover detection for better UX
|
||||
};
|
||||
@@ -251,13 +251,13 @@ app.registerExtension({
|
||||
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 = 10;
|
||||
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
|
||||
if (
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
@@ -267,19 +267,19 @@ app.registerExtension({
|
||||
// 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);
|
||||
@@ -293,27 +293,27 @@ app.registerExtension({
|
||||
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 = 10;
|
||||
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);
|
||||
@@ -321,4 +321,4 @@ app.registerExtension({
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user