Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228b74ae5e | ||
|
|
6197b482df | ||
|
|
f62129afda | ||
|
|
8d5065c975 | ||
|
|
2d27c32bfd | ||
|
|
3ecab5ac08 | ||
|
|
70592114f9 | ||
|
|
407fc4ca7b | ||
|
|
cb7d5246f9 | ||
|
|
9829fc001d | ||
|
|
e84ec6721c | ||
|
|
80fac8e544 | ||
|
|
efc079a95b | ||
|
|
bbd239cbd6 | ||
|
|
589fbf3568 | ||
|
|
c595cabaa0 | ||
|
|
ca504d5f74 | ||
|
|
f559fe220e | ||
|
|
90c1aa402d | ||
|
|
932e30ade0 | ||
|
|
f9540bd984 | ||
|
|
a8af833c31 | ||
|
|
005c3bdf65 | ||
|
|
67a59a0d3b | ||
|
|
bb5653fc0e | ||
|
|
ab23992c29 | ||
|
|
9007b10d42 | ||
|
|
b71bfa8d4e |
@@ -0,0 +1,35 @@
|
||||
[flake8]
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
.pytest_cache,
|
||||
venv,
|
||||
env,
|
||||
build,
|
||||
dist,
|
||||
*.egg-info,
|
||||
.tox
|
||||
ignore =
|
||||
# W503: line break before binary operator (conflicts with Black)
|
||||
W503,
|
||||
# E203: whitespace before ':' (conflicts with Black)
|
||||
E203,
|
||||
# E501: line too long (we use max-line-length)
|
||||
E501
|
||||
|
||||
per-file-ignores =
|
||||
# Allow unused imports in __init__.py files
|
||||
__init__.py:F401,F403
|
||||
# Allow assertions in tests
|
||||
tests/*:S101
|
||||
# Allow higher complexity for Gemini prompt module
|
||||
kikotools/tools/gemini_prompt/logic.py:C901
|
||||
kikotools/tools/gemini_prompt/models.py:C901
|
||||
kikotools/tools/gemini_prompt/node.py:C901
|
||||
|
||||
# Statistics
|
||||
count = True
|
||||
statistics = True
|
||||
@@ -0,0 +1,41 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Python files
|
||||
*.py text eol=lf
|
||||
*.pyi text eol=lf
|
||||
|
||||
# Configuration files
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ini text eol=lf
|
||||
*.cfg text eol=lf
|
||||
|
||||
# Documentation
|
||||
*.md text eol=lf
|
||||
*.rst text eol=lf
|
||||
*.txt text eol=lf
|
||||
|
||||
# Scripts
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Git files
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
|
||||
# ComfyUI specific
|
||||
*.workflow text eol=lf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.safetensors binary
|
||||
*.ckpt binary
|
||||
*.pt binary
|
||||
*.pth binary
|
||||
@@ -45,4 +45,4 @@ Paste any error messages or stack traces here
|
||||
If possible, attach the ComfyUI workflow file (.json) that reproduces the issue.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -37,7 +37,7 @@ Describe how the tool should process inputs and generate outputs.
|
||||
|
||||
**Model Compatibility:**
|
||||
- [ ] SDXL optimized
|
||||
- [ ] FLUX optimized
|
||||
- [ ] FLUX optimized
|
||||
- [ ] General purpose
|
||||
- [ ] Specific model requirements: [describe]
|
||||
|
||||
@@ -64,4 +64,4 @@ Are there existing ComfyUI nodes that do something similar? How would this be di
|
||||
- [ ] Yes, I can help with implementation
|
||||
- [ ] Yes, I can help with testing
|
||||
- [ ] Yes, I can help with documentation
|
||||
- [ ] No, but I'd be happy to test it
|
||||
- [ ] No, but I'd be happy to test it
|
||||
|
||||
@@ -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,64 +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')
|
||||
"
|
||||
|
||||
@@ -151,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"
|
||||
@@ -180,103 +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')
|
||||
"
|
||||
|
||||
@@ -284,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-1
@@ -158,4 +158,7 @@ input/
|
||||
test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.claude/
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Pre-commit hooks configuration for ComfyUI-KikoTools
|
||||
# This ensures code quality checks are run before each commit
|
||||
|
||||
repos:
|
||||
# Python code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.10
|
||||
args: ['--line-length=88'] # Match CI configuration
|
||||
|
||||
# Python linting with flake8
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.3.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ['--max-line-length=88', '--max-complexity=10']
|
||||
exclude: '^tests/'
|
||||
|
||||
# Python type checking with mypy
|
||||
# Note: Mypy is disabled in pre-commit due to package name issue
|
||||
# Run manually with: mypy kikotools/
|
||||
# - repo: https://github.com/pre-commit/mirrors-mypy
|
||||
# rev: v1.8.0
|
||||
# hooks:
|
||||
# - id: mypy
|
||||
# args: ['--config-file=mypy.ini']
|
||||
# files: '^kikotools/'
|
||||
# exclude: '^tests/'
|
||||
# additional_dependencies: ['types-requests']
|
||||
|
||||
# Security checks with bandit
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.8.6
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: ['-ll', '-r']
|
||||
files: '^kikotools/'
|
||||
|
||||
# General file checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-docstring-first
|
||||
- id: debug-statements
|
||||
- id: mixed-line-ending
|
||||
|
||||
# Check for hardcoded secrets
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args: ['--baseline', '.secrets.baseline']
|
||||
exclude: '^(tests/|\.git/)'
|
||||
|
||||
# Configuration for specific hooks
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
|
||||
# Run hooks on all files by default
|
||||
fail_fast: false
|
||||
|
||||
# Exclude patterns
|
||||
exclude: |
|
||||
(?x)^(
|
||||
\.git/|
|
||||
\.mypy_cache/|
|
||||
\.pytest_cache/|
|
||||
__pycache__/|
|
||||
build/|
|
||||
dist/|
|
||||
\.eggs/|
|
||||
.*\.egg-info/|
|
||||
venv/|
|
||||
env/
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "AzureStorageKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "DiscordBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitHubTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitLabTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "IPPublicDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "NpmDetector"
|
||||
},
|
||||
{
|
||||
"name": "OpenAIDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "PypiTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "SendGridDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "SquareOAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TelegramBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
"examples/workflows/resolution_calculator_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/resolution_calculator_example.json",
|
||||
"hashed_secret": "5264b0f1a47aeafad88f33511dda3191b32dbf38",
|
||||
"is_verified": false,
|
||||
"line_number": 57
|
||||
}
|
||||
],
|
||||
"examples/workflows/sampler_combo_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/sampler_combo_example.json",
|
||||
"hashed_secret": "e3c1848dd1141985e412fa39922ac9ba37c4714d",
|
||||
"is_verified": false,
|
||||
"line_number": 348
|
||||
}
|
||||
],
|
||||
"examples/workflows/seed_history_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/seed_history_example.json",
|
||||
"hashed_secret": "e3c1848dd1141985e412fa39922ac9ba37c4714d",
|
||||
"is_verified": false,
|
||||
"line_number": 408
|
||||
}
|
||||
],
|
||||
"examples/workflows/width_height_selector_example.json": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "examples/workflows/width_height_selector_example.json",
|
||||
"hashed_secret": "e3c1848dd1141985e412fa39922ac9ba37c4714d",
|
||||
"is_verified": false,
|
||||
"line_number": 425
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2025-07-31T23:51:20Z"
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ComfyUI-KikoTools is a planned modular collection of custom ComfyUI nodes that will provide essential tools missing from the standard ComfyUI release. All nodes will be grouped under "ComfyAssets" in the ComfyUI interface. The project is designed for extensibility, allowing new tools to be added easily while maintaining clean separation of concerns.
|
||||
|
||||
**Current Status**: Project is in initial planning phase. Only documentation and licensing files exist.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Principles
|
||||
- **Modular Design**: Each tool is a separate, self-contained module
|
||||
- **ComfyAssets Grouping**: All nodes appear under the "ComfyAssets" category
|
||||
- **Test-Driven Development**: Every tool includes comprehensive tests
|
||||
- **Clean Interfaces**: Standardized input/output patterns across tools
|
||||
|
||||
### Core Components
|
||||
- **Tool Registry**: Central registration system for all KikoTools nodes
|
||||
- **Base Classes**: Shared functionality for consistent tool behavior
|
||||
- **Individual Tools**: Self-contained modules for specific functionality
|
||||
|
||||
### Current Tools
|
||||
|
||||
#### 1. Resolution Calculator (First Tool)
|
||||
- **Purpose**: Calculate upscale resolution from image or latent inputs
|
||||
- **Inputs**:
|
||||
- Image or Latent tensor
|
||||
- Scale factor (1, 2, 3, 1.2, 1.5, 2.0)
|
||||
- **Outputs**:
|
||||
- Width (INT)
|
||||
- Height (INT)
|
||||
- **Target Models**: Flux and SDXL optimized
|
||||
- **Use Case**: Connect calculated dimensions to upscaler nodes
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Python with ComfyUI node patterns
|
||||
- **Node Framework**: ComfyUI INPUT_TYPES, RETURN_TYPES, execute() patterns
|
||||
- **Testing**: pytest with ComfyUI test fixtures
|
||||
- **Code Quality**: black, flake8, mypy
|
||||
- **Integration**: ComfyUI execution queue and tensor systems
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Note**: These commands are planned for when the project structure is implemented.
|
||||
|
||||
### Initial Setup
|
||||
```bash
|
||||
# Create basic project structure
|
||||
mkdir -p kikotools/{base,tools} tests/{unit,integration,fixtures} scripts examples
|
||||
|
||||
# Create entry point files
|
||||
touch __init__.py kikotools/__init__.py
|
||||
```
|
||||
|
||||
### Code Quality (Future)
|
||||
```bash
|
||||
# Format Python code
|
||||
black .
|
||||
|
||||
# Python linting
|
||||
flake8 .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
### Testing (Future TDD Workflow)
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/
|
||||
|
||||
# Run tests for specific tool
|
||||
pytest tests/unit/tools/test_{tool_name}.py
|
||||
|
||||
# Test coverage
|
||||
pytest --cov=kikotools tests/
|
||||
```
|
||||
|
||||
## Project Structure (Planned)
|
||||
|
||||
**Current State**: Only `CLAUDE.md` and `LICENSE` files exist.
|
||||
|
||||
**Planned Structure**:
|
||||
```
|
||||
├── __init__.py # ComfyUI node registration entry point
|
||||
├── kikotools/ # Main package
|
||||
│ ├── __init__.py # Package initialization and tool registry
|
||||
│ ├── base/ # Base classes and shared utilities
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_node.py # Base node class with ComfyAssets grouping
|
||||
│ │ └── utils.py # Shared utility functions
|
||||
│ ├── tools/ # Individual tool implementations
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── resolution_calculator/ # First planned tool
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── node.py # ResolutionCalculatorNode implementation
|
||||
│ │ │ └── logic.py # Core calculation logic
|
||||
│ │ └── template/ # Template for new tools
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── node.py
|
||||
│ │ └── logic.py
|
||||
├── tests/ # Comprehensive test suite (TDD approach)
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # pytest fixtures and ComfyUI test setup
|
||||
│ ├── unit/ # Unit tests for individual components
|
||||
│ │ ├── test_base_node.py
|
||||
│ │ └── tools/
|
||||
│ │ └── test_resolution_calculator.py
|
||||
│ ├── integration/ # ComfyUI integration tests
|
||||
│ │ ├── test_node_registration.py
|
||||
│ │ └── test_workflow_execution.py
|
||||
│ └── fixtures/ # Test data and workflow files
|
||||
│ ├── workflows/ # .json workflow files for testing
|
||||
│ ├── images/ # Test images
|
||||
│ └── latents/ # Test latent tensors
|
||||
├── scripts/ # Development automation
|
||||
│ ├── create_tool.py # Tool template generator
|
||||
│ ├── register_tool.py # Tool registration helper
|
||||
│ └── validate_nodes.py # Node validation script
|
||||
├── examples/ # Usage examples and demonstrations
|
||||
│ ├── workflows/ # Example workflow .json files
|
||||
│ └── documentation/ # Usage documentation per tool
|
||||
└── requirements-dev.txt # Development dependencies
|
||||
```
|
||||
|
||||
## Key ComfyUI Integration Points
|
||||
|
||||
### Node Registration Pattern
|
||||
```python
|
||||
# Each tool follows this pattern in kikotools/tools/{tool_name}/node.py
|
||||
class ResolutionCalculatorNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"scale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"latent": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
CATEGORY = "ComfyAssets" # All tools use this category
|
||||
|
||||
def calculate_resolution(self, scale_factor, image=None, latent=None):
|
||||
# Implementation here
|
||||
pass
|
||||
```
|
||||
|
||||
### Base Node Class
|
||||
- Provides consistent "ComfyAssets" categorization
|
||||
- Standardizes error handling and logging
|
||||
- Implements common validation patterns
|
||||
- Ensures consistent return type handling
|
||||
|
||||
### Tool Registry System
|
||||
- Automatic discovery of tools in `kikotools/tools/`
|
||||
- Dynamic node registration during ComfyUI startup
|
||||
- Version compatibility checking
|
||||
- Dependency validation
|
||||
|
||||
## Test-Driven Development (TDD) Workflow
|
||||
|
||||
### 1. Write Tests First
|
||||
```python
|
||||
# tests/unit/tools/test_resolution_calculator.py
|
||||
def test_resolution_calculator_with_image():
|
||||
"""Test resolution calculation with image input."""
|
||||
# Arrange
|
||||
node = ResolutionCalculatorNode()
|
||||
test_image = create_test_image(512, 512) # fixture
|
||||
scale_factor = 2.0
|
||||
|
||||
# Act
|
||||
width, height = node.calculate_resolution(scale_factor, image=test_image)
|
||||
|
||||
# Assert
|
||||
assert width == 1024
|
||||
assert height == 1024
|
||||
|
||||
def test_resolution_calculator_with_latent():
|
||||
"""Test resolution calculation with latent input."""
|
||||
# Similar pattern for latent inputs
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. Run Tests (Should Fail)
|
||||
```bash
|
||||
pytest tests/unit/tools/test_resolution_calculator.py -v
|
||||
```
|
||||
|
||||
### 3. Implement Minimal Code
|
||||
```python
|
||||
# kikotools/tools/resolution_calculator/logic.py
|
||||
def calculate_upscale_resolution(input_tensor, scale_factor):
|
||||
"""Calculate new resolution based on input and scale factor."""
|
||||
# Minimal implementation to pass tests
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. Refactor and Expand
|
||||
- Add error handling
|
||||
- Optimize for Flux/SDXL specific requirements
|
||||
- Add comprehensive validation
|
||||
- Implement edge case handling
|
||||
|
||||
### 5. Integration Testing
|
||||
```python
|
||||
# tests/integration/test_workflow_execution.py
|
||||
def test_resolution_calculator_in_workflow():
|
||||
"""Test resolution calculator in full ComfyUI workflow."""
|
||||
workflow = load_test_workflow("resolution_calculator_example.json")
|
||||
result = execute_comfyui_workflow(workflow)
|
||||
assert result.success
|
||||
```
|
||||
|
||||
## Tool-Specific Implementation Notes
|
||||
|
||||
### Resolution Calculator
|
||||
- **Input Validation**: Handle both image and latent tensors
|
||||
- **Scale Factors**: Support integer (1, 2, 3) and float (1.2, 1.5, 2.0) multipliers
|
||||
- **Model Optimization**: Consider Flux and SDXL specific resolution requirements
|
||||
- **Output Format**: Integer width/height suitable for upscaler node connections
|
||||
- **Error Handling**: Graceful handling of invalid inputs or edge cases
|
||||
|
||||
### Future Tools (Planned)
|
||||
- Batch Image Processor
|
||||
- Advanced Prompt Utilities
|
||||
- Model Management Tools
|
||||
- Custom Sampling Methods
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New Tool
|
||||
1. **Plan**: Define tool purpose, inputs, outputs, and test cases
|
||||
2. **Generate**: Use `python scripts/create_tool.py --name "NewTool"`
|
||||
3. **Test**: Write comprehensive tests following TDD principles
|
||||
4. **Implement**: Build tool logic with proper ComfyUI integration
|
||||
5. **Register**: Add tool to registry and validate registration
|
||||
6. **Document**: Update examples and documentation
|
||||
7. **Validate**: Test in real ComfyUI environment with actual workflows
|
||||
|
||||
### Code Quality Standards
|
||||
- **Type Hints**: Full type annotation for all functions
|
||||
- **Documentation**: Docstrings for all public methods and classes
|
||||
- **Testing**: Minimum 90% test coverage for all tools
|
||||
- **Linting**: Pass all flake8 and mypy checks
|
||||
- **Formatting**: Auto-formatted with black
|
||||
|
||||
### Release Process
|
||||
1. Run full test suite: `pytest tests/`
|
||||
2. Validate in ComfyUI: `python scripts/validate_nodes.py`
|
||||
3. Update version numbers and changelog
|
||||
4. Create example workflows demonstrating new features
|
||||
5. Update ComfyUI-Manager compatibility metadata
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
### ComfyUI Compatibility
|
||||
- Follow ComfyUI tensor format conventions
|
||||
- Implement proper memory management for large tensors
|
||||
- Handle ComfyUI execution context correctly
|
||||
- Ensure compatibility with ComfyUI's automatic typing system
|
||||
|
||||
### Performance Considerations
|
||||
- Optimize for real-time workflow execution
|
||||
- Minimize memory allocation during processing
|
||||
- Cache expensive computations when appropriate
|
||||
- Profile performance with typical Flux/SDXL workflows
|
||||
|
||||
### User Experience
|
||||
- Clear, descriptive node names and parameter labels
|
||||
- Helpful tooltips and parameter descriptions
|
||||
- Consistent visual styling within ComfyAssets group
|
||||
- Robust error messages with actionable guidance
|
||||
|
||||
### Extensibility
|
||||
- Plugin architecture for easy tool addition
|
||||
- Shared utilities for common operations
|
||||
- Consistent API patterns across all tools
|
||||
- Future-proof design for ComfyUI updates
|
||||
@@ -123,7 +123,7 @@ test-fast: $(VENV_DIR)
|
||||
test: test-fast
|
||||
@echo "Running comprehensive test suite..."
|
||||
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)"
|
||||
@echo "✅ Error handling test passed"
|
||||
@echo "🎉 All comprehensive tests passed!"
|
||||
@@ -196,4 +196,4 @@ test-width-height-selector: $(VENV_DIR)
|
||||
"
|
||||
|
||||
test-all-tools: test-resolution-calculator test-width-height-selector
|
||||
@echo "🎉 All tool-specific tests completed!"
|
||||
@echo "🎉 All tool-specific tests completed!"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,6 +104,26 @@ Enhanced image saving with format selection, quality control, and floating popup
|
||||
- **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality
|
||||
- **Popup Toggle**: Enable/disable popup viewer per save operation
|
||||
|
||||
#### 🤖 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)
|
||||
@@ -157,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
|
||||
@@ -169,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
|
||||
@@ -181,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
|
||||
@@ -192,8 +212,8 @@ Sampler Combo → KSampler → VAE Decode → Save Image
|
||||
⚙️ All Settings ↘ sampler/scheduler/steps/cfg ↗
|
||||
```
|
||||
|
||||
**Configuration:** euler, normal, 20 steps, CFG 7.0
|
||||
**Output:** Complete sampling configuration in one node
|
||||
**Configuration:** euler, normal, 20 steps, CFG 7.0
|
||||
**Output:** Complete sampling configuration in one node
|
||||
**Smart Features:** Recommendations and compatibility validation
|
||||
|
||||
### Empty Latent Batch Example
|
||||
@@ -205,9 +225,9 @@ Empty Latent Batch → KSampler → VAE Decode → Kiko Save Image
|
||||
[swap button]
|
||||
```
|
||||
|
||||
**Preset:** SDXL Square (1024×1024)
|
||||
**Batch Size:** 4 empty latents
|
||||
**Output:** 4×4×128×128 latent tensor ready for sampling
|
||||
**Preset:** SDXL Square (1024×1024)
|
||||
**Batch Size:** 4 empty latents
|
||||
**Output:** 4×4×128×128 latent tensor ready for sampling
|
||||
**Swap Button:** Click to switch to any available swapped preset
|
||||
|
||||
### Kiko Save Image Example
|
||||
@@ -219,12 +239,25 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
[popup: enabled]
|
||||
```
|
||||
|
||||
**Format:** WebP (efficient compression, modern format)
|
||||
**Quality:** 85% (balanced size/quality)
|
||||
**Popup Viewer:** Floating, draggable window with saved images
|
||||
**Features:** Click images to open in new tabs, download individual files, batch selection
|
||||
**Format:** WebP (efficient compression, modern format)
|
||||
**Quality:** 85% (balanced size/quality)
|
||||
**Popup Viewer:** Floating, draggable window with saved images
|
||||
**Features:** Click images to open in new tabs, download individual files, batch selection
|
||||
**Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls
|
||||
|
||||
### 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>
|
||||
@@ -233,7 +266,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
```json
|
||||
{
|
||||
"workflow": "Load SDXL portrait → Calculate 1.5x dimensions → Feed to upscaler",
|
||||
"input_resolution": "832×1216",
|
||||
"input_resolution": "832×1216",
|
||||
"scale_factor": 1.5,
|
||||
"output_resolution": "1248×1824",
|
||||
"memory_efficient": true
|
||||
@@ -248,7 +281,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
{
|
||||
"workflow": "Generate latents → Calculate target size → Batch upscale",
|
||||
"input_resolution": "1024×1024",
|
||||
"scale_factor": 2.0,
|
||||
"scale_factor": 2.0,
|
||||
"output_resolution": "2048×2048",
|
||||
"batch_optimized": true
|
||||
}
|
||||
@@ -276,7 +309,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**Inputs:**
|
||||
- `scale_factor` (FLOAT): 1.0-8.0, default 2.0
|
||||
- `image` (IMAGE, optional): Input image tensor
|
||||
- `image` (IMAGE, optional): Input image tensor
|
||||
- `latent` (LATENT, optional): Input latent tensor
|
||||
|
||||
**Outputs:**
|
||||
@@ -298,7 +331,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**Outputs:**
|
||||
- `width` (INT): Selected or calculated width
|
||||
- `height` (INT): Selected or calculated height
|
||||
- `height` (INT): Selected or calculated height
|
||||
|
||||
**UI Features:**
|
||||
- Visual blue swap button in bottom-right corner
|
||||
@@ -342,7 +375,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**Outputs:**
|
||||
- `sampler_name` (STRING): Selected sampler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `scheduler` (STRING): Selected scheduler algorithm
|
||||
- `steps` (INT): Validated step count
|
||||
- `cfg` (FLOAT): Validated CFG scale
|
||||
|
||||
@@ -400,7 +433,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
|
||||
|
||||
**UI Features:**
|
||||
- Floating, draggable popup window showing saved images immediately
|
||||
- Interactive image grid with click-to-open functionality
|
||||
- Interactive image grid with click-to-open functionality
|
||||
- Individual image download buttons with format-specific quality indicators
|
||||
- Batch selection with multi-select checkboxes for bulk operations
|
||||
- Window controls: minimize, maximize, roll-up, close, and dragging
|
||||
@@ -440,6 +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
|
||||
@@ -456,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
|
||||
@@ -485,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
|
||||
```
|
||||
|
||||
@@ -555,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>
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
import comfy.sd
|
||||
import comfy.utils
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from comfy.sd import CLIP
|
||||
from diffusers import ConsistencyDecoderVAE
|
||||
from folder_paths import get_folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def find_or_create_cache():
|
||||
cwd = os.getcwd()
|
||||
if os.path.exists(os.path.join(cwd, "ComfyUI")):
|
||||
cwd = os.path.join(cwd, "ComfyUI")
|
||||
if os.path.exists(os.path.join(cwd, "models")):
|
||||
cwd = os.path.join(cwd, "models")
|
||||
if not os.path.exists(os.path.join(cwd, "huggingface_cache")):
|
||||
print("Creating huggingface_cache directory within comfy")
|
||||
os.mkdir(os.path.join(cwd, "huggingface_cache"))
|
||||
|
||||
return str(os.path.join(cwd, "huggingface_cache"))
|
||||
|
||||
|
||||
class ConsistencyDecoder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"latent": ("LATENT",)}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "decode"
|
||||
CATEGORY = "latent"
|
||||
|
||||
def __init__(self):
|
||||
self.vae = (
|
||||
ConsistencyDecoderVAE.from_pretrained(
|
||||
"openai/consistency-decoder",
|
||||
torch_dtype=torch.float16,
|
||||
variant="fp16",
|
||||
use_safetensors=True,
|
||||
cache_dir=find_or_create_cache(),
|
||||
)
|
||||
.eval()
|
||||
.to("cuda")
|
||||
)
|
||||
|
||||
def _decode(self, latent):
|
||||
"""Used when patching another vae."""
|
||||
return self.vae.decode(latent.half().cuda()).sample
|
||||
|
||||
def decode(self, latent):
|
||||
"""Used for standalone decoding."""
|
||||
sample = self._decode(latent["samples"])
|
||||
sample = sample.clamp(-1, 1).movedim(1, -1).add(1.0).mul(0.5).cpu()
|
||||
return (sample,)
|
||||
|
||||
|
||||
class PatchDecoderTiled:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"vae": ("VAE",)}}
|
||||
|
||||
RETURN_TYPES = ("VAE",)
|
||||
FUNCTION = "patch"
|
||||
category = "vae"
|
||||
|
||||
def __init__(self):
|
||||
self.vae = ConsistencyDecoder()
|
||||
|
||||
def patch(self, vae):
|
||||
del vae.first_stage_model.decoder
|
||||
vae.first_stage_model.decode = self.vae._decode
|
||||
vae.decode = (
|
||||
lambda x: vae.decode_tiled_(
|
||||
x,
|
||||
tile_x=512,
|
||||
tile_y=512,
|
||||
overlap=64,
|
||||
)
|
||||
.to("cuda")
|
||||
.movedim(1, -1)
|
||||
)
|
||||
|
||||
return (vae,)
|
||||
|
||||
|
||||
# quick node to set SDXL-friendly aspect ratios in 1024^2
|
||||
# adapted from throttlekitty
|
||||
class SDXLAspectRatio:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image"
|
||||
|
||||
def run(self, image: Tensor) -> Tuple[int, int]:
|
||||
_, height, width, _ = image.shape
|
||||
aspect_ratio = width / height
|
||||
|
||||
aspect_ratios = (
|
||||
(1 / 1, 1024, 1024),
|
||||
(2 / 3, 832, 1216),
|
||||
(3 / 4, 896, 1152),
|
||||
(5 / 8, 768, 1216),
|
||||
(9 / 16, 768, 1344),
|
||||
(9 / 19, 704, 1472),
|
||||
(9 / 21, 640, 1536),
|
||||
(3 / 2, 1216, 832),
|
||||
(4 / 3, 1152, 896),
|
||||
(8 / 5, 1216, 768),
|
||||
(16 / 9, 1344, 768),
|
||||
(19 / 9, 1472, 704),
|
||||
(21 / 9, 1536, 640),
|
||||
)
|
||||
|
||||
# find the closest aspect ratio
|
||||
closest = min(aspect_ratios, key=lambda x: abs(x[0] - aspect_ratio))
|
||||
|
||||
return (closest[1], closest[2])
|
||||
|
||||
|
||||
class ImageToMultipleOf:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"multiple_of": (
|
||||
"INT",
|
||||
{
|
||||
"default": 64,
|
||||
"min": 1,
|
||||
"max": 256,
|
||||
"step": 16,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
"method": (["center crop", "rescale"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "image"
|
||||
|
||||
def run(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
|
||||
"""Center crop the image to a specific multiple of a number."""
|
||||
_, height, width, _ = image.shape
|
||||
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
|
||||
if method == "rescale":
|
||||
return (
|
||||
F.interpolate(
|
||||
image.unsqueeze(0),
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
).squeeze(0),
|
||||
)
|
||||
else:
|
||||
top = (height - new_height) // 2
|
||||
left = (width - new_width) // 2
|
||||
bottom = top + new_height
|
||||
right = left + new_width
|
||||
return (image[:, top:bottom, left:right, :],)
|
||||
|
||||
|
||||
class HFHubLoraLoader:
|
||||
def __init__(self):
|
||||
self.loaded_lora = None
|
||||
self.loaded_lora_path = None
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"repo_id": ("STRING", {"default": ""}),
|
||||
"subfolder": ("STRING", {"default": ""}),
|
||||
"filename": ("STRING", {"default": ""}),
|
||||
"strength_model": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
|
||||
),
|
||||
"strength_clip": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP")
|
||||
FUNCTION = "load_lora"
|
||||
|
||||
CATEGORY = "loaders"
|
||||
|
||||
def load_lora(
|
||||
self,
|
||||
model,
|
||||
clip,
|
||||
repo_id: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
strength_model: float,
|
||||
strength_clip: float,
|
||||
):
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
return (model, clip)
|
||||
|
||||
lora_path = hf_hub_download(
|
||||
repo_id=repo_id.strip(),
|
||||
subfolder=(
|
||||
None
|
||||
if subfolder is None or subfolder.strip() == ""
|
||||
else subfolder.strip()
|
||||
),
|
||||
filename=filename.strip(),
|
||||
cache_dir=find_or_create_cache(),
|
||||
)
|
||||
|
||||
lora = None
|
||||
if self.loaded_lora is not None:
|
||||
if self.loaded_lora_path == lora_path:
|
||||
lora = self.loaded_lora
|
||||
else:
|
||||
self.loaded_lora = None
|
||||
self.loaded_lora_path = None
|
||||
|
||||
if lora is None:
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
self.loaded_lora = lora
|
||||
self.loaded_lora_path = lora_path
|
||||
|
||||
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
||||
model, clip, lora, strength_model, strength_clip
|
||||
)
|
||||
return (model_lora, clip_lora)
|
||||
|
||||
|
||||
class HFHubEmbeddingLoader:
|
||||
"""Load a text model embedding from Huggingface Hub.
|
||||
The connected CLIP model is not manipulated."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"clip": ("CLIP",),
|
||||
"repo_id": ("STRING", {"default": ""}),
|
||||
"subfolder": ("STRING", {"default": ""}),
|
||||
"filename": ("STRING", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
FUNCTION = "download_embedding"
|
||||
|
||||
CATEGORY = "n/a"
|
||||
|
||||
def download_embedding(
|
||||
self,
|
||||
clip: CLIP, # added to signify it's best put in between nodes
|
||||
repo_id: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
):
|
||||
hf_hub_download(
|
||||
repo_id=repo_id.strip(),
|
||||
subfolder=(
|
||||
None
|
||||
if subfolder is None or subfolder.strip() == ""
|
||||
else subfolder.strip()
|
||||
),
|
||||
filename=filename.strip(),
|
||||
local_dir=get_folder_paths("embeddings")[0],
|
||||
)
|
||||
|
||||
return (clip,)
|
||||
|
||||
|
||||
class GlifVariable:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"variable": (
|
||||
[
|
||||
"",
|
||||
],
|
||||
),
|
||||
"fallback": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"single_line": True,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "FLOAT")
|
||||
FUNCTION = "do_it"
|
||||
|
||||
CATEGORY = "glif/variables"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, variable: str, fallback: str):
|
||||
# Since we populate dynamically, comfy will report invalid inputs. Override to always return True
|
||||
return True
|
||||
|
||||
def do_it(self, variable: str, fallback: str):
|
||||
variable = variable.strip()
|
||||
fallback = fallback.strip()
|
||||
if variable == "" or (variable.startswith("{") and variable.endswith("}")):
|
||||
variable = fallback
|
||||
|
||||
int_val = 0
|
||||
float_val = 0.0
|
||||
string_val = f"{variable}"
|
||||
try:
|
||||
int_val = int(variable)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
float_val = float(variable)
|
||||
except Exception:
|
||||
pass
|
||||
return (string_val, int_val, float_val)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"GlifConsistencyDecoder": ConsistencyDecoder,
|
||||
"GlifPatchConsistencyDecoderTiled": PatchDecoderTiled,
|
||||
"SDXLAspectRatio": SDXLAspectRatio,
|
||||
"ImageToMultipleOf": ImageToMultipleOf,
|
||||
"HFHubLoraLoader": HFHubLoraLoader,
|
||||
"HFHubEmbeddingLoader": HFHubEmbeddingLoader,
|
||||
"GlifVariable": GlifVariable,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"GlifConsistencyDecoder": "Consistency VAE Decoder",
|
||||
"GlifPatchConsistencyDecoderTiled": "Patch Consistency VAE Decoder",
|
||||
"SDXLAspectRatio": "Image to SDXL compatible WH",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"HFHubLoraLoader": "Load HF Lora",
|
||||
"HFHubEmbeddingLoader": "Load HF Embedding",
|
||||
"GlifVariable": "Glif Variable",
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# Display Any
|
||||
|
||||
The Display Any node is a debugging and inspection tool that can display any type of input value in ComfyUI. It's particularly useful for understanding data structures and tensor shapes during workflow development.
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal Input**: Accepts any type of input data (tensors, strings, numbers, lists, dictionaries, etc.)
|
||||
- **Two Display Modes**:
|
||||
- **Raw Value**: Shows the string representation of the input
|
||||
- **Tensor Shape**: Extracts and displays the shapes of any tensors found in the input
|
||||
- **Nested Structure Support**: Can find tensors within nested dictionaries and lists
|
||||
- **UI Output**: Displays results directly in the ComfyUI interface
|
||||
|
||||
## Inputs
|
||||
|
||||
- **input** (*): Any value you want to display or inspect
|
||||
- **mode** (DROPDOWN): Display mode selection
|
||||
- `raw value`: Shows the complete string representation of the input
|
||||
- `tensor shape`: Extracts and shows shapes of any tensors in the input
|
||||
|
||||
## Outputs
|
||||
|
||||
- **display_text** (STRING): The formatted display text
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Display Simple Values
|
||||
|
||||
Connect any output to see its raw value:
|
||||
```
|
||||
String Input: "Hello, ComfyUI!"
|
||||
Mode: raw value
|
||||
Output: "Hello, ComfyUI!"
|
||||
```
|
||||
|
||||
### 2. Inspect Tensor Shapes
|
||||
|
||||
Great for debugging image processing pipelines:
|
||||
```
|
||||
Image Tensor: [1, 3, 512, 512]
|
||||
Mode: tensor shape
|
||||
Output: "[[1, 3, 512, 512]]"
|
||||
```
|
||||
|
||||
### 3. Debug Complex Data Structures
|
||||
|
||||
View nested data structures with multiple tensors:
|
||||
```python
|
||||
Input: {
|
||||
"images": tensor([1, 3, 256, 256]),
|
||||
"masks": [tensor([256, 256]), tensor([256, 256, 1])],
|
||||
"config": {"steps": 20}
|
||||
}
|
||||
Mode: tensor shape
|
||||
Output: "[[1, 3, 256, 256], [256, 256], [256, 256, 1]]"
|
||||
```
|
||||
|
||||
### 4. Workflow Debugging
|
||||
|
||||
Use Display Any nodes at various points in your workflow to understand data flow:
|
||||
- After loading images to verify dimensions
|
||||
- Before/after processing nodes to track shape changes
|
||||
- To inspect conditioning or latent data structures
|
||||
- To view metadata or configuration dictionaries
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Image Pipeline Debugging
|
||||
Place Display Any nodes after image loading and processing nodes to track dimension changes:
|
||||
```
|
||||
Load Image → Display Any (tensor shape) → Resize → Display Any (tensor shape)
|
||||
```
|
||||
|
||||
### Latent Space Inspection
|
||||
Understand latent dimensions in your workflows:
|
||||
```
|
||||
VAE Encode → Display Any (tensor shape) → KSampler → Display Any (raw value)
|
||||
```
|
||||
|
||||
### Configuration Verification
|
||||
Display complex configuration objects to ensure correct settings:
|
||||
```
|
||||
Config Node → Display Any (raw value) → Processing Node
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Multiple Display Nodes**: You can use multiple Display Any nodes in a single workflow to track data at different stages
|
||||
|
||||
2. **Tensor Shape Mode**: Particularly useful when working with:
|
||||
- Image batches to verify batch size
|
||||
- Latent tensors to understand dimensions
|
||||
- Mask arrays to check compatibility
|
||||
|
||||
3. **Raw Value Mode**: Best for:
|
||||
- String prompts and text
|
||||
- Configuration dictionaries
|
||||
- Debugging node outputs
|
||||
- Understanding data structure
|
||||
|
||||
4. **No Tensors Found**: If you see "No tensors found in input" in tensor shape mode, the input doesn't contain any tensor-like objects (numpy arrays, torch tensors, etc.)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- The node uses `str()` for raw value display, providing Python's string representation
|
||||
- Tensor shape detection works with any object that has a `shape` attribute
|
||||
- Nested structure traversal supports dictionaries, lists, and tuples
|
||||
- The output is both displayed in the UI and available as a string output for further processing
|
||||
|
||||
## Example Workflow Integration
|
||||
|
||||
```
|
||||
[Load Image] → [Image Processing] → [Display Any (tensor shape)]
|
||||
↓
|
||||
"[[1, 3, 512, 512]]"
|
||||
↓
|
||||
[Text Multiline] ← [Concatenate] ← "Image dimensions: "
|
||||
```
|
||||
|
||||
This creates a text output showing the current image dimensions that can be used elsewhere in your workflow.
|
||||
@@ -219,4 +219,4 @@ Memory Usage: 262,144 × 4 bytes = 1.0 MB per batch
|
||||
- **Position Calculation**: Dynamic positioning based on node size
|
||||
- **State Management**: Visual feedback for button interactions
|
||||
- **Preset Intelligence**: Smart switching between compatible presets
|
||||
- **Fallback Logic**: Custom dimension swapping when preset not available
|
||||
- **Fallback Logic**: Custom dimension swapping when preset not available
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Gemini Prompt Engineer
|
||||
|
||||
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Model Support**: Generate prompts optimized for FLUX, SDXL, Danbooru, and Video generation
|
||||
- **Custom Prompts**: Override templates with your own system prompts
|
||||
- **Visual Feedback**: UI shows processing status and error states
|
||||
- **Flexible API Key Management**: Multiple ways to provide API credentials
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Get API Key
|
||||
|
||||
Get your free Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey)
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install google-generativeai
|
||||
```
|
||||
|
||||
### 3. Configure API Key
|
||||
|
||||
Choose one of these methods:
|
||||
|
||||
1. **Environment Variable** (Recommended):
|
||||
```bash
|
||||
export GEMINI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
2. **Config File**:
|
||||
Create `gemini_config.json` in your ComfyUI root directory:
|
||||
```json
|
||||
{
|
||||
"api_key": "your-api-key-here"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Node Input**:
|
||||
Enter the API key directly in the node's `api_key` field
|
||||
|
||||
## Inputs
|
||||
|
||||
- **image** (IMAGE): The image to analyze
|
||||
- **prompt_type** (DROPDOWN): Type of prompt to generate
|
||||
- `flux`: Detailed artistic prompts with quality markers
|
||||
- `sdxl`: Positive/negative prompt pairs with weight emphasis
|
||||
- `danbooru`: Anime-style booru tags with underscores
|
||||
- `video`: Motion and temporal descriptions for video generation
|
||||
- **api_key** (STRING, optional): Gemini API key if not set elsewhere
|
||||
- **custom_prompt** (STRING, optional): Override template with custom system prompt
|
||||
|
||||
## Outputs
|
||||
|
||||
- **prompt** (STRING): Generated prompt text
|
||||
- **negative_prompt** (STRING): Negative prompt (only populated for SDXL format)
|
||||
|
||||
## Prompt Type Details
|
||||
|
||||
### FLUX Format
|
||||
Generates detailed prompts optimized for FLUX models:
|
||||
- Starts with main subject and action
|
||||
- Includes style and medium descriptors
|
||||
- Adds lighting and atmosphere details
|
||||
- Uses quality markers like "4K", "highly detailed", "award-winning"
|
||||
|
||||
Example output:
|
||||
```
|
||||
majestic mountain landscape at golden hour, oil painting style, dramatic lighting with sun rays piercing through clouds, wide angle composition, warm color palette with orange and purple hues, highly detailed, 4K resolution, trending on ArtStation, photorealistic rendering
|
||||
```
|
||||
|
||||
### SDXL Format
|
||||
Generates positive and negative prompt pairs:
|
||||
- Detailed positive prompts with weight emphasis
|
||||
- Comprehensive negative prompts to avoid common issues
|
||||
- Uses parentheses for emphasis: `(detailed eyes:1.2)`
|
||||
|
||||
Example output:
|
||||
```
|
||||
Positive: beautiful woman, (detailed eyes:1.2), flowing red dress, golden hour lighting, professional photography, 85mm lens, shallow depth of field, bokeh, high resolution, masterpiece
|
||||
Negative: low quality, blurry, distorted features, bad anatomy, poorly drawn, amateur, oversaturated, jpeg artifacts
|
||||
```
|
||||
|
||||
### Danbooru Format
|
||||
Generates booru-style tags for anime artwork:
|
||||
- Uses underscores for multi-word concepts
|
||||
- Includes character count descriptors (1girl, 2boys)
|
||||
- Orders tags from most to least important
|
||||
|
||||
Example output:
|
||||
```
|
||||
1girl, solo, long_hair, blue_eyes, blonde_hair, school_uniform, serafuku, pleated_skirt, thighhighs, smile, looking_at_viewer, classroom, sitting, desk, window, sunlight, highres, masterpiece
|
||||
```
|
||||
|
||||
### Video Format
|
||||
Generates prompts for video generation models:
|
||||
- Describes motion and camera movements
|
||||
- Includes temporal markers and transitions
|
||||
- Specifies technical details like fps and duration
|
||||
|
||||
Example output:
|
||||
```
|
||||
Aerial shot slowly descending toward a misty forest at dawn, camera smoothly transitions to tracking shot following a deer through the trees, photorealistic style, soft golden hour lighting with fog, 10 second duration, 4K resolution 24fps, ending with close-up of deer looking at camera
|
||||
```
|
||||
|
||||
## Custom System Prompts
|
||||
|
||||
You can override any template by providing your own system prompt. This is useful for:
|
||||
- Specialized use cases
|
||||
- Different language outputs
|
||||
- Custom formatting requirements
|
||||
- Integration with specific workflows
|
||||
|
||||
Example custom prompt:
|
||||
```
|
||||
You are an expert at analyzing images and creating simple, concise descriptions.
|
||||
Focus only on the main subject and primary colors.
|
||||
Keep your response under 50 words.
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The node provides clear error messages for common issues:
|
||||
- Missing API key
|
||||
- API request failures
|
||||
- Invalid image inputs
|
||||
- Rate limiting
|
||||
|
||||
Errors are displayed in the prompt output for easy debugging.
|
||||
|
||||
## Tips
|
||||
|
||||
1. **API Usage**: Gemini has generous free tier limits, but be mindful of rate limits
|
||||
2. **Image Quality**: Higher resolution images provide better analysis results
|
||||
3. **Prompt Refinement**: You can chain multiple Gemini nodes with different custom prompts
|
||||
4. **Caching**: Results are not cached, so identical images will make new API calls
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. Load an image using Load Image node
|
||||
2. Connect to Gemini Prompt Engineer
|
||||
3. Select appropriate prompt_type for your target model
|
||||
4. Connect prompt output to your generation model
|
||||
5. For SDXL, connect both prompt and negative_prompt outputs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"API key not found" error**:
|
||||
- Check environment variable is set correctly
|
||||
- Verify config file path and JSON format
|
||||
- Try entering key directly in node
|
||||
|
||||
**"No response generated" error**:
|
||||
- Check internet connection
|
||||
- Verify API key is valid
|
||||
- Image might be too large (resize if needed)
|
||||
|
||||
**Import error for google-generativeai**:
|
||||
- Run `pip install google-generativeai` in your ComfyUI environment
|
||||
- Restart ComfyUI after installation
|
||||
@@ -0,0 +1,82 @@
|
||||
# Image to Multiple Of
|
||||
|
||||
## Overview
|
||||
|
||||
The **Image to Multiple Of** node adjusts image dimensions to be multiples of a specified value. This is particularly useful for models that require input dimensions to be multiples of certain values (e.g., 8, 16, 32, 64) for optimal performance or compatibility.
|
||||
|
||||
## Purpose
|
||||
|
||||
Many AI models, especially diffusion models and VAEs, require input dimensions to be multiples of specific values due to their architecture (e.g., downsampling layers). This node ensures your images meet these requirements without manual calculation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **image** (IMAGE, required): The input image to process
|
||||
- **multiple_of** (INT, required): The value that dimensions should be multiple of
|
||||
- Default: 64
|
||||
- Range: 1-256
|
||||
- Step: 16
|
||||
- **method** (COMBO, required): Processing method
|
||||
- Options: "center crop", "rescale"
|
||||
|
||||
## Outputs
|
||||
|
||||
- **image** (IMAGE): Processed image with dimensions adjusted to multiples of the specified value
|
||||
|
||||
## Processing Methods
|
||||
|
||||
### Center Crop
|
||||
- Crops the image from the center to achieve the target dimensions
|
||||
- Preserves image quality but may lose edge content
|
||||
- Best for images where the important content is centered
|
||||
|
||||
### Rescale
|
||||
- Resizes the image to the target dimensions using bilinear interpolation
|
||||
- Keeps all content but may slightly affect image quality
|
||||
- Best when you need to preserve all image content
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Prepare for VAE Encoding
|
||||
```
|
||||
Load Image → Image to Multiple Of (multiple_of: 64) → VAE Encode
|
||||
```
|
||||
|
||||
### Example 2: Prepare for Specific Model Requirements
|
||||
```
|
||||
Load Image → Image to Multiple Of (multiple_of: 32) → Model Processing
|
||||
```
|
||||
|
||||
### Example 3: Batch Processing
|
||||
```
|
||||
Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batch Process
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Supports batch processing (processes all images in a batch)
|
||||
- Works with any number of channels (RGB, RGBA, grayscale, etc.)
|
||||
- Calculates the largest dimensions that are less than or equal to the original size
|
||||
- For center crop: crops equally from all sides to maintain centering
|
||||
- For rescale: uses bilinear interpolation with align_corners=False
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **VAE Preprocessing**: Ensure images are compatible with VAE encoders that require dimensions divisible by 64
|
||||
2. **Model Compatibility**: Adjust images for models with specific architectural requirements
|
||||
3. **Batch Uniformity**: Ensure all images in a batch have dimensions that meet model requirements
|
||||
4. **Performance Optimization**: Some models perform better with dimensions that are powers of 2
|
||||
|
||||
## Tips
|
||||
|
||||
- Use **center crop** when your subject is centered and you don't mind losing edge details
|
||||
- Use **rescale** when you need to preserve all image content
|
||||
- Common multiple_of values: 8, 16, 32, 64, 128
|
||||
- For Stable Diffusion models, 64 is typically recommended
|
||||
- For some upscaling models, 32 or 16 may be sufficient
|
||||
|
||||
## Error Handling
|
||||
|
||||
The node will raise an error if:
|
||||
- The image dimensions are smaller than the specified multiple_of value
|
||||
- Invalid input types are provided
|
||||
- The resulting dimensions would be 0 or negative
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -19,6 +22,9 @@ NODE_CLASS_MAPPINGS = {
|
||||
"SamplerComboCompact": SamplerComboCompactNode,
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -29,6 +35,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SamplerComboCompact": "Sampler Combo (Compact)",
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"GeminiPrompt": "Gemini Prompt Engineer",
|
||||
"DisplayAny": "Display Any",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""DisplayAny tool for ComfyUI."""
|
||||
|
||||
from .node import DisplayAnyNode
|
||||
|
||||
__all__ = ["DisplayAnyNode"]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Logic for DisplayAny node - displays any input value or tensor shape."""
|
||||
|
||||
from typing import Any, List, Union
|
||||
|
||||
|
||||
def get_tensor_shapes(input_value: Any) -> List[List[int]]:
|
||||
"""Extract tensor shapes from nested structures.
|
||||
|
||||
Args:
|
||||
input_value: Any input value that may contain tensors
|
||||
|
||||
Returns:
|
||||
List of tensor shapes found in the input
|
||||
"""
|
||||
shapes = []
|
||||
|
||||
def extract_shapes(value: Any) -> None:
|
||||
"""Recursively extract shapes from nested structures."""
|
||||
if isinstance(value, dict):
|
||||
for v in value.values():
|
||||
extract_shapes(v)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
extract_shapes(item)
|
||||
elif hasattr(value, "shape"):
|
||||
# Handle tensors (numpy arrays, torch tensors, etc.)
|
||||
shapes.append(list(value.shape))
|
||||
|
||||
extract_shapes(input_value)
|
||||
return shapes
|
||||
|
||||
|
||||
def format_display_value(input_value: Any, mode: str = "raw value") -> str:
|
||||
"""Format input value for display based on selected mode.
|
||||
|
||||
Args:
|
||||
input_value: Any input value to display
|
||||
mode: Display mode - "raw value" or "tensor shape"
|
||||
|
||||
Returns:
|
||||
Formatted string representation of the input
|
||||
"""
|
||||
if mode == "tensor shape":
|
||||
shapes = get_tensor_shapes(input_value)
|
||||
if shapes:
|
||||
return str(shapes)
|
||||
else:
|
||||
return "No tensors found in input"
|
||||
|
||||
# Default to raw value display
|
||||
return str(input_value)
|
||||
|
||||
|
||||
def validate_display_mode(mode: str) -> bool:
|
||||
"""Validate if the display mode is supported.
|
||||
|
||||
Args:
|
||||
mode: Display mode to validate
|
||||
|
||||
Returns:
|
||||
True if mode is valid, False otherwise
|
||||
"""
|
||||
valid_modes = ["raw value", "tensor shape"]
|
||||
return mode in valid_modes
|
||||
@@ -0,0 +1,66 @@
|
||||
"""DisplayAny node for ComfyUI - displays any input value or tensor information."""
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import format_display_value, validate_display_mode
|
||||
|
||||
|
||||
# Define AnyType for wildcard input matching
|
||||
class AnyType(str):
|
||||
"""A special type that matches any input type in ComfyUI."""
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
"""Display any input value or tensor shape information.
|
||||
|
||||
This node can display any type of input in two modes:
|
||||
- Raw value: Shows the string representation of the input
|
||||
- Tensor shape: Extracts and displays shapes of any tensors in the input
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {
|
||||
"input": (AnyType("*"), {}), # Accept any type of input
|
||||
"mode": (["raw value", "tensor shape"],),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs) -> bool:
|
||||
"""Validate inputs - always returns True as we accept any input."""
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("display_text",)
|
||||
FUNCTION = "display"
|
||||
OUTPUT_NODE = True # This node displays output in the UI
|
||||
|
||||
def display(self, input: Any, mode: str = "raw value") -> Dict[str, Any]:
|
||||
"""Display the input value according to the selected mode.
|
||||
|
||||
Args:
|
||||
input: Any input value to display
|
||||
mode: Display mode - "raw value" or "tensor shape"
|
||||
|
||||
Returns:
|
||||
Dictionary with UI display and result
|
||||
"""
|
||||
# Validate mode
|
||||
if not validate_display_mode(mode):
|
||||
mode = "raw value" # Default to raw value if invalid
|
||||
|
||||
# Format the display text
|
||||
display_text = format_display_value(input, mode)
|
||||
|
||||
# Return both UI display and result
|
||||
return {
|
||||
"ui": {"text": display_text},
|
||||
"result": (display_text,),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Gemini Prompt Engineer node for ComfyUI."""
|
||||
|
||||
from .node import GeminiPromptNode
|
||||
|
||||
__all__ = ["GeminiPromptNode"]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Logic for Gemini API integration and prompt generation."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PROMPT_TEMPLATES
|
||||
|
||||
|
||||
def tensor_to_pil(tensor: np.ndarray) -> Image.Image:
|
||||
"""Convert ComfyUI tensor to PIL Image.
|
||||
|
||||
Args:
|
||||
tensor: Input tensor in ComfyUI format (B, H, W, C)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
# ComfyUI tensors are in [0, 1] range
|
||||
if tensor.ndim == 4:
|
||||
# Take first image from batch
|
||||
tensor = tensor[0]
|
||||
|
||||
# Convert to uint8
|
||||
image_array = (tensor * 255).astype(np.uint8)
|
||||
|
||||
# Convert to PIL
|
||||
return Image.fromarray(image_array, mode="RGB")
|
||||
|
||||
|
||||
def image_to_base64(image: Image.Image, format: str = "PNG") -> str:
|
||||
"""Convert PIL Image to base64 string.
|
||||
|
||||
Args:
|
||||
image: PIL Image object
|
||||
format: Image format (PNG or JPEG)
|
||||
|
||||
Returns:
|
||||
Base64 encoded string
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=format)
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.read()).decode("utf-8")
|
||||
|
||||
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""Get Gemini API key from environment or config.
|
||||
|
||||
Returns:
|
||||
API key string or None if not found
|
||||
"""
|
||||
# Check environment variable first
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
# Check for config file in ComfyUI directory
|
||||
try:
|
||||
config_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "gemini_config.json"
|
||||
)
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
api_key = config.get("api_key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
def analyze_image_with_gemini(
|
||||
image: np.ndarray,
|
||||
prompt_type: str,
|
||||
api_key: Optional[str] = None,
|
||||
custom_prompt: Optional[str] = None,
|
||||
model_name: str = "gemini-1.5-flash",
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""Analyze image using Gemini API and generate appropriate prompt.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
prompt_type: Type of prompt to generate (flux, sdxl, danbooru, video)
|
||||
api_key: Gemini API key (optional, will try to get from env/config)
|
||||
custom_prompt: Custom system prompt to use instead of templates
|
||||
model_name: Gemini model to use (default: gemini-1.5-flash)
|
||||
|
||||
Returns:
|
||||
Tuple of (generated_prompt, error_message)
|
||||
"""
|
||||
# Get API key
|
||||
if not api_key:
|
||||
api_key = get_api_key()
|
||||
|
||||
if not api_key:
|
||||
return (
|
||||
"",
|
||||
"Gemini API key not found. Please set GEMINI_API_KEY environment variable or provide it in the node.",
|
||||
)
|
||||
|
||||
# Convert tensor to PIL image
|
||||
try:
|
||||
pil_image = tensor_to_pil(image)
|
||||
except Exception as e:
|
||||
return "", f"Failed to convert image: {str(e)}"
|
||||
|
||||
# Get system prompt
|
||||
if custom_prompt:
|
||||
system_prompt = custom_prompt
|
||||
else:
|
||||
system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"])
|
||||
|
||||
# Here we would normally make the API call to Gemini
|
||||
# For now, we'll import the google-generativeai library
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError:
|
||||
return (
|
||||
"",
|
||||
"google-generativeai library not installed. Please run: pip install google-generativeai",
|
||||
)
|
||||
|
||||
try:
|
||||
# Configure Gemini
|
||||
genai.configure(api_key=api_key)
|
||||
|
||||
# Create model
|
||||
model = genai.GenerativeModel(model_name)
|
||||
|
||||
# Generate content
|
||||
response = model.generate_content(
|
||||
[
|
||||
system_prompt,
|
||||
pil_image,
|
||||
"Analyze this image and generate an appropriate prompt according to the instructions.",
|
||||
]
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
if response.text:
|
||||
return response.text.strip(), None
|
||||
else:
|
||||
return "", "No response generated from Gemini"
|
||||
|
||||
except Exception as e:
|
||||
return "", f"Gemini API error: {str(e)}"
|
||||
|
||||
|
||||
def validate_prompt_type(prompt_type: str) -> bool:
|
||||
"""Validate if prompt type is supported.
|
||||
|
||||
Args:
|
||||
prompt_type: Type of prompt to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return prompt_type in PROMPT_TEMPLATES
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Dynamic model fetching and caching for Gemini API."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache settings
|
||||
CACHE_DURATION = 3600 * 24 # 24 hours in seconds
|
||||
CACHE_FILE = os.path.join(os.path.dirname(__file__), ".gemini_models_cache.json")
|
||||
|
||||
|
||||
def get_available_models(
|
||||
api_key: Optional[str] = None, silent: bool = False
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""Fetch available Gemini models that support generateContent.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, will try to get from environment.
|
||||
silent: If True, suppress error logging (useful for initial load).
|
||||
|
||||
Returns:
|
||||
Tuple of (model_names_list, model_descriptions_dict)
|
||||
"""
|
||||
# Check cache first
|
||||
cached_data = _load_cache()
|
||||
if cached_data:
|
||||
return cached_data["models"], cached_data["descriptions"]
|
||||
|
||||
# Try to fetch from API
|
||||
try:
|
||||
models, descriptions = _fetch_models_from_api(api_key, silent=silent)
|
||||
if models:
|
||||
_save_cache(models, descriptions)
|
||||
return models, descriptions
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
logger.warning(f"Failed to fetch models from API: {e}")
|
||||
|
||||
# Fall back to defaults
|
||||
from .prompts import DEFAULT_GEMINI_MODELS
|
||||
|
||||
return DEFAULT_GEMINI_MODELS, {}
|
||||
|
||||
|
||||
def _fetch_models_from_api(
|
||||
api_key: Optional[str] = None, silent: bool = False
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""Fetch models from Gemini API.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key.
|
||||
silent: If True, suppress error logging.
|
||||
|
||||
Returns:
|
||||
Tuple of (model_names_list, model_descriptions_dict)
|
||||
"""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError:
|
||||
if not silent:
|
||||
logger.error("google-generativeai not installed")
|
||||
return [], {}
|
||||
|
||||
# Get API key
|
||||
if not api_key:
|
||||
from .logic import get_api_key
|
||||
|
||||
api_key = get_api_key()
|
||||
|
||||
if not api_key:
|
||||
if not silent:
|
||||
logger.debug("No API key available for fetching models")
|
||||
return [], {}
|
||||
|
||||
try:
|
||||
genai.configure(api_key=api_key)
|
||||
|
||||
models = []
|
||||
descriptions = {}
|
||||
|
||||
# Fetch all models
|
||||
for model in genai.list_models():
|
||||
# Only include models that support generateContent
|
||||
if "generateContent" in model.supported_generation_methods:
|
||||
# Remove "models/" prefix from name
|
||||
model_name = model.name.replace("models/", "")
|
||||
models.append(model_name)
|
||||
descriptions[model_name] = model.display_name
|
||||
|
||||
# Sort models by priority (newer versions first)
|
||||
models = _sort_models(models)
|
||||
|
||||
return models, descriptions
|
||||
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
logger.error(f"Error fetching models from API: {e}")
|
||||
return [], {}
|
||||
|
||||
|
||||
def _sort_models(models: List[str]) -> List[str]:
|
||||
"""Sort models by version and capability.
|
||||
|
||||
Prioritizes:
|
||||
1. Newer versions (2.5 > 2.0 > 1.5)
|
||||
2. Non-experimental models
|
||||
3. Flash models for general use
|
||||
"""
|
||||
|
||||
def sort_key(model: str):
|
||||
# Priority scoring
|
||||
score = 0
|
||||
|
||||
# Version priority
|
||||
if "2.5" in model:
|
||||
score += 1000
|
||||
elif "2.0" in model:
|
||||
score += 800
|
||||
elif "1.5" in model:
|
||||
score += 600
|
||||
|
||||
# Model type priority
|
||||
if "pro" in model and "preview" not in model and "exp" not in model:
|
||||
score += 100
|
||||
elif "flash" in model and "preview" not in model and "exp" not in model:
|
||||
score += 90
|
||||
|
||||
# Penalize experimental/preview models
|
||||
if "exp" in model or "experimental" in model:
|
||||
score -= 50
|
||||
if "preview" in model:
|
||||
score -= 30
|
||||
|
||||
# Penalize specific variants
|
||||
if "thinking" in model:
|
||||
score -= 100
|
||||
if "tts" in model:
|
||||
score -= 100
|
||||
if "lite" in model:
|
||||
score -= 20
|
||||
|
||||
return -score # Negative for descending sort
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
|
||||
|
||||
def _load_cache() -> Optional[Dict]:
|
||||
"""Load cached model data if available and not expired."""
|
||||
if not os.path.exists(CACHE_FILE):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if cache is expired
|
||||
if time.time() - data.get("timestamp", 0) > CACHE_DURATION:
|
||||
return None
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_cache(models: List[str], descriptions: Dict[str, str]) -> None:
|
||||
"""Save model data to cache."""
|
||||
try:
|
||||
data = {
|
||||
"models": models,
|
||||
"descriptions": descriptions,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
with open(CACHE_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save cache: {e}")
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear the model cache."""
|
||||
if os.path.exists(CACHE_FILE):
|
||||
try:
|
||||
os.remove(CACHE_FILE)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clear cache: {e}")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Gemini Prompt Engineer node implementation."""
|
||||
|
||||
import torch
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
from .logic import analyze_image_with_gemini, validate_prompt_type
|
||||
from .prompts import PROMPT_OPTIONS, DEFAULT_GEMINI_MODELS
|
||||
from .models import get_available_models
|
||||
|
||||
|
||||
class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
# Get available models dynamically (silent mode for initial load)
|
||||
models, _ = get_available_models(silent=True)
|
||||
|
||||
# Use default if no models available
|
||||
if not models:
|
||||
models = DEFAULT_GEMINI_MODELS
|
||||
|
||||
# Find best default model
|
||||
default_model = models[0] if models else "gemini-2.5-flash"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"prompt_type": (PROMPT_OPTIONS, {"default": "flux"}),
|
||||
"model": (models, {"default": default_model}),
|
||||
},
|
||||
"optional": {
|
||||
"api_key": ("STRING", {"default": "", "multiline": False}),
|
||||
"custom_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Optional: Enter custom system prompt instead of using templates",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("prompt", "negative_prompt")
|
||||
FUNCTION = "generate_prompt"
|
||||
CATEGORY = "ComfyAssets"
|
||||
|
||||
DESCRIPTION = """
|
||||
Analyzes images using Google's Gemini AI to generate optimized prompts.
|
||||
|
||||
Supports multiple prompt formats:
|
||||
- FLUX: Detailed artistic prompts with quality markers
|
||||
- SDXL: Positive/negative prompt pairs with weight emphasis
|
||||
- Danbooru: Anime-style booru tags with underscores
|
||||
- Video: Motion and temporal descriptions for video generation
|
||||
|
||||
Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
|
||||
Install: pip install google-generativeai
|
||||
"""
|
||||
|
||||
def generate_prompt(self, image, prompt_type, model, api_key="", custom_prompt=""):
|
||||
"""Generate prompt from image using Gemini.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
prompt_type: Type of prompt to generate
|
||||
model: Gemini model to use
|
||||
api_key: Optional API key
|
||||
custom_prompt: Optional custom system prompt
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt, negative_prompt)
|
||||
"""
|
||||
# Validate prompt type
|
||||
if not validate_prompt_type(prompt_type):
|
||||
raise ValueError(f"Invalid prompt type: {prompt_type}")
|
||||
|
||||
# Convert torch tensor to numpy if needed
|
||||
if isinstance(image, torch.Tensor):
|
||||
image_np = image.cpu().numpy()
|
||||
else:
|
||||
image_np = image
|
||||
|
||||
# If API key is provided, try to refresh model list in background
|
||||
if api_key:
|
||||
try:
|
||||
from .models import get_available_models
|
||||
|
||||
# Try to get fresh models with the provided API key
|
||||
fresh_models, _ = get_available_models(api_key=api_key, silent=True)
|
||||
if fresh_models and fresh_models != DEFAULT_GEMINI_MODELS:
|
||||
# Models were successfully fetched with this API key
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Analyze image with Gemini
|
||||
prompt, error = analyze_image_with_gemini(
|
||||
image_np,
|
||||
prompt_type,
|
||||
api_key=api_key or None,
|
||||
custom_prompt=custom_prompt or None,
|
||||
model_name=model,
|
||||
)
|
||||
|
||||
if error:
|
||||
# Return error as prompt for visibility
|
||||
return (f"Error: {error}", "")
|
||||
|
||||
# Handle different prompt types
|
||||
if prompt_type == "sdxl":
|
||||
# SDXL returns positive and negative prompts
|
||||
lines = prompt.split("\n")
|
||||
positive_prompt = ""
|
||||
negative_prompt = ""
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("Positive:"):
|
||||
positive_prompt = line.replace("Positive:", "").strip()
|
||||
elif line.startswith("Negative:"):
|
||||
negative_prompt = line.replace("Negative:", "").strip()
|
||||
|
||||
# If format not found, assume entire response is positive prompt
|
||||
if not positive_prompt:
|
||||
positive_prompt = prompt
|
||||
|
||||
return (positive_prompt, negative_prompt)
|
||||
|
||||
else:
|
||||
# Other formats don't use negative prompts
|
||||
return (prompt, "")
|
||||
|
||||
|
||||
# Node display name
|
||||
NODE_DISPLAY_NAME = "Gemini Prompt Engineer"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""System prompts for different AI model types."""
|
||||
|
||||
FLUX_PROMPT = """You are an expert FLUX prompt engineer. Analyze the provided image and generate ONLY a FLUX prompt - no explanations, analysis, or additional text.
|
||||
|
||||
FLUX uses natural language descriptions, not comma-separated tags. Write a detailed, flowing description that reads like you're explaining the image to someone.
|
||||
|
||||
Include these elements in your description:
|
||||
- Main subject with specific details (appearance, clothing, expression, pose)
|
||||
- Environment and background details
|
||||
- Lighting conditions and atmosphere
|
||||
- Artistic style or photographic approach
|
||||
- Color palette and mood
|
||||
- Technical details if relevant (camera angle, focal length, etc.)
|
||||
- Textures and materials
|
||||
|
||||
Write in a natural, descriptive style. Use complete sentences that flow together. Be specific and detailed but maintain readability.
|
||||
|
||||
IMPORTANT: Return ONLY the prompt text. No analysis, headers, or additional commentary. Just the natural language description that can be directly used in FLUX.
|
||||
|
||||
Example of correct output:
|
||||
A close-up portrait of a middle-aged woman with curly red hair and green eyes, wearing a blue silk blouse. She has a warm smile and freckles across her cheeks. The lighting is soft and natural, coming from a window to her left, creating gentle shadows that accentuate her features. The background is softly blurred, showing hints of a cozy bookshelf. The overall mood is warm and inviting, captured in a photorealistic style with shallow depth of field."""
|
||||
|
||||
SDXL_PROMPT = """You are an expert SDXL prompt engineer. Analyze the image and generate ONLY the positive and negative prompts for SDXL - no explanations or analysis.
|
||||
|
||||
SDXL works best with natural language descriptions but also supports comma-separated keywords. Keep prompts concise but descriptive.
|
||||
|
||||
Return your response in EXACTLY this format:
|
||||
Positive: [your positive prompt here]
|
||||
Negative: [your negative prompt here]
|
||||
|
||||
Guidelines for Positive prompt:
|
||||
- Start with the main subject and medium (e.g., "photograph of", "digital art of")
|
||||
- Use natural language or keywords separated by commas
|
||||
- Include style descriptors (photographic, cinematic, fantasy art, etc.)
|
||||
- Add quality markers like "8K", "highly detailed", "professional"
|
||||
- Use (parentheses:1.1) sparingly for slight emphasis (max 1.4)
|
||||
- Keep it clear and specific but not overly long
|
||||
|
||||
Guidelines for Negative prompt:
|
||||
- Keep it simple and minimal
|
||||
- Common negatives: ugly, blurry, low quality, distorted, deformed
|
||||
- Only add specifics you want to avoid (e.g., "cartoon" for photorealistic)
|
||||
- Don't overload with negative prompts - SDXL needs fewer than SD1.5
|
||||
|
||||
IMPORTANT: Return ONLY the two lines starting with "Positive:" and "Negative:". No other text."""
|
||||
|
||||
DANBOORU_PROMPT = """You are a Danbooru tagging expert specializing in anime-style image tagging. Analyze the image and generate ONLY Danbooru-style tags - no explanations or analysis.
|
||||
|
||||
CRITICAL: Use strict Danbooru conventions:
|
||||
- Use underscores for multi-word tags (e.g., long_hair, school_uniform)
|
||||
- All tags must be lowercase
|
||||
- Character count comes first (1girl, 2boys, multiple_girls)
|
||||
- For anime models trained on Danbooru data, proper tagging is essential
|
||||
|
||||
Tag order and categories:
|
||||
1. Character count (1girl, solo, 2boys, etc.)
|
||||
2. Character features (hair_color, eye_color, hair_length)
|
||||
3. Expression/pose (smile, looking_at_viewer, sitting)
|
||||
4. Clothing (specific items with underscores)
|
||||
5. Background/setting (simple_background, outdoors, classroom)
|
||||
6. View/composition (upper_body, full_body, from_side)
|
||||
7. Quality tags (masterpiece, best_quality, highres)
|
||||
|
||||
Common quality prefix for anime models:
|
||||
"masterpiece, best_quality, very_aesthetic"
|
||||
|
||||
IMPORTANT: Return ONLY the comma-separated tags. Use underscores, not spaces. All lowercase.
|
||||
|
||||
Example of correct output:
|
||||
1girl, solo, long_hair, blue_eyes, blonde_hair, school_uniform, serafuku, pleated_skirt, smile, looking_at_viewer, classroom, sitting, desk, window, sunlight, upper_body, masterpiece, best_quality"""
|
||||
|
||||
VIDEO_PROMPT = """You are a WAN 2.2 video generation prompt specialist. Analyze the content and generate ONLY a video generation prompt optimized for WAN 2.2 - no explanations or analysis.
|
||||
|
||||
WAN 2.2 excels with rich, descriptive prompts that focus on:
|
||||
- Visual composition and scene elements
|
||||
- Specific movements and actions
|
||||
- Lighting and aesthetic details
|
||||
- Cinematographic elements
|
||||
|
||||
Write a single detailed paragraph describing the video scene. Focus on:
|
||||
- Main subjects and their actions
|
||||
- Visual style and atmosphere
|
||||
- Movement dynamics (use words like "intensely", "smoothly", "rapidly")
|
||||
- Environmental details and lighting
|
||||
- Specific visual elements and their interactions
|
||||
|
||||
Keep the prompt descriptive but concise. WAN 2.2 works best with natural language that paints a clear picture of the desired video.
|
||||
|
||||
IMPORTANT: Return ONLY the video prompt as a single descriptive paragraph. No analysis, headers, or additional text.
|
||||
|
||||
Example of correct output:
|
||||
Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage, their movements fluid and dynamic as they exchange rapid punches under dramatic theater lighting that casts long shadows across the ring, with the crowd visible as blurred silhouettes in the darkened background."""
|
||||
|
||||
PROMPT_TEMPLATES = {
|
||||
"flux": FLUX_PROMPT,
|
||||
"sdxl": SDXL_PROMPT,
|
||||
"danbooru": DANBOORU_PROMPT,
|
||||
"video": VIDEO_PROMPT,
|
||||
}
|
||||
|
||||
PROMPT_OPTIONS = ["flux", "sdxl", "danbooru", "video"]
|
||||
|
||||
# Default models list (fallback if API is unavailable)
|
||||
DEFAULT_GEMINI_MODELS = [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-pro",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""ImageToMultipleOf tool for ComfyUI-KikoTools."""
|
||||
|
||||
from .node import ImageToMultipleOfNode
|
||||
|
||||
__all__ = ["ImageToMultipleOfNode"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Core logic for ImageToMultipleOf tool."""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def calculate_dimensions_to_multiple(
|
||||
height: int, width: int, multiple_of: int
|
||||
) -> Tuple[int, int]:
|
||||
"""Calculate new dimensions that are multiples of the specified value.
|
||||
|
||||
Args:
|
||||
height: Original height
|
||||
width: Original width
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
|
||||
Returns:
|
||||
Tuple of (new_height, new_width)
|
||||
"""
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
return new_height, new_width
|
||||
|
||||
|
||||
def process_image_to_multiple_of(
|
||||
image: Tensor, multiple_of: int, method: str
|
||||
) -> Tensor:
|
||||
"""Process image to ensure dimensions are multiples of specified value.
|
||||
|
||||
Args:
|
||||
image: Input image tensor of shape (batch, height, width, channels)
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
method: Processing method - "center crop" or "rescale"
|
||||
|
||||
Returns:
|
||||
Processed image tensor
|
||||
"""
|
||||
_, height, width, _ = image.shape
|
||||
new_height, new_width = calculate_dimensions_to_multiple(height, width, multiple_of)
|
||||
|
||||
if method == "rescale":
|
||||
# Rescale the image to the new dimensions
|
||||
# Convert from BHWC to BCHW for interpolation
|
||||
image_chw = image.permute(0, 3, 1, 2)
|
||||
rescaled = F.interpolate(
|
||||
image_chw,
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
# Convert back to BHWC
|
||||
return rescaled.permute(0, 2, 3, 1)
|
||||
else: # center crop
|
||||
# Calculate crop offsets to center the crop
|
||||
top = (height - new_height) // 2
|
||||
left = (width - new_width) // 2
|
||||
bottom = top + new_height
|
||||
right = left + new_width
|
||||
return image[:, top:bottom, left:right, :]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""ComfyUI node implementation for ImageToMultipleOf."""
|
||||
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from torch import Tensor
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import process_image_to_multiple_of
|
||||
|
||||
|
||||
class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Adjusts image dimensions to be multiples of a specified value.
|
||||
|
||||
Useful for models that require specific dimension constraints.
|
||||
Supports both center cropping and rescaling methods.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"multiple_of": (
|
||||
"INT",
|
||||
{
|
||||
"default": 64,
|
||||
"min": 1,
|
||||
"max": 256,
|
||||
"step": 16,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
"method": (["center crop", "rescale"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
|
||||
"""
|
||||
Process image to ensure dimensions are multiples of specified value.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
method: Processing method - "center crop" or "rescale"
|
||||
|
||||
Returns:
|
||||
Tuple containing processed image tensor
|
||||
"""
|
||||
try:
|
||||
self.validate_inputs(image=image, multiple_of=multiple_of, method=method)
|
||||
|
||||
# Process the image
|
||||
processed_image = process_image_to_multiple_of(image, multiple_of, method)
|
||||
|
||||
_, new_height, new_width, _ = processed_image.shape
|
||||
self.log_info(
|
||||
f"Processed image from {image.shape[1]}x{image.shape[2]} "
|
||||
f"to {new_height}x{new_width} (multiple of {multiple_of}) "
|
||||
f"using {method}"
|
||||
)
|
||||
|
||||
return (processed_image,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Failed to process image: {str(e)}", e)
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""Validate inputs for ImageToMultipleOf node."""
|
||||
image = kwargs.get("image")
|
||||
multiple_of = kwargs.get("multiple_of")
|
||||
method = kwargs.get("method")
|
||||
|
||||
if image is None:
|
||||
raise ValueError("Image input is required")
|
||||
|
||||
if not isinstance(image, Tensor) or len(image.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected image tensor with shape (batch, height, width, channels), "
|
||||
f"got shape {image.shape if isinstance(image, Tensor) else 'non-tensor'}"
|
||||
)
|
||||
|
||||
if multiple_of <= 0:
|
||||
raise ValueError(f"multiple_of must be positive, got {multiple_of}")
|
||||
|
||||
if method not in ["center crop", "rescale"]:
|
||||
raise ValueError(f"Invalid method: {method}")
|
||||
|
||||
# Check if resulting dimensions would be too small
|
||||
_, height, width, _ = image.shape
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
|
||||
if new_height <= 0 or new_width <= 0:
|
||||
raise ValueError(
|
||||
f"Image dimensions ({height}x{width}) are too small "
|
||||
f"to be adjusted to multiple of {multiple_of}"
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
+54
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
@@ -40,3 +40,56 @@ PublisherId = "kiko9"
|
||||
DisplayName = "ComfyUI-KikoTools"
|
||||
Icon = "https://avatars.githubusercontent.com/u/213204677?s=200"
|
||||
includes = []
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py310']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
ignore_missing_imports = true
|
||||
no_strict_optional = true
|
||||
files = ["kikotools"]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra -q --strict-markers"
|
||||
markers = [
|
||||
"unit: Unit tests",
|
||||
"integration: Integration tests",
|
||||
"slow: Slow tests"
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["kikotools"]
|
||||
omit = ["*/tests/*", "*/__init__.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if False:"
|
||||
]
|
||||
|
||||
+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"
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Unit tests for DisplayAny node."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.display_any import DisplayAnyNode
|
||||
from kikotools.tools.display_any.logic import (
|
||||
format_display_value,
|
||||
get_tensor_shapes,
|
||||
validate_display_mode,
|
||||
)
|
||||
from kikotools.tools.display_any.node import AnyType
|
||||
|
||||
|
||||
class TestAnyType:
|
||||
"""Test cases for AnyType class."""
|
||||
|
||||
def test_anytype_not_equal(self):
|
||||
"""Test that AnyType is never equal to other types."""
|
||||
any_type = AnyType("*")
|
||||
|
||||
# Should not be equal to any other type
|
||||
assert not (any_type != "STRING")
|
||||
assert not (any_type != "IMAGE")
|
||||
assert not (any_type != "LATENT")
|
||||
assert not (any_type != 123)
|
||||
assert not (any_type != None)
|
||||
assert not (any_type != ["LIST"])
|
||||
|
||||
def test_anytype_string_representation(self):
|
||||
"""Test string representation of AnyType."""
|
||||
any_type = AnyType("*")
|
||||
assert str(any_type) == "*"
|
||||
|
||||
|
||||
class TestDisplayAnyNode:
|
||||
"""Test cases for DisplayAnyNode."""
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert DisplayAnyNode.CATEGORY == "ComfyAssets"
|
||||
assert DisplayAnyNode.FUNCTION == "display"
|
||||
assert DisplayAnyNode.RETURN_TYPES == ("STRING",)
|
||||
assert DisplayAnyNode.RETURN_NAMES == ("display_text",)
|
||||
assert DisplayAnyNode.OUTPUT_NODE is True
|
||||
|
||||
def test_input_types(self):
|
||||
"""Test INPUT_TYPES configuration."""
|
||||
input_types = DisplayAnyNode.INPUT_TYPES()
|
||||
|
||||
# Check required inputs
|
||||
assert "required" in input_types
|
||||
assert "input" in input_types["required"]
|
||||
# Check that input is AnyType with wildcard
|
||||
input_type = input_types["required"]["input"]
|
||||
assert len(input_type) == 2
|
||||
assert isinstance(input_type[0], AnyType)
|
||||
assert str(input_type[0]) == "*"
|
||||
assert input_type[1] == {}
|
||||
assert "mode" in input_types["required"]
|
||||
assert input_types["required"]["mode"] == (["raw value", "tensor shape"],)
|
||||
|
||||
def test_validate_inputs(self):
|
||||
"""Test VALIDATE_INPUTS always returns True."""
|
||||
assert DisplayAnyNode.VALIDATE_INPUTS() is True
|
||||
assert DisplayAnyNode.VALIDATE_INPUTS(input="test") is True
|
||||
assert DisplayAnyNode.VALIDATE_INPUTS(input=123, mode="raw value") is True
|
||||
|
||||
def test_display_raw_value_string(self):
|
||||
"""Test displaying raw string value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display("Hello, World!", "raw value")
|
||||
|
||||
assert "ui" in result
|
||||
assert "text" in result["ui"]
|
||||
assert result["ui"]["text"] == "Hello, World!"
|
||||
assert "result" in result
|
||||
assert result["result"] == ("Hello, World!",)
|
||||
|
||||
def test_display_raw_value_number(self):
|
||||
"""Test displaying raw number value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display(42, "raw value")
|
||||
|
||||
assert result["ui"]["text"] == "42"
|
||||
assert result["result"] == ("42",)
|
||||
|
||||
def test_display_raw_value_list(self):
|
||||
"""Test displaying raw list value."""
|
||||
node = DisplayAnyNode()
|
||||
test_list = [1, 2, 3, "test"]
|
||||
result = node.display(test_list, "raw value")
|
||||
|
||||
assert result["ui"]["text"] == str(test_list)
|
||||
assert result["result"] == (str(test_list),)
|
||||
|
||||
def test_display_raw_value_dict(self):
|
||||
"""Test displaying raw dictionary value."""
|
||||
node = DisplayAnyNode()
|
||||
test_dict = {"key": "value", "number": 123}
|
||||
result = node.display(test_dict, "raw value")
|
||||
|
||||
assert result["ui"]["text"] == str(test_dict)
|
||||
assert result["result"] == (str(test_dict),)
|
||||
|
||||
def test_display_tensor_shape_numpy(self):
|
||||
"""Test displaying numpy tensor shape."""
|
||||
node = DisplayAnyNode()
|
||||
tensor = np.random.rand(4, 3, 224, 224)
|
||||
result = node.display(tensor, "tensor shape")
|
||||
|
||||
assert result["ui"]["text"] == "[[4, 3, 224, 224]]"
|
||||
assert result["result"] == ("[[4, 3, 224, 224]]",)
|
||||
|
||||
@pytest.mark.skipif(not torch, reason="PyTorch not installed")
|
||||
def test_display_tensor_shape_torch(self):
|
||||
"""Test displaying PyTorch tensor shape."""
|
||||
node = DisplayAnyNode()
|
||||
tensor = torch.randn(2, 10, 512, 512)
|
||||
result = node.display(tensor, "tensor shape")
|
||||
|
||||
assert result["ui"]["text"] == "[[2, 10, 512, 512]]"
|
||||
assert result["result"] == ("[[2, 10, 512, 512]]",)
|
||||
|
||||
def test_display_nested_tensors(self):
|
||||
"""Test displaying shapes from nested structure with tensors."""
|
||||
node = DisplayAnyNode()
|
||||
nested_data = {
|
||||
"images": np.random.rand(1, 3, 256, 256),
|
||||
"masks": [
|
||||
np.random.rand(256, 256),
|
||||
np.random.rand(256, 256, 1),
|
||||
],
|
||||
"metadata": {"info": "test", "tensor": np.random.rand(10)},
|
||||
}
|
||||
result = node.display(nested_data, "tensor shape")
|
||||
|
||||
expected = "[[1, 3, 256, 256], [256, 256], [256, 256, 1], [10]]"
|
||||
assert result["ui"]["text"] == expected
|
||||
assert result["result"] == (expected,)
|
||||
|
||||
def test_display_no_tensors(self):
|
||||
"""Test displaying when no tensors are present."""
|
||||
node = DisplayAnyNode()
|
||||
data = {"text": "hello", "number": 42, "list": [1, 2, 3]}
|
||||
result = node.display(data, "tensor shape")
|
||||
|
||||
assert result["ui"]["text"] == "No tensors found in input"
|
||||
assert result["result"] == ("No tensors found in input",)
|
||||
|
||||
def test_invalid_mode_defaults_to_raw(self):
|
||||
"""Test that invalid mode defaults to raw value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display("test", "invalid_mode")
|
||||
|
||||
assert result["ui"]["text"] == "test"
|
||||
assert result["result"] == ("test",)
|
||||
|
||||
|
||||
class TestDisplayAnyLogic:
|
||||
"""Test cases for DisplayAny logic functions."""
|
||||
|
||||
def test_get_tensor_shapes_single(self):
|
||||
"""Test getting shape from single tensor."""
|
||||
tensor = np.random.rand(3, 224, 224)
|
||||
shapes = get_tensor_shapes(tensor)
|
||||
|
||||
assert len(shapes) == 1
|
||||
assert shapes[0] == [3, 224, 224]
|
||||
|
||||
def test_get_tensor_shapes_nested_dict(self):
|
||||
"""Test getting shapes from nested dictionary."""
|
||||
data = {
|
||||
"level1": {
|
||||
"tensor1": np.random.rand(10, 20),
|
||||
"level2": {"tensor2": np.random.rand(5, 5, 5)},
|
||||
}
|
||||
}
|
||||
shapes = get_tensor_shapes(data)
|
||||
|
||||
assert len(shapes) == 2
|
||||
assert [10, 20] in shapes
|
||||
assert [5, 5, 5] in shapes
|
||||
|
||||
def test_get_tensor_shapes_nested_list(self):
|
||||
"""Test getting shapes from nested list."""
|
||||
data = [
|
||||
np.random.rand(1, 2, 3),
|
||||
[np.random.rand(4, 5), np.random.rand(6, 7, 8)],
|
||||
"not a tensor",
|
||||
]
|
||||
shapes = get_tensor_shapes(data)
|
||||
|
||||
assert len(shapes) == 3
|
||||
assert [1, 2, 3] in shapes
|
||||
assert [4, 5] in shapes
|
||||
assert [6, 7, 8] in shapes
|
||||
|
||||
def test_get_tensor_shapes_tuple(self):
|
||||
"""Test getting shapes from tuple."""
|
||||
data = (np.random.rand(2, 2), np.random.rand(3, 3))
|
||||
shapes = get_tensor_shapes(data)
|
||||
|
||||
assert len(shapes) == 2
|
||||
assert [2, 2] in shapes
|
||||
assert [3, 3] in shapes
|
||||
|
||||
def test_format_display_value_raw(self):
|
||||
"""Test formatting for raw value display."""
|
||||
result = format_display_value({"key": "value"}, "raw value")
|
||||
assert result == "{'key': 'value'}"
|
||||
|
||||
def test_format_display_value_tensor_shape(self):
|
||||
"""Test formatting for tensor shape display."""
|
||||
tensor = np.random.rand(10, 10)
|
||||
result = format_display_value(tensor, "tensor shape")
|
||||
assert result == "[[10, 10]]"
|
||||
|
||||
def test_format_display_value_no_tensors(self):
|
||||
"""Test formatting when no tensors present."""
|
||||
result = format_display_value("just a string", "tensor shape")
|
||||
assert result == "No tensors found in input"
|
||||
|
||||
def test_validate_display_mode(self):
|
||||
"""Test display mode validation."""
|
||||
assert validate_display_mode("raw value") is True
|
||||
assert validate_display_mode("tensor shape") is True
|
||||
assert validate_display_mode("invalid") is False
|
||||
assert validate_display_mode("") is False
|
||||
assert validate_display_mode(None) is False
|
||||
|
||||
|
||||
class TestDisplayAnyEdgeCases:
|
||||
"""Test edge cases for DisplayAny."""
|
||||
|
||||
def test_display_none(self):
|
||||
"""Test displaying None value."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display(None, "raw value")
|
||||
assert result["ui"]["text"] == "None"
|
||||
|
||||
def test_display_empty_list(self):
|
||||
"""Test displaying empty list."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display([], "raw value")
|
||||
assert result["ui"]["text"] == "[]"
|
||||
|
||||
def test_display_empty_dict(self):
|
||||
"""Test displaying empty dictionary."""
|
||||
node = DisplayAnyNode()
|
||||
result = node.display({}, "raw value")
|
||||
assert result["ui"]["text"] == "{}"
|
||||
|
||||
def test_display_complex_nested_structure(self):
|
||||
"""Test displaying complex nested structure."""
|
||||
node = DisplayAnyNode()
|
||||
complex_data = {
|
||||
"images": [np.random.rand(1, 3, 64, 64) for _ in range(3)],
|
||||
"config": {
|
||||
"steps": 20,
|
||||
"cfg": 7.5,
|
||||
"sampler": "euler",
|
||||
"latents": np.random.rand(1, 4, 32, 32),
|
||||
},
|
||||
"prompts": ["test1", "test2"],
|
||||
}
|
||||
result = node.display(complex_data, "tensor shape")
|
||||
|
||||
# Should find 4 tensors total (3 images + 1 latent)
|
||||
shapes_text = result["ui"]["text"]
|
||||
assert "[1, 3, 64, 64]" in shapes_text
|
||||
assert "[1, 4, 32, 32]" in shapes_text
|
||||
|
||||
def test_display_very_long_string(self):
|
||||
"""Test displaying very long string."""
|
||||
node = DisplayAnyNode()
|
||||
long_string = "x" * 10000
|
||||
result = node.display(long_string, "raw value")
|
||||
assert result["ui"]["text"] == long_string
|
||||
|
||||
def test_display_unicode(self):
|
||||
"""Test displaying unicode characters."""
|
||||
node = DisplayAnyNode()
|
||||
unicode_text = "Hello 世界 🌍"
|
||||
result = node.display(unicode_text, "raw value")
|
||||
assert result["ui"]["text"] == unicode_text
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Unit tests for Gemini Prompt Engineer node."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import patch, MagicMock
|
||||
from PIL import Image
|
||||
|
||||
from kikotools.tools.gemini_prompt import GeminiPromptNode
|
||||
from kikotools.tools.gemini_prompt.logic import (
|
||||
tensor_to_pil,
|
||||
image_to_base64,
|
||||
get_api_key,
|
||||
validate_prompt_type,
|
||||
analyze_image_with_gemini,
|
||||
)
|
||||
from kikotools.tools.gemini_prompt.prompts import (
|
||||
PROMPT_OPTIONS,
|
||||
PROMPT_TEMPLATES,
|
||||
GEMINI_MODELS,
|
||||
)
|
||||
|
||||
|
||||
class TestGeminiPromptNode:
|
||||
"""Test cases for GeminiPromptNode."""
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert GeminiPromptNode.CATEGORY == "ComfyAssets"
|
||||
assert GeminiPromptNode.FUNCTION == "generate_prompt"
|
||||
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
|
||||
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
|
||||
|
||||
def test_input_types(self):
|
||||
"""Test INPUT_TYPES configuration."""
|
||||
input_types = GeminiPromptNode.INPUT_TYPES()
|
||||
|
||||
# Check required inputs
|
||||
assert "required" in input_types
|
||||
assert "image" in input_types["required"]
|
||||
assert input_types["required"]["image"] == ("IMAGE",)
|
||||
assert "prompt_type" in input_types["required"]
|
||||
assert input_types["required"]["prompt_type"][0] == PROMPT_OPTIONS
|
||||
assert "model" in input_types["required"]
|
||||
assert input_types["required"]["model"][0] == GEMINI_MODELS
|
||||
|
||||
# Check optional inputs
|
||||
assert "optional" in input_types
|
||||
assert "api_key" in input_types["optional"]
|
||||
assert "custom_prompt" in input_types["optional"]
|
||||
|
||||
def test_gemini_models_available(self):
|
||||
"""Test that all expected Gemini models are available."""
|
||||
expected_models = [
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-pro-vision",
|
||||
"gemini-1.0-pro",
|
||||
]
|
||||
for model in expected_models:
|
||||
assert model in GEMINI_MODELS
|
||||
|
||||
@patch("kikotools.tools.gemini_prompt.node.analyze_image_with_gemini")
|
||||
def test_generate_prompt_success(self, mock_analyze):
|
||||
"""Test successful prompt generation."""
|
||||
# Setup
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
mock_analyze.return_value = ("A beautiful landscape with mountains", None)
|
||||
|
||||
# Execute
|
||||
result = node.generate_prompt(test_image, "flux")
|
||||
|
||||
# Assert
|
||||
assert result == ("A beautiful landscape with mountains", "")
|
||||
mock_analyze.assert_called_once()
|
||||
|
||||
@patch("kikotools.tools.gemini_prompt.node.analyze_image_with_gemini")
|
||||
def test_generate_prompt_sdxl_format(self, mock_analyze):
|
||||
"""Test SDXL format with positive and negative prompts."""
|
||||
# Setup
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
mock_analyze.return_value = (
|
||||
"Positive: beautiful landscape, mountains, sunset\nNegative: blurry, low quality",
|
||||
None,
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = node.generate_prompt(test_image, "sdxl")
|
||||
|
||||
# Assert
|
||||
assert result == (
|
||||
"beautiful landscape, mountains, sunset",
|
||||
"blurry, low quality",
|
||||
)
|
||||
|
||||
@patch("kikotools.tools.gemini_prompt.node.analyze_image_with_gemini")
|
||||
def test_generate_prompt_error(self, mock_analyze):
|
||||
"""Test error handling in prompt generation."""
|
||||
# Setup
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
mock_analyze.return_value = ("", "API key not found")
|
||||
|
||||
# Execute
|
||||
result = node.generate_prompt(test_image, "flux")
|
||||
|
||||
# Assert
|
||||
assert result[0].startswith("Error:")
|
||||
assert result[1] == ""
|
||||
|
||||
def test_invalid_prompt_type(self):
|
||||
"""Test handling of invalid prompt type."""
|
||||
node = GeminiPromptNode()
|
||||
test_image = np.random.rand(1, 512, 512, 3).astype(np.float32)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid prompt type"):
|
||||
node.generate_prompt(test_image, "invalid_type")
|
||||
|
||||
|
||||
class TestGeminiLogic:
|
||||
"""Test cases for Gemini logic functions."""
|
||||
|
||||
def test_tensor_to_pil(self):
|
||||
"""Test tensor to PIL conversion."""
|
||||
# Test 4D tensor
|
||||
tensor_4d = np.random.rand(1, 64, 64, 3)
|
||||
result = tensor_to_pil(tensor_4d)
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.size == (64, 64)
|
||||
assert result.mode == "RGB"
|
||||
|
||||
# Test 3D tensor
|
||||
tensor_3d = np.random.rand(64, 64, 3)
|
||||
result = tensor_to_pil(tensor_3d)
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.size == (64, 64)
|
||||
|
||||
def test_image_to_base64(self):
|
||||
"""Test image to base64 conversion."""
|
||||
# Create test image
|
||||
image = Image.new("RGB", (64, 64), color="red")
|
||||
|
||||
# Convert to base64
|
||||
result = image_to_base64(image)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
# Test JPEG format
|
||||
result_jpeg = image_to_base64(image, format="JPEG")
|
||||
assert isinstance(result_jpeg, str)
|
||||
assert (
|
||||
result != result_jpeg
|
||||
) # Different formats should produce different results
|
||||
|
||||
@patch.dict("os.environ", {"GEMINI_API_KEY": "test_key_123"})
|
||||
def test_get_api_key_from_env(self):
|
||||
"""Test getting API key from environment."""
|
||||
result = get_api_key()
|
||||
assert result == "test_key_123"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
@patch("os.path.exists")
|
||||
@patch("builtins.open")
|
||||
def test_get_api_key_from_config(self, mock_open, mock_exists):
|
||||
"""Test getting API key from config file."""
|
||||
# Setup
|
||||
mock_exists.return_value = True
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = (
|
||||
'{"api_key": "config_key_456"}'
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = get_api_key()
|
||||
|
||||
# Assert
|
||||
assert result == "config_key_456"
|
||||
|
||||
def test_validate_prompt_type(self):
|
||||
"""Test prompt type validation."""
|
||||
# Valid types
|
||||
for prompt_type in PROMPT_OPTIONS:
|
||||
assert validate_prompt_type(prompt_type) is True
|
||||
|
||||
# Invalid types
|
||||
assert validate_prompt_type("invalid") is False
|
||||
assert validate_prompt_type("") is False
|
||||
assert validate_prompt_type(None) is False
|
||||
|
||||
@patch("google.generativeai.configure")
|
||||
@patch("google.generativeai.GenerativeModel")
|
||||
def test_analyze_image_with_gemini_success(self, mock_model_class, mock_configure):
|
||||
"""Test successful image analysis with Gemini."""
|
||||
# Setup
|
||||
mock_model = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "A beautiful sunset over mountains"
|
||||
mock_model.generate_content.return_value = mock_response
|
||||
mock_model_class.return_value = mock_model
|
||||
|
||||
test_image = np.random.rand(64, 64, 3)
|
||||
|
||||
# Execute
|
||||
result, error = analyze_image_with_gemini(
|
||||
test_image, "flux", api_key="test_key"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == "A beautiful sunset over mountains"
|
||||
assert error is None
|
||||
mock_configure.assert_called_once_with(api_key="test_key")
|
||||
mock_model.generate_content.assert_called_once()
|
||||
|
||||
def test_analyze_image_no_api_key(self):
|
||||
"""Test analysis without API key."""
|
||||
test_image = np.random.rand(64, 64, 3)
|
||||
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.logic.get_api_key", return_value=None
|
||||
):
|
||||
result, error = analyze_image_with_gemini(test_image, "flux")
|
||||
|
||||
assert result == ""
|
||||
assert "API key not found" in error
|
||||
|
||||
@patch("google.generativeai.configure")
|
||||
@patch("google.generativeai.GenerativeModel")
|
||||
def test_analyze_image_with_custom_prompt(self, mock_model_class, mock_configure):
|
||||
"""Test analysis with custom prompt."""
|
||||
# Setup
|
||||
mock_model = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Custom analysis result"
|
||||
mock_model.generate_content.return_value = mock_response
|
||||
mock_model_class.return_value = mock_model
|
||||
|
||||
test_image = np.random.rand(64, 64, 3)
|
||||
custom_prompt = "Analyze this image and describe the colors"
|
||||
|
||||
# Execute
|
||||
result, error = analyze_image_with_gemini(
|
||||
test_image, "flux", api_key="test_key", custom_prompt=custom_prompt
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == "Custom analysis result"
|
||||
assert error is None
|
||||
|
||||
# Check that custom prompt was used
|
||||
call_args = mock_model.generate_content.call_args[0][0]
|
||||
assert custom_prompt in call_args
|
||||
|
||||
|
||||
class TestPromptTemplates:
|
||||
"""Test prompt template configurations."""
|
||||
|
||||
def test_all_prompt_types_have_templates(self):
|
||||
"""Test that all prompt options have corresponding templates."""
|
||||
for prompt_type in PROMPT_OPTIONS:
|
||||
assert prompt_type in PROMPT_TEMPLATES
|
||||
assert isinstance(PROMPT_TEMPLATES[prompt_type], str)
|
||||
assert len(PROMPT_TEMPLATES[prompt_type]) > 0
|
||||
|
||||
def test_prompt_template_content(self):
|
||||
"""Test that prompt templates contain expected content."""
|
||||
# FLUX prompt should mention FLUX
|
||||
assert "FLUX" in PROMPT_TEMPLATES["flux"]
|
||||
|
||||
# SDXL prompt should mention positive and negative
|
||||
assert "Positive" in PROMPT_TEMPLATES["sdxl"]
|
||||
assert "Negative" in PROMPT_TEMPLATES["sdxl"]
|
||||
|
||||
# Danbooru should mention tags and underscores
|
||||
assert "tag" in PROMPT_TEMPLATES["danbooru"].lower()
|
||||
assert "underscore" in PROMPT_TEMPLATES["danbooru"].lower()
|
||||
|
||||
# Video should mention motion and temporal
|
||||
assert "motion" in PROMPT_TEMPLATES["video"].lower()
|
||||
assert "temporal" in PROMPT_TEMPLATES["video"].lower()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for ImageToMultipleOf tool."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to the Python path for tests
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from kikotools.tools.image_to_multiple_of.logic import (
|
||||
calculate_dimensions_to_multiple,
|
||||
process_image_to_multiple_of,
|
||||
)
|
||||
from kikotools.tools.image_to_multiple_of.node import ImageToMultipleOfNode
|
||||
|
||||
|
||||
class TestImageToMultipleOfLogic:
|
||||
"""Test core logic functions."""
|
||||
|
||||
def test_calculate_dimensions_to_multiple(self):
|
||||
"""Test dimension calculation for various inputs."""
|
||||
# Test exact multiples
|
||||
assert calculate_dimensions_to_multiple(256, 512, 64) == (256, 512)
|
||||
|
||||
# Test non-exact multiples
|
||||
assert calculate_dimensions_to_multiple(300, 400, 64) == (256, 384)
|
||||
assert calculate_dimensions_to_multiple(150, 200, 32) == (128, 192)
|
||||
|
||||
# Test small values
|
||||
assert calculate_dimensions_to_multiple(10, 20, 8) == (8, 16)
|
||||
|
||||
# Test with multiple_of = 1 (should return original)
|
||||
assert calculate_dimensions_to_multiple(123, 456, 1) == (123, 456)
|
||||
|
||||
def test_process_image_center_crop(self):
|
||||
"""Test center crop processing."""
|
||||
# Create test image (batch=1, height=300, width=400, channels=3)
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
# Process with center crop
|
||||
result = process_image_to_multiple_of(image, 64, "center crop")
|
||||
|
||||
# Check dimensions
|
||||
assert result.shape == (1, 256, 384, 3)
|
||||
|
||||
# Check that center portion is preserved
|
||||
# The crop should start at (22, 8) and end at (278, 392)
|
||||
# This is a rough check that values are from the center
|
||||
assert result.dtype == image.dtype
|
||||
|
||||
def test_process_image_rescale(self):
|
||||
"""Test rescale processing."""
|
||||
# Create test image
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
# Process with rescale
|
||||
result = process_image_to_multiple_of(image, 64, "rescale")
|
||||
|
||||
# Check dimensions
|
||||
assert result.shape == (1, 256, 384, 3)
|
||||
assert result.dtype == image.dtype
|
||||
|
||||
def test_process_image_batch(self):
|
||||
"""Test processing with batch of images."""
|
||||
# Create batch of images
|
||||
batch_size = 4
|
||||
image = torch.rand(batch_size, 300, 400, 3)
|
||||
|
||||
# Process with center crop
|
||||
result_crop = process_image_to_multiple_of(image, 32, "center crop")
|
||||
assert result_crop.shape == (batch_size, 288, 384, 3)
|
||||
|
||||
# Process with rescale
|
||||
result_rescale = process_image_to_multiple_of(image, 32, "rescale")
|
||||
assert result_rescale.shape == (batch_size, 288, 384, 3)
|
||||
|
||||
def test_process_image_different_channels(self):
|
||||
"""Test with different channel counts."""
|
||||
# Test with 1 channel (grayscale)
|
||||
image_gray = torch.rand(1, 256, 256, 1)
|
||||
result = process_image_to_multiple_of(image_gray, 64, "center crop")
|
||||
assert result.shape == (1, 256, 256, 1)
|
||||
|
||||
# Test with 4 channels (RGBA)
|
||||
image_rgba = torch.rand(1, 300, 400, 4)
|
||||
result = process_image_to_multiple_of(image_rgba, 64, "rescale")
|
||||
assert result.shape == (1, 256, 384, 4)
|
||||
|
||||
|
||||
class TestImageToMultipleOfNode:
|
||||
"""Test ComfyUI node implementation."""
|
||||
|
||||
def test_node_input_types(self):
|
||||
"""Test node input type definitions."""
|
||||
input_types = ImageToMultipleOfNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
assert "image" in input_types["required"]
|
||||
assert "multiple_of" in input_types["required"]
|
||||
assert "method" in input_types["required"]
|
||||
|
||||
# Check multiple_of configuration
|
||||
multiple_config = input_types["required"]["multiple_of"][1]
|
||||
assert multiple_config["default"] == 64
|
||||
assert multiple_config["min"] == 1
|
||||
assert multiple_config["max"] == 256
|
||||
assert multiple_config["step"] == 16
|
||||
|
||||
# Check method options
|
||||
methods = input_types["required"]["method"][0]
|
||||
assert "center crop" in methods
|
||||
assert "rescale" in methods
|
||||
|
||||
def test_node_metadata(self):
|
||||
"""Test node metadata."""
|
||||
assert ImageToMultipleOfNode.RETURN_TYPES == ("IMAGE",)
|
||||
assert ImageToMultipleOfNode.RETURN_NAMES == ("image",)
|
||||
assert ImageToMultipleOfNode.FUNCTION == "process"
|
||||
assert ImageToMultipleOfNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
def test_node_process_center_crop(self):
|
||||
"""Test node processing with center crop."""
|
||||
node = ImageToMultipleOfNode()
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
result = node.process(image, 64, "center crop")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 256, 384, 3)
|
||||
|
||||
def test_node_process_rescale(self):
|
||||
"""Test node processing with rescale."""
|
||||
node = ImageToMultipleOfNode()
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
result = node.process(image, 32, "rescale")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 288, 384, 3)
|
||||
|
||||
def test_node_validation_errors(self):
|
||||
"""Test input validation error handling."""
|
||||
node = ImageToMultipleOfNode()
|
||||
|
||||
# Test with None image
|
||||
with pytest.raises(ValueError, match="Image input is required"):
|
||||
node.validate_inputs(image=None, multiple_of=64, method="center crop")
|
||||
|
||||
# Test with invalid image shape
|
||||
invalid_image = torch.rand(300, 400, 3) # Missing batch dimension
|
||||
with pytest.raises(ValueError, match="Expected image tensor with shape"):
|
||||
node.validate_inputs(
|
||||
image=invalid_image, multiple_of=64, method="center crop"
|
||||
)
|
||||
|
||||
# Test with negative multiple_of
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
with pytest.raises(ValueError, match="multiple_of must be positive"):
|
||||
node.validate_inputs(image=image, multiple_of=-64, method="center crop")
|
||||
|
||||
# Test with invalid method
|
||||
with pytest.raises(ValueError, match="Invalid method"):
|
||||
node.validate_inputs(image=image, multiple_of=64, method="invalid")
|
||||
|
||||
# Test with image too small
|
||||
small_image = torch.rand(1, 30, 40, 3)
|
||||
with pytest.raises(ValueError, match="too small to be adjusted"):
|
||||
node.validate_inputs(
|
||||
image=small_image, multiple_of=64, method="center crop"
|
||||
)
|
||||
|
||||
def test_node_edge_cases(self):
|
||||
"""Test edge cases."""
|
||||
node = ImageToMultipleOfNode()
|
||||
|
||||
# Test with already multiple dimensions
|
||||
image = torch.rand(1, 256, 512, 3)
|
||||
result = node.process(image, 64, "center crop")
|
||||
assert result[0].shape == image.shape
|
||||
|
||||
# Test with multiple_of = 1
|
||||
image = torch.rand(1, 123, 456, 3)
|
||||
result = node.process(image, 1, "center crop")
|
||||
assert result[0].shape == image.shape
|
||||
|
||||
# Test with very large multiple_of
|
||||
image = torch.rand(1, 1024, 1024, 3)
|
||||
result = node.process(image, 256, "rescale")
|
||||
assert result[0].shape == (1, 1024, 1024, 3)
|
||||
@@ -0,0 +1,176 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.GeminiPrompt",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeminiPrompt") {
|
||||
// Add visual enhancements to the node
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Store reference to widgets
|
||||
this.promptTypeWidget = this.widgets.find(w => w.name === "prompt_type");
|
||||
this.modelWidget = this.widgets.find(w => w.name === "model");
|
||||
this.apiKeyWidget = this.widgets.find(w => w.name === "api_key");
|
||||
this.customPromptWidget = this.widgets.find(w => w.name === "custom_prompt");
|
||||
|
||||
// Add helper text button
|
||||
const helpButton = this.addWidget("button", "Help / API Setup", null, () => {
|
||||
this.showHelpDialog();
|
||||
});
|
||||
|
||||
// Style the button
|
||||
helpButton.serialize = false;
|
||||
|
||||
// Add status indicator
|
||||
this.status = this.addWidget("text", "status", "Ready", () => {}, {
|
||||
serialize: false
|
||||
});
|
||||
this.status.disabled = true;
|
||||
|
||||
// Update custom prompt visibility based on selection
|
||||
if (this.promptTypeWidget && this.customPromptWidget) {
|
||||
const originalCallback = this.promptTypeWidget.callback;
|
||||
this.promptTypeWidget.callback = (value) => {
|
||||
if (originalCallback) originalCallback.call(this.promptTypeWidget, value);
|
||||
this.updateCustomPromptVisibility();
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Add method to show help dialog
|
||||
nodeType.prototype.showHelpDialog = function() {
|
||||
const helpContent = `
|
||||
<div style="padding: 20px; max-width: 600px;">
|
||||
<h2>Gemini Prompt Engineer Setup</h2>
|
||||
|
||||
<h3>1. Get API Key</h3>
|
||||
<p>Get your free API key from: <a href="https://makersuite.google.com/app/apikey" target="_blank">Google AI Studio</a></p>
|
||||
|
||||
<h3>2. Set API Key</h3>
|
||||
<p>Choose one of these methods:</p>
|
||||
<ul>
|
||||
<li><strong>Environment Variable:</strong> Set GEMINI_API_KEY in your system</li>
|
||||
<li><strong>Config File:</strong> Create gemini_config.json in ComfyUI root with {"api_key": "your-key"}</li>
|
||||
<li><strong>Node Input:</strong> Enter directly in the api_key field</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Install Dependencies</h3>
|
||||
<code>pip install google-generativeai</code>
|
||||
|
||||
<h3>Prompt Types</h3>
|
||||
<ul>
|
||||
<li><strong>FLUX:</strong> Detailed artistic prompts with quality markers</li>
|
||||
<li><strong>SDXL:</strong> Positive/negative prompt pairs with weights</li>
|
||||
<li><strong>Danbooru:</strong> Anime-style booru tags</li>
|
||||
<li><strong>Video:</strong> Motion and temporal descriptions</li>
|
||||
</ul>
|
||||
|
||||
<h3>Gemini Models</h3>
|
||||
<ul>
|
||||
<li><strong>gemini-1.5-flash:</strong> Fast and efficient (recommended for most uses)</li>
|
||||
<li><strong>gemini-1.5-flash-8b:</strong> Smaller and faster, good for simple prompts</li>
|
||||
<li><strong>gemini-1.5-pro:</strong> Most capable, best quality results</li>
|
||||
<li><strong>gemini-1.0-pro:</strong> Previous generation, stable option</li>
|
||||
</ul>
|
||||
|
||||
<h3>Custom Prompts</h3>
|
||||
<p>You can override any template by entering your own system prompt in the custom_prompt field.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.ui.dialog.show(helpContent);
|
||||
};
|
||||
|
||||
// Add method to update custom prompt visibility
|
||||
nodeType.prototype.updateCustomPromptVisibility = function() {
|
||||
// You could implement logic here to show/hide custom prompt based on selection
|
||||
// For now, it's always visible but this method provides extensibility
|
||||
};
|
||||
|
||||
// Override execute to show status
|
||||
const onExecute = nodeType.prototype.onExecute;
|
||||
nodeType.prototype.onExecute = function() {
|
||||
if (this.status) {
|
||||
this.status.value = "Processing...";
|
||||
}
|
||||
const result = onExecute?.apply(this, arguments);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Handle execution feedback
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
const result = onExecuted?.apply(this, arguments);
|
||||
|
||||
if (this.status) {
|
||||
// Check if there was an error in the output
|
||||
const outputs = message.output;
|
||||
if (outputs && outputs.prompt && outputs.prompt[0] && outputs.prompt[0].startsWith("Error:")) {
|
||||
this.status.value = "Error - Check output";
|
||||
this.bgcolor = "#552222";
|
||||
} else {
|
||||
this.status.value = "Success!";
|
||||
this.bgcolor = "#225522";
|
||||
}
|
||||
|
||||
// Reset color after delay
|
||||
setTimeout(() => {
|
||||
this.bgcolor = "";
|
||||
if (this.status) {
|
||||
this.status.value = "Ready";
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Add custom styling
|
||||
async setup() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.gemini-prompt-help {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gemini-prompt-help h2 {
|
||||
color: #4285f4;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gemini-prompt-help h3 {
|
||||
color: #8ab4f8;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.gemini-prompt-help code {
|
||||
background: #333;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.gemini-prompt-help a {
|
||||
color: #8ab4f8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gemini-prompt-help a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
});
|
||||
+129
-129
@@ -51,7 +51,7 @@ kiko-image-viewer.dragging .kiko-viewer-header {
|
||||
max-height: calc(80vh - 80px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
@@ -389,12 +389,12 @@ function formatFileSize(bytes) {
|
||||
// Open image in new tab
|
||||
function openImageInTab(imagePath, subfolder = '', enablePopup = true) {
|
||||
console.log(`KikoSaveImage: Attempting to open image: ${imagePath}, subfolder: ${subfolder}`);
|
||||
|
||||
|
||||
// Note: enablePopup parameter is kept for compatibility but not used
|
||||
// since popup now controls viewer visibility, not individual image clicks
|
||||
|
||||
|
||||
const basePath = window.location.origin;
|
||||
|
||||
|
||||
// Construct proper image URL handling subfolder
|
||||
let fullPath;
|
||||
if (subfolder && subfolder.trim()) {
|
||||
@@ -402,12 +402,12 @@ function openImageInTab(imagePath, subfolder = '', enablePopup = true) {
|
||||
} else {
|
||||
fullPath = `${basePath}/api/view?filename=${encodeURIComponent(imagePath)}&type=output`;
|
||||
}
|
||||
|
||||
|
||||
console.log(`KikoSaveImage: Opening URL: ${fullPath}`);
|
||||
|
||||
|
||||
// Extract clean filename for window name (remove timestamp and batch number)
|
||||
const cleanName = imagePath.split('_').slice(0, -2).join('_') || 'KikoSaveImage';
|
||||
|
||||
|
||||
// Try to open the image with a clean window name
|
||||
const newWindow = window.open(fullPath, cleanName.replace(/[^a-zA-Z0-9]/g, '_'));
|
||||
if (newWindow) {
|
||||
@@ -434,14 +434,14 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.isRolledUp = false;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
this.lastHeaderClick = 0;
|
||||
|
||||
|
||||
// Always start at default position
|
||||
}
|
||||
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['data'];
|
||||
}
|
||||
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (name === 'data' && newValue) {
|
||||
try {
|
||||
@@ -453,7 +453,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setImageData(data) {
|
||||
console.log('KikoImageViewer: setImageData called with:', data);
|
||||
console.log('KikoImageViewer: First image data:', data[0]);
|
||||
@@ -462,7 +462,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
|
||||
setupEventListeners() {
|
||||
// Window dragging and double-click functionality
|
||||
const header = this.querySelector('.kiko-viewer-header');
|
||||
@@ -470,19 +470,19 @@ class KikoImageViewer extends HTMLElement {
|
||||
header.addEventListener('mousedown', this.startDrag.bind(this));
|
||||
header.addEventListener('dblclick', this.handleHeaderDoubleClick.bind(this));
|
||||
}
|
||||
|
||||
|
||||
// Control buttons
|
||||
const minimizeBtn = this.querySelector('.kiko-minimize-btn');
|
||||
const closeBtn = this.querySelector('.kiko-close-btn');
|
||||
|
||||
|
||||
if (minimizeBtn) {
|
||||
minimizeBtn.addEventListener('click', this.toggleMinimize.bind(this));
|
||||
}
|
||||
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', this.close.bind(this));
|
||||
}
|
||||
|
||||
|
||||
// Image selection checkboxes
|
||||
const selectors = this.querySelectorAll('.kiko-image-selector');
|
||||
selectors.forEach((selector, index) => {
|
||||
@@ -491,7 +491,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.toggleImageSelection(index);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Action buttons
|
||||
const actionButtons = this.querySelectorAll('.kiko-action-btn');
|
||||
actionButtons.forEach((btn) => {
|
||||
@@ -502,7 +502,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.handleActionButton(action, index);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Bulk action buttons
|
||||
const bulkButtons = this.querySelectorAll('.kiko-bulk-btn');
|
||||
bulkButtons.forEach((btn) => {
|
||||
@@ -512,68 +512,68 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.handleBulkAction(action);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Global mouse events for dragging
|
||||
document.addEventListener('mousemove', this.handleDrag.bind(this));
|
||||
document.addEventListener('mouseup', this.endDrag.bind(this));
|
||||
}
|
||||
|
||||
|
||||
startDrag(e) {
|
||||
// Don't start dragging if clicking on control buttons
|
||||
if (e.target.closest('.kiko-viewer-controls')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.isDragging = true;
|
||||
const rect = this.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
|
||||
// Prevent text selection while dragging
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
// Add dragging class for visual feedback
|
||||
this.classList.add('dragging');
|
||||
}
|
||||
|
||||
|
||||
handleDrag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
const x = e.clientX - this.dragOffset.x;
|
||||
const y = e.clientY - this.dragOffset.y;
|
||||
|
||||
|
||||
// Keep window within viewport bounds
|
||||
const maxX = window.innerWidth - this.offsetWidth;
|
||||
const maxY = window.innerHeight - this.offsetHeight;
|
||||
|
||||
|
||||
const boundedX = Math.max(0, Math.min(x, maxX));
|
||||
const boundedY = Math.max(0, Math.min(y, maxY));
|
||||
|
||||
|
||||
this.style.left = boundedX + 'px';
|
||||
this.style.top = boundedY + 'px';
|
||||
this.style.right = 'auto'; // Override CSS right positioning
|
||||
}
|
||||
|
||||
|
||||
endDrag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
|
||||
this.isDragging = false;
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
|
||||
handleHeaderDoubleClick(e) {
|
||||
// Don't toggle if clicking on control buttons
|
||||
if (e.target.closest('.kiko-viewer-controls')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.toggleRollUp();
|
||||
}
|
||||
|
||||
|
||||
autoUnrollOnNewImages() {
|
||||
// Auto-unroll if currently rolled up and we have new image data
|
||||
if (this.isRolledUp && this.imageData && this.imageData.length > 0) {
|
||||
@@ -581,22 +581,22 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.classList.remove('rolled-up');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleRollUp() {
|
||||
this.isRolledUp = !this.isRolledUp;
|
||||
|
||||
|
||||
if (this.isRolledUp) {
|
||||
this.classList.add('rolled-up');
|
||||
} else {
|
||||
this.classList.remove('rolled-up');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleMinimize() {
|
||||
this.isMinimized = !this.isMinimized;
|
||||
const container = this.querySelector('.kiko-viewer-container');
|
||||
const minimizeBtn = this.querySelector('.kiko-minimize-btn');
|
||||
|
||||
|
||||
if (container && minimizeBtn) {
|
||||
if (this.isMinimized) {
|
||||
container.style.display = 'none';
|
||||
@@ -609,19 +609,19 @@ class KikoImageViewer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
close() {
|
||||
this.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
show() {
|
||||
this.style.display = 'block';
|
||||
}
|
||||
|
||||
|
||||
toggleImageSelection(index) {
|
||||
const selector = this.querySelector(`[data-index="${index}"].kiko-image-selector`);
|
||||
if (!selector) return;
|
||||
|
||||
|
||||
if (this.selectedImages.has(index)) {
|
||||
this.selectedImages.delete(index);
|
||||
selector.classList.remove('selected');
|
||||
@@ -629,36 +629,36 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.selectedImages.add(index);
|
||||
selector.classList.add('selected');
|
||||
}
|
||||
|
||||
|
||||
this.updateBulkActionsVisibility();
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
|
||||
updateBulkActionsVisibility() {
|
||||
const bulkActions = this.querySelector('.kiko-bulk-actions');
|
||||
if (bulkActions) {
|
||||
bulkActions.style.display = this.selectedImages.size > 0 ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateSelectedCount() {
|
||||
const countSpan = this.querySelector('.selected-count');
|
||||
if (countSpan) {
|
||||
countSpan.textContent = this.selectedImages.size;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleActionButton(action, index) {
|
||||
const imageData = this.imageData[index];
|
||||
if (!imageData) return;
|
||||
|
||||
|
||||
switch (action) {
|
||||
case 'download':
|
||||
this.downloadImage(imageData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleBulkAction(action) {
|
||||
switch (action) {
|
||||
case 'open-all':
|
||||
@@ -693,16 +693,16 @@ class KikoImageViewer extends HTMLElement {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
openAllImagesWithDelay() {
|
||||
const selectedIndices = Array.from(this.selectedImages);
|
||||
if (selectedIndices.length === 0) {
|
||||
console.log('KikoSaveImage: No images selected for opening');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log(`KikoSaveImage: Opening ${selectedIndices.length} images with delay`);
|
||||
|
||||
|
||||
// Open images with 150ms delay between each to prevent popup blocking
|
||||
selectedIndices.forEach((index, i) => {
|
||||
setTimeout(() => {
|
||||
@@ -713,7 +713,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
}, i * 150);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
downloadImage(imageData) {
|
||||
// Construct proper image URL handling subfolder
|
||||
let imageUrl;
|
||||
@@ -722,7 +722,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
} else {
|
||||
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(imageData.filename)}&type=${imageData.type}`;
|
||||
}
|
||||
|
||||
|
||||
// Create temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
@@ -732,8 +732,8 @@ class KikoImageViewer extends HTMLElement {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.imageData || this.imageData.length === 0) {
|
||||
this.innerHTML = `
|
||||
@@ -746,13 +746,13 @@ class KikoImageViewer extends HTMLElement {
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const header = this.imageData.length === 1
|
||||
|
||||
const header = this.imageData.length === 1
|
||||
? `Saved Image (${this.imageData[0].format})`
|
||||
: `Saved Images (${this.imageData.length} files)`;
|
||||
|
||||
|
||||
const containerStyle = this.isMinimized ? 'style="display: none;"' : '';
|
||||
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="kiko-viewer-header">
|
||||
<div class="kiko-viewer-title">${header}</div>
|
||||
@@ -768,11 +768,11 @@ class KikoImageViewer extends HTMLElement {
|
||||
${this.imageData.length > 1 ? this.createBulkActions() : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
// Add all event handlers
|
||||
this.addClickHandlers();
|
||||
}
|
||||
|
||||
|
||||
createBulkActions() {
|
||||
return `
|
||||
<div class="kiko-bulk-actions" style="margin-top: 12px; padding: 8px; background: #333; border-radius: 6px; display: none;">
|
||||
@@ -788,7 +788,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
createImageItem(data, index) {
|
||||
// Construct proper image URL handling subfolder
|
||||
let imageUrl;
|
||||
@@ -798,12 +798,12 @@ class KikoImageViewer extends HTMLElement {
|
||||
} else {
|
||||
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}`;
|
||||
}
|
||||
|
||||
|
||||
const formatClass = `kiko-format-${data.format.toLowerCase()}`;
|
||||
|
||||
|
||||
// Format file size
|
||||
const fileSize = data.file_size ? formatFileSize(data.file_size) : 'Unknown';
|
||||
|
||||
|
||||
// Build quality info
|
||||
let qualityInfo = '';
|
||||
if (data.format === 'PNG' && data.compress_level !== undefined) {
|
||||
@@ -815,19 +815,19 @@ class KikoImageViewer extends HTMLElement {
|
||||
qualityInfo = `Q${data.quality}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return `
|
||||
<div class="kiko-image-item" data-index="${index}">
|
||||
<img src="${imageUrl}" alt="${data.filename}" loading="lazy" />
|
||||
|
||||
|
||||
${this.imageData.length > 1 ? `
|
||||
<div class="kiko-image-selector" data-index="${index}" title="Select image"></div>
|
||||
` : ''}
|
||||
|
||||
|
||||
<div class="kiko-image-actions">
|
||||
<button class="kiko-action-btn download" data-action="download" data-index="${index}" title="Download">💾</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="kiko-image-info">
|
||||
<div class="kiko-image-filename" title="${data.filename}">
|
||||
${data.filename}
|
||||
@@ -843,7 +843,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
addClickHandlers() {
|
||||
const items = this.querySelectorAll('.kiko-image-item');
|
||||
items.forEach((item, index) => {
|
||||
@@ -869,33 +869,33 @@ if (!customElements.get('kiko-image-viewer')) {
|
||||
function createImagePreview(imageData) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'kiko-save-image-preview';
|
||||
|
||||
|
||||
// Create image element
|
||||
const img = document.createElement('img');
|
||||
const imagePath = `/api/view?filename=${imageData.filename}&type=${imageData.type}`;
|
||||
img.src = imagePath;
|
||||
img.alt = imageData.filename;
|
||||
|
||||
|
||||
// Create info overlay
|
||||
const info = document.createElement('div');
|
||||
info.className = 'kiko-save-image-info';
|
||||
|
||||
|
||||
const formatSpan = document.createElement('span');
|
||||
formatSpan.className = 'kiko-save-image-format';
|
||||
formatSpan.textContent = imageData.format || 'PNG';
|
||||
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'kiko-save-image-size';
|
||||
sizeSpan.textContent = ` • ${imageData.dimensions || 'Unknown'}`;
|
||||
|
||||
|
||||
const fileSizeSpan = document.createElement('span');
|
||||
fileSizeSpan.className = 'kiko-save-image-size';
|
||||
fileSizeSpan.textContent = ` • ${formatFileSize(imageData.file_size || 0)}`;
|
||||
|
||||
|
||||
info.appendChild(formatSpan);
|
||||
info.appendChild(sizeSpan);
|
||||
info.appendChild(fileSizeSpan);
|
||||
|
||||
|
||||
// Add quality info for JPEG/WebP
|
||||
if (imageData.quality && (imageData.format === 'JPEG' || imageData.format === 'WEBP')) {
|
||||
const qualitySpan = document.createElement('span');
|
||||
@@ -912,15 +912,15 @@ function createImagePreview(imageData) {
|
||||
compressSpan.textContent = ` • C${imageData.compress_level}`;
|
||||
info.appendChild(compressSpan);
|
||||
}
|
||||
|
||||
|
||||
container.appendChild(img);
|
||||
container.appendChild(info);
|
||||
|
||||
|
||||
// Add click handler to open in new tab
|
||||
container.addEventListener('click', () => {
|
||||
openImageInTab(imageData.filename, imageData.subfolder || '');
|
||||
});
|
||||
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -928,68 +928,68 @@ function createImagePreview(imageData) {
|
||||
function addFormatIndicator(node, widget) {
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = 'kiko-format-indicator';
|
||||
|
||||
|
||||
const updateIndicator = (format) => {
|
||||
indicator.textContent = format;
|
||||
indicator.className = `kiko-format-indicator kiko-format-${format.toLowerCase()}`;
|
||||
};
|
||||
|
||||
|
||||
// Update indicator when format changes
|
||||
updateIndicator(widget.value);
|
||||
|
||||
|
||||
// Find widget element and append indicator
|
||||
const widgetElement = widget.element || widget.domWidget;
|
||||
if (widgetElement && widgetElement.parentNode) {
|
||||
widgetElement.parentNode.appendChild(indicator);
|
||||
}
|
||||
|
||||
|
||||
return { indicator, updateIndicator };
|
||||
}
|
||||
|
||||
// Register ComfyUI extension
|
||||
app.registerExtension({
|
||||
name: "comfyassets.KikoSaveImage",
|
||||
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "KikoSaveImage") {
|
||||
// Inject styles when node is registered
|
||||
injectStyles();
|
||||
|
||||
|
||||
// Store original onNodeCreated
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
// Call original onNodeCreated
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
|
||||
|
||||
// Add format indicator to format widget
|
||||
const formatWidget = this.widgets?.find(w => w.name === "format");
|
||||
if (formatWidget) {
|
||||
const { indicator, updateIndicator } = addFormatIndicator(this, formatWidget);
|
||||
|
||||
|
||||
// Store update function for later use
|
||||
this.updateFormatIndicator = updateIndicator;
|
||||
}
|
||||
|
||||
|
||||
// Add quality preview for quality widget
|
||||
const qualityWidget = this.widgets?.find(w => w.name === "quality");
|
||||
if (qualityWidget) {
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'kiko-quality-preview';
|
||||
preview.textContent = `Quality: ${qualityWidget.value}%`;
|
||||
|
||||
|
||||
const widgetElement = qualityWidget.element || qualityWidget.domWidget;
|
||||
if (widgetElement && widgetElement.parentNode) {
|
||||
widgetElement.parentNode.appendChild(preview);
|
||||
}
|
||||
|
||||
|
||||
// Store preview element for updates
|
||||
this.qualityPreview = preview;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Override onWidgetChange to update indicators
|
||||
const originalOnWidgetChange = nodeType.prototype.onWidgetChange;
|
||||
nodeType.prototype.onWidgetChange = function(name, value, oldValue, widget) {
|
||||
@@ -997,26 +997,26 @@ app.registerExtension({
|
||||
if (name === "format" && this.updateFormatIndicator) {
|
||||
this.updateFormatIndicator(value);
|
||||
}
|
||||
|
||||
|
||||
// Update quality preview
|
||||
if (name === "quality" && this.qualityPreview) {
|
||||
this.qualityPreview.textContent = `Quality: ${value}%`;
|
||||
}
|
||||
|
||||
|
||||
// Call original handler
|
||||
if (originalOnWidgetChange) {
|
||||
return originalOnWidgetChange.call(this, name, value, oldValue, widget);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Replace the standard image viewer with our custom web component
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
console.log('KikoSaveImage: onExecuted called with message:', message);
|
||||
|
||||
|
||||
// Skip the original ComfyUI preview system
|
||||
// Don't call originalOnExecuted to prevent default image display
|
||||
|
||||
|
||||
// Use our custom web component instead
|
||||
if (message && message.kiko_enhanced && message.kiko_enhanced.length > 0) {
|
||||
console.log('KikoSaveImage: Creating custom image viewer');
|
||||
@@ -1029,20 +1029,20 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add method to create custom image viewer
|
||||
nodeType.prototype.createCustomImageViewer = function(imageData) {
|
||||
console.log('KikoSaveImage: Creating custom viewer for', imageData.length, 'images');
|
||||
|
||||
|
||||
// Check if popup is enabled for any image (use first image's popup setting)
|
||||
const popupEnabled = imageData.length > 0 ? imageData[0].popup : true;
|
||||
console.log('KikoSaveImage: Popup enabled:', popupEnabled);
|
||||
|
||||
|
||||
if (!popupEnabled) {
|
||||
console.log('KikoSaveImage: Popup disabled, not showing custom viewer');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if viewer already exists
|
||||
let existingViewer = document.querySelector('kiko-image-viewer');
|
||||
if (existingViewer) {
|
||||
@@ -1052,35 +1052,35 @@ app.registerExtension({
|
||||
console.log('KikoSaveImage: Updated existing viewer');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Create toggle button in node if it doesn't exist
|
||||
if (!this.kikoToggleButton) {
|
||||
this.createToggleButton();
|
||||
}
|
||||
|
||||
|
||||
// Create new viewer - always visible on workflow execution
|
||||
const viewer = document.createElement('kiko-image-viewer');
|
||||
viewer.setImageData(imageData);
|
||||
|
||||
|
||||
// Store reference for toggle button
|
||||
this.kikoViewer = viewer;
|
||||
|
||||
|
||||
// Update toggle button text
|
||||
if (this.kikoToggleButton) {
|
||||
this.kikoToggleButton.textContent = '👁️ Hide Images';
|
||||
}
|
||||
|
||||
|
||||
// Append to body (floating window) - always visible
|
||||
document.body.appendChild(viewer);
|
||||
|
||||
|
||||
console.log('KikoSaveImage: Custom viewer created successfully');
|
||||
};
|
||||
|
||||
|
||||
// Add method to create toggle button
|
||||
nodeType.prototype.createToggleButton = function() {
|
||||
// Find the node element to add button to
|
||||
let nodeElement = null;
|
||||
|
||||
|
||||
if (this.domElement) {
|
||||
nodeElement = this.domElement;
|
||||
} else if (this.widgets && this.widgets[0] && this.widgets[0].element) {
|
||||
@@ -1095,9 +1095,9 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!nodeElement) return;
|
||||
|
||||
|
||||
// Create toggle button
|
||||
const toggleButton = document.createElement('button');
|
||||
toggleButton.textContent = '👁️ Show Images';
|
||||
@@ -1113,7 +1113,7 @@ app.registerExtension({
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
`;
|
||||
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
if (this.kikoViewer) {
|
||||
const isHidden = this.kikoViewer.style.display === 'none';
|
||||
@@ -1126,7 +1126,7 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add hover effects
|
||||
toggleButton.addEventListener('mouseenter', () => {
|
||||
toggleButton.style.background = '#45a049';
|
||||
@@ -1134,13 +1134,13 @@ app.registerExtension({
|
||||
toggleButton.addEventListener('mouseleave', () => {
|
||||
toggleButton.style.background = '#4CAF50';
|
||||
});
|
||||
|
||||
|
||||
nodeElement.appendChild(toggleButton);
|
||||
this.kikoToggleButton = toggleButton;
|
||||
|
||||
|
||||
console.log('KikoSaveImage: Toggle button created');
|
||||
};
|
||||
|
||||
|
||||
// Legacy method kept for compatibility (not used with web component)
|
||||
nodeType.prototype.enhanceImagePreviews = function(imageData) {
|
||||
console.log('KikoSaveImage: Legacy enhanceImagePreviews called (should use web component instead)');
|
||||
@@ -1152,24 +1152,24 @@ app.registerExtension({
|
||||
// Export helper functions to global scope for debugging
|
||||
window.kikoSaveImageTest = function() {
|
||||
console.log('KikoSaveImage: Testing click functionality...');
|
||||
|
||||
|
||||
// Find all images in the document
|
||||
const allImages = document.querySelectorAll('img');
|
||||
console.log(`Found ${allImages.length} images in document`);
|
||||
|
||||
|
||||
// Try to find images that look like our saved images
|
||||
allImages.forEach((img, index) => {
|
||||
console.log(`Image ${index}: src = ${img.src}`);
|
||||
|
||||
|
||||
// Add test click handler to all images
|
||||
img.style.border = '2px solid red';
|
||||
img.style.cursor = 'pointer';
|
||||
img.title = 'TEST: Click to open in new tab';
|
||||
|
||||
|
||||
// Remove old handlers and add new one
|
||||
const newImg = img.cloneNode(true);
|
||||
img.parentNode.replaceChild(newImg, img);
|
||||
|
||||
|
||||
newImg.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1177,32 +1177,32 @@ window.kikoSaveImageTest = function() {
|
||||
window.open(newImg.src, '_blank');
|
||||
}, true);
|
||||
});
|
||||
|
||||
|
||||
console.log('KikoSaveImage: Test setup complete. All images should now be clickable with red borders.');
|
||||
};
|
||||
|
||||
// 🎉 RESET VIEWER WINDOWS 🎉
|
||||
window.kikoResetViewer = function() {
|
||||
console.log('🎉 Resetting KikoSaveImage viewer windows...');
|
||||
|
||||
|
||||
// Remove any existing viewers
|
||||
const existingViewers = document.querySelectorAll('kiko-image-viewer');
|
||||
existingViewers.forEach(viewer => viewer.remove());
|
||||
|
||||
|
||||
// Reset toggle buttons
|
||||
const toggleButtons = document.querySelectorAll('.kiko-toggle-viewer-btn');
|
||||
toggleButtons.forEach(btn => {
|
||||
btn.textContent = '👁️ Show Images';
|
||||
btn.style.background = '#4CAF50';
|
||||
});
|
||||
|
||||
|
||||
console.log('✨ Reset complete! Run your workflow to create a new viewer!');
|
||||
};
|
||||
|
||||
// 🚀 FORCE SHOW VIEWER WITH DEMO DATA 🚀
|
||||
window.kikoForceViewer = function() {
|
||||
console.log('🚀 Force showing KikoSaveImage viewer...');
|
||||
|
||||
|
||||
// Look for existing viewer
|
||||
let viewer = document.querySelector('kiko-image-viewer');
|
||||
if (viewer) {
|
||||
@@ -1210,7 +1210,7 @@ window.kikoForceViewer = function() {
|
||||
console.log('✨ Found and showed existing viewer!');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log('⚠️ No existing viewer found. Run your KikoSaveImage workflow to create a real viewer with actual images!');
|
||||
alert('⚠️ No viewer found! Run your KikoSaveImage workflow to create a viewer with real images.');
|
||||
};
|
||||
@@ -1219,4 +1219,4 @@ console.log('🎉 KikoSaveImage: Extension loaded!');
|
||||
console.log('💡 Helper functions available:');
|
||||
console.log(' 📞 kikoResetViewer() - Remove existing viewer windows');
|
||||
console.log(' 🚀 kikoForceViewer() - Show demo viewer');
|
||||
console.log(' 🧪 kikoSaveImageTest() - Test image click functionality');
|
||||
console.log(' 🧪 kikoSaveImageTest() - Test image click functionality');
|
||||
|
||||
+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