Compare commits

..
Author SHA1 Message Date
Vito Sansevero efc079a95b fix: reformat with black default settings (88 char) to match CI 2025-08-01 09:41:21 -07:00
Vito Sansevero bbd239cbd6 fix: apply black formatting with line-length 127 for CI compliance 2025-08-01 09:41:21 -07:00
Vito Sansevero 589fbf3568 fix: remove trailing whitespace in gemini_prompt node.py 2025-08-01 09:41:21 -07:00
Vito Sansevero c595cabaa0 chore: trigger CI 2025-08-01 09:41:21 -07:00
Vito Sansevero ca504d5f74 fix: code formatting for Gemini prompt node
- Fix missing newlines at end of files
- Apply black formatting
- Remaining non-critical warnings for long lines in prompts
2025-08-01 09:41:21 -07:00
Vito Sansevero f559fe220e feat: add Gemini Prompt Engineer node
- Add GeminiPromptNode for AI-powered prompt engineering
- Integrates with Google's Gemini API for prompt generation
- Includes various prompt templates and generation modes
- Add comprehensive tests and documentation
- Register node in ComfyAssets category
2025-08-01 09:41:21 -07:00
Vito 932e30ade0 Merge pull request #13 from ComfyAssets/feature/image-to-multiple-of
Feature/image to multiple of
2025-08-01 09:34:25 -07:00
Vito Sansevero a8af833c31 chore: trigger CI 2025-08-01 09:10:28 -07:00
Vito Sansevero 005c3bdf65 fix: code formatting for Image to Multiple Of node
- Apply black formatting
- Remove unused torch import from logic.py
- All critical linting issues resolved
2025-08-01 09:01:39 -07:00
Vito 67a59a0d3b Merge pull request #11 from ComfyAssets/chore/housekeeping
chore: project housekeeping and configuration updates
2025-08-01 08:53:03 -07:00
Vito Sansevero bb5653fc0e fix: resolve flake8 linting errors in example.py
- Remove unused variable 'temp' assignment
- Remove unused exception variable assignments
- All flake8 checks now pass
2025-08-01 08:43:22 -07:00
Vito Sansevero ab23992c29 chore: project housekeeping and configuration updates
- Add code quality tools: flake8, mypy, black, pre-commit
- Add .gitattributes for line ending consistency
- Add .secrets.baseline for secret scanning
- Update GitHub workflows for better CI/CD
- Update documentation formatting and examples
- Add CLAUDE.md for AI assistant guidance
- Add scripts directory for automation tools
- Update project configuration in pyproject.toml
- Improve type hints and code formatting across all modules
- Update test configurations and fixtures
2025-08-01 08:35:08 -07:00
Vito Sansevero 9007b10d42 feat: add Image to Multiple Of node
- Add ImageToMultipleOfNode for image dimension adjustment
- Ensures image dimensions are multiples of specified values
- Supports both padding and cropping modes
- Useful for model-specific dimension requirements
- Add tests and documentation
- Register node in ComfyAssets category
2025-08-01 08:28:10 -07:00
Vito b71bfa8d4e Merge pull request #10 from ComfyAssets/fix-samplers
Fix samplers
2025-07-26 14:18:48 -07:00
Vito Sansevero c1128addc7 chore: bump version to 1.0.7 in pyproject.toml 2025-07-26 14:16:06 -07:00
Vito Sansevero a49071f824 fix(resolution_calculator): update scale factor tooltip 2025-07-26 14:15:39 -07:00
Vito Sansevero bbdd27f498 fix(ci): correct return type in tests.yml configuration 2025-07-23 13:56:57 -07:00
Vito Sansevero 22f62bf7b4 test: Update test assertions for sampler combo node 2025-07-23 13:56:45 -07:00
Vito Sansevero 4ff6067dad refactor(compact_node): update sampler return type 2025-07-23 13:29:46 -07:00
Vito Sansevero ad13e66506 refactor(node): update sampler handling logic 2025-07-23 13:29:32 -07:00
Vito Sansevero b16f6f40bd style(logic): fix whitespace issues in logic.py 2025-07-21 08:23:18 -07:00
Vito Sansevero 6dfa66963b feat: Add subfolder support in image URL handling 2025-07-21 08:20:27 -07:00
Vito Sansevero ab016e0903 feat(logic): add subfolder info to enhanced data 2025-07-21 08:20:17 -07:00
Vito Sansevero f4228a850c refactor(logic): improve path handling in image saving 2025-07-21 07:56:13 -07:00
Vito Sansevero 79042b78d2 chore: bump version to 1.0.5 in pyproject.toml 2025-06-28 09:31:40 -07:00
Vito Sansevero 0c4e59c4e9 test: Remove unused imports from test file 2025-06-28 09:14:45 -07:00
Vito Sansevero 4a0a206d61 refactor(node): use helper methods for tensor validation 2025-06-28 09:14:34 -07:00
Vito Sansevero 8e0d4485bd style: Remove unused imports in node.py 2025-06-28 09:14:23 -07:00
Vito Sansevero 92a3b1db4e style: Remove unused import 'Any' 2025-06-28 09:13:03 -07:00
Vito Sansevero ab628b1bf2 style: Remove unused import 'os' 2025-06-28 09:11:49 -07:00
Vito Sansevero 7e712a17d9 docs: Add Kiko Save Image section to README.md 2025-06-28 09:11:42 -07:00
Vito Sansevero 269fb2ba80 ci: add checks for KikoSaveImageNode imports 2025-06-28 09:11:36 -07:00
Vito Sansevero e17fdddcd7 refactor(tests/ui): Remove 'subfolder' support 2025-06-28 08:55:25 -07:00
Vito Sansevero 5d1f01e6cb refactor(node): replace 'subfolder' with 'popup' 2025-06-28 08:54:46 -07:00
Vito Sansevero b3b8826044 refactor(logic): Rename 'subfolder' to 'popup' parameter 2025-06-28 08:54:35 -07:00
Vito Sansevero 9dbb1f749d chore: bump version to 1.0.4 in pyproject.toml 2025-06-27 21:06:30 -07:00
Vito Sansevero 682f2b0a47 feat(ui): Add KikoSaveImage UI enhancements 2025-06-27 21:06:00 -07:00
Vito Sansevero 1233cf693e test(kiko_save_image): add unit tests for save image tool 2025-06-27 21:05:52 -07:00
Vito Sansevero 32d44a282f feat(kiko_save_image): add new image saving tool 2025-06-27 21:05:42 -07:00
Vito Sansevero bb79c7434f feat(init): add KikoSaveImageNode to tools and mappings 2025-06-27 21:05:25 -07:00
Vito Sansevero bd8c0a42bc fix: handle import error for testing environment 2025-06-27 21:05:14 -07:00
Vito Sansevero 5d9e71dc7b chore: bump version to 1.0.3 in pyproject.toml 2025-06-20 08:03:48 -07:00
Vito 321d89dcc4 Merge pull request #9 from ComfyAssets/latent-batch
style: Add blank lines for better readability
2025-06-20 08:02:57 -07:00
Vito d23ff34b27 Merge pull request #8 from ComfyAssets/latent-batch
Latent batch
2025-06-19 12:00:57 -07:00
69 changed files with 5635 additions and 580 deletions
+31
View File
@@ -0,0 +1,31 @@
[flake8]
max-line-length = 127
max-complexity = 10
exclude =
.git,
__pycache__,
.mypy_cache,
.pytest_cache,
venv,
env,
build,
dist,
*.egg-info,
.tox
ignore =
# W503: line break before binary operator (conflicts with Black)
W503,
# E203: whitespace before ':' (conflicts with Black)
E203,
# E501: line too long (we use max-line-length)
E501
per-file-ignores =
# Allow unused imports in __init__.py files
__init__.py:F401,F403
# Allow assertions in tests
tests/*:S101
# Statistics
count = True
statistics = True
+41
View File
@@ -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
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+62 -37
View File
@@ -59,7 +59,7 @@ jobs:
import sys
import os
sys.path.insert(0, os.getcwd())
# Test that all imports work correctly
try:
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
@@ -67,60 +67,64 @@ jobs:
except ImportError as e:
print(f'Warning: Package-level imports failed: {e}')
# This is expected since we don't have ComfyUI installed
# Test individual module imports
from kikotools.base import ComfyAssetsBaseNode
from kikotools.tools.resolution_calculator import ResolutionCalculatorNode
from kikotools.tools.resolution_calculator.logic import extract_dimensions
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode as NodeClass
# Test Width Height Selector imports
from kikotools.tools.width_height_selector import WidthHeightSelectorNode
from kikotools.tools.width_height_selector.logic import get_preset_dimensions
from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS, PRESET_METADATA
# Test Sampler Combo imports
from kikotools.tools.sampler_combo import SamplerComboNode
from kikotools.tools.sampler_combo.logic import get_sampler_combo, SAMPLERS, SCHEDULERS
# Test Seed History imports
from kikotools.tools.seed_history import SeedHistoryNode
from kikotools.tools.seed_history.logic import generate_random_seed, validate_seed_value
# Test Kiko Save Image imports
from kikotools.tools.kiko_save_image import KikoSaveImageNode
from kikotools.tools.kiko_save_image.logic import process_image_batch, validate_save_inputs
print('✓ All module imports successful')
"
- name: Check code style consistency
run: |
echo "Checking code style consistency..."
# Check for consistent naming
find kikotools/ -name "*.py" -exec grep -l "class.*Node" {} \; | while read file; do
if ! grep -q "ComfyAssetsBaseNode" "$file" && ! grep -q "class ComfyAssetsBaseNode" "$file"; then
echo "Checking $file for ComfyUI node inheritance..."
fi
done
# Check for proper docstrings
python -c "
import ast
import os
def check_docstrings(filepath):
with open(filepath, 'r') as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
if not ast.get_docstring(node) and not node.name.startswith('_'):
print(f'Warning: {filepath}:{node.lineno} - {node.name} missing docstring')
for root, dirs, files in os.walk('kikotools'):
for file in files:
if file.endswith('.py') and not file.startswith('__'):
filepath = os.path.join(root, file)
check_docstrings(filepath)
print('✓ Docstring check completed')
"
@@ -147,7 +151,7 @@ jobs:
- name: Check for hardcoded secrets
run: |
echo "Checking for potential secrets..."
# Check for common secret patterns
if grep -r -i "password\|secret\|key\|token" kikotools/ --include="*.py" | grep -v "# " | grep -v "def " | grep -v "class "; then
echo "Warning: Potential hardcoded secrets found"
@@ -176,82 +180,103 @@ jobs:
import sys
import os
sys.path.insert(0, os.getcwd())
print('Checking architecture compliance...')
# Test separation of concerns
from kikotools.tools.resolution_calculator import logic, node
# Logic module should not import node-specific things
import inspect
logic_source = inspect.getsource(logic)
if 'ComfyUI' in logic_source and 'INPUT_TYPES' not in logic_source:
print('⚠️ Warning: Logic module contains ComfyUI-specific code')
else:
print('✓ Logic module properly separated')
# Node module should inherit from base
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
from kikotools.base import ComfyAssetsBaseNode
if issubclass(ResolutionCalculatorNode, ComfyAssetsBaseNode):
print('✓ Node properly inherits from base class')
else:
print('❌ Node does not inherit from base class')
sys.exit(1)
# Check that nodes have proper ComfyUI interface
required_attrs = ['INPUT_TYPES', 'RETURN_TYPES', 'RETURN_NAMES', 'FUNCTION', 'CATEGORY']
# Test Resolution Calculator Node
for attr in required_attrs:
if not hasattr(ResolutionCalculatorNode, attr):
print(f'❌ ResolutionCalculatorNode missing required attribute: {attr}')
sys.exit(1)
# Test Width Height Selector Node
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
if issubclass(WidthHeightSelectorNode, ComfyAssetsBaseNode):
print('✓ WidthHeightSelectorNode properly inherits from base class')
else:
print('❌ WidthHeightSelectorNode does not inherit from base class')
sys.exit(1)
for attr in required_attrs:
if not hasattr(WidthHeightSelectorNode, attr):
print(f'❌ WidthHeightSelectorNode missing required attribute: {attr}')
sys.exit(1)
# Test Sampler Combo Node
from kikotools.tools.sampler_combo.node import SamplerComboNode
if issubclass(SamplerComboNode, ComfyAssetsBaseNode):
print('✓ SamplerComboNode properly inherits from base class')
else:
print('❌ SamplerComboNode does not inherit from base class')
sys.exit(1)
for attr in required_attrs:
if not hasattr(SamplerComboNode, attr):
print(f'❌ SamplerComboNode missing required attribute: {attr}')
sys.exit(1)
# Test Seed History Node
from kikotools.tools.seed_history.node import SeedHistoryNode
if issubclass(SeedHistoryNode, ComfyAssetsBaseNode):
print('✓ SeedHistoryNode properly inherits from base class')
else:
print('❌ SeedHistoryNode does not inherit from base class')
sys.exit(1)
for attr in required_attrs:
if not hasattr(SeedHistoryNode, attr):
print(f'❌ SeedHistoryNode missing required attribute: {attr}')
sys.exit(1)
# Test Kiko Save Image Node
from kikotools.tools.kiko_save_image.node import KikoSaveImageNode
if issubclass(KikoSaveImageNode, ComfyAssetsBaseNode):
print('✓ KikoSaveImageNode properly inherits from base class')
else:
print('❌ KikoSaveImageNode does not inherit from base class')
sys.exit(1)
# KikoSaveImage is an output node, so it doesn't have RETURN_TYPES/RETURN_NAMES
save_required_attrs = ['INPUT_TYPES', 'FUNCTION', 'CATEGORY']
for attr in save_required_attrs:
if not hasattr(KikoSaveImageNode, attr):
print(f'❌ KikoSaveImageNode missing required attribute: {attr}')
sys.exit(1)
# Check that it's properly marked as an output node
if not hasattr(KikoSaveImageNode, 'OUTPUT_NODE') or not KikoSaveImageNode.OUTPUT_NODE:
print('❌ KikoSaveImageNode missing OUTPUT_NODE = True')
sys.exit(1)
print('✓ All architecture checks passed for all tools')
"
@@ -259,22 +284,22 @@ jobs:
run: |
python -c "
import os
# Count test files vs implementation files
test_files = 0
impl_files = 0
for root, dirs, files in os.walk('tests'):
test_files += len([f for f in files if f.startswith('test_') and f.endswith('.py')])
for root, dirs, files in os.walk('kikotools'):
impl_files += len([f for f in files if f.endswith('.py') and not f.startswith('__')])
print(f'Implementation files: {impl_files}')
print(f'Test files: {test_files}')
if test_files >= impl_files * 0.5: # At least 50% test coverage by file count
print('✓ Adequate test file coverage')
else:
print('⚠️ Warning: Low test file coverage')
"
"
+24 -24
View File
@@ -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"
+6 -6
View File
@@ -159,7 +159,7 @@ jobs:
print('✓ Sampler Combo interface tests passed')
# Test return types
assert node.RETURN_TYPES == (SAMPLERS, SCHEDULERS, 'INT', 'FLOAT')
assert node.RETURN_TYPES == ('SAMPLER', SCHEDULERS, 'INT', 'FLOAT')
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
assert node.CATEGORY == 'ComfyAssets'
print('✓ Sampler Combo return types tests passed')
@@ -424,24 +424,24 @@ jobs:
# Check key files
test -f kikotools/__init__.py || (echo "kikotools/__init__.py missing" && exit 1)
test -f kikotools/base/base_node.py || (echo "base_node.py missing" && exit 1)
# Resolution Calculator files
test -f kikotools/tools/resolution_calculator/node.py || (echo "resolution_calculator node.py missing" && exit 1)
test -f kikotools/tools/resolution_calculator/logic.py || (echo "resolution_calculator logic.py missing" && exit 1)
# Width Height Selector files
test -f kikotools/tools/width_height_selector/node.py || (echo "width_height_selector node.py missing" && exit 1)
test -f kikotools/tools/width_height_selector/logic.py || (echo "width_height_selector logic.py missing" && exit 1)
test -f kikotools/tools/width_height_selector/presets.py || (echo "width_height_selector presets.py missing" && exit 1)
# Sampler Combo files
test -f kikotools/tools/sampler_combo/node.py || (echo "sampler_combo node.py missing" && exit 1)
test -f kikotools/tools/sampler_combo/logic.py || (echo "sampler_combo logic.py missing" && exit 1)
# Seed History files
test -f kikotools/tools/seed_history/node.py || (echo "seed_history node.py missing" && exit 1)
test -f kikotools/tools/seed_history/logic.py || (echo "seed_history logic.py missing" && exit 1)
# Web files
test -f web/width_height_swap.js || (echo "width_height_swap.js missing" && exit 1)
test -f web/seed_history_ui.js || (echo "seed_history_ui.js missing" && exit 1)
+1 -1
View File
@@ -158,4 +158,4 @@ input/
test_images/
test_outputs/
experiments/
.claude/
.claude/
+84
View File
@@ -0,0 +1,84 @@
# Pre-commit hooks configuration for ComfyUI-KikoTools
# This ensures code quality checks are run before each commit
repos:
# Python code formatting with Black
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
language_version: python3.10
args: ['--line-length=127'] # Match CI configuration
# Python linting with flake8
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ['--max-line-length=127', '--max-complexity=10']
exclude: '^tests/'
# Python type checking with mypy
# Note: Mypy is disabled in pre-commit due to package name issue
# Run manually with: mypy kikotools/
# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.8.0
# hooks:
# - id: mypy
# args: ['--config-file=mypy.ini']
# files: '^kikotools/'
# exclude: '^tests/'
# additional_dependencies: ['types-requests']
# Security checks with bandit
- repo: https://github.com/PyCQA/bandit
rev: 1.8.6
hooks:
- id: bandit
args: ['-ll', '-r']
files: '^kikotools/'
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-case-conflict
- id: check-merge-conflict
- id: check-docstring-first
- id: debug-statements
- id: mixed-line-ending
# Check for hardcoded secrets
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: '^(tests/|\.git/)'
# Configuration for specific hooks
default_language_version:
python: python3.10
# Run hooks on all files by default
fail_fast: false
# Exclude patterns
exclude: |
(?x)^(
\.git/|
\.mypy_cache/|
\.pytest_cache/|
__pycache__/|
build/|
dist/|
\.eggs/|
.*\.egg-info/|
venv/|
env/
)
+164
View File
@@ -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"
}
+288
View File
@@ -0,0 +1,288 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ComfyUI-KikoTools is a planned modular collection of custom ComfyUI nodes that will provide essential tools missing from the standard ComfyUI release. All nodes will be grouped under "ComfyAssets" in the ComfyUI interface. The project is designed for extensibility, allowing new tools to be added easily while maintaining clean separation of concerns.
**Current Status**: Project is in initial planning phase. Only documentation and licensing files exist.
## Architecture
### Design Principles
- **Modular Design**: Each tool is a separate, self-contained module
- **ComfyAssets Grouping**: All nodes appear under the "ComfyAssets" category
- **Test-Driven Development**: Every tool includes comprehensive tests
- **Clean Interfaces**: Standardized input/output patterns across tools
### Core Components
- **Tool Registry**: Central registration system for all KikoTools nodes
- **Base Classes**: Shared functionality for consistent tool behavior
- **Individual Tools**: Self-contained modules for specific functionality
### Current Tools
#### 1. Resolution Calculator (First Tool)
- **Purpose**: Calculate upscale resolution from image or latent inputs
- **Inputs**:
- Image or Latent tensor
- Scale factor (1, 2, 3, 1.2, 1.5, 2.0)
- **Outputs**:
- Width (INT)
- Height (INT)
- **Target Models**: Flux and SDXL optimized
- **Use Case**: Connect calculated dimensions to upscaler nodes
## Technology Stack
- **Backend**: Python with ComfyUI node patterns
- **Node Framework**: ComfyUI INPUT_TYPES, RETURN_TYPES, execute() patterns
- **Testing**: pytest with ComfyUI test fixtures
- **Code Quality**: black, flake8, mypy
- **Integration**: ComfyUI execution queue and tensor systems
## Development Commands
**Note**: These commands are planned for when the project structure is implemented.
### Initial Setup
```bash
# Create basic project structure
mkdir -p kikotools/{base,tools} tests/{unit,integration,fixtures} scripts examples
# Create entry point files
touch __init__.py kikotools/__init__.py
```
### Code Quality (Future)
```bash
# Format Python code
black .
# Python linting
flake8 .
# Type checking
mypy .
```
### Testing (Future TDD Workflow)
```bash
# Run all tests
pytest tests/
# Run tests for specific tool
pytest tests/unit/tools/test_{tool_name}.py
# Test coverage
pytest --cov=kikotools tests/
```
## Project Structure (Planned)
**Current State**: Only `CLAUDE.md` and `LICENSE` files exist.
**Planned Structure**:
```
├── __init__.py # ComfyUI node registration entry point
├── kikotools/ # Main package
│ ├── __init__.py # Package initialization and tool registry
│ ├── base/ # Base classes and shared utilities
│ │ ├── __init__.py
│ │ ├── base_node.py # Base node class with ComfyAssets grouping
│ │ └── utils.py # Shared utility functions
│ ├── tools/ # Individual tool implementations
│ │ ├── __init__.py
│ │ ├── resolution_calculator/ # First planned tool
│ │ │ ├── __init__.py
│ │ │ ├── node.py # ResolutionCalculatorNode implementation
│ │ │ └── logic.py # Core calculation logic
│ │ └── template/ # Template for new tools
│ │ ├── __init__.py
│ │ ├── node.py
│ │ └── logic.py
├── tests/ # Comprehensive test suite (TDD approach)
│ ├── __init__.py
│ ├── conftest.py # pytest fixtures and ComfyUI test setup
│ ├── unit/ # Unit tests for individual components
│ │ ├── test_base_node.py
│ │ └── tools/
│ │ └── test_resolution_calculator.py
│ ├── integration/ # ComfyUI integration tests
│ │ ├── test_node_registration.py
│ │ └── test_workflow_execution.py
│ └── fixtures/ # Test data and workflow files
│ ├── workflows/ # .json workflow files for testing
│ ├── images/ # Test images
│ └── latents/ # Test latent tensors
├── scripts/ # Development automation
│ ├── create_tool.py # Tool template generator
│ ├── register_tool.py # Tool registration helper
│ └── validate_nodes.py # Node validation script
├── examples/ # Usage examples and demonstrations
│ ├── workflows/ # Example workflow .json files
│ └── documentation/ # Usage documentation per tool
└── requirements-dev.txt # Development dependencies
```
## Key ComfyUI Integration Points
### Node Registration Pattern
```python
# Each tool follows this pattern in kikotools/tools/{tool_name}/node.py
class ResolutionCalculatorNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"scale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1}),
},
"optional": {
"image": ("IMAGE",),
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "calculate_resolution"
CATEGORY = "ComfyAssets" # All tools use this category
def calculate_resolution(self, scale_factor, image=None, latent=None):
# Implementation here
pass
```
### Base Node Class
- Provides consistent "ComfyAssets" categorization
- Standardizes error handling and logging
- Implements common validation patterns
- Ensures consistent return type handling
### Tool Registry System
- Automatic discovery of tools in `kikotools/tools/`
- Dynamic node registration during ComfyUI startup
- Version compatibility checking
- Dependency validation
## Test-Driven Development (TDD) Workflow
### 1. Write Tests First
```python
# tests/unit/tools/test_resolution_calculator.py
def test_resolution_calculator_with_image():
"""Test resolution calculation with image input."""
# Arrange
node = ResolutionCalculatorNode()
test_image = create_test_image(512, 512) # fixture
scale_factor = 2.0
# Act
width, height = node.calculate_resolution(scale_factor, image=test_image)
# Assert
assert width == 1024
assert height == 1024
def test_resolution_calculator_with_latent():
"""Test resolution calculation with latent input."""
# Similar pattern for latent inputs
pass
```
### 2. Run Tests (Should Fail)
```bash
pytest tests/unit/tools/test_resolution_calculator.py -v
```
### 3. Implement Minimal Code
```python
# kikotools/tools/resolution_calculator/logic.py
def calculate_upscale_resolution(input_tensor, scale_factor):
"""Calculate new resolution based on input and scale factor."""
# Minimal implementation to pass tests
pass
```
### 4. Refactor and Expand
- Add error handling
- Optimize for Flux/SDXL specific requirements
- Add comprehensive validation
- Implement edge case handling
### 5. Integration Testing
```python
# tests/integration/test_workflow_execution.py
def test_resolution_calculator_in_workflow():
"""Test resolution calculator in full ComfyUI workflow."""
workflow = load_test_workflow("resolution_calculator_example.json")
result = execute_comfyui_workflow(workflow)
assert result.success
```
## Tool-Specific Implementation Notes
### Resolution Calculator
- **Input Validation**: Handle both image and latent tensors
- **Scale Factors**: Support integer (1, 2, 3) and float (1.2, 1.5, 2.0) multipliers
- **Model Optimization**: Consider Flux and SDXL specific resolution requirements
- **Output Format**: Integer width/height suitable for upscaler node connections
- **Error Handling**: Graceful handling of invalid inputs or edge cases
### Future Tools (Planned)
- Batch Image Processor
- Advanced Prompt Utilities
- Model Management Tools
- Custom Sampling Methods
## Development Workflow
### Adding a New Tool
1. **Plan**: Define tool purpose, inputs, outputs, and test cases
2. **Generate**: Use `python scripts/create_tool.py --name "NewTool"`
3. **Test**: Write comprehensive tests following TDD principles
4. **Implement**: Build tool logic with proper ComfyUI integration
5. **Register**: Add tool to registry and validate registration
6. **Document**: Update examples and documentation
7. **Validate**: Test in real ComfyUI environment with actual workflows
### Code Quality Standards
- **Type Hints**: Full type annotation for all functions
- **Documentation**: Docstrings for all public methods and classes
- **Testing**: Minimum 90% test coverage for all tools
- **Linting**: Pass all flake8 and mypy checks
- **Formatting**: Auto-formatted with black
### Release Process
1. Run full test suite: `pytest tests/`
2. Validate in ComfyUI: `python scripts/validate_nodes.py`
3. Update version numbers and changelog
4. Create example workflows demonstrating new features
5. Update ComfyUI-Manager compatibility metadata
## Critical Implementation Notes
### ComfyUI Compatibility
- Follow ComfyUI tensor format conventions
- Implement proper memory management for large tensors
- Handle ComfyUI execution context correctly
- Ensure compatibility with ComfyUI's automatic typing system
### Performance Considerations
- Optimize for real-time workflow execution
- Minimize memory allocation during processing
- Cache expensive computations when appropriate
- Profile performance with typical Flux/SDXL workflows
### User Experience
- Clear, descriptive node names and parameter labels
- Helpful tooltips and parameter descriptions
- Consistent visual styling within ComfyAssets group
- Robust error messages with actionable guidance
### Extensibility
- Plugin architecture for easy tool addition
- Shared utilities for common operations
- Consistent API patterns across all tools
- Future-proof design for ComfyUI updates
+2 -2
View File
@@ -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!"
+161 -27
View File
@@ -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
@@ -92,6 +92,54 @@ Advanced empty latent creation with preset support and batch processing capabili
- Optimize memory usage with batch size planning
- Quick preset-based latent generation for different aspect ratios
#### 💾 Kiko Save Image
Enhanced image saving with format selection, quality control, and floating popup viewer.
- **Multiple Format Support**: Save as PNG, JPEG, or WebP with format-specific optimizations
- **Advanced Quality Controls**: JPEG/WebP quality (1-100), PNG compression (0-9), WebP lossless mode
- **Floating Popup Viewer**: Draggable, resizable window that shows saved images immediately
- **Interactive Previews**: Click any image to open in new tab, download individual images
- **Batch Selection**: Multi-select images for bulk actions (open all, download all)
- **Format-Specific Settings**: Quality indicators, file size display, compression info
- **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality
- **Popup Toggle**: Enable/disable popup viewer per save operation
#### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
- **Custom System Prompts**: Override templates with your own analysis instructions
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
**Use Cases:**
- Reverse-engineer prompts from reference images
- Convert artistic descriptions between different AI model formats
- Generate consistent style descriptions across workflows
- Create detailed scene breakdowns for complex compositions
- Analyze and replicate lighting/mood from existing artwork
### 💾 Kiko Save Image Features
**Use Cases:**
- Quick preview and management of saved images without file browser navigation
- Compare multiple format outputs side-by-side (PNG vs JPEG vs WebP)
- Batch download or open selected images efficiently
- Monitor file sizes and compression effectiveness in real-time
- Streamlined workflow for iterative image generation and saving
**Why Better Than Standard Save Image:**
- **Immediate Visual Feedback**: See your saved images instantly without opening file explorer
- **Multi-Format Flexibility**: Choose optimal format for your use case (PNG for quality, JPEG for size, WebP for modern efficiency)
- **Advanced Compression Control**: Fine-tune file sizes with format-specific quality settings
- **Batch Operations**: Handle multiple images efficiently with selection and bulk actions
- **Modern UI**: Floating, draggable interface that doesn't interrupt your workflow
- **Smart Memory Usage**: File size indicators help optimize storage and sharing
- **One-Click Access**: Direct image opening in browser tabs for quick sharing or review
### 🔧 Architecture Highlights
- **Modular Design**: Each tool is self-contained and independently testable
@@ -129,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
@@ -141,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
@@ -153,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
@@ -164,24 +212,52 @@ Sampler Combo → KSampler → VAE Decode → Save Image
⚙️ All Settings ↘ sampler/scheduler/steps/cfg ↗
```
**Configuration:** euler, normal, 20 steps, CFG 7.0
**Output:** Complete sampling configuration in one node
**Configuration:** euler, normal, 20 steps, CFG 7.0
**Output:** Complete sampling configuration in one node
**Smart Features:** Recommendations and compatibility validation
### Empty Latent Batch Example
```
Empty Latent Batch → KSampler → VAE Decode → Save Image
📦 preset: "1024×1024" ↘ batch latents ↗
Empty Latent Batch → KSampler → VAE Decode → Kiko Save Image
📦 preset: "1024×1024" ↘ batch latents ↗ ↘ popup viewer ↗
batch_size: 4
[swap button]
```
**Preset:** SDXL Square (1024×1024)
**Batch Size:** 4 empty latents
**Output:** 4×4×128×128 latent tensor ready for sampling
**Preset:** SDXL Square (1024×1024)
**Batch Size:** 4 empty latents
**Output:** 4×4×128×128 latent tensor ready for sampling
**Swap Button:** Click to switch to any available swapped preset
### Kiko Save Image Example
```
Generate Image → Kiko Save Image → Floating Popup Viewer
📷 output ↘ format: WEBP ↘ draggable window ↗
quality: 85
[popup: enabled]
```
**Format:** WebP (efficient compression, modern format)
**Quality:** 85% (balanced size/quality)
**Popup Viewer:** Floating, draggable window with saved images
**Features:** Click images to open in new tabs, download individual files, batch selection
**Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls
### Gemini Prompt Engineer Example
```
Load Image → Gemini Prompt → Text Generation Model
🖼️ reference ↘ type: FLUX ↘ "majestic landscape..."
[API key] → FLUX model
```
**Input:** Reference image for style analysis
**Prompt Type:** FLUX (detailed artistic prompts)
**Output:** Optimized prompt with style, lighting, composition details
**API:** Requires Gemini API key (free tier available)
**Use Case:** Recreate similar style/mood from reference images
### Common Workflows
<details>
@@ -190,7 +266,7 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
```json
{
"workflow": "Load SDXL portrait → Calculate 1.5x dimensions → Feed to upscaler",
"input_resolution": "832×1216",
"input_resolution": "832×1216",
"scale_factor": 1.5,
"output_resolution": "1248×1824",
"memory_efficient": true
@@ -205,7 +281,7 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
{
"workflow": "Generate latents → Calculate target size → Batch upscale",
"input_resolution": "1024×1024",
"scale_factor": 2.0,
"scale_factor": 2.0,
"output_resolution": "2048×2048",
"batch_optimized": true
}
@@ -223,6 +299,7 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
| **Seed History** | Advanced seed tracking with interactive history management | ✅ Complete | [Docs](examples/documentation/seed_history.md) |
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -232,7 +309,7 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
**Inputs:**
- `scale_factor` (FLOAT): 1.0-8.0, default 2.0
- `image` (IMAGE, optional): Input image tensor
- `image` (IMAGE, optional): Input image tensor
- `latent` (LATENT, optional): Input latent tensor
**Outputs:**
@@ -254,7 +331,7 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
**Outputs:**
- `width` (INT): Selected or calculated width
- `height` (INT): Selected or calculated height
- `height` (INT): Selected or calculated height
**UI Features:**
- Visual blue swap button in bottom-right corner
@@ -298,7 +375,7 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
**Outputs:**
- `sampler_name` (STRING): Selected sampler algorithm
- `scheduler` (STRING): Selected scheduler algorithm
- `scheduler` (STRING): Selected scheduler algorithm
- `steps` (INT): Validated step count
- `cfg` (FLOAT): Validated CFG scale
@@ -340,6 +417,40 @@ Empty Latent Batch → KSampler → VAE Decode → Save Image
- Formatted display with aspect ratio and megapixel information
- Intelligent fallback to custom dimensions for invalid presets
#### Kiko Save Image
**Inputs:**
- `images` (IMAGE): Batch of images to save
- `filename_prefix` (STRING): Prefix for saved filenames, default "KikoSave"
- `format` (DROPDOWN): Output format (PNG, JPEG, WEBP), default PNG
- `quality` (INT): JPEG/WebP quality (1-100), default 90
- `png_compress_level` (INT): PNG compression level (0-9), default 4
- `webp_lossless` (BOOLEAN): Use lossless WebP compression, default False
- `popup` (BOOLEAN): Enable popup viewer window, default True
**Outputs:**
- `UI`: Enhanced image preview data with popup viewer functionality
**UI Features:**
- Floating, draggable popup window showing saved images immediately
- Interactive image grid with click-to-open functionality
- Individual image download buttons with format-specific quality indicators
- Batch selection with multi-select checkboxes for bulk operations
- Window controls: minimize, maximize, roll-up, close, and dragging
- Auto-hide/show behavior with smart positioning
**Format Support:**
- **PNG**: Lossless compression with metadata preservation, configurable compression levels
- **JPEG**: Quality-controlled lossy compression with automatic transparency handling
- **WebP**: Modern format with both lossy and lossless modes, superior compression ratios
**Advanced Features:**
- File size monitoring and display for optimization feedback
- Format-specific quality indicators (PNG compression level, JPEG/WebP quality percentage)
- Smart filename sanitization with timestamp-based uniqueness
- Persistent popup viewer across multiple save operations
- Toggle button integration in node UI for manual viewer control
## 🛠️ Development
### Prerequisites
@@ -362,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
@@ -378,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
@@ -407,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
```
@@ -460,13 +593,14 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 5 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch)
- **Nodes**: 6 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image)
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 3 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button)
- **Test Coverage**: 100% (180+ comprehensive tests)
- **Interactive Features**: 4 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer)
- **Test Coverage**: 100% (200+ comprehensive tests)
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow)
---
@@ -476,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>
+5 -2
View File
@@ -3,11 +3,14 @@ ComfyUI-KikoTools: Modular collection of custom ComfyUI nodes
All nodes are grouped under the "ComfyAssets" category
"""
import os
import re
from pathlib import Path
from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
try:
from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
except ImportError:
# Fallback for testing environment
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# Tell ComfyUI where to find our JavaScript extensions
WEB_DIRECTORY = "./web"
+362
View File
@@ -0,0 +1,362 @@
import os
from typing import Tuple
import comfy.sd
import comfy.utils
import torch
import torch.nn.functional as F
from comfy.sd import CLIP
from diffusers import ConsistencyDecoderVAE
from folder_paths import get_folder_paths
from huggingface_hub import hf_hub_download
from torch import Tensor
def find_or_create_cache():
cwd = os.getcwd()
if os.path.exists(os.path.join(cwd, "ComfyUI")):
cwd = os.path.join(cwd, "ComfyUI")
if os.path.exists(os.path.join(cwd, "models")):
cwd = os.path.join(cwd, "models")
if not os.path.exists(os.path.join(cwd, "huggingface_cache")):
print("Creating huggingface_cache directory within comfy")
os.mkdir(os.path.join(cwd, "huggingface_cache"))
return str(os.path.join(cwd, "huggingface_cache"))
class ConsistencyDecoder:
@classmethod
def INPUT_TYPES(s):
return {"required": {"latent": ("LATENT",)}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "decode"
CATEGORY = "latent"
def __init__(self):
self.vae = (
ConsistencyDecoderVAE.from_pretrained(
"openai/consistency-decoder",
torch_dtype=torch.float16,
variant="fp16",
use_safetensors=True,
cache_dir=find_or_create_cache(),
)
.eval()
.to("cuda")
)
def _decode(self, latent):
"""Used when patching another vae."""
return self.vae.decode(latent.half().cuda()).sample
def decode(self, latent):
"""Used for standalone decoding."""
sample = self._decode(latent["samples"])
sample = sample.clamp(-1, 1).movedim(1, -1).add(1.0).mul(0.5).cpu()
return (sample,)
class PatchDecoderTiled:
@classmethod
def INPUT_TYPES(s):
return {"required": {"vae": ("VAE",)}}
RETURN_TYPES = ("VAE",)
FUNCTION = "patch"
category = "vae"
def __init__(self):
self.vae = ConsistencyDecoder()
def patch(self, vae):
del vae.first_stage_model.decoder
vae.first_stage_model.decode = self.vae._decode
vae.decode = (
lambda x: vae.decode_tiled_(
x,
tile_x=512,
tile_y=512,
overlap=64,
)
.to("cuda")
.movedim(1, -1)
)
return (vae,)
# quick node to set SDXL-friendly aspect ratios in 1024^2
# adapted from throttlekitty
class SDXLAspectRatio:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
}
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "run"
CATEGORY = "image"
def run(self, image: Tensor) -> Tuple[int, int]:
_, height, width, _ = image.shape
aspect_ratio = width / height
aspect_ratios = (
(1 / 1, 1024, 1024),
(2 / 3, 832, 1216),
(3 / 4, 896, 1152),
(5 / 8, 768, 1216),
(9 / 16, 768, 1344),
(9 / 19, 704, 1472),
(9 / 21, 640, 1536),
(3 / 2, 1216, 832),
(4 / 3, 1152, 896),
(8 / 5, 1216, 768),
(16 / 9, 1344, 768),
(19 / 9, 1472, 704),
(21 / 9, 1536, 640),
)
# find the closest aspect ratio
closest = min(aspect_ratios, key=lambda x: abs(x[0] - aspect_ratio))
return (closest[1], closest[2])
class ImageToMultipleOf:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"multiple_of": (
"INT",
{
"default": 64,
"min": 1,
"max": 256,
"step": 16,
"display": "number",
},
),
"method": (["center crop", "rescale"],),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "run"
CATEGORY = "image"
def run(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
"""Center crop the image to a specific multiple of a number."""
_, height, width, _ = image.shape
new_height = height - (height % multiple_of)
new_width = width - (width % multiple_of)
if method == "rescale":
return (
F.interpolate(
image.unsqueeze(0),
size=(new_height, new_width),
mode="bilinear",
align_corners=False,
).squeeze(0),
)
else:
top = (height - new_height) // 2
left = (width - new_width) // 2
bottom = top + new_height
right = left + new_width
return (image[:, top:bottom, left:right, :],)
class HFHubLoraLoader:
def __init__(self):
self.loaded_lora = None
self.loaded_lora_path = None
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"repo_id": ("STRING", {"default": ""}),
"subfolder": ("STRING", {"default": ""}),
"filename": ("STRING", {"default": ""}),
"strength_model": (
"FLOAT",
{"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
),
"strength_clip": (
"FLOAT",
{"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP")
FUNCTION = "load_lora"
CATEGORY = "loaders"
def load_lora(
self,
model,
clip,
repo_id: str,
subfolder: str,
filename: str,
strength_model: float,
strength_clip: float,
):
if strength_model == 0 and strength_clip == 0:
return (model, clip)
lora_path = hf_hub_download(
repo_id=repo_id.strip(),
subfolder=None
if subfolder is None or subfolder.strip() == ""
else subfolder.strip(),
filename=filename.strip(),
cache_dir=find_or_create_cache(),
)
lora = None
if self.loaded_lora is not None:
if self.loaded_lora_path == lora_path:
lora = self.loaded_lora
else:
self.loaded_lora = None
self.loaded_lora_path = None
if lora is None:
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
self.loaded_lora = lora
self.loaded_lora_path = lora_path
model_lora, clip_lora = comfy.sd.load_lora_for_models(
model, clip, lora, strength_model, strength_clip
)
return (model_lora, clip_lora)
class HFHubEmbeddingLoader:
"""Load a text model embedding from Huggingface Hub.
The connected CLIP model is not manipulated."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip": ("CLIP",),
"repo_id": ("STRING", {"default": ""}),
"subfolder": ("STRING", {"default": ""}),
"filename": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("CLIP",)
FUNCTION = "download_embedding"
CATEGORY = "n/a"
def download_embedding(
self,
clip: CLIP, # added to signify it's best put in between nodes
repo_id: str,
subfolder: str,
filename: str,
):
hf_hub_download(
repo_id=repo_id.strip(),
subfolder=None
if subfolder is None or subfolder.strip() == ""
else subfolder.strip(),
filename=filename.strip(),
local_dir=get_folder_paths("embeddings")[0],
)
return (clip,)
class GlifVariable:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"variable": (
[
"",
],
),
"fallback": (
"STRING",
{
"default": "",
"single_line": True,
},
),
}
}
RETURN_TYPES = ("STRING", "INT", "FLOAT")
FUNCTION = "do_it"
CATEGORY = "glif/variables"
@classmethod
def VALIDATE_INPUTS(cls, variable: str, fallback: str):
# Since we populate dynamically, comfy will report invalid inputs. Override to always return True
return True
def do_it(self, variable: str, fallback: str):
variable = variable.strip()
fallback = fallback.strip()
if variable == "" or (variable.startswith("{") and variable.endswith("}")):
variable = fallback
int_val = 0
float_val = 0.0
string_val = f"{variable}"
try:
int_val = int(variable)
except Exception:
pass
try:
float_val = float(variable)
except Exception:
pass
return (string_val, int_val, float_val)
NODE_CLASS_MAPPINGS = {
"GlifConsistencyDecoder": ConsistencyDecoder,
"GlifPatchConsistencyDecoderTiled": PatchDecoderTiled,
"SDXLAspectRatio": SDXLAspectRatio,
"ImageToMultipleOf": ImageToMultipleOf,
"HFHubLoraLoader": HFHubLoraLoader,
"HFHubEmbeddingLoader": HFHubEmbeddingLoader,
"GlifVariable": GlifVariable,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GlifConsistencyDecoder": "Consistency VAE Decoder",
"GlifPatchConsistencyDecoderTiled": "Patch Consistency VAE Decoder",
"SDXLAspectRatio": "Image to SDXL compatible WH",
"ImageToMultipleOf": "Image to Multiple of",
"HFHubLoraLoader": "Load HF Lora",
"HFHubEmbeddingLoader": "Load HF Embedding",
"GlifVariable": "Glif Variable",
}
+1 -1
View File
@@ -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
+162
View File
@@ -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
+7 -7
View File
@@ -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.
+1 -1
View File
@@ -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
}
}
+1 -1
View File
@@ -641,4 +641,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -719,4 +719,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
+9
View File
@@ -8,6 +8,9 @@ from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.seed_history import SeedHistoryNode
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
from .tools.empty_latent_batch import EmptyLatentBatchNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.gemini_prompt import GeminiPromptNode
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
@@ -17,6 +20,9 @@ NODE_CLASS_MAPPINGS = {
"SamplerCombo": SamplerComboNode,
"SamplerComboCompact": SamplerComboCompactNode,
"EmptyLatentBatch": EmptyLatentBatchNode,
"KikoSaveImage": KikoSaveImageNode,
"ImageToMultipleOf": ImageToMultipleOfNode,
"GeminiPrompt": GeminiPromptNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -26,6 +32,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SamplerCombo": "Sampler Combo",
"SamplerComboCompact": "Sampler Combo (Compact)",
"EmptyLatentBatch": "Empty Latent Batch",
"KikoSaveImage": "Kiko Save Image",
"ImageToMultipleOf": "Image to Multiple of",
"GeminiPrompt": "Gemini Prompt Engineer",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1 -3
View File
@@ -35,9 +35,7 @@ class ComfyAssetsBaseNode:
"""
pass
def handle_error(
self, error_msg: str, exception: Optional[Exception] = None
) -> None:
def handle_error(self, error_msg: str, exception: Optional[Exception] = None) -> None:
"""
Standardized error handling with logging
+3 -7
View File
@@ -1,12 +1,10 @@
"""Logic for creating empty latent tensors with batch support."""
import torch
from typing import Dict, Tuple, Any
from typing import Dict, Tuple
def create_empty_latent_batch(
width: int, height: int, batch_size: int = 1
) -> Dict[str, torch.Tensor]:
def create_empty_latent_batch(width: int, height: int, batch_size: int = 1) -> Dict[str, torch.Tensor]:
"""
Create empty latent tensor with batch support.
@@ -30,9 +28,7 @@ def create_empty_latent_batch(
# Ensure dimensions are divisible by 8 (VAE requirement)
if width % 8 != 0 or height % 8 != 0:
raise ValueError(
f"Width and height must be divisible by 8, got {width}x{height}"
)
raise ValueError(f"Width and height must be divisible by 8, got {width}x{height}")
# Convert pixel dimensions to latent space (divide by 8)
latent_width = width // 8
+9 -29
View File
@@ -1,7 +1,7 @@
"""Empty Latent Batch node for ComfyUI."""
import torch
from typing import Dict, Any, Tuple
from typing import Dict, Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
@@ -13,9 +13,6 @@ from ..width_height_selector.logic import get_preset_dimensions
from ..width_height_selector.presets import (
PRESET_OPTIONS,
PRESET_METADATA,
get_model_recommendation,
get_preset_metadata,
get_presets_by_model_group,
)
@@ -39,8 +36,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
metadata = PRESET_METADATA.get(preset_name)
if metadata:
formatted_option = (
f"{preset_name} - {metadata.aspect_ratio} "
f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
f"{preset_name} - {metadata.aspect_ratio} " f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
)
preset_options.append(formatted_option)
else:
@@ -89,8 +85,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Number of empty latents to create in the batch. "
"Useful for batch processing workflows.",
"tooltip": "Number of empty latents to create in the batch. " "Useful for batch processing workflows.",
},
),
}
@@ -121,9 +116,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
original_preset = self._extract_preset_name(preset)
# Get base dimensions from preset or custom input
base_width, base_height = get_preset_dimensions(
original_preset, width, height
)
base_width, base_height = get_preset_dimensions(original_preset, width, height)
# Sanitize dimensions to ensure they meet requirements
final_width, final_height = sanitize_dimensions(base_width, base_height)
@@ -137,23 +130,17 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
# Validate final dimensions
if not validate_dimensions(final_width, final_height):
self.handle_error(
f"Invalid dimensions after sanitization: {final_width}×{final_height}"
)
self.handle_error(f"Invalid dimensions after sanitization: {final_width}×{final_height}")
# Validate batch size
if batch_size <= 0:
self.handle_error(f"Batch size must be positive, got {batch_size}")
if batch_size > 64:
self.log_info(
f"Large batch size ({batch_size}) may use significant memory"
)
self.log_info(f"Large batch size ({batch_size}) may use significant memory")
# Create the empty latent batch
latent_dict = create_empty_latent_batch(
final_width, final_height, batch_size
)
latent_dict = create_empty_latent_batch(final_width, final_height, batch_size)
# Log the operation
latent_height = final_height // 8
@@ -201,9 +188,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
# Default to "custom" if we can't parse it
return "custom"
def validate_inputs(
self, preset: str, width: int, height: int, batch_size: int
) -> bool:
def validate_inputs(self, preset: str, width: int, height: int, batch_size: int) -> bool:
"""
Validate node inputs.
@@ -294,12 +279,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
def __repr__(self) -> str:
"""Detailed string representation of the node."""
return (
f"EmptyLatentBatchNode("
f"category='{self.CATEGORY}', "
f"function='{self.FUNCTION}'"
f")"
)
return f"EmptyLatentBatchNode(" f"category='{self.CATEGORY}', " f"function='{self.FUNCTION}'" f")"
# Node class mappings for ComfyUI registration
@@ -0,0 +1,5 @@
"""Gemini Prompt Engineer node for ComfyUI."""
from .node import GeminiPromptNode
__all__ = ["GeminiPromptNode"]
+163
View File
@@ -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
+115
View File
@@ -0,0 +1,115 @@
"""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, GEMINI_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."""
return {
"required": {
"image": ("IMAGE",),
"prompt_type": (PROMPT_OPTIONS, {"default": "flux"}),
"model": (GEMINI_MODELS, {"default": "gemini-1.5-flash"}),
},
"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
# 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"
+200
View File
@@ -0,0 +1,200 @@
"""System prompts for different AI model types."""
FLUX_PROMPT = """You are an expert visual analyst and FLUX prompt engineer. Your role is to examine images in detail and create precise, effective prompts that can recreate similar images using the FLUX image generation model.
When analyzing an image, systematically observe and document:
1. **Subject & Composition**
- Primary subjects and their positions
- Background elements and environment
- Overall composition and framing
- Perspective and camera angle
2. **Visual Style & Technique**
- Art style (photorealistic, illustration, painting, etc.)
- Rendering technique (digital art, oil painting, watercolor, etc.)
- Level of detail and texture quality
- Any specific artistic influences or movements
3. **Lighting & Atmosphere**
- Light sources and direction
- Time of day/lighting conditions
- Shadows and highlights
- Overall mood and atmosphere
4. **Colors & Tones**
- Color palette and dominant colors
- Color temperature (warm/cool)
- Contrast and saturation levels
- Any color grading or filters
5. **Details & Textures**
- Surface textures and materials
- Fine details and patterns
- Quality indicators (4K, 8K, high resolution, etc.)
Format your FLUX prompt following these guidelines:
- Start with the main subject and action
- Add style and medium descriptors
- Include lighting and atmosphere details
- Specify quality markers and technical aspects
- Use precise, descriptive language
- Separate concepts with commas
- Order from most to least important elements
Example output format:
"[main subject and action], [style/medium], [lighting/atmosphere], [composition details], [color descriptions], [quality markers], [additional artistic details]"
Remember: FLUX responds well to specific artistic references, quality indicators like "highly detailed," "4K," "award-winning," and style descriptors like "trending on ArtStation" or "photorealistic."
"""
SDXL_PROMPT = """You are an expert SDXL prompt engineer specializing in analyzing images and creating optimized prompts for Stable Diffusion XL models.
When analyzing an image, systematically evaluate:
1. **Core Subject Analysis**
- Primary subject with specific descriptors
- Pose, expression, and action
- Clothing and accessories details
- Physical characteristics
2. **Style & Medium**
- Artistic style and influences
- Medium (photography, digital art, oil painting, etc.)
- Specific artist references (if applicable)
- Visual aesthetic keywords
3. **Technical Specifications**
- Camera settings (aperture, focal length, ISO)
- Shot type (close-up, wide angle, portrait, etc.)
- Resolution and quality markers
- Post-processing effects
4. **Environment & Context**
- Setting and location details
- Props and surrounding objects
- Weather and environmental conditions
- Time period or era
Format your SDXL prompt with:
- **Positive prompt**: Detailed description emphasizing what you want
- **Negative prompt**: Elements to avoid (low quality, blurry, distorted, etc.)
- Weight emphasis using (parentheses) or [brackets] for importance
- Break into logical chunks with commas
Example format:
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"
"""
DANBOORU_PROMPT = """You are a Danbooru tagging expert, specialized in analyzing images and creating precise tag sets following booru-style conventions for anime/manga artwork.
Analyze images for these tag categories:
1. **Character Tags**
- Hair: color, length, style (e.g., long_hair, blonde_hair, twintails)
- Eyes: color, style (e.g., blue_eyes, heterochromia)
- Body: proportions, pose (e.g., standing, sitting, looking_at_viewer)
- Expression (e.g., smile, blush, closed_eyes)
2. **Clothing & Accessories**
- Outfit type (e.g., school_uniform, dress, armor)
- Specific clothing items (e.g., thighhighs, gloves, hat)
- Accessories (e.g., hair_ribbon, necklace, glasses)
- State of dress (e.g., torn_clothes, wet_clothes)
3. **Scene & Composition**
- Number of characters (e.g., 1girl, 2boys, multiple_girls)
- Background (e.g., simple_background, outdoors, classroom)
- Viewpoint (e.g., from_below, from_side, cowboy_shot)
- Composition elements (e.g., upper_body, full_body, portrait)
4. **Meta Tags**
- Quality (e.g., highres, absurdres, masterpiece)
- Source/artist style (if recognizable)
- Content rating (e.g., safe, questionable, explicit)
- Special effects (e.g., lens_flare, chromatic_aberration)
Format tags using:
- Underscores for multi-word concepts (not spaces)
- Order from most to least important
- Include count descriptors (1girl, 2boys)
- Separate with commas and spaces
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_PROMPT = """You are a video generation prompt specialist, expert at analyzing video content and creating comprehensive prompts for video generation models.
When analyzing video content, document:
1. **Motion & Action**
- Primary actions and movements
- Motion speed and dynamics
- Camera movements (pan, zoom, tracking, static)
- Transition types between scenes
2. **Temporal Elements**
- Scene duration and pacing
- Sequence of events
- Time of day changes
- Motion continuity
3. **Visual Consistency**
- Character/object persistence
- Style consistency throughout
- Lighting continuity
- Color grading consistency
4. **Scene Breakdown**
- Opening frame description
- Key action moments
- Transitions and cuts
- Closing frame details
5. **Technical Specifications**
- Frame rate and resolution
- Aspect ratio
- Video length
- Special effects or post-processing
Format your video prompt as:
"[Opening scene], [camera movement], [main action sequence], [visual style], [lighting/atmosphere], [duration], [technical specs], [ending scene]"
Include:
- Specific motion descriptors (slowly, rapidly, smoothly)
- Camera terminology (dolly in, pan left, aerial shot)
- Temporal markers (then, meanwhile, gradually)
- Consistency notes for multi-scene videos
Example:
"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"
"""
PROMPT_TEMPLATES = {
"flux": FLUX_PROMPT,
"sdxl": SDXL_PROMPT,
"danbooru": DANBOORU_PROMPT,
"video": VIDEO_PROMPT,
}
PROMPT_OPTIONS = ["flux", "sdxl", "danbooru", "video"]
# Available Gemini models
GEMINI_MODELS = [
"gemini-1.5-pro", # Most capable model
"gemini-1.5-flash", # Fast, efficient model
"gemini-1.5-flash-8b", # Smaller, faster variant
"gemini-pro-vision", # Vision-optimized model
"gemini-1.0-pro", # Previous generation pro model
]
# Model descriptions for UI
MODEL_DESCRIPTIONS = {
"gemini-1.5-pro": "Most capable Gemini model for complex tasks",
"gemini-1.5-flash": "Faster and cost-effective (recommended for most uses)",
"gemini-1.5-flash-8b": "Smaller and faster, good for simple prompts",
"gemini-pro-vision": "Optimized for vision tasks and image analysis",
"gemini-1.0-pro": "Previous generation, stable option",
}
@@ -0,0 +1,5 @@
"""ImageToMultipleOf tool for ComfyUI-KikoTools."""
from .node import ImageToMultipleOfNode
__all__ = ["ImageToMultipleOfNode"]
@@ -0,0 +1,61 @@
"""Core logic for ImageToMultipleOf tool."""
from typing import Tuple
import torch.nn.functional as F
from torch import Tensor
def calculate_dimensions_to_multiple(
height: int, width: int, multiple_of: int
) -> Tuple[int, int]:
"""Calculate new dimensions that are multiples of the specified value.
Args:
height: Original height
width: Original width
multiple_of: Value that dimensions should be multiple of
Returns:
Tuple of (new_height, new_width)
"""
new_height = height - (height % multiple_of)
new_width = width - (width % multiple_of)
return new_height, new_width
def process_image_to_multiple_of(
image: Tensor, multiple_of: int, method: str
) -> Tensor:
"""Process image to ensure dimensions are multiples of specified value.
Args:
image: Input image tensor of shape (batch, height, width, channels)
multiple_of: Value that dimensions should be multiple of
method: Processing method - "center crop" or "rescale"
Returns:
Processed image tensor
"""
_, height, width, _ = image.shape
new_height, new_width = calculate_dimensions_to_multiple(height, width, multiple_of)
if method == "rescale":
# Rescale the image to the new dimensions
# Convert from BHWC to BCHW for interpolation
image_chw = image.permute(0, 3, 1, 2)
rescaled = F.interpolate(
image_chw,
size=(new_height, new_width),
mode="bilinear",
align_corners=False,
)
# Convert back to BHWC
return rescaled.permute(0, 2, 3, 1)
else: # center crop
# Calculate crop offsets to center the crop
top = (height - new_height) // 2
left = (width - new_width) // 2
bottom = top + new_height
right = left + new_width
return image[:, top:bottom, left:right, :]
@@ -0,0 +1,102 @@
"""ComfyUI node implementation for ImageToMultipleOf."""
from typing import Dict, Any, Tuple
from torch import Tensor
from ...base import ComfyAssetsBaseNode
from .logic import process_image_to_multiple_of
class ImageToMultipleOfNode(ComfyAssetsBaseNode):
"""
Adjusts image dimensions to be multiples of a specified value.
Useful for models that require specific dimension constraints.
Supports both center cropping and rescaling methods.
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"image": ("IMAGE",),
"multiple_of": (
"INT",
{
"default": 64,
"min": 1,
"max": 256,
"step": 16,
"display": "number",
},
),
"method": (["center crop", "rescale"],),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "process"
def process(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
"""
Process image to ensure dimensions are multiples of specified value.
Args:
image: Input image tensor
multiple_of: Value that dimensions should be multiple of
method: Processing method - "center crop" or "rescale"
Returns:
Tuple containing processed image tensor
"""
try:
self.validate_inputs(image=image, multiple_of=multiple_of, method=method)
# Process the image
processed_image = process_image_to_multiple_of(image, multiple_of, method)
_, new_height, new_width, _ = processed_image.shape
self.log_info(
f"Processed image from {image.shape[1]}x{image.shape[2]} "
f"to {new_height}x{new_width} (multiple of {multiple_of}) "
f"using {method}"
)
return (processed_image,)
except Exception as e:
self.handle_error(f"Failed to process image: {str(e)}", e)
def validate_inputs(self, **kwargs) -> None:
"""Validate inputs for ImageToMultipleOf node."""
image = kwargs.get("image")
multiple_of = kwargs.get("multiple_of")
method = kwargs.get("method")
if image is None:
raise ValueError("Image input is required")
if not isinstance(image, Tensor) or len(image.shape) != 4:
raise ValueError(
f"Expected image tensor with shape (batch, height, width, channels), "
f"got shape {image.shape if isinstance(image, Tensor) else 'non-tensor'}"
)
if multiple_of <= 0:
raise ValueError(f"multiple_of must be positive, got {multiple_of}")
if method not in ["center crop", "rescale"]:
raise ValueError(f"Invalid method: {method}")
# Check if resulting dimensions would be too small
_, height, width, _ = image.shape
new_height = height - (height % multiple_of)
new_width = width - (width % multiple_of)
if new_height <= 0 or new_width <= 0:
raise ValueError(
f"Image dimensions ({height}x{width}) are too small "
f"to be adjusted to multiple of {multiple_of}"
)
@@ -0,0 +1,8 @@
"""
KikoSaveImage tool module
Enhanced image saving with format selection, quality control, and clickable previews
"""
from .node import KikoSaveImageNode
__all__ = ["KikoSaveImageNode"]
+344
View File
@@ -0,0 +1,344 @@
"""
KikoSaveImage core logic
Enhanced image saving functionality with multiple format support
"""
import os
import json
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import torch
from typing import Dict, List, Any, Optional, Tuple
import time
try:
import folder_paths
except ImportError:
# Fallback for testing without ComfyUI
class folder_paths:
@staticmethod
def get_output_directory():
return "./output"
def get_save_image_path(
filename_prefix: str,
batch_number: int,
format_ext: str,
output_dir: str,
subfolder: str = "",
) -> Tuple[str, str]:
"""
Generate save path for image with proper filename handling
Args:
filename_prefix: Base filename prefix
batch_number: Batch index for multiple images
format_ext: File extension (.png, .jpg, .webp)
output_dir: Output directory path
subfolder: Optional subfolder within output directory
Returns:
Tuple of (full_path, relative_filename)
"""
# Split filename_prefix into directory path and actual filename prefix
# This allows for directory structures like "kittybear/anime/images/kittybear"
prefix_dir = os.path.dirname(filename_prefix)
prefix_name = os.path.basename(filename_prefix)
# Sanitize only the filename part (not the directory path)
safe_prefix = prefix_name.replace(":", "_") # Only sanitize problematic chars for filenames
safe_prefix = "".join(c for c in safe_prefix if c.isalnum() or c in "._-")
# Create unique filename with timestamp to avoid conflicts
timestamp = int(time.time())
filename = f"{safe_prefix}_{timestamp:010d}_{batch_number:05d}{format_ext}"
# Handle subfolder and prefix directory (but not the filename part)
path_components = []
path_components.append(output_dir)
if subfolder:
path_components.append(subfolder)
# Only add prefix_dir if it exists (the directory part, not the filename part)
if prefix_dir:
path_components.append(prefix_dir)
full_output_folder = os.path.join(*path_components)
# Ensure directory exists
os.makedirs(full_output_folder, exist_ok=True)
full_path = os.path.join(full_output_folder, filename)
# For the preview, ComfyUI needs the filename and subfolder separately
# The subfolder needs to be relative to the output directory root
# Build the relative subfolder path including prefix directory (but not filename part)
relative_path_components = []
if subfolder:
relative_path_components.append(subfolder.strip("/\\"))
if prefix_dir:
relative_path_components.append(prefix_dir.strip("/\\"))
if relative_path_components:
relative_subfolder = os.path.join(*relative_path_components)
else:
relative_subfolder = ""
preview_filename = filename
return full_path, preview_filename, relative_subfolder
def convert_tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image:
"""
Convert ComfyUI image tensor to PIL Image
Args:
image_tensor: Tensor in format [height, width, channels] with values 0-1
Returns:
PIL Image in RGB/RGBA format
"""
# Convert tensor (0-1 float) to 0-255 numpy array
i = 255.0 * image_tensor.cpu().numpy()
img_array = np.clip(i, 0, 255).astype(np.uint8)
# Create PIL image from numpy array
img = Image.fromarray(img_array)
return img
def create_png_metadata(prompt: Optional[Dict] = None, extra_pnginfo: Optional[Dict] = None) -> Optional[PngInfo]:
"""
Create PNG metadata with workflow information
Args:
prompt: ComfyUI prompt data
extra_pnginfo: Additional PNG metadata
Returns:
PngInfo object or None if no metadata
"""
if prompt is None and extra_pnginfo is None:
return None
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for key, value in extra_pnginfo.items():
metadata.add_text(key, json.dumps(value))
return metadata
def save_image_with_format(
img: Image.Image,
filepath: str,
format_type: str,
quality: int = 90,
png_compress_level: int = 4,
webp_lossless: bool = False,
metadata: Optional[PngInfo] = None,
) -> Dict[str, Any]:
"""
Save PIL image with specified format and quality settings
Args:
img: PIL Image to save
filepath: Full path to save file
format_type: Image format (PNG, JPEG, WEBP)
quality: JPEG/WebP quality (1-100)
png_compress_level: PNG compression level (0-9)
webp_lossless: Use lossless WebP compression
metadata: PNG metadata to embed
Returns:
Dict with save information
"""
save_kwargs = {}
if format_type == "PNG":
if metadata:
save_kwargs["pnginfo"] = metadata
save_kwargs["compress_level"] = png_compress_level
elif format_type == "JPEG":
# Convert RGBA to RGB for JPEG (no transparency support)
if img.mode == "RGBA":
# Create white background
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
save_kwargs["quality"] = quality
save_kwargs["optimize"] = True
elif format_type == "WEBP":
save_kwargs["quality"] = quality if not webp_lossless else 100
save_kwargs["lossless"] = webp_lossless
else:
raise ValueError(f"Unsupported format: {format_type}")
# Save the image
img.save(filepath, **save_kwargs)
# Get file size for info
file_size = os.path.getsize(filepath)
return {
"filepath": filepath,
"format": format_type,
"file_size": file_size,
"quality": quality if format_type != "PNG" else None,
"compress_level": png_compress_level if format_type == "PNG" else None,
"lossless": webp_lossless if format_type == "WEBP" else None,
}
def process_image_batch(
images: torch.Tensor,
filename_prefix: str = "KikoSave",
format_type: str = "PNG",
quality: int = 90,
png_compress_level: int = 4,
webp_lossless: bool = False,
popup: bool = True,
prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None,
) -> List[Dict[str, Any]]:
"""
Process and save a batch of images with specified format settings
Args:
images: Batch of image tensors [batch, height, width, channels]
filename_prefix: Prefix for saved filenames
format_type: Image format (PNG, JPEG, WEBP)
quality: JPEG/WebP quality (1-100)
png_compress_level: PNG compression level (0-9)
webp_lossless: Use lossless WebP compression
popup: Enable popup windows in UI
prompt: ComfyUI prompt data for metadata
extra_pnginfo: Additional PNG metadata
Returns:
List of saved image information dicts
"""
# Get output directory
output_dir = folder_paths.get_output_directory()
# Determine file extension
format_extensions = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"}
if format_type not in format_extensions:
raise ValueError(f"Unsupported format: {format_type}. " f"Supported: {list(format_extensions.keys())}")
format_ext = format_extensions[format_type]
# Create metadata for PNG
metadata = None
if format_type == "PNG":
metadata = create_png_metadata(prompt, extra_pnginfo)
# Process each image in the batch
results = []
enhanced_data = []
for batch_number, image_tensor in enumerate(images):
# Convert tensor to PIL Image
img = convert_tensor_to_pil(image_tensor)
# Generate save path
filepath, preview_filename, relative_subfolder = get_save_image_path(
filename_prefix, batch_number, format_ext, output_dir, ""
)
# Save with format-specific settings
save_info = save_image_with_format(
img,
filepath,
format_type,
quality,
png_compress_level,
webp_lossless,
metadata,
)
# Build result info for ComfyUI preview
# ONLY the core fields that ComfyUI expects - no extra metadata
result = {
"filename": preview_filename,
"subfolder": relative_subfolder,
"type": "output",
}
# Store enhanced data separately
enhanced_info = {
"filename": preview_filename,
"subfolder": relative_subfolder,
"popup": popup,
"type": "output",
"format": format_type,
"file_size": save_info["file_size"],
"dimensions": f"{img.width}x{img.height}",
}
# Add format-specific info to enhanced data
if format_type == "PNG":
enhanced_info["compress_level"] = png_compress_level
elif format_type in ["JPEG", "WEBP"]:
enhanced_info["quality"] = quality
if format_type == "WEBP":
enhanced_info["lossless"] = webp_lossless
results.append(result)
enhanced_data.append(enhanced_info)
return results, enhanced_data
def validate_save_inputs(images: torch.Tensor, format_type: str, quality: int, png_compress_level: int) -> None:
"""
Validate inputs for image saving
Args:
images: Image tensor batch to validate
format_type: Image format to validate
quality: Quality setting to validate
png_compress_level: PNG compression level to validate
Raises:
ValueError: If validation fails
"""
# Validate images tensor
if not isinstance(images, torch.Tensor):
raise ValueError(f"images must be a torch.Tensor, got {type(images).__name__}")
if len(images.shape) != 4:
raise ValueError(f"images tensor must have 4 dimensions [batch, height, width, channels], " f"got {len(images.shape)}")
# Validate format
supported_formats = ["PNG", "JPEG", "WEBP"]
if format_type not in supported_formats:
raise ValueError(f"format must be one of {supported_formats}, got {format_type}")
# Validate quality (for JPEG/WebP)
if format_type in ["JPEG", "WEBP"]:
if not isinstance(quality, int) or not (1 <= quality <= 100):
raise ValueError(f"quality must be an integer between 1 and 100, got {quality}")
# Validate PNG compression level
if format_type == "PNG":
if not isinstance(png_compress_level, int) or not (0 <= png_compress_level <= 9):
raise ValueError(f"png_compress_level must be an integer between 0 and 9, " f"got {png_compress_level}")
+220
View File
@@ -0,0 +1,220 @@
"""
KikoSaveImage ComfyUI Node
Enhanced image saving with format selection, quality control, and clickable previews
"""
import torch
from typing import Dict, Any, Optional
from ...base import ComfyAssetsBaseNode
from .logic import process_image_batch, validate_save_inputs
class KikoSaveImageNode(ComfyAssetsBaseNode):
"""
Enhanced ComfyUI image saving node with multiple format support
Features:
- Multiple format support (PNG, JPEG, WebP)
- Quality/compression controls
- Clickable image previews
- Metadata preservation
- Batch processing
Inputs:
- images (IMAGE): Images to save
- filename_prefix (STRING): Prefix for saved filenames
- format (COMBO): Output format (PNG, JPEG, WebP)
- quality (INT): JPEG/WebP quality (1-100)
- png_compress_level (INT): PNG compression level (0-9)
- webp_lossless (BOOLEAN): Use lossless WebP compression
- subfolder (STRING): Optional subfolder for organization
Outputs:
- UI: Image preview data for ComfyUI interface
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""
Define ComfyUI input interface with enhanced save options
Returns:
Dict with required and optional input specifications
"""
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save"}),
"filename_prefix": (
"STRING",
{"default": "KikoSave", "tooltip": "Prefix for saved filenames"},
),
"format": (
["PNG", "JPEG", "WEBP"],
{"default": "PNG", "tooltip": "Output image format"},
),
},
"optional": {
"quality": (
"INT",
{
"default": 90,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "JPEG/WebP quality (1-100, higher = better quality)",
},
),
"png_compress_level": (
"INT",
{
"default": 4,
"min": 0,
"max": 9,
"step": 1,
"tooltip": "PNG compression level (0-9, higher = smaller file)",
},
),
"webp_lossless": (
"BOOLEAN",
{
"default": False,
"tooltip": "Use lossless WebP compression " "(ignores quality setting)",
},
),
"popup": (
"BOOLEAN",
{
"default": True,
"tooltip": "Enable popup windows when clicking on images in the viewer",
},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
def save_images(
self,
images: torch.Tensor,
filename_prefix: str = "KikoSave",
format: str = "PNG",
quality: int = 90,
png_compress_level: int = 4,
webp_lossless: bool = False,
popup: bool = True,
prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
Save images with enhanced format and quality options
Args:
images: Batch of image tensors to save
filename_prefix: Prefix for saved filenames
format: Output format (PNG, JPEG, WebP)
quality: JPEG/WebP quality setting
png_compress_level: PNG compression level
webp_lossless: Use lossless WebP compression
popup: Enable popup windows when clicking on images
prompt: ComfyUI prompt data for metadata
extra_pnginfo: Additional PNG metadata
Returns:
Dict with UI data for image previews
Raises:
ValueError: If validation fails
"""
try:
# Validate inputs
self.validate_inputs(
images=images,
format=format,
quality=quality,
png_compress_level=png_compress_level,
webp_lossless=webp_lossless,
popup=popup,
)
# Log the save operation
self.log_info(
f"Saving {len(images)} images as {format} "
f"(quality={quality if format != 'PNG' else 'N/A'}, "
f"png_compress={png_compress_level if format == 'PNG' else 'N/A'})"
)
# Process and save images
results, enhanced_data = process_image_batch(
images=images,
filename_prefix=filename_prefix,
format_type=format,
quality=quality,
png_compress_level=png_compress_level,
webp_lossless=webp_lossless,
popup=popup,
prompt=prompt,
extra_pnginfo=extra_pnginfo,
)
# Log results
total_size = sum(data["file_size"] for data in enhanced_data)
self.log_info(f"Successfully saved {len(results)} images " f"(total size: {total_size / 1024:.1f} KB)")
# Return UI data for ComfyUI preview (clean) + enhanced data for our JS
return {
"ui": {
"images": results, # Clean data for ComfyUI
"kiko_enhanced": enhanced_data, # Enhanced data for our JavaScript
}
}
except Exception as e:
error_msg = f"Failed to save images: {str(e)}"
self.handle_error(error_msg, e)
def validate_inputs(
self,
images: torch.Tensor,
format: str,
quality: int,
png_compress_level: int,
webp_lossless: bool,
popup: bool,
) -> None:
"""
Validate inputs specific to KikoSaveImage
Args:
images: Image tensor batch
format: Image format
quality: Quality setting
png_compress_level: PNG compression level
webp_lossless: WebP lossless setting
popup: Enable popup windows
Raises:
ValueError: If validation fails
"""
# Use logic module validation
validate_save_inputs(images, format, quality, png_compress_level)
# Additional node-specific validation
if not isinstance(webp_lossless, bool):
raise ValueError(f"webp_lossless must be a boolean, got {type(webp_lossless).__name__}")
if not isinstance(popup, bool):
raise ValueError(f"popup must be a boolean, got {type(popup).__name__}")
# Node class mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {
"KikoSaveImage": KikoSaveImageNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KikoSaveImage": "Kiko Save Image",
}
+8 -25
View File
@@ -28,9 +28,7 @@ def extract_dimensions(
if image is not None:
# IMAGE tensor format: [batch, height, width, channels]
if len(image.shape) != 4:
raise ValueError(
f"Expected IMAGE tensor with 4 dimensions, got {len(image.shape)}"
)
raise ValueError(f"Expected IMAGE tensor with 4 dimensions, got {len(image.shape)}")
_, height, width, _ = image.shape
return int(width), int(height)
@@ -42,10 +40,7 @@ def extract_dimensions(
samples = latent["samples"]
if len(samples.shape) != 4:
raise ValueError(
f"Expected LATENT samples tensor with 4 dimensions, "
f"got {len(samples.shape)}"
)
raise ValueError(f"Expected LATENT samples tensor with 4 dimensions, " f"got {len(samples.shape)}")
_, _, latent_height, latent_width = samples.shape
@@ -79,9 +74,7 @@ def ensure_divisible_by_8(width: int, height: int) -> Tuple[int, int]:
return int(new_width), int(new_height)
def calculate_scaled_dimensions(
width: int, height: int, scale_factor: float
) -> Tuple[int, int]:
def calculate_scaled_dimensions(width: int, height: int, scale_factor: float) -> Tuple[int, int]:
"""
Calculate new dimensions with scale factor and ensure divisible by 8
@@ -104,9 +97,7 @@ def calculate_scaled_dimensions(
return ensure_divisible_by_8(new_width, new_height)
def validate_scale_factor(
scale_factor: float, min_scale: float = 0.1, max_scale: float = 8.0
) -> None:
def validate_scale_factor(scale_factor: float, min_scale: float = 0.1, max_scale: float = 8.0) -> None:
"""
Validate scale factor is within reasonable bounds
@@ -119,19 +110,13 @@ def validate_scale_factor(
ValueError: If scale factor is out of bounds
"""
if not isinstance(scale_factor, (int, float)):
raise ValueError(
f"Scale factor must be a number, got {type(scale_factor).__name__}"
)
raise ValueError(f"Scale factor must be a number, got {type(scale_factor).__name__}")
if scale_factor < min_scale:
raise ValueError(
f"Scale factor {scale_factor} is too small (minimum: {min_scale})"
)
raise ValueError(f"Scale factor {scale_factor} is too small (minimum: {min_scale})")
if scale_factor > max_scale:
raise ValueError(
f"Scale factor {scale_factor} is too large (maximum: {max_scale})"
)
raise ValueError(f"Scale factor {scale_factor} is too large (maximum: {max_scale})")
def calculate_resolution_from_input(
@@ -161,8 +146,6 @@ def calculate_resolution_from_input(
original_width, original_height = extract_dimensions(image=image, latent=latent)
# Calculate scaled dimensions
new_width, new_height = calculate_scaled_dimensions(
original_width, original_height, scale_factor
)
new_width, new_height = calculate_scaled_dimensions(original_width, original_height, scale_factor)
return new_width, new_height
+33 -44
View File
@@ -38,12 +38,11 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
"FLOAT",
{
"default": 2.0,
"min": 1.0,
"min": 0.1,
"max": 8.0,
"step": 0.1,
"display": "slider",
"tooltip": "Factor to scale the resolution by "
"(e.g., 2.0 for 2x upscale)",
"tooltip": "Factor to scale the resolution by " "(e.g., 2.0 for 2x, 0.5 for half scale)",
},
),
},
@@ -88,20 +87,11 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
self.validate_inputs(scale_factor=scale_factor, image=image, latent=latent)
# Log the operation
input_type = (
"IMAGE"
if image is not None
else "LATENT" if latent is not None else "NONE"
)
self.log_info(
f"Calculating resolution with scale_factor={scale_factor}, "
f"input_type={input_type}"
)
input_type = "IMAGE" if image is not None else "LATENT" if latent is not None else "NONE"
self.log_info(f"Calculating resolution with scale_factor={scale_factor}, " f"input_type={input_type}")
# Calculate the resolution
width, height = calculate_resolution_from_input(
scale_factor=scale_factor, image=image, latent=latent
)
width, height = calculate_resolution_from_input(scale_factor=scale_factor, image=image, latent=latent)
# Log the result
self.log_info(f"Calculated resolution: {width}x{height}")
@@ -136,42 +126,41 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
# Validate scale factor type
if not isinstance(scale_factor, (int, float)):
raise ValueError(
f"scale_factor must be a number, got {type(scale_factor).__name__}"
)
raise ValueError(f"scale_factor must be a number, got {type(scale_factor).__name__}")
# Additional tensor validation
# Validate tensors using helper methods
if image is not None:
if not isinstance(image, torch.Tensor):
raise ValueError(
f"image must be a torch.Tensor, got {type(image).__name__}"
)
if len(image.shape) != 4:
raise ValueError(
f"image tensor must have 4 dimensions "
f"[batch, height, width, channels], got {len(image.shape)}"
)
self._validate_image_tensor(image)
if latent is not None:
if not isinstance(latent, dict):
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
self._validate_latent_dict(latent)
if "samples" not in latent:
raise ValueError("latent dict must contain 'samples' key")
def _validate_image_tensor(self, image: torch.Tensor) -> None:
"""Validate image tensor format"""
if not isinstance(image, torch.Tensor):
raise ValueError(f"image must be a torch.Tensor, got {type(image).__name__}")
samples = latent["samples"]
if not isinstance(samples, torch.Tensor):
raise ValueError(
f"latent['samples'] must be a torch.Tensor, "
f"got {type(samples).__name__}"
)
if len(image.shape) != 4:
raise ValueError(
f"image tensor must have 4 dimensions " f"[batch, height, width, channels], got {len(image.shape)}"
)
if len(samples.shape) != 4:
raise ValueError(
f"latent samples tensor must have 4 dimensions "
f"[batch, channels, height, width], got {len(samples.shape)}"
)
def _validate_latent_dict(self, latent: Dict[str, torch.Tensor]) -> None:
"""Validate latent dictionary format"""
if not isinstance(latent, dict):
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
if "samples" not in latent:
raise ValueError("latent dict must contain 'samples' key")
samples = latent["samples"]
if not isinstance(samples, torch.Tensor):
raise ValueError(f"latent['samples'] must be a torch.Tensor, " f"got {type(samples).__name__}")
if len(samples.shape) != 4:
raise ValueError(
f"latent samples tensor must have 4 dimensions " f"[batch, channels, height, width], got {len(samples.shape)}"
)
# Node class mappings for ComfyUI registration
+20 -7
View File
@@ -60,14 +60,12 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
}
}
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
FUNCTION = "get_combo"
CATEGORY = "ComfyAssets"
def get_combo(
self, sampler: str, sched: str, steps: int, cfg: float
) -> Tuple[str, str, int, float]:
def get_combo(self, sampler: str, sched: str, steps: int, cfg: float) -> Tuple[object, str, int, float]:
"""
Get compact sampler combo configuration.
@@ -78,17 +76,32 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
cfg: CFG scale value
Returns:
Tuple of (sampler, scheduler, steps, cfg)
Tuple of (sampler_object, scheduler, steps, cfg)
"""
try:
# Use the same validation logic but with compact interface
result = get_sampler_combo(sampler, sched, steps, cfg)
return result
# Create the sampler object
try:
import comfy.samplers
sampler_obj = comfy.samplers.sampler_object(result[0])
except ImportError:
# Return sampler name for testing
sampler_obj = result[0]
return (sampler_obj, result[1], result[2], result[3])
except Exception as e:
# Graceful fallback
self.handle_error(f"Error in compact combo: {str(e)}")
return ("euler", "normal", 20, 7.0)
try:
import comfy.samplers
sampler_obj = comfy.samplers.sampler_object("euler")
except ImportError:
# Return sampler name for testing
sampler_obj = "euler"
return (sampler_obj, "normal", 20, 7.0)
def __str__(self) -> str:
"""String representation of the compact node."""
+2 -6
View File
@@ -40,9 +40,7 @@ except ImportError:
]
def validate_sampler_settings(
sampler_name: str, scheduler: str, steps: int, cfg: float
) -> bool:
def validate_sampler_settings(sampler_name: str, scheduler: str, steps: int, cfg: float) -> bool:
"""
Validate sampler configuration settings.
@@ -83,9 +81,7 @@ def validate_sampler_settings(
return False
def get_sampler_combo(
sampler_name: str, scheduler: str, steps: int, cfg: float
) -> Tuple[str, str, int, float]:
def get_sampler_combo(sampler_name: str, scheduler: str, steps: int, cfg: float) -> Tuple[str, str, int, float]:
"""
Process and return sampler combo settings.
+34 -24
View File
@@ -65,14 +65,12 @@ class SamplerComboNode(ComfyAssetsBaseNode):
}
}
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
FUNCTION = "get_sampler_combo"
CATEGORY = "ComfyAssets"
def get_sampler_combo(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
) -> Tuple[str, str, int, float]:
def get_sampler_combo(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> Tuple[object, str, int, float]:
"""
Get sampler combo configuration.
@@ -83,7 +81,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
cfg: CFG scale value
Returns:
Tuple of (sampler_name, scheduler, steps, cfg)
Tuple of (sampler_object, scheduler, steps, cfg)
"""
try:
# Validate inputs
@@ -98,17 +96,30 @@ class SamplerComboNode(ComfyAssetsBaseNode):
f"steps={steps}, cfg={cfg}. "
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
)
return ("euler", "normal", 20, 7.0)
try:
import comfy.samplers
sampler = comfy.samplers.sampler_object("euler")
except ImportError:
# Return mock object for testing
sampler = "euler"
return (sampler, "normal", 20, 7.0)
# Process and return the combo
result = get_sampler_combo(sampler_name, scheduler, steps, cfg)
self.log_info(
f"Configured sampler combo: {result[0]}, {result[1]}, "
f"{result[2]} steps, CFG {result[3]}"
)
# Create the sampler object
try:
import comfy.samplers
return result
sampler = comfy.samplers.sampler_object(result[0])
except ImportError:
# Return sampler name for testing
sampler = result[0]
self.log_info(f"Configured sampler combo: {result[0]}, {result[1]}, " f"{result[2]} steps, CFG {result[3]}")
return (sampler, result[1], result[2], result[3])
except Exception as e:
# Handle any unexpected errors gracefully
@@ -119,11 +130,16 @@ class SamplerComboNode(ComfyAssetsBaseNode):
f"{self.__class__.__name__}: Error processing sampler combo: {str(e)}. "
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
)
return ("euler", "normal", 20, 7.0)
try:
import comfy.samplers
def validate_inputs(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
) -> None:
sampler = comfy.samplers.sampler_object("euler")
except ImportError:
# Return mock object for testing
sampler = "euler"
return (sampler, "normal", 20, 7.0)
def validate_inputs(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> None:
"""
Validate sampler combo inputs.
@@ -138,8 +154,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
"""
if not validate_sampler_settings(sampler_name, scheduler, steps, cfg):
self.handle_error(
f"Invalid sampler settings: sampler={sampler_name}, "
f"scheduler={scheduler}, steps={steps}, cfg={cfg}"
f"Invalid sampler settings: sampler={sampler_name}, " f"scheduler={scheduler}, steps={steps}, cfg={cfg}"
)
def get_scheduler_suggestions(self, sampler_name: str) -> list:
@@ -190,9 +205,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
"recommendation": f"Recommended range: {min_cfg}-{max_cfg} CFG",
}
def get_combo_analysis(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
) -> dict:
def get_combo_analysis(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> dict:
"""
Analyze the sampler combo configuration and provide recommendations.
@@ -251,10 +264,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
def __str__(self) -> str:
"""String representation of the node."""
return (
f"SamplerComboNode(samplers={len(SAMPLERS)}, "
f"schedulers={len(SCHEDULERS)})"
)
return f"SamplerComboNode(samplers={len(SAMPLERS)}, " f"schedulers={len(SCHEDULERS)})"
def __repr__(self) -> str:
"""Detailed string representation of the node."""
+3 -9
View File
@@ -66,9 +66,7 @@ def sanitize_seed_value(seed: Any) -> int:
raise ValueError(f"Invalid seed value: {seed}") from e
def create_history_entry(
seed: int, timestamp: Optional[float] = None
) -> Dict[str, Any]:
def create_history_entry(seed: int, timestamp: Optional[float] = None) -> Dict[str, Any]:
"""
Create a standardized history entry for a seed.
@@ -89,9 +87,7 @@ def create_history_entry(
}
def filter_duplicate_seeds(
history: List[Dict[str, Any]], new_seed: int, dedup_window_ms: int = 500
) -> bool:
def filter_duplicate_seeds(history: List[Dict[str, Any]], new_seed: int, dedup_window_ms: int = 500) -> bool:
"""
Check if a seed should be filtered as a duplicate.
@@ -193,9 +189,7 @@ def format_time_ago(timestamp: float) -> str:
return f"{seconds}s ago"
def search_history_by_seed(
history: List[Dict[str, Any]], seed: int
) -> Optional[Dict[str, Any]]:
def search_history_by_seed(history: List[Dict[str, Any]], seed: int) -> Optional[Dict[str, Any]]:
"""
Search history for a specific seed value.
+3 -10
View File
@@ -28,8 +28,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
"default": 12345,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "Seed value for generation processes. "
"History UI tracks all changes automatically.",
"tooltip": "Seed value for generation processes. " "History UI tracks all changes automatically.",
},
),
}
@@ -57,10 +56,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
import logging
logger = logging.getLogger(__name__)
logger.error(
f"{self.__class__.__name__}: Invalid seed value: {seed}. "
f"Using fallback seed 12345."
)
logger.error(f"{self.__class__.__name__}: Invalid seed value: {seed}. " f"Using fallback seed 12345.")
return (12345,)
clean_seed = sanitize_seed_value(seed)
@@ -72,10 +68,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
import logging
logger = logging.getLogger(__name__)
logger.error(
f"{self.__class__.__name__}: Error processing seed: {str(e)}. "
f"Using fallback seed 12345."
)
logger.error(f"{self.__class__.__name__}: Error processing seed: {str(e)}. " f"Using fallback seed 12345.")
return (12345,)
def generate_new_seed(self) -> int:
@@ -5,9 +5,7 @@ from math import gcd
from .presets import PRESET_OPTIONS
def get_preset_dimensions(
preset: str, custom_width: int, custom_height: int
) -> Tuple[int, int]:
def get_preset_dimensions(preset: str, custom_width: int, custom_height: int) -> Tuple[int, int]:
"""
Get dimensions from preset name or use custom dimensions.
@@ -114,9 +112,7 @@ def sanitize_dimensions(width: int, height: int) -> Tuple[int, int]:
return width, height
def get_dimension_info(
preset: str, width: int, height: int, swap_enabled: bool
) -> dict:
def get_dimension_info(preset: str, width: int, height: int, swap_enabled: bool) -> dict:
"""
Get comprehensive dimension information including metadata.
@@ -205,9 +201,7 @@ def parse_dimension_string(dimension_str: str) -> Tuple[int, int]:
raise ValueError(f"Could not parse dimensions from {dimension_str}: {e}")
def get_optimal_scale_factor(
current_width: int, current_height: int, target_width: int, target_height: int
) -> float:
def get_optimal_scale_factor(current_width: int, current_height: int, target_width: int, target_height: int) -> float:
"""
Calculate optimal scale factor to get from current to target dimensions.
+5 -14
View File
@@ -36,8 +36,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
metadata = PRESET_METADATA.get(preset_name)
if metadata:
formatted_option = (
f"{preset_name} - {metadata.aspect_ratio} "
f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
f"{preset_name} - {metadata.aspect_ratio} " f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
)
preset_options.append(formatted_option)
else:
@@ -104,9 +103,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
original_preset = self._extract_preset_name(preset)
# Get base dimensions from preset or custom input
final_width, final_height = get_preset_dimensions(
original_preset, width, height
)
final_width, final_height = get_preset_dimensions(original_preset, width, height)
# Sanitize dimensions to ensure they meet ComfyUI requirements
final_width, final_height = sanitize_dimensions(final_width, final_height)
@@ -115,8 +112,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
if not validate_dimensions(final_width, final_height):
# This should not happen after sanitization, but handle gracefully
self.handle_error(
f"Generated invalid dimensions: {final_width}×{final_height}. "
f"Using fallback dimensions 1024×1024."
f"Generated invalid dimensions: {final_width}×{final_height}. " f"Using fallback dimensions 1024×1024."
)
final_width, final_height = 1024, 1024
@@ -124,9 +120,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
except Exception as e:
# Handle any unexpected errors gracefully
error_msg = (
f"Error processing dimensions: {str(e)}. Using fallback 1024×1024."
)
error_msg = f"Error processing dimensions: {str(e)}. Using fallback 1024×1024."
self.handle_error(error_msg)
return (1024, 1024)
@@ -176,10 +170,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
metadata = get_preset_metadata(preset)
if metadata.width > 0: # Valid metadata
return (
f"{preset} - {metadata.aspect_ratio} ({metadata.megapixels:.1f}MP) - "
f"{metadata.description}"
)
return f"{preset} - {metadata.aspect_ratio} ({metadata.megapixels:.1f}MP) - " f"{metadata.description}"
return f"Unknown preset: {preset}"
@@ -287,21 +287,15 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
# Legacy compatibility - maintain old preset dictionaries
SDXL_PRESETS: Dict[str, Tuple[int, int]] = {
k: (v.width, v.height)
for k, v in PRESET_METADATA.items()
if v.model_group == "SDXL"
k: (v.width, v.height) for k, v in PRESET_METADATA.items() if v.model_group == "SDXL"
}
FLUX_PRESETS: Dict[str, Tuple[int, int]] = {
k: (v.width, v.height)
for k, v in PRESET_METADATA.items()
if v.model_group == "FLUX"
k: (v.width, v.height) for k, v in PRESET_METADATA.items() if v.model_group == "FLUX"
}
ULTRA_WIDE_PRESETS: Dict[str, Tuple[int, int]] = {
k: (v.width, v.height)
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide"
k: (v.width, v.height) for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"
}
# Combined preset options for ComfyUI dropdown
@@ -314,78 +308,28 @@ PRESET_OPTIONS: Dict[str, Tuple[int, int]] = {
PRESET_CATEGORIES = {
"Custom": ["custom"],
# SDXL Categories
"SDXL Square": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "SDXL" and v.category == "Square"
],
"SDXL Portrait": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "SDXL" and v.category == "Portrait"
],
"SDXL Landscape": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "SDXL" and v.category == "Landscape"
],
"SDXL Square": [k for k, v in PRESET_METADATA.items() if v.model_group == "SDXL" and v.category == "Square"],
"SDXL Portrait": [k for k, v in PRESET_METADATA.items() if v.model_group == "SDXL" and v.category == "Portrait"],
"SDXL Landscape": [k for k, v in PRESET_METADATA.items() if v.model_group == "SDXL" and v.category == "Landscape"],
# FLUX Categories
"FLUX Square": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "FLUX" and v.category == "Square"
],
"FLUX Portrait": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "FLUX" and v.category == "Portrait"
],
"FLUX Cinematic": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "FLUX" and v.category == "Cinematic"
],
"FLUX Classic": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "FLUX" and v.category == "Classic"
],
"FLUX Photography": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "FLUX" and v.category == "Photography"
],
"FLUX Square": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX" and v.category == "Square"],
"FLUX Portrait": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX" and v.category == "Portrait"],
"FLUX Cinematic": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX" and v.category == "Cinematic"],
"FLUX Classic": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX" and v.category == "Classic"],
"FLUX Photography": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX" and v.category == "Photography"],
# Ultra-Wide Categories
"Ultra-Wide Gaming": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Gaming"
],
"Ultra-Wide Gaming": [k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide" and v.category == "Gaming"],
"Ultra-Wide Cinematic": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Cinematic"
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide" and v.category == "Cinematic"
],
"Ultra-Wide Panoramic": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Panoramic"
],
"Ultra-Wide Mobile": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Mobile"
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide" and v.category == "Panoramic"
],
"Ultra-Wide Mobile": [k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide" and v.category == "Mobile"],
"Ultra-Wide Vertical": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Vertical"
],
"Ultra-Wide Banner": [
k
for k, v in PRESET_METADATA.items()
if v.model_group == "Ultra-Wide" and v.category == "Banner"
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide" and v.category == "Vertical"
],
"Ultra-Wide Banner": [k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide" and v.category == "Banner"],
}
# Legacy compatibility - preset descriptions
@@ -395,9 +339,7 @@ PRESET_DESCRIPTIONS = {k: v.description for k, v in PRESET_METADATA.items()}
MODEL_RECOMMENDATIONS = {
"SDXL": [k for k, v in PRESET_METADATA.items() if v.model_group == "SDXL"],
"FLUX": [k for k, v in PRESET_METADATA.items() if v.model_group == "FLUX"],
"Ultra-Wide": [
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"
],
"Ultra-Wide": [k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"],
}
@@ -451,9 +393,7 @@ def validate_preset_dimensions() -> bool:
# Check divisible by 8
if width % 8 != 0 or height % 8 != 0:
print(
f"ERROR: {preset_name} dimensions not divisible by 8: {width}×{height}"
)
print(f"ERROR: {preset_name} dimensions not divisible by 8: {width}×{height}")
return False
# Check reasonable bounds
@@ -469,9 +409,7 @@ def validate_metadata_consistency() -> bool:
"""Validate metadata consistency and completeness."""
for preset_name, metadata in PRESET_METADATA.items():
# Verify aspect ratio calculation
expected_ratio, expected_decimal = calculate_aspect_ratio(
metadata.width, metadata.height
)
expected_ratio, expected_decimal = calculate_aspect_ratio(metadata.width, metadata.height)
if abs(metadata.aspect_decimal - expected_decimal) > 0.001:
print(
f"ERROR: {preset_name} aspect ratio mismatch: "
@@ -482,10 +420,7 @@ def validate_metadata_consistency() -> bool:
# Verify megapixel calculation
expected_mp = (metadata.width * metadata.height) / 1_000_000
if abs(metadata.megapixels - expected_mp) > 0.1:
print(
f"ERROR: {preset_name} megapixel mismatch: "
f"expected {expected_mp:.2f}, got {metadata.megapixels}"
)
print(f"ERROR: {preset_name} megapixel mismatch: " f"expected {expected_mp:.2f}, got {metadata.megapixels}")
return False
return True
+23
View File
@@ -0,0 +1,23 @@
[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = False
ignore_missing_imports = True
no_strict_optional = True
files = kikotools
exclude = tests
# Ignore import errors from ComfyUI
[mypy-comfy.*]
ignore_errors = True
# Ignore errors for torch imports
[mypy-torch.*]
ignore_missing_imports = True
[mypy-numpy.*]
ignore_missing_imports = True
[mypy-PIL.*]
ignore_missing_imports = True
+82 -3
View File
@@ -1,16 +1,95 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.2"
license = {file = "LICENSE"}
dependencies = ["# Development dependencies for ComfyUI-KikoTools", "# Testing framework", "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "# Code quality", "black>=23.0.0", "flake8>=6.0.0", "mypy>=1.0.0", "# Development utilities", "pre-commit>=3.0.0", "# ComfyUI testing (mock dependencies for unit tests)", "torch>=2.0.0", "numpy>=1.24.0", "pillow>=9.0.0"]
version = "1.0.7"
license = {text = "MIT"}
dependencies = []
[project.optional-dependencies]
dev = [
# Testing framework
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
# Code quality
"black>=23.0.0",
"flake8>=6.0.0",
"mypy>=1.0.0",
# Development utilities
"pre-commit>=3.0.0",
# ComfyUI testing (mock dependencies for unit tests)
"torch>=2.0.0",
"numpy>=1.24.0",
"pillow>=9.0.0"
]
[project.urls]
Repository = "https://github.com/ComfyAssets/ComfyUI-KikoTools"
# Used by Comfy Registry https://registry.comfy.org
[tool.setuptools.packages.find]
include = ["kikotools*"]
exclude = ["tests*", "web*"]
[tool.comfy]
PublisherId = "kiko9"
DisplayName = "ComfyUI-KikoTools"
Icon = "https://avatars.githubusercontent.com/u/213204677?s=200"
includes = []
[tool.black]
line-length = 127
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
View File
@@ -2,4 +2,4 @@
testpaths = tests
python_paths = .
norecursedirs = venv .git __pycache__
addopts = --ignore=__init__.py --ignore=venv
addopts = --ignore=__init__.py --ignore=venv
+1 -1
View File
@@ -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
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Run mypy type checking on kikotools package
# This is used as an alternative to pre-commit due to package name issues
set -e
echo "Running mypy type checking..."
cd "$(dirname "$0")/.."
# Run mypy with the configuration
python -m mypy kikotools/ --ignore-missing-imports --no-strict-optional || {
echo "❌ Mypy type checking failed"
exit 1
}
echo "✓ Mypy type checking passed"
+3 -9
View File
@@ -102,18 +102,12 @@ def assert_divisible_by_8(width: int, height: int) -> None:
assert height % 8 == 0, f"Height {height} must be divisible by 8"
def assert_reasonable_dimensions(
width: int, height: int, min_size: int = 64, max_size: int = 8192
) -> None:
def assert_reasonable_dimensions(width: int, height: int, min_size: int = 64, max_size: int = 8192) -> None:
"""
Helper function to assert dimensions are within reasonable bounds
"""
assert (
min_size <= width <= max_size
), f"Width {width} out of reasonable range [{min_size}, {max_size}]"
assert (
min_size <= height <= max_size
), f"Height {height} out of reasonable range [{min_size}, {max_size}]"
assert min_size <= width <= max_size, f"Width {width} out of reasonable range [{min_size}, {max_size}]"
assert min_size <= height <= max_size, f"Height {height} out of reasonable range [{min_size}, {max_size}]"
# Make helper functions available as pytest fixtures
+2 -8
View File
@@ -32,10 +32,7 @@ class TestComfyAssetsBaseNode:
node.handle_error("Test error message")
mock_logger.error.assert_called_once()
assert (
"ComfyAssetsBaseNode: Test error message"
in mock_logger.error.call_args[0][0]
)
assert "ComfyAssetsBaseNode: Test error message" in mock_logger.error.call_args[0][0]
def test_handle_error_with_exception_logs_exception(self):
"""Test error handling with original exception logs both messages"""
@@ -58,10 +55,7 @@ class TestComfyAssetsBaseNode:
node.log_info("Test information")
mock_logger.info.assert_called_once()
assert (
"ComfyAssetsBaseNode: Test information"
in mock_logger.info.call_args[0][0]
)
assert "ComfyAssetsBaseNode: Test information" in mock_logger.info.call_args[0][0]
def test_get_node_info_returns_metadata(self):
"""Test get_node_info returns correct metadata"""
+280
View File
@@ -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)
+536
View File
@@ -0,0 +1,536 @@
"""
Unit tests for KikoSaveImage tool
Tests image saving functionality with multiple formats and quality settings
"""
import pytest
import torch
import tempfile
import os
from PIL import Image
from unittest.mock import patch
from kikotools.tools.kiko_save_image.node import KikoSaveImageNode
from kikotools.tools.kiko_save_image.logic import (
convert_tensor_to_pil,
process_image_batch,
validate_save_inputs,
save_image_with_format,
get_save_image_path,
create_png_metadata,
)
class TestKikoSaveImageLogic:
"""Test core logic functions"""
def test_convert_tensor_to_pil(self):
"""Test tensor to PIL conversion"""
# Create test tensor [height, width, channels] with values 0-1
tensor = torch.rand(64, 64, 3)
# Convert to PIL
pil_image = convert_tensor_to_pil(tensor)
# Verify conversion
assert isinstance(pil_image, Image.Image)
assert pil_image.size == (64, 64) # PIL uses (width, height)
assert pil_image.mode in ["RGB", "RGBA"]
def test_convert_tensor_to_pil_rgba(self):
"""Test tensor to PIL conversion with alpha channel"""
# Create RGBA tensor
tensor = torch.rand(32, 32, 4)
pil_image = convert_tensor_to_pil(tensor)
assert isinstance(pil_image, Image.Image)
assert pil_image.size == (32, 32)
assert pil_image.mode == "RGBA"
def test_get_save_image_path(self):
"""Test save path generation"""
with tempfile.TemporaryDirectory() as temp_dir:
# Test basic path generation
full_path, filename = get_save_image_path("test_prefix", 0, ".png", temp_dir)
assert full_path.startswith(temp_dir)
assert filename.startswith("test_prefix_")
assert filename.endswith("_00000.png")
# Test with empty subfolder (standard behavior)
full_path, filename = get_save_image_path("test", 1, ".jpg", temp_dir, "")
assert full_path.startswith(temp_dir)
assert filename.startswith("test_")
assert filename.endswith("_00001.jpg")
def test_create_png_metadata(self):
"""Test PNG metadata creation"""
# Test with no metadata
metadata = create_png_metadata()
assert metadata is None
# Test with prompt data
prompt_data = {"test": "value"}
metadata = create_png_metadata(prompt=prompt_data)
assert metadata is not None
# Check that metadata contains our data (implementation detail)
assert hasattr(metadata, "text")
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_process_image_batch_png(self, mock_folder_paths):
"""Test batch processing with PNG format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
# Create test image batch [batch, height, width, channels]
images = torch.rand(2, 32, 32, 3)
# Process batch
results, enhanced_data = process_image_batch(
images=images,
filename_prefix="test_batch",
format_type="PNG",
png_compress_level=6,
)
# Verify results (clean data)
assert len(results) == 2
for i, result in enumerate(results):
assert "filename" in result
assert "subfolder" in result
assert "type" in result
assert result["type"] == "output"
# Verify enhanced data
assert len(enhanced_data) == 2
for i, enhanced in enumerate(enhanced_data):
assert enhanced["format"] == "PNG"
assert enhanced["compress_level"] == 6
assert enhanced["dimensions"] == "32x32"
assert enhanced["popup"] is True # Default popup value
assert "file_size" in enhanced
# Verify file was saved
filepath = os.path.join(temp_dir, enhanced["filename"])
assert os.path.exists(filepath)
# Verify image can be loaded
saved_img = Image.open(filepath)
assert saved_img.size == (32, 32)
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_process_image_batch_jpeg(self, mock_folder_paths):
"""Test batch processing with JPEG format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
# Create test image batch
images = torch.rand(1, 64, 64, 3)
# Process batch
results, enhanced_data = process_image_batch(
images=images,
filename_prefix="test_jpeg",
format_type="JPEG",
quality=85,
)
# Verify results
assert len(results) == 1
assert len(enhanced_data) == 1
enhanced = enhanced_data[0]
assert enhanced["format"] == "JPEG"
assert enhanced["quality"] == 85
assert enhanced["filename"].endswith(".jpg")
# Verify file exists and can be loaded
filepath = os.path.join(temp_dir, results[0]["filename"])
assert os.path.exists(filepath)
saved_img = Image.open(filepath)
assert saved_img.size == (64, 64)
assert saved_img.mode == "RGB" # JPEG converts to RGB
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_process_image_batch_webp(self, mock_folder_paths):
"""Test batch processing with WebP format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
# Create test image batch
images = torch.rand(1, 48, 48, 3)
# Test lossless WebP
results = process_image_batch(
images=images,
filename_prefix="test_webp",
format_type="WEBP",
quality=90,
webp_lossless=True,
)
assert len(results) == 1
result = results[0]
assert result["format"] == "WEBP"
assert result["lossless"] is True
assert result["filename"].endswith(".webp")
def test_validate_save_inputs_valid(self):
"""Test input validation with valid inputs"""
images = torch.rand(2, 64, 64, 3)
# Should not raise exception
validate_save_inputs(images, "PNG", 90, 4)
validate_save_inputs(images, "JPEG", 85, 4)
validate_save_inputs(images, "WEBP", 95, 6)
def test_validate_save_inputs_invalid_tensor(self):
"""Test validation with invalid tensor"""
# Wrong tensor dimensions
invalid_tensor = torch.rand(64, 64) # Missing batch and channel dims
with pytest.raises(ValueError, match="4 dimensions"):
validate_save_inputs(invalid_tensor, "PNG", 90, 4)
# Non-tensor input
with pytest.raises(ValueError, match="torch.Tensor"):
validate_save_inputs("not_a_tensor", "PNG", 90, 4)
def test_validate_save_inputs_invalid_format(self):
"""Test validation with invalid format"""
images = torch.rand(1, 32, 32, 3)
with pytest.raises(ValueError, match="format must be one of"):
validate_save_inputs(images, "BMP", 90, 4)
def test_validate_save_inputs_invalid_quality(self):
"""Test validation with invalid quality"""
images = torch.rand(1, 32, 32, 3)
# Quality out of range
with pytest.raises(ValueError, match="quality must be an integer between 1 and 100"):
validate_save_inputs(images, "JPEG", 0, 4)
with pytest.raises(ValueError, match="quality must be an integer between 1 and 100"):
validate_save_inputs(images, "JPEG", 101, 4)
def test_validate_save_inputs_invalid_compress_level(self):
"""Test validation with invalid PNG compression level"""
images = torch.rand(1, 32, 32, 3)
with pytest.raises(ValueError, match="png_compress_level must be an integer between 0 and 9"):
validate_save_inputs(images, "PNG", 90, -1)
with pytest.raises(ValueError, match="png_compress_level must be an integer between 0 and 9"):
validate_save_inputs(images, "PNG", 90, 10)
def test_save_image_with_format_png(self):
"""Test saving with PNG format"""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
temp_path = temp_file.name
try:
# Create test PIL image
img = Image.new("RGB", (32, 32), color="red")
# Save with PNG format
result = save_image_with_format(img, temp_path, "PNG", png_compress_level=8)
assert result["format"] == "PNG"
assert result["compress_level"] == 8
assert os.path.exists(temp_path)
# Verify saved image
saved_img = Image.open(temp_path)
assert saved_img.size == (32, 32)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_save_image_with_format_jpeg_rgba_conversion(self):
"""Test JPEG saving with RGBA to RGB conversion"""
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file:
temp_path = temp_file.name
try:
# Create RGBA image
img = Image.new("RGBA", (32, 32), color=(255, 0, 0, 128))
# Save as JPEG (should convert to RGB)
result = save_image_with_format(img, temp_path, "JPEG", quality=95)
assert result["format"] == "JPEG"
assert result["quality"] == 95
# Verify saved image is RGB
saved_img = Image.open(temp_path)
assert saved_img.mode == "RGB"
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
class TestKikoSaveImageNode:
"""Test KikoSaveImageNode class"""
def setup_method(self):
"""Setup test fixtures"""
self.node = KikoSaveImageNode()
def test_input_types(self):
"""Test INPUT_TYPES class method"""
input_types = KikoSaveImageNode.INPUT_TYPES()
# Check required inputs
required = input_types["required"]
assert "images" in required
assert "filename_prefix" in required
assert "format" in required
# Check format options
format_options = required["format"][0]
assert "PNG" in format_options
assert "JPEG" in format_options
assert "WEBP" in format_options
# Check optional inputs
optional = input_types["optional"]
assert "quality" in optional
assert "png_compress_level" in optional
assert "webp_lossless" in optional
assert "popup" in optional
# Check hidden inputs
hidden = input_types["hidden"]
assert "prompt" in hidden
assert "extra_pnginfo" in hidden
def test_node_attributes(self):
"""Test node class attributes"""
assert KikoSaveImageNode.RETURN_TYPES == ()
assert KikoSaveImageNode.FUNCTION == "save_images"
assert KikoSaveImageNode.OUTPUT_NODE is True
assert KikoSaveImageNode.CATEGORY == "ComfyAssets"
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
def test_save_images_success(self, mock_process):
"""Test successful image saving"""
# Setup mock - new return format (results, enhanced_data)
mock_results = [
{
"filename": "test_00001_00000.png",
"subfolder": "",
"type": "output",
}
]
mock_enhanced = [
{
"filename": "test_00001_00000.png",
"popup": True,
"type": "output",
"format": "PNG",
"file_size": 1024,
"dimensions": "64x64",
}
]
mock_process.return_value = (mock_results, mock_enhanced)
# Create test input
images = torch.rand(1, 64, 64, 3)
# Call save_images
result = self.node.save_images(
images=images,
filename_prefix="test",
format="PNG",
quality=90,
png_compress_level=4,
)
# Verify mock was called
mock_process.assert_called_once()
# Verify result format
assert "ui" in result
assert "images" in result["ui"]
assert "kiko_enhanced" in result["ui"]
assert result["ui"]["images"] == mock_results
assert result["ui"]["kiko_enhanced"] == mock_enhanced
def test_validate_inputs_success(self):
"""Test input validation with valid inputs"""
images = torch.rand(1, 32, 32, 3)
# Should not raise exception
self.node.validate_inputs(
images=images,
format="PNG",
quality=90,
png_compress_level=4,
webp_lossless=False,
popup=True,
)
def test_validate_inputs_invalid_webp_lossless(self):
"""Test validation with invalid webp_lossless type"""
images = torch.rand(1, 32, 32, 3)
with pytest.raises(ValueError, match="webp_lossless must be a boolean"):
self.node.validate_inputs(
images=images,
format="PNG",
quality=90,
png_compress_level=4,
webp_lossless="not_boolean",
popup=True,
)
def test_validate_inputs_invalid_popup(self):
"""Test validation with invalid popup"""
images = torch.rand(1, 32, 32, 3)
# Non-boolean popup
with pytest.raises(ValueError, match="popup must be a boolean"):
self.node.validate_inputs(
images=images,
format="PNG",
quality=90,
png_compress_level=4,
webp_lossless=False,
popup="not_boolean",
)
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
def test_save_images_error_handling(self, mock_process):
"""Test error handling in save_images method"""
# Setup mock to raise exception
mock_process.side_effect = Exception("Test error")
images = torch.rand(1, 32, 32, 3)
# Should handle error and re-raise with context
with pytest.raises(ValueError, match="Failed to save images"):
self.node.save_images(images=images)
def test_node_info(self):
"""Test get_node_info method"""
info = self.node.get_node_info()
assert info["class_name"] == "KikoSaveImageNode"
assert info["category"] == "ComfyAssets"
assert info["function"] == "save_images"
class TestNodeRegistration:
"""Test node registration mappings"""
def test_node_class_mappings(self):
"""Test NODE_CLASS_MAPPINGS contains KikoSaveImage"""
from kikotools.tools.kiko_save_image.node import NODE_CLASS_MAPPINGS
assert "KikoSaveImage" in NODE_CLASS_MAPPINGS
assert NODE_CLASS_MAPPINGS["KikoSaveImage"] is KikoSaveImageNode
def test_node_display_name_mappings(self):
"""Test NODE_DISPLAY_NAME_MAPPINGS contains KikoSaveImage"""
from kikotools.tools.kiko_save_image.node import NODE_DISPLAY_NAME_MAPPINGS
assert "KikoSaveImage" in NODE_DISPLAY_NAME_MAPPINGS
assert NODE_DISPLAY_NAME_MAPPINGS["KikoSaveImage"] == "Kiko Save Image"
# Integration test fixtures
@pytest.fixture
def sample_image_tensor():
"""Create sample image tensor for testing"""
# Create a colorful test image [batch, height, width, channels]
batch_size, height, width, channels = 2, 64, 64, 3
# Create gradient pattern
tensor = torch.zeros(batch_size, height, width, channels)
for b in range(batch_size):
for h in range(height):
for w in range(width):
# Create RGB gradient pattern
tensor[b, h, w, 0] = h / height # Red gradient
tensor[b, h, w, 1] = w / width # Green gradient
tensor[b, h, w, 2] = (b + 1) * 0.5 # Blue varies by batch
return tensor
class TestIntegration:
"""Integration tests using sample data"""
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_full_pipeline_png(self, mock_folder_paths, sample_image_tensor):
"""Test complete pipeline with PNG format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
node = KikoSaveImageNode()
# Save images
result = node.save_images(
images=sample_image_tensor,
filename_prefix="integration_test",
format="PNG",
png_compress_level=6,
)
# Verify result structure
assert "ui" in result
assert "images" in result["ui"]
assert len(result["ui"]["images"]) == 2
# Verify files were created
for image_info in result["ui"]["images"]:
filepath = os.path.join(temp_dir, image_info["filename"])
assert os.path.exists(filepath)
# Verify image properties
img = Image.open(filepath)
assert img.size == (64, 64)
assert img.format == "PNG"
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_full_pipeline_all_formats(self, mock_folder_paths, sample_image_tensor):
"""Test complete pipeline with all supported formats"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
node = KikoSaveImageNode()
# Test each format
formats_to_test = [
("PNG", {"png_compress_level": 8}),
("JPEG", {"quality": 85}),
("WEBP", {"quality": 90, "webp_lossless": False}),
("WEBP", {"quality": 100, "webp_lossless": True}),
]
for format_type, kwargs in formats_to_test:
result = node.save_images(
images=sample_image_tensor,
filename_prefix=f"test_{format_type.lower()}",
format=format_type,
**kwargs,
)
# Verify results
assert len(result["ui"]["images"]) == 2
for image_info in result["ui"]["images"]:
assert image_info["format"] == format_type
# Verify file exists and can be opened
filepath = os.path.join(temp_dir, image_info["filename"])
assert os.path.exists(filepath)
img = Image.open(filepath)
assert img.size == (64, 64)
+8 -24
View File
@@ -56,13 +56,9 @@ class TestDimensionExtraction:
with pytest.raises(ValueError, match="Either image or latent must be provided"):
extract_dimensions()
def test_extract_dimensions_both_inputs_prefers_image(
self, mock_image_tensor, mock_latent_tensor
):
def test_extract_dimensions_both_inputs_prefers_image(self, mock_image_tensor, mock_latent_tensor):
"""Test that when both inputs provided, image takes precedence"""
width, height = extract_dimensions(
image=mock_image_tensor, latent=mock_latent_tensor
)
width, height = extract_dimensions(image=mock_image_tensor, latent=mock_latent_tensor)
# Should return image dimensions, not latent
assert width == 832
@@ -93,9 +89,7 @@ class TestScaledDimensionsCalculation:
original_width, original_height = 832, 1216
scale_factor = 1.5
new_width, new_height = calculate_scaled_dimensions(
original_width, original_height, scale_factor
)
new_width, new_height = calculate_scaled_dimensions(original_width, original_height, scale_factor)
# Check aspect ratio is preserved (within floating point precision)
original_ratio = original_width / original_height
@@ -107,9 +101,7 @@ class TestScaledDimensionsCalculation:
base_width, base_height = 1024, 1024
for scale_factor in sample_scale_factors:
width, height = calculate_scaled_dimensions(
base_width, base_height, scale_factor
)
width, height = calculate_scaled_dimensions(base_width, base_height, scale_factor)
expected_width = int(base_width * scale_factor)
expected_height = int(base_height * scale_factor)
@@ -213,9 +205,7 @@ class TestResolutionCalculatorNode:
"""Test node calculation with IMAGE input"""
node = ResolutionCalculatorNode()
width, height = node.calculate_resolution(
scale_factor=2.0, image=mock_image_tensor
)
width, height = node.calculate_resolution(scale_factor=2.0, image=mock_image_tensor)
# Original: 832x1216, 2x scale = 1664x2432
assert isinstance(width, int)
@@ -230,9 +220,7 @@ class TestResolutionCalculatorNode:
"""Test node calculation with LATENT input"""
node = ResolutionCalculatorNode()
width, height = node.calculate_resolution(
scale_factor=1.5, latent=mock_latent_tensor
)
width, height = node.calculate_resolution(scale_factor=1.5, latent=mock_latent_tensor)
# Original: 832x1216, 1.5x scale = 1248x1824
assert isinstance(width, int)
@@ -250,16 +238,12 @@ class TestResolutionCalculatorNode:
with pytest.raises(ValueError):
node.calculate_resolution(scale_factor=2.0)
def test_calculate_resolution_with_various_scale_factors(
self, mock_image_tensor_square, sample_scale_factors
):
def test_calculate_resolution_with_various_scale_factors(self, mock_image_tensor_square, sample_scale_factors):
"""Test calculation with various scale factors"""
node = ResolutionCalculatorNode()
for scale_factor in sample_scale_factors:
width, height = node.calculate_resolution(
scale_factor=scale_factor, image=mock_image_tensor_square
)
width, height = node.calculate_resolution(scale_factor=scale_factor, image=mock_image_tensor_square)
# All results should be integers divisible by 8
assert isinstance(width, int)
+4 -6
View File
@@ -168,17 +168,17 @@ class TestSamplerComboNode:
steps_input = required["steps"]
assert steps_input[0] == "INT"
assert steps_input[1]["min"] == 1
assert steps_input[1]["max"] == 1000
assert steps_input[1]["max"] == 100
# Check CFG input structure
cfg_input = required["cfg"]
assert cfg_input[0] == "FLOAT"
assert cfg_input[1]["min"] == 0.0
assert cfg_input[1]["max"] == 30.0
assert cfg_input[1]["max"] == 20.0
def test_return_types_structure(self):
"""Test that return types are correctly defined."""
assert SamplerComboNode.RETURN_TYPES == (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_TYPES == ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_NAMES == (
"sampler_name",
"scheduler",
@@ -331,9 +331,7 @@ class TestSamplerComboIntegration:
# Test that recommendations work with the node
for scheduler in suggestions[:2]: # Test first 2 suggestions
result = node.get_sampler_combo(
sampler, scheduler, steps_rec["default"], cfg_rec["default"]
)
result = node.get_sampler_combo(sampler, scheduler, steps_rec["default"], cfg_rec["default"])
assert result[0] == sampler
assert result[1] == scheduler
assert result[2] == steps_rec["default"]
+14 -42
View File
@@ -70,9 +70,7 @@ class TestWidthHeightSelectorNode:
# Test formatted preset if available
formatted_preset = "832×1216 - 13:19 (1.0MP) - SDXL"
result = self.node.get_dimensions(
preset=formatted_preset, width=512, height=512
)
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
assert result == (832, 1216)
def test_sdxl_landscape_preset(self):
@@ -83,9 +81,7 @@ class TestWidthHeightSelectorNode:
# Test formatted preset if available
formatted_preset = "1216×832 - 19:13 (1.0MP) - SDXL"
result = self.node.get_dimensions(
preset=formatted_preset, width=512, height=512
)
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
assert result == (1216, 832)
def test_flux_preset(self):
@@ -96,9 +92,7 @@ class TestWidthHeightSelectorNode:
# Test formatted preset
formatted_preset = "1920×1080 - 16:9 (2.1MP) - FLUX"
result = self.node.get_dimensions(
preset=formatted_preset, width=512, height=512
)
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
assert result == (1920, 1080)
def test_ultra_wide_preset(self):
@@ -109,9 +103,7 @@ class TestWidthHeightSelectorNode:
# Test formatted preset if available
formatted_preset = "2560×1080 - 64:27 (2.8MP) - Ultra-Wide"
result = self.node.get_dimensions(
preset=formatted_preset, width=512, height=512
)
result = self.node.get_dimensions(preset=formatted_preset, width=512, height=512)
assert result == (2560, 1080)
def test_all_presets_available(self):
@@ -143,9 +135,7 @@ class TestWidthHeightSelectorNode:
def test_invalid_preset_fallback(self):
"""Test handling of invalid preset."""
# Should fall back to custom dimensions
result = self.node.get_dimensions(
preset="invalid_preset", width=800, height=600
)
result = self.node.get_dimensions(preset="invalid_preset", width=800, height=600)
assert result == (800, 600)
@@ -268,18 +258,14 @@ class TestPresetDefinitions:
for preset_dict in [SDXL_PRESETS, FLUX_PRESETS, ULTRA_WIDE_PRESETS]:
for preset_name, (width, height) in preset_dict.items():
assert width % 8 == 0, f"{preset_name} width {width} not divisible by 8"
assert (
height % 8 == 0
), f"{preset_name} height {height} not divisible by 8"
assert height % 8 == 0, f"{preset_name} height {height} not divisible by 8"
def test_preset_dimensions_within_limits(self):
"""Test that all preset dimensions are within acceptable limits."""
for preset_dict in [SDXL_PRESETS, FLUX_PRESETS, ULTRA_WIDE_PRESETS]:
for preset_name, (width, height) in preset_dict.items():
assert 64 <= width <= 8192, f"{preset_name} width {width} out of range"
assert (
64 <= height <= 8192
), f"{preset_name} height {height} out of range"
assert 64 <= height <= 8192, f"{preset_name} height {height} out of range"
class TestEdgeCases:
@@ -481,9 +467,7 @@ class TestFormattedPresets:
for formatted_preset, expected in test_cases:
result = self.node._extract_preset_name(formatted_preset)
assert (
result == expected
), f"Expected {expected}, got {result} for input {formatted_preset}"
assert result == expected, f"Expected {expected}, got {result} for input {formatted_preset}"
def test_formatted_preset_dimensions(self):
"""Test that formatted presets return correct dimensions."""
@@ -525,34 +509,22 @@ class TestFormattedPresets:
def test_formatted_preset_metadata_accuracy(self):
"""Test that formatted presets contain accurate metadata."""
input_types = self.node.INPUT_TYPES()
formatted_presets = [
opt for opt in input_types["required"]["preset"][0] if " - " in opt
]
formatted_presets = [opt for opt in input_types["required"]["preset"][0] if " - " in opt]
for formatted_preset in formatted_presets:
# Extract components
parts = formatted_preset.split(" - ")
assert (
len(parts) == 3
), f"Formatted preset should have 3 parts: {formatted_preset}"
assert len(parts) == 3, f"Formatted preset should have 3 parts: {formatted_preset}"
resolution = parts[0]
aspect_and_mp = parts[1]
model_group = parts[2]
# Verify resolution exists in metadata
assert (
resolution in PRESET_METADATA
), f"Resolution {resolution} not in metadata"
assert resolution in PRESET_METADATA, f"Resolution {resolution} not in metadata"
# Verify metadata matches format
metadata = PRESET_METADATA[resolution]
assert (
metadata.model_group == model_group
), f"Model group mismatch for {resolution}"
assert (
metadata.aspect_ratio in aspect_and_mp
), f"Aspect ratio not in {aspect_and_mp}"
assert (
f"{metadata.megapixels:.1f}MP" in aspect_and_mp
), f"Megapixels not in {aspect_and_mp}"
assert metadata.model_group == model_group, f"Model group mismatch for {resolution}"
assert metadata.aspect_ratio in aspect_and_mp, f"Aspect ratio not in {aspect_and_mp}"
assert f"{metadata.megapixels:.1f}MP" in aspect_and_mp, f"Megapixels not in {aspect_and_mp}"
+176
View File
@@ -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);
}
});
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -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
View File
@@ -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({
};
}
},
});
});