diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d2d5c62 --- /dev/null +++ b/.flake8 @@ -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 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c0d39dd --- /dev/null +++ b/.gitattributes @@ -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 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 134650a..1b0e0f6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. \ No newline at end of file +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index c576316..f037a09 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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 newline at end of file +- [ ] No, but I'd be happy to test it diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 2520877..7003802 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -59,7 +59,7 @@ jobs: import sys import os sys.path.insert(0, os.getcwd()) - + # Test that all imports work correctly try: from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS @@ -67,64 +67,64 @@ jobs: except ImportError as e: print(f'Warning: Package-level imports failed: {e}') # This is expected since we don't have ComfyUI installed - + # Test individual module imports from kikotools.base import ComfyAssetsBaseNode from kikotools.tools.resolution_calculator import ResolutionCalculatorNode from kikotools.tools.resolution_calculator.logic import extract_dimensions from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode as NodeClass - + # Test Width Height Selector imports from kikotools.tools.width_height_selector import WidthHeightSelectorNode from kikotools.tools.width_height_selector.logic import get_preset_dimensions from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS, PRESET_METADATA - + # Test Sampler Combo imports from kikotools.tools.sampler_combo import SamplerComboNode from kikotools.tools.sampler_combo.logic import get_sampler_combo, SAMPLERS, SCHEDULERS - + # Test Seed History imports from kikotools.tools.seed_history import SeedHistoryNode from kikotools.tools.seed_history.logic import generate_random_seed, validate_seed_value - + # Test Kiko Save Image imports from kikotools.tools.kiko_save_image import KikoSaveImageNode from kikotools.tools.kiko_save_image.logic import process_image_batch, validate_save_inputs - + print('✓ All module imports successful') " - name: Check code style consistency run: | echo "Checking code style consistency..." - + # Check for consistent naming find kikotools/ -name "*.py" -exec grep -l "class.*Node" {} \; | while read file; do if ! grep -q "ComfyAssetsBaseNode" "$file" && ! grep -q "class ComfyAssetsBaseNode" "$file"; then echo "Checking $file for ComfyUI node inheritance..." fi done - + # Check for proper docstrings python -c " import ast import os - + def check_docstrings(filepath): with open(filepath, 'r') as f: tree = ast.parse(f.read()) - + for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.ClassDef)): if not ast.get_docstring(node) and not node.name.startswith('_'): print(f'Warning: {filepath}:{node.lineno} - {node.name} missing docstring') - + for root, dirs, files in os.walk('kikotools'): for file in files: if file.endswith('.py') and not file.startswith('__'): filepath = os.path.join(root, file) check_docstrings(filepath) - + print('✓ Docstring check completed') " @@ -151,7 +151,7 @@ jobs: - name: Check for hardcoded secrets run: | echo "Checking for potential secrets..." - + # Check for common secret patterns if grep -r -i "password\|secret\|key\|token" kikotools/ --include="*.py" | grep -v "# " | grep -v "def " | grep -v "class "; then echo "Warning: Potential hardcoded secrets found" @@ -180,103 +180,103 @@ jobs: import sys import os sys.path.insert(0, os.getcwd()) - + print('Checking architecture compliance...') - + # Test separation of concerns from kikotools.tools.resolution_calculator import logic, node - + # Logic module should not import node-specific things import inspect logic_source = inspect.getsource(logic) - + if 'ComfyUI' in logic_source and 'INPUT_TYPES' not in logic_source: print('⚠️ Warning: Logic module contains ComfyUI-specific code') else: print('✓ Logic module properly separated') - + # Node module should inherit from base from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode from kikotools.base import ComfyAssetsBaseNode - + if issubclass(ResolutionCalculatorNode, ComfyAssetsBaseNode): print('✓ Node properly inherits from base class') else: print('❌ Node does not inherit from base class') sys.exit(1) - + # Check that nodes have proper ComfyUI interface required_attrs = ['INPUT_TYPES', 'RETURN_TYPES', 'RETURN_NAMES', 'FUNCTION', 'CATEGORY'] - + # Test Resolution Calculator Node for attr in required_attrs: if not hasattr(ResolutionCalculatorNode, attr): print(f'❌ ResolutionCalculatorNode missing required attribute: {attr}') sys.exit(1) - + # Test Width Height Selector Node from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode - + if issubclass(WidthHeightSelectorNode, ComfyAssetsBaseNode): print('✓ WidthHeightSelectorNode properly inherits from base class') else: print('❌ WidthHeightSelectorNode does not inherit from base class') sys.exit(1) - + for attr in required_attrs: if not hasattr(WidthHeightSelectorNode, attr): print(f'❌ WidthHeightSelectorNode missing required attribute: {attr}') sys.exit(1) - + # Test Sampler Combo Node from kikotools.tools.sampler_combo.node import SamplerComboNode - + if issubclass(SamplerComboNode, ComfyAssetsBaseNode): print('✓ SamplerComboNode properly inherits from base class') else: print('❌ SamplerComboNode does not inherit from base class') sys.exit(1) - + for attr in required_attrs: if not hasattr(SamplerComboNode, attr): print(f'❌ SamplerComboNode missing required attribute: {attr}') sys.exit(1) - + # Test Seed History Node from kikotools.tools.seed_history.node import SeedHistoryNode - + if issubclass(SeedHistoryNode, ComfyAssetsBaseNode): print('✓ SeedHistoryNode properly inherits from base class') else: print('❌ SeedHistoryNode does not inherit from base class') sys.exit(1) - + for attr in required_attrs: if not hasattr(SeedHistoryNode, attr): print(f'❌ SeedHistoryNode missing required attribute: {attr}') sys.exit(1) - + # Test Kiko Save Image Node from kikotools.tools.kiko_save_image.node import KikoSaveImageNode - + if issubclass(KikoSaveImageNode, ComfyAssetsBaseNode): print('✓ KikoSaveImageNode properly inherits from base class') else: print('❌ KikoSaveImageNode does not inherit from base class') sys.exit(1) - + # KikoSaveImage is an output node, so it doesn't have RETURN_TYPES/RETURN_NAMES save_required_attrs = ['INPUT_TYPES', 'FUNCTION', 'CATEGORY'] for attr in save_required_attrs: if not hasattr(KikoSaveImageNode, attr): print(f'❌ KikoSaveImageNode missing required attribute: {attr}') sys.exit(1) - + # Check that it's properly marked as an output node if not hasattr(KikoSaveImageNode, 'OUTPUT_NODE') or not KikoSaveImageNode.OUTPUT_NODE: print('❌ KikoSaveImageNode missing OUTPUT_NODE = True') sys.exit(1) - + print('✓ All architecture checks passed for all tools') " @@ -284,22 +284,22 @@ jobs: run: | python -c " import os - + # Count test files vs implementation files test_files = 0 impl_files = 0 - + for root, dirs, files in os.walk('tests'): test_files += len([f for f in files if f.startswith('test_') and f.endswith('.py')]) - + for root, dirs, files in os.walk('kikotools'): impl_files += len([f for f in files if f.endswith('.py') and not f.startswith('__')]) - + print(f'Implementation files: {impl_files}') print(f'Test files: {test_files}') - + if test_files >= impl_files * 0.5: # At least 50% test coverage by file count print('✓ Adequate test file coverage') else: print('⚠️ Warning: Low test file coverage') - " \ No newline at end of file + " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69df61d..507ef46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" \ No newline at end of file + echo "- 100% test coverage" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 852c1af..37d05ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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) diff --git a/.gitignore b/.gitignore index bc147f5..b032be7 100644 --- a/.gitignore +++ b/.gitignore @@ -158,4 +158,4 @@ input/ test_images/ test_outputs/ experiments/ -.claude/ \ No newline at end of file +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..da87bdd --- /dev/null +++ b/.pre-commit-config.yaml @@ -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/ + ) \ No newline at end of file diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..753db4d --- /dev/null +++ b/.secrets.baseline @@ -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" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be26b08 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index a613af2..e034663 100644 --- a/Makefile +++ b/Makefile @@ -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!" \ No newline at end of file + @echo "🎉 All tool-specific tests completed!" diff --git a/README.md b/README.md index cc3335e..6120235 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Calculate upscaled dimensions from image or latent inputs with precision. **Use Cases:** - Calculate target dimensions for upscaler nodes -- Plan memory usage for large generations +- Plan memory usage for large generations - Ensure ComfyUI tensor compatibility - Optimize batch processing workflows @@ -104,6 +104,26 @@ Enhanced image saving with format selection, quality control, and floating popup - **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality - **Popup Toggle**: Enable/disable popup viewer per save operation +#### 🤖 Gemini Prompt Engineer +AI-powered image analysis using Google's Gemini to generate optimized prompts for various models. + +- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation +- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details +- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions +- **Custom System Prompts**: Override templates with your own analysis instructions +- **Flexible API Key Management**: Environment variable, config file, or direct input +- **Visual Status Feedback**: Real-time processing indicators and error states +- **Help Integration**: Built-in setup guide and documentation + +**Use Cases:** +- Reverse-engineer prompts from reference images +- Convert artistic descriptions between different AI model formats +- Generate consistent style descriptions across workflows +- Create detailed scene breakdowns for complex compositions +- Analyze and replicate lighting/mood from existing artwork + +### 💾 Kiko Save Image Features + **Use Cases:** - Quick preview and management of saved images without file browser navigation - Compare multiple format outputs side-by-side (PNG vs JPEG vs WebP) @@ -157,8 +177,8 @@ Image Loader → Resolution Calculator → Upscaler ↘ scale_factor: 1.5 ↗ ``` -**Input:** 832×1216 (SDXL portrait format) -**Scale:** 1.5x +**Input:** 832×1216 (SDXL portrait format) +**Scale:** 1.5x **Output:** 1248×1824 (ready for upscaling) ### Width Height Selector Example @@ -169,8 +189,8 @@ preset: "1920×1080" ↘ 1920×1080 ↗ [swap button] ``` -**Preset:** FLUX HD (1920×1080) -**Output:** 1920×1080 (16:9 cinematic) +**Preset:** FLUX HD (1920×1080) +**Output:** 1920×1080 (16:9 cinematic) **Swap Button:** Click to get 1080×1920 (9:16 portrait) ### Seed History Example @@ -181,8 +201,8 @@ Seed History → KSampler → VAE Decode → Save Image [History UI: 54321, 99999, 11111...] ``` -**Current Seed:** 12345 -**History:** Auto-tracked previous seeds with timestamps +**Current Seed:** 12345 +**History:** Auto-tracked previous seeds with timestamps **Interaction:** Click any historical seed to reload instantly ### Sampler Combo Example @@ -192,8 +212,8 @@ Sampler Combo → KSampler → VAE Decode → Save Image ⚙️ All Settings ↘ sampler/scheduler/steps/cfg ↗ ``` -**Configuration:** euler, normal, 20 steps, CFG 7.0 -**Output:** Complete sampling configuration in one node +**Configuration:** euler, normal, 20 steps, CFG 7.0 +**Output:** Complete sampling configuration in one node **Smart Features:** Recommendations and compatibility validation ### Empty Latent Batch Example @@ -205,9 +225,9 @@ Empty Latent Batch → KSampler → VAE Decode → Kiko Save Image [swap button] ``` -**Preset:** SDXL Square (1024×1024) -**Batch Size:** 4 empty latents -**Output:** 4×4×128×128 latent tensor ready for sampling +**Preset:** SDXL Square (1024×1024) +**Batch Size:** 4 empty latents +**Output:** 4×4×128×128 latent tensor ready for sampling **Swap Button:** Click to switch to any available swapped preset ### Kiko Save Image Example @@ -219,12 +239,25 @@ Generate Image → Kiko Save Image → Floating Popup Viewer [popup: enabled] ``` -**Format:** WebP (efficient compression, modern format) -**Quality:** 85% (balanced size/quality) -**Popup Viewer:** Floating, draggable window with saved images -**Features:** Click images to open in new tabs, download individual files, batch selection +**Format:** WebP (efficient compression, modern format) +**Quality:** 85% (balanced size/quality) +**Popup Viewer:** Floating, draggable window with saved images +**Features:** Click images to open in new tabs, download individual files, batch selection **Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls +### Gemini Prompt Engineer Example +``` +Load Image → Gemini Prompt → Text Generation Model +🖼️ reference ↘ type: FLUX ↘ "majestic landscape..." + [API key] → FLUX model +``` + +**Input:** Reference image for style analysis +**Prompt Type:** FLUX (detailed artistic prompts) +**Output:** Optimized prompt with style, lighting, composition details +**API:** Requires Gemini API key (free tier available) +**Use Case:** Recreate similar style/mood from reference images + ### Common Workflows
@@ -233,7 +266,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer ```json { "workflow": "Load SDXL portrait → Calculate 1.5x dimensions → Feed to upscaler", - "input_resolution": "832×1216", + "input_resolution": "832×1216", "scale_factor": 1.5, "output_resolution": "1248×1824", "memory_efficient": true @@ -248,7 +281,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer { "workflow": "Generate latents → Calculate target size → Batch upscale", "input_resolution": "1024×1024", - "scale_factor": 2.0, + "scale_factor": 2.0, "output_resolution": "2048×2048", "batch_optimized": true } @@ -276,7 +309,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer **Inputs:** - `scale_factor` (FLOAT): 1.0-8.0, default 2.0 -- `image` (IMAGE, optional): Input image tensor +- `image` (IMAGE, optional): Input image tensor - `latent` (LATENT, optional): Input latent tensor **Outputs:** @@ -298,7 +331,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer **Outputs:** - `width` (INT): Selected or calculated width -- `height` (INT): Selected or calculated height +- `height` (INT): Selected or calculated height **UI Features:** - Visual blue swap button in bottom-right corner @@ -342,7 +375,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer **Outputs:** - `sampler_name` (STRING): Selected sampler algorithm -- `scheduler` (STRING): Selected scheduler algorithm +- `scheduler` (STRING): Selected scheduler algorithm - `steps` (INT): Validated step count - `cfg` (FLOAT): Validated CFG scale @@ -400,7 +433,7 @@ Generate Image → Kiko Save Image → Floating Popup Viewer **UI Features:** - Floating, draggable popup window showing saved images immediately -- Interactive image grid with click-to-open functionality +- Interactive image grid with click-to-open functionality - Individual image download buttons with format-specific quality indicators - Batch selection with multi-select checkboxes for bulk operations - Window controls: minimize, maximize, roll-up, close, and dragging @@ -440,6 +473,9 @@ source venv/bin/activate # On Windows: venv\Scripts\activate # Install development dependencies pip install -r requirements-dev.txt +# Install pre-commit hooks +pre-commit install + # Run tests python -c " import sys, os @@ -456,13 +492,32 @@ print(f'✅ Development setup successful! Test result: {result[0]}x{result[1]}') ### Code Quality -We maintain high code quality standards: +We maintain high code quality standards with automated pre-commit hooks: + +#### Pre-commit Hooks + +Our pre-commit configuration automatically runs: +- **Black**: Code formatting (127 char line length) +- **Flake8**: Linting and style checks +- **Bandit**: Security vulnerability scanning +- **detect-secrets**: Prevents accidental secret commits +- File checks: trailing whitespace, YAML validation, merge conflicts + +```bash +# Run all pre-commit hooks manually +pre-commit run --all-files + +# Update hooks to latest versions +pre-commit autoupdate +``` + +#### Manual Code Quality Checks ```bash # Format code black . -# Lint code +# Lint code flake8 . # Type checking @@ -485,7 +540,7 @@ Following **Test-Driven Development (TDD)**: # Test structure tests/ ├── unit/ # Individual component tests -├── integration/ # ComfyUI workflow tests +├── integration/ # ComfyUI workflow tests └── fixtures/ # Test data and workflows ``` @@ -555,4 +610,4 @@ MIT License - see [LICENSE](LICENSE) file for details. [⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) - \ No newline at end of file + diff --git a/example.py b/example.py new file mode 100644 index 0000000..b1843f6 --- /dev/null +++ b/example.py @@ -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", +} diff --git a/examples/documentation/empty_latent_batch.md b/examples/documentation/empty_latent_batch.md index 6a47e54..68cb849 100644 --- a/examples/documentation/empty_latent_batch.md +++ b/examples/documentation/empty_latent_batch.md @@ -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 \ No newline at end of file +- **Fallback Logic**: Custom dimension swapping when preset not available diff --git a/examples/documentation/resolution_calculator.md b/examples/documentation/resolution_calculator.md index b522f15..3267509 100644 --- a/examples/documentation/resolution_calculator.md +++ b/examples/documentation/resolution_calculator.md @@ -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 \ No newline at end of file +- Custom latent processing workflows diff --git a/examples/documentation/sampler_combo.md b/examples/documentation/sampler_combo.md index 17d3336..dc8e28b 100644 --- a/examples/documentation/sampler_combo.md +++ b/examples/documentation/sampler_combo.md @@ -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. \ No newline at end of file +Connect the outputs directly to your sampling node inputs for streamlined configuration. diff --git a/examples/documentation/seed_history.md b/examples/documentation/seed_history.md index 9326aad..c95fec5 100644 --- a/examples/documentation/seed_history.md +++ b/examples/documentation/seed_history.md @@ -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 \ No newline at end of file +- Batch processing with seed management diff --git a/examples/documentation/width_height_selector.md b/examples/documentation/width_height_selector.md index c5be7c8..231ed63 100644 --- a/examples/documentation/width_height_selector.md +++ b/examples/documentation/width_height_selector.md @@ -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 \ No newline at end of file +- Comprehensive tooltips for each preset diff --git a/examples/workflows/resolution_calculator_example.json b/examples/workflows/resolution_calculator_example.json index 67c629c..d0ff947 100644 --- a/examples/workflows/resolution_calculator_example.json +++ b/examples/workflows/resolution_calculator_example.json @@ -256,4 +256,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/sampler_combo_example.json b/examples/workflows/sampler_combo_example.json index e3d6f33..f6aecf6 100644 --- a/examples/workflows/sampler_combo_example.json +++ b/examples/workflows/sampler_combo_example.json @@ -534,4 +534,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/seed_history_example.json b/examples/workflows/seed_history_example.json index 53eb347..dc8caec 100644 --- a/examples/workflows/seed_history_example.json +++ b/examples/workflows/seed_history_example.json @@ -641,4 +641,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/width_height_selector_example.json b/examples/workflows/width_height_selector_example.json index a664efb..ce5199c 100644 --- a/examples/workflows/width_height_selector_example.json +++ b/examples/workflows/width_height_selector_example.json @@ -719,4 +719,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/kikotools/base/base_node.py b/kikotools/base/base_node.py index cecba5c..e4b769e 100644 --- a/kikotools/base/base_node.py +++ b/kikotools/base/base_node.py @@ -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 diff --git a/kikotools/tools/empty_latent_batch/logic.py b/kikotools/tools/empty_latent_batch/logic.py index 426842b..1a91e23 100644 --- a/kikotools/tools/empty_latent_batch/logic.py +++ b/kikotools/tools/empty_latent_batch/logic.py @@ -4,9 +4,7 @@ import torch 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 diff --git a/kikotools/tools/empty_latent_batch/node.py b/kikotools/tools/empty_latent_batch/node.py index 9d7b3a2..6ebb030 100644 --- a/kikotools/tools/empty_latent_batch/node.py +++ b/kikotools/tools/empty_latent_batch/node.py @@ -36,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: @@ -86,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.", }, ), } @@ -118,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) @@ -134,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 @@ -198,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. @@ -291,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 diff --git a/kikotools/tools/kiko_save_image/logic.py b/kikotools/tools/kiko_save_image/logic.py index 02c8927..308ea3e 100644 --- a/kikotools/tools/kiko_save_image/logic.py +++ b/kikotools/tools/kiko_save_image/logic.py @@ -48,9 +48,7 @@ def get_save_image_path( 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 = 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 @@ -116,9 +114,7 @@ def convert_tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image: return img -def create_png_metadata( - prompt: Optional[Dict] = None, extra_pnginfo: Optional[Dict] = None -) -> Optional[PngInfo]: +def create_png_metadata(prompt: Optional[Dict] = None, extra_pnginfo: Optional[Dict] = None) -> Optional[PngInfo]: """ Create PNG metadata with workflow information @@ -246,10 +242,7 @@ def process_image_batch( 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())}" - ) + raise ValueError(f"Unsupported format: {format_type}. " f"Supported: {list(format_extensions.keys())}") format_ext = format_extensions[format_type] @@ -315,9 +308,7 @@ def process_image_batch( return results, enhanced_data -def validate_save_inputs( - images: torch.Tensor, format_type: str, quality: int, png_compress_level: int -) -> None: +def validate_save_inputs(images: torch.Tensor, format_type: str, quality: int, png_compress_level: int) -> None: """ Validate inputs for image saving @@ -335,31 +326,19 @@ def validate_save_inputs( 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)}" - ) + 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}" - ) + 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}" - ) + 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}" - ) + 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}") diff --git a/kikotools/tools/kiko_save_image/node.py b/kikotools/tools/kiko_save_image/node.py index d11833c..74ba015 100644 --- a/kikotools/tools/kiko_save_image/node.py +++ b/kikotools/tools/kiko_save_image/node.py @@ -79,8 +79,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode): "BOOLEAN", { "default": False, - "tooltip": "Use lossless WebP compression " - "(ignores quality setting)", + "tooltip": "Use lossless WebP compression " "(ignores quality setting)", }, ), "popup": ( @@ -163,10 +162,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode): # 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)" - ) + 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 { @@ -208,9 +204,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode): # Additional node-specific validation if not isinstance(webp_lossless, bool): - raise ValueError( - f"webp_lossless must be a boolean, got {type(webp_lossless).__name__}" - ) + 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__}") diff --git a/kikotools/tools/resolution_calculator/logic.py b/kikotools/tools/resolution_calculator/logic.py index 9928605..7dda0f1 100644 --- a/kikotools/tools/resolution_calculator/logic.py +++ b/kikotools/tools/resolution_calculator/logic.py @@ -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 diff --git a/kikotools/tools/resolution_calculator/node.py b/kikotools/tools/resolution_calculator/node.py index 4caffb9..611f35a 100644 --- a/kikotools/tools/resolution_calculator/node.py +++ b/kikotools/tools/resolution_calculator/node.py @@ -42,8 +42,7 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode): "max": 8.0, "step": 0.1, "display": "slider", - "tooltip": "Factor to scale the resolution by " - "(e.g., 2.0 for 2x, 0.5 for half scale)", + "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,9 +126,7 @@ 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__}") # Validate tensors using helper methods if image is not None: @@ -150,14 +138,11 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode): 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__}" - ) + 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)}" + f"image tensor must have 4 dimensions " f"[batch, height, width, channels], got {len(image.shape)}" ) def _validate_latent_dict(self, latent: Dict[str, torch.Tensor]) -> None: @@ -170,15 +155,11 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode): samples = latent["samples"] if not isinstance(samples, torch.Tensor): - raise ValueError( - f"latent['samples'] must be a torch.Tensor, " - f"got {type(samples).__name__}" - ) + 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)}" + f"latent samples tensor must have 4 dimensions " f"[batch, channels, height, width], got {len(samples.shape)}" ) diff --git a/kikotools/tools/sampler_combo/compact_node.py b/kikotools/tools/sampler_combo/compact_node.py index 642b4f0..f012e61 100644 --- a/kikotools/tools/sampler_combo/compact_node.py +++ b/kikotools/tools/sampler_combo/compact_node.py @@ -65,9 +65,7 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode): FUNCTION = "get_combo" CATEGORY = "ComfyAssets" - def get_combo( - self, sampler: str, sched: str, steps: int, cfg: float - ) -> Tuple[object, 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. diff --git a/kikotools/tools/sampler_combo/logic.py b/kikotools/tools/sampler_combo/logic.py index 21196fc..0b0b337 100644 --- a/kikotools/tools/sampler_combo/logic.py +++ b/kikotools/tools/sampler_combo/logic.py @@ -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. diff --git a/kikotools/tools/sampler_combo/node.py b/kikotools/tools/sampler_combo/node.py index 660aec2..9d6ee40 100644 --- a/kikotools/tools/sampler_combo/node.py +++ b/kikotools/tools/sampler_combo/node.py @@ -70,9 +70,7 @@ class SamplerComboNode(ComfyAssetsBaseNode): FUNCTION = "get_sampler_combo" CATEGORY = "ComfyAssets" - def get_sampler_combo( - self, sampler_name: str, scheduler: str, steps: int, cfg: float - ) -> Tuple[object, str, int, float]: + def get_sampler_combo(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> Tuple[object, str, int, float]: """ Get sampler combo configuration. @@ -119,10 +117,7 @@ class SamplerComboNode(ComfyAssetsBaseNode): # 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]}" - ) + 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]) @@ -144,9 +139,7 @@ class SamplerComboNode(ComfyAssetsBaseNode): sampler = "euler" return (sampler, "normal", 20, 7.0) - def validate_inputs( - self, sampler_name: str, scheduler: str, steps: int, cfg: float - ) -> None: + def validate_inputs(self, sampler_name: str, scheduler: str, steps: int, cfg: float) -> None: """ Validate sampler combo inputs. @@ -161,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: @@ -213,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. @@ -274,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.""" diff --git a/kikotools/tools/seed_history/logic.py b/kikotools/tools/seed_history/logic.py index c5fde46..37b6832 100644 --- a/kikotools/tools/seed_history/logic.py +++ b/kikotools/tools/seed_history/logic.py @@ -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. diff --git a/kikotools/tools/seed_history/node.py b/kikotools/tools/seed_history/node.py index cc070d7..5543293 100644 --- a/kikotools/tools/seed_history/node.py +++ b/kikotools/tools/seed_history/node.py @@ -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: diff --git a/kikotools/tools/width_height_selector/logic.py b/kikotools/tools/width_height_selector/logic.py index 4fda97b..a848cfc 100644 --- a/kikotools/tools/width_height_selector/logic.py +++ b/kikotools/tools/width_height_selector/logic.py @@ -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. diff --git a/kikotools/tools/width_height_selector/node.py b/kikotools/tools/width_height_selector/node.py index 316689f..5af7309 100644 --- a/kikotools/tools/width_height_selector/node.py +++ b/kikotools/tools/width_height_selector/node.py @@ -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}" diff --git a/kikotools/tools/width_height_selector/presets.py b/kikotools/tools/width_height_selector/presets.py index fa58042..6883ce1 100644 --- a/kikotools/tools/width_height_selector/presets.py +++ b/kikotools/tools/width_height_selector/presets.py @@ -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 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..57cdacb --- /dev/null +++ b/mypy.ini @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8c5475a..fc08512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,3 +40,56 @@ 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:" +] diff --git a/pytest.ini b/pytest.ini index 76e860a..935826f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,4 +2,4 @@ testpaths = tests python_paths = . norecursedirs = venv .git __pycache__ -addopts = --ignore=__init__.py --ignore=venv \ No newline at end of file +addopts = --ignore=__init__.py --ignore=venv diff --git a/requirements-dev.txt b/requirements-dev.txt index 355f3d7..f2dd303 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 \ No newline at end of file +pillow>=9.0.0 diff --git a/requirements.txt b/requirements.txt index 355f3d7..1e47e44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +# Gemini API integration (optional - only needed for Gemini Prompt node) +google-generativeai>=0.3.0 diff --git a/scripts/run_mypy.sh b/scripts/run_mypy.sh new file mode 100755 index 0000000..d0c85f7 --- /dev/null +++ b/scripts/run_mypy.sh @@ -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" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index c4769ef..c271fb5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/unit/test_base_node.py b/tests/unit/test_base_node.py index 74461e7..d702f9a 100644 --- a/tests/unit/test_base_node.py +++ b/tests/unit/test_base_node.py @@ -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""" diff --git a/tests/unit/tools/test_kiko_save_image.py b/tests/unit/tools/test_kiko_save_image.py index 8f18222..d73652c 100644 --- a/tests/unit/tools/test_kiko_save_image.py +++ b/tests/unit/tools/test_kiko_save_image.py @@ -52,9 +52,7 @@ class TestKikoSaveImageLogic: """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 - ) + full_path, filename = get_save_image_path("test_prefix", 0, ".png", temp_dir) assert full_path.startswith(temp_dir) assert filename.startswith("test_prefix_") @@ -213,28 +211,20 @@ class TestKikoSaveImageLogic: 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" - ): + 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" - ): + 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" - ): + 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" - ): + 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): diff --git a/tests/unit/tools/test_resolution_calculator.py b/tests/unit/tools/test_resolution_calculator.py index 0556552..60d345b 100644 --- a/tests/unit/tools/test_resolution_calculator.py +++ b/tests/unit/tools/test_resolution_calculator.py @@ -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) diff --git a/tests/unit/tools/test_sampler_combo.py b/tests/unit/tools/test_sampler_combo.py index c786429..efafddf 100644 --- a/tests/unit/tools/test_sampler_combo.py +++ b/tests/unit/tools/test_sampler_combo.py @@ -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"] diff --git a/tests/unit/tools/test_width_height_selector.py b/tests/unit/tools/test_width_height_selector.py index 18c8ad7..8707af2 100644 --- a/tests/unit/tools/test_width_height_selector.py +++ b/tests/unit/tools/test_width_height_selector.py @@ -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}" diff --git a/web/kiko_save_image_ui.js b/web/kiko_save_image_ui.js index 216e2a4..25d1f88 100644 --- a/web/kiko_save_image_ui.js +++ b/web/kiko_save_image_ui.js @@ -51,7 +51,7 @@ kiko-image-viewer.dragging .kiko-viewer-header { max-height: calc(80vh - 80px); overflow-y: auto; overflow-x: hidden; - transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), + transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; } @@ -389,12 +389,12 @@ function formatFileSize(bytes) { // Open image in new tab function openImageInTab(imagePath, subfolder = '', enablePopup = true) { console.log(`KikoSaveImage: Attempting to open image: ${imagePath}, subfolder: ${subfolder}`); - + // Note: enablePopup parameter is kept for compatibility but not used // since popup now controls viewer visibility, not individual image clicks - + const basePath = window.location.origin; - + // Construct proper image URL handling subfolder let fullPath; if (subfolder && subfolder.trim()) { @@ -402,12 +402,12 @@ function openImageInTab(imagePath, subfolder = '', enablePopup = true) { } else { fullPath = `${basePath}/api/view?filename=${encodeURIComponent(imagePath)}&type=output`; } - + console.log(`KikoSaveImage: Opening URL: ${fullPath}`); - + // Extract clean filename for window name (remove timestamp and batch number) const cleanName = imagePath.split('_').slice(0, -2).join('_') || 'KikoSaveImage'; - + // Try to open the image with a clean window name const newWindow = window.open(fullPath, cleanName.replace(/[^a-zA-Z0-9]/g, '_')); if (newWindow) { @@ -434,14 +434,14 @@ class KikoImageViewer extends HTMLElement { this.isRolledUp = false; this.dragOffset = { x: 0, y: 0 }; this.lastHeaderClick = 0; - + // Always start at default position } - + static get observedAttributes() { return ['data']; } - + attributeChangedCallback(name, oldValue, newValue) { if (name === 'data' && newValue) { try { @@ -453,7 +453,7 @@ class KikoImageViewer extends HTMLElement { } } } - + setImageData(data) { console.log('KikoImageViewer: setImageData called with:', data); console.log('KikoImageViewer: First image data:', data[0]); @@ -462,7 +462,7 @@ class KikoImageViewer extends HTMLElement { this.render(); this.setupEventListeners(); } - + setupEventListeners() { // Window dragging and double-click functionality const header = this.querySelector('.kiko-viewer-header'); @@ -470,19 +470,19 @@ class KikoImageViewer extends HTMLElement { header.addEventListener('mousedown', this.startDrag.bind(this)); header.addEventListener('dblclick', this.handleHeaderDoubleClick.bind(this)); } - + // Control buttons const minimizeBtn = this.querySelector('.kiko-minimize-btn'); const closeBtn = this.querySelector('.kiko-close-btn'); - + if (minimizeBtn) { minimizeBtn.addEventListener('click', this.toggleMinimize.bind(this)); } - + if (closeBtn) { closeBtn.addEventListener('click', this.close.bind(this)); } - + // Image selection checkboxes const selectors = this.querySelectorAll('.kiko-image-selector'); selectors.forEach((selector, index) => { @@ -491,7 +491,7 @@ class KikoImageViewer extends HTMLElement { this.toggleImageSelection(index); }); }); - + // Action buttons const actionButtons = this.querySelectorAll('.kiko-action-btn'); actionButtons.forEach((btn) => { @@ -502,7 +502,7 @@ class KikoImageViewer extends HTMLElement { this.handleActionButton(action, index); }); }); - + // Bulk action buttons const bulkButtons = this.querySelectorAll('.kiko-bulk-btn'); bulkButtons.forEach((btn) => { @@ -512,68 +512,68 @@ class KikoImageViewer extends HTMLElement { this.handleBulkAction(action); }); }); - + // Global mouse events for dragging document.addEventListener('mousemove', this.handleDrag.bind(this)); document.addEventListener('mouseup', this.endDrag.bind(this)); } - + startDrag(e) { // Don't start dragging if clicking on control buttons if (e.target.closest('.kiko-viewer-controls')) { return; } - + this.isDragging = true; const rect = this.getBoundingClientRect(); this.dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - + // Prevent text selection while dragging e.preventDefault(); - + // Add dragging class for visual feedback this.classList.add('dragging'); } - + handleDrag(e) { if (!this.isDragging) return; - + e.preventDefault(); - + const x = e.clientX - this.dragOffset.x; const y = e.clientY - this.dragOffset.y; - + // Keep window within viewport bounds const maxX = window.innerWidth - this.offsetWidth; const maxY = window.innerHeight - this.offsetHeight; - + const boundedX = Math.max(0, Math.min(x, maxX)); const boundedY = Math.max(0, Math.min(y, maxY)); - + this.style.left = boundedX + 'px'; this.style.top = boundedY + 'px'; this.style.right = 'auto'; // Override CSS right positioning } - + endDrag(e) { if (!this.isDragging) return; - + this.isDragging = false; this.classList.remove('dragging'); } - + handleHeaderDoubleClick(e) { // Don't toggle if clicking on control buttons if (e.target.closest('.kiko-viewer-controls')) { return; } - + this.toggleRollUp(); } - + autoUnrollOnNewImages() { // Auto-unroll if currently rolled up and we have new image data if (this.isRolledUp && this.imageData && this.imageData.length > 0) { @@ -581,22 +581,22 @@ class KikoImageViewer extends HTMLElement { this.classList.remove('rolled-up'); } } - + toggleRollUp() { this.isRolledUp = !this.isRolledUp; - + if (this.isRolledUp) { this.classList.add('rolled-up'); } else { this.classList.remove('rolled-up'); } } - + toggleMinimize() { this.isMinimized = !this.isMinimized; const container = this.querySelector('.kiko-viewer-container'); const minimizeBtn = this.querySelector('.kiko-minimize-btn'); - + if (container && minimizeBtn) { if (this.isMinimized) { container.style.display = 'none'; @@ -609,19 +609,19 @@ class KikoImageViewer extends HTMLElement { } } } - + close() { this.style.display = 'none'; } - + show() { this.style.display = 'block'; } - + toggleImageSelection(index) { const selector = this.querySelector(`[data-index="${index}"].kiko-image-selector`); if (!selector) return; - + if (this.selectedImages.has(index)) { this.selectedImages.delete(index); selector.classList.remove('selected'); @@ -629,36 +629,36 @@ class KikoImageViewer extends HTMLElement { this.selectedImages.add(index); selector.classList.add('selected'); } - + this.updateBulkActionsVisibility(); this.updateSelectedCount(); } - + updateBulkActionsVisibility() { const bulkActions = this.querySelector('.kiko-bulk-actions'); if (bulkActions) { bulkActions.style.display = this.selectedImages.size > 0 ? 'block' : 'none'; } } - + updateSelectedCount() { const countSpan = this.querySelector('.selected-count'); if (countSpan) { countSpan.textContent = this.selectedImages.size; } } - + handleActionButton(action, index) { const imageData = this.imageData[index]; if (!imageData) return; - + switch (action) { case 'download': this.downloadImage(imageData); break; } } - + handleBulkAction(action) { switch (action) { case 'open-all': @@ -693,16 +693,16 @@ class KikoImageViewer extends HTMLElement { break; } } - + openAllImagesWithDelay() { const selectedIndices = Array.from(this.selectedImages); if (selectedIndices.length === 0) { console.log('KikoSaveImage: No images selected for opening'); return; } - + console.log(`KikoSaveImage: Opening ${selectedIndices.length} images with delay`); - + // Open images with 150ms delay between each to prevent popup blocking selectedIndices.forEach((index, i) => { setTimeout(() => { @@ -713,7 +713,7 @@ class KikoImageViewer extends HTMLElement { }, i * 150); }); } - + downloadImage(imageData) { // Construct proper image URL handling subfolder let imageUrl; @@ -722,7 +722,7 @@ class KikoImageViewer extends HTMLElement { } else { imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(imageData.filename)}&type=${imageData.type}`; } - + // Create temporary link and trigger download const link = document.createElement('a'); link.href = imageUrl; @@ -732,8 +732,8 @@ class KikoImageViewer extends HTMLElement { link.click(); document.body.removeChild(link); } - - + + render() { if (!this.imageData || this.imageData.length === 0) { this.innerHTML = ` @@ -746,13 +746,13 @@ class KikoImageViewer extends HTMLElement { `; return; } - - const header = this.imageData.length === 1 + + const header = this.imageData.length === 1 ? `Saved Image (${this.imageData[0].format})` : `Saved Images (${this.imageData.length} files)`; - + const containerStyle = this.isMinimized ? 'style="display: none;"' : ''; - + this.innerHTML = `
${header}
@@ -768,11 +768,11 @@ class KikoImageViewer extends HTMLElement { ${this.imageData.length > 1 ? this.createBulkActions() : ''}
`; - + // Add all event handlers this.addClickHandlers(); } - + createBulkActions() { return ` `; } - + createImageItem(data, index) { // Construct proper image URL handling subfolder let imageUrl; @@ -798,12 +798,12 @@ class KikoImageViewer extends HTMLElement { } else { imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}`; } - + const formatClass = `kiko-format-${data.format.toLowerCase()}`; - + // Format file size const fileSize = data.file_size ? formatFileSize(data.file_size) : 'Unknown'; - + // Build quality info let qualityInfo = ''; if (data.format === 'PNG' && data.compress_level !== undefined) { @@ -815,19 +815,19 @@ class KikoImageViewer extends HTMLElement { qualityInfo = `Q${data.quality}`; } } - + return `
${data.filename} - + ${this.imageData.length > 1 ? `
` : ''} - +
- +
${data.filename} @@ -843,7 +843,7 @@ class KikoImageViewer extends HTMLElement {
`; } - + addClickHandlers() { const items = this.querySelectorAll('.kiko-image-item'); items.forEach((item, index) => { @@ -869,33 +869,33 @@ if (!customElements.get('kiko-image-viewer')) { function createImagePreview(imageData) { const container = document.createElement('div'); container.className = 'kiko-save-image-preview'; - + // Create image element const img = document.createElement('img'); const imagePath = `/api/view?filename=${imageData.filename}&type=${imageData.type}`; img.src = imagePath; img.alt = imageData.filename; - + // Create info overlay const info = document.createElement('div'); info.className = 'kiko-save-image-info'; - + const formatSpan = document.createElement('span'); formatSpan.className = 'kiko-save-image-format'; formatSpan.textContent = imageData.format || 'PNG'; - + const sizeSpan = document.createElement('span'); sizeSpan.className = 'kiko-save-image-size'; sizeSpan.textContent = ` • ${imageData.dimensions || 'Unknown'}`; - + const fileSizeSpan = document.createElement('span'); fileSizeSpan.className = 'kiko-save-image-size'; fileSizeSpan.textContent = ` • ${formatFileSize(imageData.file_size || 0)}`; - + info.appendChild(formatSpan); info.appendChild(sizeSpan); info.appendChild(fileSizeSpan); - + // Add quality info for JPEG/WebP if (imageData.quality && (imageData.format === 'JPEG' || imageData.format === 'WEBP')) { const qualitySpan = document.createElement('span'); @@ -912,15 +912,15 @@ function createImagePreview(imageData) { compressSpan.textContent = ` • C${imageData.compress_level}`; info.appendChild(compressSpan); } - + container.appendChild(img); container.appendChild(info); - + // Add click handler to open in new tab container.addEventListener('click', () => { openImageInTab(imageData.filename, imageData.subfolder || ''); }); - + return container; } @@ -928,68 +928,68 @@ function createImagePreview(imageData) { function addFormatIndicator(node, widget) { const indicator = document.createElement('span'); indicator.className = 'kiko-format-indicator'; - + const updateIndicator = (format) => { indicator.textContent = format; indicator.className = `kiko-format-indicator kiko-format-${format.toLowerCase()}`; }; - + // Update indicator when format changes updateIndicator(widget.value); - + // Find widget element and append indicator const widgetElement = widget.element || widget.domWidget; if (widgetElement && widgetElement.parentNode) { widgetElement.parentNode.appendChild(indicator); } - + return { indicator, updateIndicator }; } // Register ComfyUI extension app.registerExtension({ name: "comfyassets.KikoSaveImage", - + async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "KikoSaveImage") { // Inject styles when node is registered injectStyles(); - + // Store original onNodeCreated const onNodeCreated = nodeType.prototype.onNodeCreated; - + nodeType.prototype.onNodeCreated = function() { // Call original onNodeCreated if (onNodeCreated) { onNodeCreated.apply(this, arguments); } - + // Add format indicator to format widget const formatWidget = this.widgets?.find(w => w.name === "format"); if (formatWidget) { const { indicator, updateIndicator } = addFormatIndicator(this, formatWidget); - + // Store update function for later use this.updateFormatIndicator = updateIndicator; } - + // Add quality preview for quality widget const qualityWidget = this.widgets?.find(w => w.name === "quality"); if (qualityWidget) { const preview = document.createElement('div'); preview.className = 'kiko-quality-preview'; preview.textContent = `Quality: ${qualityWidget.value}%`; - + const widgetElement = qualityWidget.element || qualityWidget.domWidget; if (widgetElement && widgetElement.parentNode) { widgetElement.parentNode.appendChild(preview); } - + // Store preview element for updates this.qualityPreview = preview; } }; - + // Override onWidgetChange to update indicators const originalOnWidgetChange = nodeType.prototype.onWidgetChange; nodeType.prototype.onWidgetChange = function(name, value, oldValue, widget) { @@ -997,26 +997,26 @@ app.registerExtension({ if (name === "format" && this.updateFormatIndicator) { this.updateFormatIndicator(value); } - + // Update quality preview if (name === "quality" && this.qualityPreview) { this.qualityPreview.textContent = `Quality: ${value}%`; } - + // Call original handler if (originalOnWidgetChange) { return originalOnWidgetChange.call(this, name, value, oldValue, widget); } }; - + // Replace the standard image viewer with our custom web component const originalOnExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function(message) { console.log('KikoSaveImage: onExecuted called with message:', message); - + // Skip the original ComfyUI preview system // Don't call originalOnExecuted to prevent default image display - + // Use our custom web component instead if (message && message.kiko_enhanced && message.kiko_enhanced.length > 0) { console.log('KikoSaveImage: Creating custom image viewer'); @@ -1029,20 +1029,20 @@ app.registerExtension({ } } }; - + // Add method to create custom image viewer nodeType.prototype.createCustomImageViewer = function(imageData) { console.log('KikoSaveImage: Creating custom viewer for', imageData.length, 'images'); - + // Check if popup is enabled for any image (use first image's popup setting) const popupEnabled = imageData.length > 0 ? imageData[0].popup : true; console.log('KikoSaveImage: Popup enabled:', popupEnabled); - + if (!popupEnabled) { console.log('KikoSaveImage: Popup disabled, not showing custom viewer'); return; } - + // Check if viewer already exists let existingViewer = document.querySelector('kiko-image-viewer'); if (existingViewer) { @@ -1052,35 +1052,35 @@ app.registerExtension({ console.log('KikoSaveImage: Updated existing viewer'); return; } - + // Create toggle button in node if it doesn't exist if (!this.kikoToggleButton) { this.createToggleButton(); } - + // Create new viewer - always visible on workflow execution const viewer = document.createElement('kiko-image-viewer'); viewer.setImageData(imageData); - + // Store reference for toggle button this.kikoViewer = viewer; - + // Update toggle button text if (this.kikoToggleButton) { this.kikoToggleButton.textContent = '👁️ Hide Images'; } - + // Append to body (floating window) - always visible document.body.appendChild(viewer); - + console.log('KikoSaveImage: Custom viewer created successfully'); }; - + // Add method to create toggle button nodeType.prototype.createToggleButton = function() { // Find the node element to add button to let nodeElement = null; - + if (this.domElement) { nodeElement = this.domElement; } else if (this.widgets && this.widgets[0] && this.widgets[0].element) { @@ -1095,9 +1095,9 @@ app.registerExtension({ } } } - + if (!nodeElement) return; - + // Create toggle button const toggleButton = document.createElement('button'); toggleButton.textContent = '👁️ Show Images'; @@ -1113,7 +1113,7 @@ app.registerExtension({ font-size: 11px; font-family: inherit; `; - + toggleButton.addEventListener('click', () => { if (this.kikoViewer) { const isHidden = this.kikoViewer.style.display === 'none'; @@ -1126,7 +1126,7 @@ app.registerExtension({ } } }); - + // Add hover effects toggleButton.addEventListener('mouseenter', () => { toggleButton.style.background = '#45a049'; @@ -1134,13 +1134,13 @@ app.registerExtension({ toggleButton.addEventListener('mouseleave', () => { toggleButton.style.background = '#4CAF50'; }); - + nodeElement.appendChild(toggleButton); this.kikoToggleButton = toggleButton; - + console.log('KikoSaveImage: Toggle button created'); }; - + // Legacy method kept for compatibility (not used with web component) nodeType.prototype.enhanceImagePreviews = function(imageData) { console.log('KikoSaveImage: Legacy enhanceImagePreviews called (should use web component instead)'); @@ -1152,24 +1152,24 @@ app.registerExtension({ // Export helper functions to global scope for debugging window.kikoSaveImageTest = function() { console.log('KikoSaveImage: Testing click functionality...'); - + // Find all images in the document const allImages = document.querySelectorAll('img'); console.log(`Found ${allImages.length} images in document`); - + // Try to find images that look like our saved images allImages.forEach((img, index) => { console.log(`Image ${index}: src = ${img.src}`); - + // Add test click handler to all images img.style.border = '2px solid red'; img.style.cursor = 'pointer'; img.title = 'TEST: Click to open in new tab'; - + // Remove old handlers and add new one const newImg = img.cloneNode(true); img.parentNode.replaceChild(newImg, img); - + newImg.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); @@ -1177,32 +1177,32 @@ window.kikoSaveImageTest = function() { window.open(newImg.src, '_blank'); }, true); }); - + console.log('KikoSaveImage: Test setup complete. All images should now be clickable with red borders.'); }; // 🎉 RESET VIEWER WINDOWS 🎉 window.kikoResetViewer = function() { console.log('🎉 Resetting KikoSaveImage viewer windows...'); - + // Remove any existing viewers const existingViewers = document.querySelectorAll('kiko-image-viewer'); existingViewers.forEach(viewer => viewer.remove()); - + // Reset toggle buttons const toggleButtons = document.querySelectorAll('.kiko-toggle-viewer-btn'); toggleButtons.forEach(btn => { btn.textContent = '👁️ Show Images'; btn.style.background = '#4CAF50'; }); - + console.log('✨ Reset complete! Run your workflow to create a new viewer!'); }; // 🚀 FORCE SHOW VIEWER WITH DEMO DATA 🚀 window.kikoForceViewer = function() { console.log('🚀 Force showing KikoSaveImage viewer...'); - + // Look for existing viewer let viewer = document.querySelector('kiko-image-viewer'); if (viewer) { @@ -1210,7 +1210,7 @@ window.kikoForceViewer = function() { console.log('✨ Found and showed existing viewer!'); return; } - + console.log('⚠️ No existing viewer found. Run your KikoSaveImage workflow to create a real viewer with actual images!'); alert('⚠️ No viewer found! Run your KikoSaveImage workflow to create a viewer with real images.'); }; @@ -1219,4 +1219,4 @@ console.log('🎉 KikoSaveImage: Extension loaded!'); console.log('💡 Helper functions available:'); console.log(' 📞 kikoResetViewer() - Remove existing viewer windows'); console.log(' 🚀 kikoForceViewer() - Show demo viewer'); -console.log(' 🧪 kikoSaveImageTest() - Test image click functionality'); \ No newline at end of file +console.log(' 🧪 kikoSaveImageTest() - Test image click functionality'); diff --git a/web/seed_history_ui.js b/web/seed_history_ui.js index 5d2cf6d..363d7c0 100644 --- a/web/seed_history_ui.js +++ b/web/seed_history_ui.js @@ -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 = '
No seeds tracked
Generate seeds to build history
'; 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({ }; } }, -}); \ No newline at end of file +}); diff --git a/web/width_height_swap.js b/web/width_height_swap.js index 5262b8d..3addd46 100644 --- a/web/width_height_swap.js +++ b/web/width_height_swap.js @@ -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({ }; } }, -}); \ No newline at end of file +});