Compare commits

..
Author SHA1 Message Date
Vito Sansevero a8af833c31 chore: trigger CI 2025-08-01 09:10:28 -07:00
Vito Sansevero 005c3bdf65 fix: code formatting for Image to Multiple Of node
- Apply black formatting
- Remove unused torch import from logic.py
- All critical linting issues resolved
2025-08-01 09:01:39 -07:00
Vito Sansevero 9007b10d42 feat: add Image to Multiple Of node
- Add ImageToMultipleOfNode for image dimension adjustment
- Ensures image dimensions are multiples of specified values
- Supports both padding and cropping modes
- Useful for model-specific dimension requirements
- Add tests and documentation
- Register node in ComfyAssets category
2025-08-01 08:28:10 -07:00
Vito b71bfa8d4e Merge pull request #10 from ComfyAssets/fix-samplers
Fix samplers
2025-07-26 14:18:48 -07:00
Vito Sansevero c1128addc7 chore: bump version to 1.0.7 in pyproject.toml 2025-07-26 14:16:06 -07:00
Vito Sansevero a49071f824 fix(resolution_calculator): update scale factor tooltip 2025-07-26 14:15:39 -07:00
Vito Sansevero bbdd27f498 fix(ci): correct return type in tests.yml configuration 2025-07-23 13:56:57 -07:00
Vito Sansevero 22f62bf7b4 test: Update test assertions for sampler combo node 2025-07-23 13:56:45 -07:00
Vito Sansevero 4ff6067dad refactor(compact_node): update sampler return type 2025-07-23 13:29:46 -07:00
Vito Sansevero ad13e66506 refactor(node): update sampler handling logic 2025-07-23 13:29:32 -07:00
Vito Sansevero b16f6f40bd style(logic): fix whitespace issues in logic.py 2025-07-21 08:23:18 -07:00
Vito Sansevero 6dfa66963b feat: Add subfolder support in image URL handling 2025-07-21 08:20:27 -07:00
Vito Sansevero ab016e0903 feat(logic): add subfolder info to enhanced data 2025-07-21 08:20:17 -07:00
Vito Sansevero f4228a850c refactor(logic): improve path handling in image saving 2025-07-21 07:56:13 -07:00
Vito Sansevero 79042b78d2 chore: bump version to 1.0.5 in pyproject.toml 2025-06-28 09:31:40 -07:00
Vito Sansevero 0c4e59c4e9 test: Remove unused imports from test file 2025-06-28 09:14:45 -07:00
Vito Sansevero 4a0a206d61 refactor(node): use helper methods for tensor validation 2025-06-28 09:14:34 -07:00
Vito Sansevero 8e0d4485bd style: Remove unused imports in node.py 2025-06-28 09:14:23 -07:00
Vito Sansevero 92a3b1db4e style: Remove unused import 'Any' 2025-06-28 09:13:03 -07:00
Vito Sansevero ab628b1bf2 style: Remove unused import 'os' 2025-06-28 09:11:49 -07:00
Vito Sansevero 7e712a17d9 docs: Add Kiko Save Image section to README.md 2025-06-28 09:11:42 -07:00
Vito Sansevero 269fb2ba80 ci: add checks for KikoSaveImageNode imports 2025-06-28 09:11:36 -07:00
Vito Sansevero e17fdddcd7 refactor(tests/ui): Remove 'subfolder' support 2025-06-28 08:55:25 -07:00
Vito Sansevero 5d1f01e6cb refactor(node): replace 'subfolder' with 'popup' 2025-06-28 08:54:46 -07:00
Vito Sansevero b3b8826044 refactor(logic): Rename 'subfolder' to 'popup' parameter 2025-06-28 08:54:35 -07:00
Vito Sansevero 9dbb1f749d chore: bump version to 1.0.4 in pyproject.toml 2025-06-27 21:06:30 -07:00
Vito Sansevero 682f2b0a47 feat(ui): Add KikoSaveImage UI enhancements 2025-06-27 21:06:00 -07:00
Vito Sansevero 1233cf693e test(kiko_save_image): add unit tests for save image tool 2025-06-27 21:05:52 -07:00
Vito Sansevero 32d44a282f feat(kiko_save_image): add new image saving tool 2025-06-27 21:05:42 -07:00
Vito Sansevero bb79c7434f feat(init): add KikoSaveImageNode to tools and mappings 2025-06-27 21:05:25 -07:00
Vito Sansevero bd8c0a42bc fix: handle import error for testing environment 2025-06-27 21:05:14 -07:00
Vito Sansevero 5d9e71dc7b chore: bump version to 1.0.3 in pyproject.toml 2025-06-20 08:03:48 -07:00
Vito 321d89dcc4 Merge pull request #9 from ComfyAssets/latent-batch
style: Add blank lines for better readability
2025-06-20 08:02:57 -07:00
Vito Sansevero cd77d06ac9 style: Add blank lines for better readability 2025-06-20 07:50:49 -07:00
Vito d23ff34b27 Merge pull request #8 from ComfyAssets/latent-batch
Latent batch
2025-06-19 12:00:57 -07:00
Vito Sansevero cc725d27f6 chore: bump version to 1.0.2 in pyproject.toml 2025-06-19 11:56:26 -07:00
Vito Sansevero 4c3d3958d6 docs: Add Empty Latent Batch documentation 2025-06-19 11:56:05 -07:00
Vito Sansevero 2c992b5c97 feat(empty-latent-batch): add preset & batch processing 2025-06-19 11:55:51 -07:00
Vito Sansevero 8628bc39bb feat(init): add EmptyLatentBatchNode support 2025-06-19 10:22:33 -07:00
Vito Sansevero 3654867a21 feat(empty_latent_batch): add empty latent batch tool 2025-06-19 10:22:05 -07:00
Vito Sansevero 85af1b38f9 test: Add unit tests for EmptyLatentBatch features 2025-06-19 10:21:45 -07:00
Vito 03189afd85 Merge pull request #7 from ComfyAssets/version
Version
2025-06-16 18:32:40 -07:00
27 changed files with 4486 additions and 52 deletions
+25
View File
@@ -87,6 +87,10 @@ jobs:
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')
"
@@ -252,6 +256,27 @@ jobs:
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')
"
+1 -1
View File
@@ -159,7 +159,7 @@ jobs:
print('✓ Sampler Combo interface tests passed')
# Test return types
assert node.RETURN_TYPES == (SAMPLERS, SCHEDULERS, 'INT', 'FLOAT')
assert node.RETURN_TYPES == ('SAMPLER', SCHEDULERS, 'INT', 'FLOAT')
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
assert node.CATEGORY == 'ComfyAssets'
print('✓ Sampler Combo return types tests passed')
+145 -4
View File
@@ -76,6 +76,50 @@ Unified sampling configuration interface combining sampler, scheduler, steps, an
- Reduce node clutter in workflows
- Quick sampling parameter experimentation
#### 📦 Empty Latent Batch
Advanced empty latent creation with preset support and batch processing capabilities.
- **Preset Integration**: 26 curated resolution presets with model optimization
- **Batch Processing**: Create multiple empty latents (1-64) in a single operation
- **Visual Swap Button**: Interactive blue button for quick dimension swapping
- **Smart Validation**: Automatic dimension sanitization for VAE compatibility
- **Memory Estimation**: Built-in memory usage calculation and warnings
- **Model-Aware Presets**: SDXL (~1MP), FLUX (high-res), and Ultra-wide options
**Use Cases:**
- Initialize batch processing workflows efficiently
- Create consistent latent dimensions across model types
- Optimize memory usage with batch size planning
- Quick preset-based latent generation for different aspect ratios
#### 💾 Kiko Save Image
Enhanced image saving with format selection, quality control, and floating popup viewer.
- **Multiple Format Support**: Save as PNG, JPEG, or WebP with format-specific optimizations
- **Advanced Quality Controls**: JPEG/WebP quality (1-100), PNG compression (0-9), WebP lossless mode
- **Floating Popup Viewer**: Draggable, resizable window that shows saved images immediately
- **Interactive Previews**: Click any image to open in new tab, download individual images
- **Batch Selection**: Multi-select images for bulk actions (open all, download all)
- **Format-Specific Settings**: Quality indicators, file size display, compression info
- **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality
- **Popup Toggle**: Enable/disable popup viewer per save operation
**Use Cases:**
- Quick preview and management of saved images without file browser navigation
- Compare multiple format outputs side-by-side (PNG vs JPEG vs WebP)
- Batch download or open selected images efficiently
- Monitor file sizes and compression effectiveness in real-time
- Streamlined workflow for iterative image generation and saving
**Why Better Than Standard Save Image:**
- **Immediate Visual Feedback**: See your saved images instantly without opening file explorer
- **Multi-Format Flexibility**: Choose optimal format for your use case (PNG for quality, JPEG for size, WebP for modern efficiency)
- **Advanced Compression Control**: Fine-tune file sizes with format-specific quality settings
- **Batch Operations**: Handle multiple images efficiently with selection and bulk actions
- **Modern UI**: Floating, draggable interface that doesn't interrupt your workflow
- **Smart Memory Usage**: File size indicators help optimize storage and sharing
- **One-Click Access**: Direct image opening in browser tabs for quick sharing or review
### 🔧 Architecture Highlights
- **Modular Design**: Each tool is self-contained and independently testable
@@ -152,6 +196,35 @@ Sampler Combo → KSampler → VAE Decode → Save Image
**Output:** Complete sampling configuration in one node
**Smart Features:** Recommendations and compatibility validation
### Empty Latent Batch Example
```
Empty Latent Batch → KSampler → VAE Decode → Kiko Save Image
📦 preset: "1024×1024" ↘ batch latents ↗ ↘ popup viewer ↗
batch_size: 4
[swap button]
```
**Preset:** SDXL Square (1024×1024)
**Batch Size:** 4 empty latents
**Output:** 4×4×128×128 latent tensor ready for sampling
**Swap Button:** Click to switch to any available swapped preset
### Kiko Save Image Example
```
Generate Image → Kiko Save Image → Floating Popup Viewer
📷 output ↘ format: WEBP ↘ draggable window ↗
quality: 85
[popup: enabled]
```
**Format:** WebP (efficient compression, modern format)
**Quality:** 85% (balanced size/quality)
**Popup Viewer:** Floating, draggable window with saved images
**Features:** Click images to open in new tabs, download individual files, batch selection
**Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls
### Common Workflows
<details>
@@ -192,6 +265,8 @@ Sampler Combo → KSampler → VAE Decode → Save Image
| **Width Height Selector** | Preset-based dimension selection with 26 curated options | ✅ Complete | [Docs](examples/documentation/width_height_selector.md) |
| **Seed History** | Advanced seed tracking with interactive history management | ✅ Complete | [Docs](examples/documentation/seed_history.md) |
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -278,6 +353,71 @@ Sampler Combo → KSampler → VAE Decode → Save Image
- Graceful error handling with safe defaults
- Comprehensive tooltips for user guidance
#### Empty Latent Batch
**Inputs:**
- `preset` (DROPDOWN): 26 preset options + custom with formatted metadata display
- `width` (INT): 64-8192, step 8, default 1024
- `height` (INT): 64-8192, step 8, default 1024
- `batch_size` (INT): 1-64, default 1
**Outputs:**
- `latent` (LATENT): Batch of empty latent tensors in ComfyUI format
- `width` (INT): Final sanitized width (divisible by 8)
- `height` (INT): Final sanitized height (divisible by 8)
**UI Features:**
- Visual blue swap button with hover and click feedback
- Intelligent preset switching when swapping dimensions
- Memory usage estimation and warnings for large batches
- Auto-update width/height widgets when presets change
**Batch Processing:**
- Creates tensors with shape: [batch_size, 4, height//8, width//8]
- Efficient memory allocation with torch.zeros
- Validates batch size limits (1-64) with performance warnings
- Compatible with all ComfyUI latent processing nodes
**Preset Integration:**
- Full access to 26 curated resolution presets from Width Height Selector
- Model-aware categorization (SDXL, FLUX, Ultra-wide)
- Formatted display with aspect ratio and megapixel information
- Intelligent fallback to custom dimensions for invalid presets
#### Kiko Save Image
**Inputs:**
- `images` (IMAGE): Batch of images to save
- `filename_prefix` (STRING): Prefix for saved filenames, default "KikoSave"
- `format` (DROPDOWN): Output format (PNG, JPEG, WEBP), default PNG
- `quality` (INT): JPEG/WebP quality (1-100), default 90
- `png_compress_level` (INT): PNG compression level (0-9), default 4
- `webp_lossless` (BOOLEAN): Use lossless WebP compression, default False
- `popup` (BOOLEAN): Enable popup viewer window, default True
**Outputs:**
- `UI`: Enhanced image preview data with popup viewer functionality
**UI Features:**
- Floating, draggable popup window showing saved images immediately
- Interactive image grid with click-to-open functionality
- Individual image download buttons with format-specific quality indicators
- Batch selection with multi-select checkboxes for bulk operations
- Window controls: minimize, maximize, roll-up, close, and dragging
- Auto-hide/show behavior with smart positioning
**Format Support:**
- **PNG**: Lossless compression with metadata preservation, configurable compression levels
- **JPEG**: Quality-controlled lossy compression with automatic transparency handling
- **WebP**: Modern format with both lossy and lossless modes, superior compression ratios
**Advanced Features:**
- File size monitoring and display for optimization feedback
- Format-specific quality indicators (PNG compression level, JPEG/WebP quality percentage)
- Smart filename sanitization with timestamp-based uniqueness
- Persistent popup viewer across multiple save operations
- Toggle button integration in node UI for manual viewer control
## 🛠️ Development
### Prerequisites
@@ -398,13 +538,14 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 4 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo)
- **Nodes**: 6 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image)
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 2 (Swap Button, History UI)
- **Test Coverage**: 100% (180+ comprehensive tests)
- **Interactive Features**: 4 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer)
- **Test Coverage**: 100% (200+ comprehensive tests)
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow)
---
+7 -2
View File
@@ -3,11 +3,14 @@ ComfyUI-KikoTools: Modular collection of custom ComfyUI nodes
All nodes are grouped under the "ComfyAssets" category
"""
import os
import re
from pathlib import Path
from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
try:
from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
except ImportError:
# Fallback for testing environment
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# Tell ComfyUI where to find our JavaScript extensions
WEB_DIRECTORY = "./web"
@@ -28,9 +31,11 @@ def get_version():
# Print startup message with loaded tools
print()
print(f"\033[94m[ComfyUI-KikoTools] Version:\033[0m {get_version()}")
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
print(f"🫶 \033[94mLoaded:\033[0m {display_name}")
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
print()
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -0,0 +1,222 @@
# Empty Latent Batch Documentation
## Overview
The Empty Latent Batch is a ComfyUI node that creates empty latent tensors with batch support and preset integration. It combines the preset functionality of Width Height Selector with efficient batch processing capabilities, making it ideal for batch workflows and optimized generation pipelines.
## Features
### 🎯 **Preset Integration**
- **26 Curated Presets**: Full access to SDXL, FLUX, and Ultra-wide presets
- **Formatted Display**: Shows aspect ratio, megapixels, and model group
- **Smart Fallback**: Automatic fallback to custom dimensions for invalid presets
- **Model Optimization**: Preset categories optimized for different model types
### 📦 **Batch Processing**
- **Configurable Batch Size**: Create 1-64 empty latents in single operation
- **Memory Efficient**: Uses torch.zeros for optimal memory allocation
- **Batch Validation**: Prevents excessive memory usage with warnings
- **ComfyUI Compatible**: Standard latent format for seamless integration
### 🔄 **Visual Swap Button**
- **Interactive UI**: Blue swap button with hover and click feedback
- **Preset-Aware Swapping**: Intelligent switching between matching presets
- **Custom Dimension Support**: Simple value swapping for custom inputs
- **Visual Feedback**: Button state changes during interaction
### ✅ **Smart Validation**
- **Dimension Sanitization**: Automatic adjustment to divisible-by-8 constraint
- **Memory Estimation**: Built-in memory usage calculation
- **Error Handling**: Graceful handling of invalid inputs with helpful messages
- **Logging**: Detailed operation logging for debugging
## Node Interface
### Inputs
- **preset**: Dropdown with 26 formatted preset options + custom
- **width**: Custom width (64-8192, step 8, default 1024)
- **height**: Custom height (64-8192, step 8, default 1024)
- **batch_size**: Number of latents to create (1-64, default 1)
### Outputs
- **latent**: Dictionary containing batch of empty latent tensors
- **width**: Final sanitized width (guaranteed divisible by 8)
- **height**: Final sanitized height (guaranteed divisible by 8)
## Preset Reference
The Empty Latent Batch node uses the same 26 curated presets as the Width Height Selector:
### SDXL Presets (~1 Megapixel)
Optimized for SDXL models with ~1MP resolution constraint.
### FLUX Presets (High Resolution)
Higher resolution presets optimized for FLUX models with better quality/speed balance.
### Ultra-Wide Presets (Modern Ratios)
Modern aspect ratios for ultra-wide and panoramic generation.
*For complete preset details, see [Width Height Selector Documentation](width_height_selector.md#preset-reference)*
## Usage Examples
### Basic Empty Latent Creation
1. **Select Preset**: Choose from dropdown (e.g., "1024×1024 - 1:1 (1.0MP) - SDXL")
2. **Set Batch Size**: Enter desired number of latents (e.g., 4)
3. **Connect Output**: Link latent output to KSampler or other processing nodes
### Custom Batch Creation
1. **Set Preset**: Select "custom"
2. **Enter Dimensions**: Input width and height manually
3. **Set Batch Size**: Configure number of latents needed
4. **Validation**: Automatic sanitization ensures compatibility
### Orientation Swapping
1. **Choose Preset**: Any preset (e.g., "1920×1080")
2. **Click Swap Button**: Blue button in bottom-right corner
3. **Result**: Gets swapped preset if available, or custom dimensions with swapped values
4. **Widget Update**: Width/height widgets automatically update
### Memory-Aware Batch Processing
1. **Large Batch**: Set batch_size to 16 or higher
2. **Memory Warning**: Node provides memory usage estimation
3. **Optimization**: Choose appropriate resolution preset for available VRAM
## Common Workflows
### Batch Generation Pipeline
```
Empty Latent Batch → KSampler → VAE Decode → Save Image
(batch_size: 4) ↓ ↓ ↓
4 samples 4 images 4 files
```
- Create 4 empty latents at once
- Process all through sampling
- Generate 4 images in single operation
- Efficient for parameter exploration
### Model Comparison Workflow
```
Empty Latent Batch → [Multiple KSamplers] → [Multiple VAE Decoders] → Compare Results
(batch_size: 8) ↓ ↓ ↓
Split batch Process variants Side-by-side
```
- Create consistent batch of empty latents
- Split across different samplers/models
- Compare results with identical starting conditions
### Upscaling Preparation
```
Empty Latent Batch → KSampler → VAE Decode → Resolution Calculator → Upscaler
(832×1216, batch:4) ↓ ↓ ↓ ↓
Sample Decode Calculate 2x Upscale batch
```
- Generate batch at base resolution
- Calculate upscale dimensions
- Process entire batch through upscaler
### Aspect Ratio Exploration
```
Empty Latent Batch → [Clone to multiple orientations] → Parallel Processing
(1920×1080) ↓ ↓
[Swap Button] → Portrait & Landscape versions Compare orientations
```
- Start with base preset
- Use swap button to create orientation variants
- Process both simultaneously
## Advanced Features
### Memory Estimation
The node provides built-in memory estimation for batch operations:
```python
# Example memory calculations
Batch Size: 4, Resolution: 1024×1024
Latent Tensor: 4 × 4 × 128 × 128 = 262,144 elements
Memory Usage: 262,144 × 4 bytes = 1.0 MB per batch
```
### Intelligent Preset Handling
- **Formatted Display**: Shows full metadata in dropdown
- **Original Extraction**: Extracts original preset name from formatted strings
- **Validation**: Verifies preset exists before processing
- **Fallback Logic**: Uses custom dimensions if preset is invalid
### Batch Size Optimization
- **Performance Warnings**: Alerts for large batch sizes
- **Memory Limits**: Prevents excessive memory allocation
- **Hardware Awareness**: Considers available system resources
## Tips and Best Practices
### Batch Size Selection
- **Small Batches (1-4)**: Good for testing and development
- **Medium Batches (5-16)**: Efficient for most production workflows
- **Large Batches (17-64)**: Only for high-memory systems and specific use cases
### Preset Selection
- **SDXL Projects**: Use SDXL presets for memory efficiency
- **FLUX Projects**: Use FLUX presets for optimal quality
- **Ultra-wide Projects**: Ensure sufficient VRAM for large resolutions
- **Custom Projects**: Use custom dimensions for specific requirements
### Memory Management
- Monitor memory usage with large batches
- Use appropriate resolution presets for available VRAM
- Consider splitting very large batches across multiple nodes
- Clear GPU memory between large batch operations
### Workflow Integration
- Always connect all three outputs (latent, width, height)
- Use width/height outputs for downstream dimension calculations
- Combine with Resolution Calculator for upscaling workflows
- Leverage batch processing for efficient parameter exploration
## Troubleshooting
### Common Issues
- **Out of Memory**: Reduce batch_size or use lower resolution presets
- **Invalid Dimensions**: Node automatically sanitizes to valid values
- **Preset Not Found**: Falls back to custom dimensions with warning
- **Swap Button Not Working**: Ensure node is not collapsed and button is visible
### Performance Optimization
- **Batch Size**: Start with smaller batches and increase as needed
- **Resolution**: Use appropriate presets for your model and VRAM
- **Memory Monitoring**: Watch for memory warnings and adjust accordingly
- **Cleanup**: Clear unused tensors between large batch operations
### Error Handling
- **Dimension Validation**: Automatic rounding to nearest valid values
- **Batch Size Limits**: Clamped to 1-64 range with warnings
- **Memory Allocation**: Graceful handling of insufficient memory
- **Preset Fallbacks**: Automatic fallback to custom dimensions
## Technical Details
### Latent Tensor Format
- **Shape**: [batch_size, 4, height//8, width//8]
- **Data Type**: torch.float32
- **Initialization**: torch.zeros for clean empty state
- **Memory Layout**: Contiguous tensor for optimal performance
### Validation Pipeline
1. **Preset Extraction**: Parse formatted preset strings
2. **Dimension Calculation**: Get base dimensions from preset or custom
3. **Sanitization**: Ensure divisible-by-8 constraint
4. **Batch Validation**: Check batch size limits
5. **Memory Estimation**: Calculate expected memory usage
6. **Tensor Creation**: Allocate and initialize latent tensor
### UI Integration
- **JavaScript Extension**: Custom UI for swap button functionality
- **Widget Synchronization**: Auto-update width/height when preset changes
- **Visual Feedback**: Hover effects and click animations
- **Event Handling**: Proper mouse event management
### Swap Button Implementation
- **Position Calculation**: Dynamic positioning based on node size
- **State Management**: Visual feedback for button interactions
- **Preset Intelligence**: Smart switching between compatible presets
- **Fallback Logic**: Custom dimension swapping when preset not available
@@ -0,0 +1,82 @@
# Image to Multiple Of
## Overview
The **Image to Multiple Of** node adjusts image dimensions to be multiples of a specified value. This is particularly useful for models that require input dimensions to be multiples of certain values (e.g., 8, 16, 32, 64) for optimal performance or compatibility.
## Purpose
Many AI models, especially diffusion models and VAEs, require input dimensions to be multiples of specific values due to their architecture (e.g., downsampling layers). This node ensures your images meet these requirements without manual calculation.
## Inputs
- **image** (IMAGE, required): The input image to process
- **multiple_of** (INT, required): The value that dimensions should be multiple of
- Default: 64
- Range: 1-256
- Step: 16
- **method** (COMBO, required): Processing method
- Options: "center crop", "rescale"
## Outputs
- **image** (IMAGE): Processed image with dimensions adjusted to multiples of the specified value
## Processing Methods
### Center Crop
- Crops the image from the center to achieve the target dimensions
- Preserves image quality but may lose edge content
- Best for images where the important content is centered
### Rescale
- Resizes the image to the target dimensions using bilinear interpolation
- Keeps all content but may slightly affect image quality
- Best when you need to preserve all image content
## Usage Examples
### Example 1: Prepare for VAE Encoding
```
Load Image → Image to Multiple Of (multiple_of: 64) → VAE Encode
```
### Example 2: Prepare for Specific Model Requirements
```
Load Image → Image to Multiple Of (multiple_of: 32) → Model Processing
```
### Example 3: Batch Processing
```
Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batch Process
```
## Technical Details
- Supports batch processing (processes all images in a batch)
- Works with any number of channels (RGB, RGBA, grayscale, etc.)
- Calculates the largest dimensions that are less than or equal to the original size
- For center crop: crops equally from all sides to maintain centering
- For rescale: uses bilinear interpolation with align_corners=False
## Common Use Cases
1. **VAE Preprocessing**: Ensure images are compatible with VAE encoders that require dimensions divisible by 64
2. **Model Compatibility**: Adjust images for models with specific architectural requirements
3. **Batch Uniformity**: Ensure all images in a batch have dimensions that meet model requirements
4. **Performance Optimization**: Some models perform better with dimensions that are powers of 2
## Tips
- Use **center crop** when your subject is centered and you don't mind losing edge details
- Use **rescale** when you need to preserve all image content
- Common multiple_of values: 8, 16, 32, 64, 128
- For Stable Diffusion models, 64 is typically recommended
- For some upscaling models, 32 or 16 may be sufficient
## Error Handling
The node will raise an error if:
- The image dimensions are smaller than the specified multiple_of value
- Invalid input types are provided
- The resulting dimensions would be 0 or negative
@@ -0,0 +1,123 @@
{
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [100, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"shape": 3,
"label": "IMAGE"
},
{
"name": "MASK",
"type": "MASK",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "ImageToMultipleOf",
"pos": [500, 200],
"size": [315, 106],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [2, 3],
"shape": 3,
"label": "image"
}
],
"properties": {
"Node name for S&R": "ImageToMultipleOf"
},
"widgets_values": [64, "center crop"]
},
{
"id": 3,
"type": "PreviewImage",
"pos": [900, 100],
"size": [210, 246],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"properties": {
"Node name for S&R": "PreviewImage"
}
},
{
"id": 4,
"type": "VAEEncode",
"pos": [900, 400],
"size": [210, 46],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 3
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"],
[3, 2, 0, 4, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}
+9
View File
@@ -7,6 +7,9 @@ from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.seed_history import SeedHistoryNode
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
from .tools.empty_latent_batch import EmptyLatentBatchNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
@@ -15,6 +18,9 @@ NODE_CLASS_MAPPINGS = {
"SeedHistory": SeedHistoryNode,
"SamplerCombo": SamplerComboNode,
"SamplerComboCompact": SamplerComboCompactNode,
"EmptyLatentBatch": EmptyLatentBatchNode,
"KikoSaveImage": KikoSaveImageNode,
"ImageToMultipleOf": ImageToMultipleOfNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -23,6 +29,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SeedHistory": "Seed History",
"SamplerCombo": "Sampler Combo",
"SamplerComboCompact": "Sampler Combo (Compact)",
"EmptyLatentBatch": "Empty Latent Batch",
"KikoSaveImage": "Kiko Save Image",
"ImageToMultipleOf": "Image to Multiple of",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
@@ -0,0 +1,5 @@
"""Empty Latent Batch tool for ComfyUI."""
from .node import EmptyLatentBatchNode
__all__ = ["EmptyLatentBatchNode"]
+101
View File
@@ -0,0 +1,101 @@
"""Logic for creating empty latent tensors with batch support."""
import torch
from typing import Dict, Tuple
def create_empty_latent_batch(
width: int, height: int, batch_size: int = 1
) -> Dict[str, torch.Tensor]:
"""
Create empty latent tensor with batch support.
Args:
width: Width in pixels (will be divided by 8 for latent space)
height: Height in pixels (will be divided by 8 for latent space)
batch_size: Number of latents in the batch
Returns:
Dictionary containing the latent samples tensor
Raises:
ValueError: If dimensions are invalid
"""
# Validate inputs
if width <= 0 or height <= 0:
raise ValueError(f"Width and height must be positive, got {width}x{height}")
if batch_size <= 0:
raise ValueError(f"Batch size must be positive, got {batch_size}")
# Ensure dimensions are divisible by 8 (VAE requirement)
if width % 8 != 0 or height % 8 != 0:
raise ValueError(
f"Width and height must be divisible by 8, got {width}x{height}"
)
# Convert pixel dimensions to latent space (divide by 8)
latent_width = width // 8
latent_height = height // 8
# Create empty latent tensor
# ComfyUI latent format: [batch, channels, height, width]
# Standard VAE uses 4 channels
latent_tensor = torch.zeros(batch_size, 4, latent_height, latent_width)
return {"samples": latent_tensor}
def validate_dimensions(width: int, height: int) -> bool:
"""
Validate that dimensions are suitable for latent creation.
Args:
width: Width in pixels
height: Height in pixels
Returns:
True if dimensions are valid
"""
# Check basic constraints
if width <= 0 or height <= 0:
return False
# Check divisibility by 8
if width % 8 != 0 or height % 8 != 0:
return False
# Check reasonable size limits (64x64 to 8192x8192)
if width < 64 or height < 64:
return False
if width > 8192 or height > 8192:
return False
return True
def sanitize_dimensions(width: int, height: int) -> Tuple[int, int]:
"""
Sanitize dimensions to ensure they meet latent requirements.
Args:
width: Input width
height: Input height
Returns:
Tuple of (sanitized_width, sanitized_height)
"""
# Ensure minimum dimensions
width = max(64, width)
height = max(64, height)
# Ensure maximum dimensions
width = min(8192, width)
height = min(8192, height)
# Round to nearest multiple of 8
width = (width + 7) // 8 * 8
height = (height + 7) // 8 * 8
return width, height
+309
View File
@@ -0,0 +1,309 @@
"""Empty Latent Batch node for ComfyUI."""
import torch
from typing import Dict, Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
create_empty_latent_batch,
validate_dimensions,
sanitize_dimensions,
)
from ..width_height_selector.logic import get_preset_dimensions
from ..width_height_selector.presets import (
PRESET_OPTIONS,
PRESET_METADATA,
)
class EmptyLatentBatchNode(ComfyAssetsBaseNode):
"""
Empty Latent Batch node for creating empty latent tensors with batch support.
Creates empty latent tensors with specified dimensions and batch size,
compatible with ComfyUI's latent format for use with VAE and diffusion models.
"""
@classmethod
def INPUT_TYPES(cls):
"""Define the input types for the ComfyUI node."""
# Create formatted preset options with metadata
preset_options = ["custom"] # Custom first
# Add formatted presets with metadata
for preset_name in PRESET_OPTIONS.keys():
if preset_name != "custom":
metadata = PRESET_METADATA.get(preset_name)
if metadata:
formatted_option = (
f"{preset_name} - {metadata.aspect_ratio} "
f"({metadata.megapixels:.1f}MP) - {metadata.model_group}"
)
preset_options.append(formatted_option)
else:
preset_options.append(preset_name)
return {
"required": {
"preset": (
preset_options,
{
"default": "custom",
"tooltip": "Select from optimized resolution presets or use "
"custom dimensions. SDXL presets are ~1MP, FLUX presets are "
"higher resolution, Ultra-wide presets support modern "
"aspect ratios.",
},
),
"width": (
"INT",
{
"default": 1024,
"min": 64,
"max": 8192,
"step": 8,
"tooltip": "Custom width in pixels (must be multiple of 8). "
"Used when preset is 'custom' or as fallback for invalid "
"presets. This will be converted to latent space dimensions.",
},
),
"height": (
"INT",
{
"default": 1024,
"min": 64,
"max": 8192,
"step": 8,
"tooltip": "Custom height in pixels (must be multiple of 8). "
"Used when preset is 'custom' or as fallback for invalid "
"presets. This will be converted to latent space dimensions.",
},
),
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Number of empty latents to create in the batch. "
"Useful for batch processing workflows.",
},
),
}
}
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
FUNCTION = "create_empty_latent"
CATEGORY = "ComfyAssets"
def create_empty_latent(
self, preset: str, width: int, height: int, batch_size: int
) -> Tuple[Dict[str, torch.Tensor], int, int]:
"""
Create empty latent tensor with specified dimensions and batch size.
Args:
preset: Selected preset name or formatted preset string
width: Custom width value
height: Custom height value
batch_size: Number of latents in the batch
Returns:
Tuple containing (latent dictionary with 'samples' tensor, width, height)
"""
try:
# Extract original preset name from formatted string if needed
original_preset = self._extract_preset_name(preset)
# Get base dimensions from preset or custom input
base_width, base_height = get_preset_dimensions(
original_preset, width, height
)
# Sanitize dimensions to ensure they meet requirements
final_width, final_height = sanitize_dimensions(base_width, base_height)
# Log if dimensions were changed from the base dimensions
if final_width != base_width or final_height != base_height:
self.log_info(
f"Dimensions adjusted from {base_width}×{base_height} to "
f"{final_width}×{final_height} to meet VAE requirements"
)
# Validate final dimensions
if not validate_dimensions(final_width, final_height):
self.handle_error(
f"Invalid dimensions after sanitization: {final_width}×{final_height}"
)
# Validate batch size
if batch_size <= 0:
self.handle_error(f"Batch size must be positive, got {batch_size}")
if batch_size > 64:
self.log_info(
f"Large batch size ({batch_size}) may use significant memory"
)
# Create the empty latent batch
latent_dict = create_empty_latent_batch(
final_width, final_height, batch_size
)
# Log the operation
latent_height = final_height // 8
latent_width = final_width // 8
self.log_info(
f"Created empty latent batch: {batch_size}×4×{latent_height}×{latent_width} "
f"(pixel dims: {final_width}×{final_height})"
)
return (latent_dict, final_width, final_height)
except Exception as e:
# Handle any unexpected errors gracefully
error_msg = f"Error creating empty latent batch: {str(e)}"
self.handle_error(error_msg, e)
def _extract_preset_name(self, formatted_preset: str) -> str:
"""
Extract the original preset name from a formatted preset string.
Args:
formatted_preset: Either original preset name or formatted string
Returns:
Original preset name
"""
# If it's already "custom", return as-is
if formatted_preset == "custom":
return formatted_preset
# If it contains formatting metadata, extract the resolution part
if " - " in formatted_preset:
# Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
# Extract the first part (resolution)
resolution_part = formatted_preset.split(" - ")[0]
# Verify this is a valid preset name
if resolution_part in PRESET_OPTIONS:
return resolution_part
# If no formatting or not found, check if it's directly a valid preset
if formatted_preset in PRESET_OPTIONS:
return formatted_preset
# Default to "custom" if we can't parse it
return "custom"
def validate_inputs(
self, preset: str, width: int, height: int, batch_size: int
) -> bool:
"""
Validate node inputs.
Args:
preset: Preset name or formatted preset string
width: Width value
height: Height value
batch_size: Batch size value
Returns:
True if inputs are valid
"""
# Extract original preset name
original_preset = self._extract_preset_name(preset)
# Check if preset exists or is custom
if original_preset != "custom" and original_preset not in PRESET_OPTIONS:
return False
# Get dimensions from preset or use custom
base_width, base_height = get_preset_dimensions(original_preset, width, height)
# Check dimension validity (after sanitization)
sanitized_width, sanitized_height = sanitize_dimensions(base_width, base_height)
if not validate_dimensions(sanitized_width, sanitized_height):
return False
# Check batch size
if batch_size <= 0 or batch_size > 64:
return False
return True
def get_latent_info(self, width: int, height: int, batch_size: int) -> str:
"""
Get descriptive information about the latent that will be created.
Args:
width: Width in pixels
height: Height in pixels
batch_size: Batch size
Returns:
Description string for the latent
"""
sanitized_width, sanitized_height = sanitize_dimensions(width, height)
latent_width = sanitized_width // 8
latent_height = sanitized_height // 8
return (
f"Empty latent batch: {batch_size} × 4 × {latent_height} × {latent_width} "
f"(pixel dimensions: {sanitized_width}×{sanitized_height})"
)
def get_memory_estimate(self, width: int, height: int, batch_size: int) -> str:
"""
Estimate memory usage for the latent batch.
Args:
width: Width in pixels
height: Height in pixels
batch_size: Batch size
Returns:
Memory estimate string
"""
sanitized_width, sanitized_height = sanitize_dimensions(width, height)
latent_width = sanitized_width // 8
latent_height = sanitized_height // 8
# Calculate tensor size in bytes (float32 = 4 bytes per element)
elements = batch_size * 4 * latent_height * latent_width
bytes_size = elements * 4 # 4 bytes per float32
# Convert to human-readable format
if bytes_size < 1024:
return f"{bytes_size} bytes"
elif bytes_size < 1024 * 1024:
return f"{bytes_size / 1024:.1f} KB"
elif bytes_size < 1024 * 1024 * 1024:
return f"{bytes_size / (1024 * 1024):.1f} MB"
else:
return f"{bytes_size / (1024 * 1024 * 1024):.1f} GB"
def __str__(self) -> str:
"""String representation of the node."""
return "EmptyLatentBatchNode"
def __repr__(self) -> str:
"""Detailed string representation of the node."""
return (
f"EmptyLatentBatchNode("
f"category='{self.CATEGORY}', "
f"function='{self.FUNCTION}'"
f")"
)
# Node class mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {
"EmptyLatentBatch": EmptyLatentBatchNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmptyLatentBatch": "Empty Latent Batch",
}
@@ -0,0 +1,5 @@
"""ImageToMultipleOf tool for ComfyUI-KikoTools."""
from .node import ImageToMultipleOfNode
__all__ = ["ImageToMultipleOfNode"]
@@ -0,0 +1,61 @@
"""Core logic for ImageToMultipleOf tool."""
from typing import Tuple
import torch.nn.functional as F
from torch import Tensor
def calculate_dimensions_to_multiple(
height: int, width: int, multiple_of: int
) -> Tuple[int, int]:
"""Calculate new dimensions that are multiples of the specified value.
Args:
height: Original height
width: Original width
multiple_of: Value that dimensions should be multiple of
Returns:
Tuple of (new_height, new_width)
"""
new_height = height - (height % multiple_of)
new_width = width - (width % multiple_of)
return new_height, new_width
def process_image_to_multiple_of(
image: Tensor, multiple_of: int, method: str
) -> Tensor:
"""Process image to ensure dimensions are multiples of specified value.
Args:
image: Input image tensor of shape (batch, height, width, channels)
multiple_of: Value that dimensions should be multiple of
method: Processing method - "center crop" or "rescale"
Returns:
Processed image tensor
"""
_, height, width, _ = image.shape
new_height, new_width = calculate_dimensions_to_multiple(height, width, multiple_of)
if method == "rescale":
# Rescale the image to the new dimensions
# Convert from BHWC to BCHW for interpolation
image_chw = image.permute(0, 3, 1, 2)
rescaled = F.interpolate(
image_chw,
size=(new_height, new_width),
mode="bilinear",
align_corners=False,
)
# Convert back to BHWC
return rescaled.permute(0, 2, 3, 1)
else: # center crop
# Calculate crop offsets to center the crop
top = (height - new_height) // 2
left = (width - new_width) // 2
bottom = top + new_height
right = left + new_width
return image[:, top:bottom, left:right, :]
@@ -0,0 +1,102 @@
"""ComfyUI node implementation for ImageToMultipleOf."""
from typing import Dict, Any, Tuple
from torch import Tensor
from ...base import ComfyAssetsBaseNode
from .logic import process_image_to_multiple_of
class ImageToMultipleOfNode(ComfyAssetsBaseNode):
"""
Adjusts image dimensions to be multiples of a specified value.
Useful for models that require specific dimension constraints.
Supports both center cropping and rescaling methods.
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"image": ("IMAGE",),
"multiple_of": (
"INT",
{
"default": 64,
"min": 1,
"max": 256,
"step": 16,
"display": "number",
},
),
"method": (["center crop", "rescale"],),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "process"
def process(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
"""
Process image to ensure dimensions are multiples of specified value.
Args:
image: Input image tensor
multiple_of: Value that dimensions should be multiple of
method: Processing method - "center crop" or "rescale"
Returns:
Tuple containing processed image tensor
"""
try:
self.validate_inputs(image=image, multiple_of=multiple_of, method=method)
# Process the image
processed_image = process_image_to_multiple_of(image, multiple_of, method)
_, new_height, new_width, _ = processed_image.shape
self.log_info(
f"Processed image from {image.shape[1]}x{image.shape[2]} "
f"to {new_height}x{new_width} (multiple of {multiple_of}) "
f"using {method}"
)
return (processed_image,)
except Exception as e:
self.handle_error(f"Failed to process image: {str(e)}", e)
def validate_inputs(self, **kwargs) -> None:
"""Validate inputs for ImageToMultipleOf node."""
image = kwargs.get("image")
multiple_of = kwargs.get("multiple_of")
method = kwargs.get("method")
if image is None:
raise ValueError("Image input is required")
if not isinstance(image, Tensor) or len(image.shape) != 4:
raise ValueError(
f"Expected image tensor with shape (batch, height, width, channels), "
f"got shape {image.shape if isinstance(image, Tensor) else 'non-tensor'}"
)
if multiple_of <= 0:
raise ValueError(f"multiple_of must be positive, got {multiple_of}")
if method not in ["center crop", "rescale"]:
raise ValueError(f"Invalid method: {method}")
# Check if resulting dimensions would be too small
_, height, width, _ = image.shape
new_height = height - (height % multiple_of)
new_width = width - (width % multiple_of)
if new_height <= 0 or new_width <= 0:
raise ValueError(
f"Image dimensions ({height}x{width}) are too small "
f"to be adjusted to multiple of {multiple_of}"
)
@@ -0,0 +1,8 @@
"""
KikoSaveImage tool module
Enhanced image saving with format selection, quality control, and clickable previews
"""
from .node import KikoSaveImageNode
__all__ = ["KikoSaveImageNode"]
+365
View File
@@ -0,0 +1,365 @@
"""
KikoSaveImage core logic
Enhanced image saving functionality with multiple format support
"""
import os
import json
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import torch
from typing import Dict, List, Any, Optional, Tuple
import time
try:
import folder_paths
except ImportError:
# Fallback for testing without ComfyUI
class folder_paths:
@staticmethod
def get_output_directory():
return "./output"
def get_save_image_path(
filename_prefix: str,
batch_number: int,
format_ext: str,
output_dir: str,
subfolder: str = "",
) -> Tuple[str, str]:
"""
Generate save path for image with proper filename handling
Args:
filename_prefix: Base filename prefix
batch_number: Batch index for multiple images
format_ext: File extension (.png, .jpg, .webp)
output_dir: Output directory path
subfolder: Optional subfolder within output directory
Returns:
Tuple of (full_path, relative_filename)
"""
# Split filename_prefix into directory path and actual filename prefix
# This allows for directory structures like "kittybear/anime/images/kittybear"
prefix_dir = os.path.dirname(filename_prefix)
prefix_name = os.path.basename(filename_prefix)
# Sanitize only the filename part (not the directory path)
safe_prefix = prefix_name.replace(
":", "_"
) # Only sanitize problematic chars for filenames
safe_prefix = "".join(c for c in safe_prefix if c.isalnum() or c in "._-")
# Create unique filename with timestamp to avoid conflicts
timestamp = int(time.time())
filename = f"{safe_prefix}_{timestamp:010d}_{batch_number:05d}{format_ext}"
# Handle subfolder and prefix directory (but not the filename part)
path_components = []
path_components.append(output_dir)
if subfolder:
path_components.append(subfolder)
# Only add prefix_dir if it exists (the directory part, not the filename part)
if prefix_dir:
path_components.append(prefix_dir)
full_output_folder = os.path.join(*path_components)
# Ensure directory exists
os.makedirs(full_output_folder, exist_ok=True)
full_path = os.path.join(full_output_folder, filename)
# For the preview, ComfyUI needs the filename and subfolder separately
# The subfolder needs to be relative to the output directory root
# Build the relative subfolder path including prefix directory (but not filename part)
relative_path_components = []
if subfolder:
relative_path_components.append(subfolder.strip("/\\"))
if prefix_dir:
relative_path_components.append(prefix_dir.strip("/\\"))
if relative_path_components:
relative_subfolder = os.path.join(*relative_path_components)
else:
relative_subfolder = ""
preview_filename = filename
return full_path, preview_filename, relative_subfolder
def convert_tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image:
"""
Convert ComfyUI image tensor to PIL Image
Args:
image_tensor: Tensor in format [height, width, channels] with values 0-1
Returns:
PIL Image in RGB/RGBA format
"""
# Convert tensor (0-1 float) to 0-255 numpy array
i = 255.0 * image_tensor.cpu().numpy()
img_array = np.clip(i, 0, 255).astype(np.uint8)
# Create PIL image from numpy array
img = Image.fromarray(img_array)
return img
def create_png_metadata(
prompt: Optional[Dict] = None, extra_pnginfo: Optional[Dict] = None
) -> Optional[PngInfo]:
"""
Create PNG metadata with workflow information
Args:
prompt: ComfyUI prompt data
extra_pnginfo: Additional PNG metadata
Returns:
PngInfo object or None if no metadata
"""
if prompt is None and extra_pnginfo is None:
return None
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for key, value in extra_pnginfo.items():
metadata.add_text(key, json.dumps(value))
return metadata
def save_image_with_format(
img: Image.Image,
filepath: str,
format_type: str,
quality: int = 90,
png_compress_level: int = 4,
webp_lossless: bool = False,
metadata: Optional[PngInfo] = None,
) -> Dict[str, Any]:
"""
Save PIL image with specified format and quality settings
Args:
img: PIL Image to save
filepath: Full path to save file
format_type: Image format (PNG, JPEG, WEBP)
quality: JPEG/WebP quality (1-100)
png_compress_level: PNG compression level (0-9)
webp_lossless: Use lossless WebP compression
metadata: PNG metadata to embed
Returns:
Dict with save information
"""
save_kwargs = {}
if format_type == "PNG":
if metadata:
save_kwargs["pnginfo"] = metadata
save_kwargs["compress_level"] = png_compress_level
elif format_type == "JPEG":
# Convert RGBA to RGB for JPEG (no transparency support)
if img.mode == "RGBA":
# Create white background
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
save_kwargs["quality"] = quality
save_kwargs["optimize"] = True
elif format_type == "WEBP":
save_kwargs["quality"] = quality if not webp_lossless else 100
save_kwargs["lossless"] = webp_lossless
else:
raise ValueError(f"Unsupported format: {format_type}")
# Save the image
img.save(filepath, **save_kwargs)
# Get file size for info
file_size = os.path.getsize(filepath)
return {
"filepath": filepath,
"format": format_type,
"file_size": file_size,
"quality": quality if format_type != "PNG" else None,
"compress_level": png_compress_level if format_type == "PNG" else None,
"lossless": webp_lossless if format_type == "WEBP" else None,
}
def process_image_batch(
images: torch.Tensor,
filename_prefix: str = "KikoSave",
format_type: str = "PNG",
quality: int = 90,
png_compress_level: int = 4,
webp_lossless: bool = False,
popup: bool = True,
prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None,
) -> List[Dict[str, Any]]:
"""
Process and save a batch of images with specified format settings
Args:
images: Batch of image tensors [batch, height, width, channels]
filename_prefix: Prefix for saved filenames
format_type: Image format (PNG, JPEG, WEBP)
quality: JPEG/WebP quality (1-100)
png_compress_level: PNG compression level (0-9)
webp_lossless: Use lossless WebP compression
popup: Enable popup windows in UI
prompt: ComfyUI prompt data for metadata
extra_pnginfo: Additional PNG metadata
Returns:
List of saved image information dicts
"""
# Get output directory
output_dir = folder_paths.get_output_directory()
# Determine file extension
format_extensions = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"}
if format_type not in format_extensions:
raise ValueError(
f"Unsupported format: {format_type}. "
f"Supported: {list(format_extensions.keys())}"
)
format_ext = format_extensions[format_type]
# Create metadata for PNG
metadata = None
if format_type == "PNG":
metadata = create_png_metadata(prompt, extra_pnginfo)
# Process each image in the batch
results = []
enhanced_data = []
for batch_number, image_tensor in enumerate(images):
# Convert tensor to PIL Image
img = convert_tensor_to_pil(image_tensor)
# Generate save path
filepath, preview_filename, relative_subfolder = get_save_image_path(
filename_prefix, batch_number, format_ext, output_dir, ""
)
# Save with format-specific settings
save_info = save_image_with_format(
img,
filepath,
format_type,
quality,
png_compress_level,
webp_lossless,
metadata,
)
# Build result info for ComfyUI preview
# ONLY the core fields that ComfyUI expects - no extra metadata
result = {
"filename": preview_filename,
"subfolder": relative_subfolder,
"type": "output",
}
# Store enhanced data separately
enhanced_info = {
"filename": preview_filename,
"subfolder": relative_subfolder,
"popup": popup,
"type": "output",
"format": format_type,
"file_size": save_info["file_size"],
"dimensions": f"{img.width}x{img.height}",
}
# Add format-specific info to enhanced data
if format_type == "PNG":
enhanced_info["compress_level"] = png_compress_level
elif format_type in ["JPEG", "WEBP"]:
enhanced_info["quality"] = quality
if format_type == "WEBP":
enhanced_info["lossless"] = webp_lossless
results.append(result)
enhanced_data.append(enhanced_info)
return results, enhanced_data
def validate_save_inputs(
images: torch.Tensor, format_type: str, quality: int, png_compress_level: int
) -> None:
"""
Validate inputs for image saving
Args:
images: Image tensor batch to validate
format_type: Image format to validate
quality: Quality setting to validate
png_compress_level: PNG compression level to validate
Raises:
ValueError: If validation fails
"""
# Validate images tensor
if not isinstance(images, torch.Tensor):
raise ValueError(f"images must be a torch.Tensor, got {type(images).__name__}")
if len(images.shape) != 4:
raise ValueError(
f"images tensor must have 4 dimensions [batch, height, width, channels], "
f"got {len(images.shape)}"
)
# Validate format
supported_formats = ["PNG", "JPEG", "WEBP"]
if format_type not in supported_formats:
raise ValueError(
f"format must be one of {supported_formats}, got {format_type}"
)
# Validate quality (for JPEG/WebP)
if format_type in ["JPEG", "WEBP"]:
if not isinstance(quality, int) or not (1 <= quality <= 100):
raise ValueError(
f"quality must be an integer between 1 and 100, got {quality}"
)
# Validate PNG compression level
if format_type == "PNG":
if not isinstance(png_compress_level, int) or not (
0 <= png_compress_level <= 9
):
raise ValueError(
f"png_compress_level must be an integer between 0 and 9, "
f"got {png_compress_level}"
)
+226
View File
@@ -0,0 +1,226 @@
"""
KikoSaveImage ComfyUI Node
Enhanced image saving with format selection, quality control, and clickable previews
"""
import torch
from typing import Dict, Any, Optional
from ...base import ComfyAssetsBaseNode
from .logic import process_image_batch, validate_save_inputs
class KikoSaveImageNode(ComfyAssetsBaseNode):
"""
Enhanced ComfyUI image saving node with multiple format support
Features:
- Multiple format support (PNG, JPEG, WebP)
- Quality/compression controls
- Clickable image previews
- Metadata preservation
- Batch processing
Inputs:
- images (IMAGE): Images to save
- filename_prefix (STRING): Prefix for saved filenames
- format (COMBO): Output format (PNG, JPEG, WebP)
- quality (INT): JPEG/WebP quality (1-100)
- png_compress_level (INT): PNG compression level (0-9)
- webp_lossless (BOOLEAN): Use lossless WebP compression
- subfolder (STRING): Optional subfolder for organization
Outputs:
- UI: Image preview data for ComfyUI interface
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""
Define ComfyUI input interface with enhanced save options
Returns:
Dict with required and optional input specifications
"""
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save"}),
"filename_prefix": (
"STRING",
{"default": "KikoSave", "tooltip": "Prefix for saved filenames"},
),
"format": (
["PNG", "JPEG", "WEBP"],
{"default": "PNG", "tooltip": "Output image format"},
),
},
"optional": {
"quality": (
"INT",
{
"default": 90,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "JPEG/WebP quality (1-100, higher = better quality)",
},
),
"png_compress_level": (
"INT",
{
"default": 4,
"min": 0,
"max": 9,
"step": 1,
"tooltip": "PNG compression level (0-9, higher = smaller file)",
},
),
"webp_lossless": (
"BOOLEAN",
{
"default": False,
"tooltip": "Use lossless WebP compression "
"(ignores quality setting)",
},
),
"popup": (
"BOOLEAN",
{
"default": True,
"tooltip": "Enable popup windows when clicking on images in the viewer",
},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
def save_images(
self,
images: torch.Tensor,
filename_prefix: str = "KikoSave",
format: str = "PNG",
quality: int = 90,
png_compress_level: int = 4,
webp_lossless: bool = False,
popup: bool = True,
prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
Save images with enhanced format and quality options
Args:
images: Batch of image tensors to save
filename_prefix: Prefix for saved filenames
format: Output format (PNG, JPEG, WebP)
quality: JPEG/WebP quality setting
png_compress_level: PNG compression level
webp_lossless: Use lossless WebP compression
popup: Enable popup windows when clicking on images
prompt: ComfyUI prompt data for metadata
extra_pnginfo: Additional PNG metadata
Returns:
Dict with UI data for image previews
Raises:
ValueError: If validation fails
"""
try:
# Validate inputs
self.validate_inputs(
images=images,
format=format,
quality=quality,
png_compress_level=png_compress_level,
webp_lossless=webp_lossless,
popup=popup,
)
# Log the save operation
self.log_info(
f"Saving {len(images)} images as {format} "
f"(quality={quality if format != 'PNG' else 'N/A'}, "
f"png_compress={png_compress_level if format == 'PNG' else 'N/A'})"
)
# Process and save images
results, enhanced_data = process_image_batch(
images=images,
filename_prefix=filename_prefix,
format_type=format,
quality=quality,
png_compress_level=png_compress_level,
webp_lossless=webp_lossless,
popup=popup,
prompt=prompt,
extra_pnginfo=extra_pnginfo,
)
# Log results
total_size = sum(data["file_size"] for data in enhanced_data)
self.log_info(
f"Successfully saved {len(results)} images "
f"(total size: {total_size / 1024:.1f} KB)"
)
# Return UI data for ComfyUI preview (clean) + enhanced data for our JS
return {
"ui": {
"images": results, # Clean data for ComfyUI
"kiko_enhanced": enhanced_data, # Enhanced data for our JavaScript
}
}
except Exception as e:
error_msg = f"Failed to save images: {str(e)}"
self.handle_error(error_msg, e)
def validate_inputs(
self,
images: torch.Tensor,
format: str,
quality: int,
png_compress_level: int,
webp_lossless: bool,
popup: bool,
) -> None:
"""
Validate inputs specific to KikoSaveImage
Args:
images: Image tensor batch
format: Image format
quality: Quality setting
png_compress_level: PNG compression level
webp_lossless: WebP lossless setting
popup: Enable popup windows
Raises:
ValueError: If validation fails
"""
# Use logic module validation
validate_save_inputs(images, format, quality, png_compress_level)
# Additional node-specific validation
if not isinstance(webp_lossless, bool):
raise ValueError(
f"webp_lossless must be a boolean, got {type(webp_lossless).__name__}"
)
if not isinstance(popup, bool):
raise ValueError(f"popup must be a boolean, got {type(popup).__name__}")
# Node class mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {
"KikoSaveImage": KikoSaveImageNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KikoSaveImage": "Kiko Save Image",
}
+36 -28
View File
@@ -38,12 +38,12 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
"FLOAT",
{
"default": 2.0,
"min": 1.0,
"min": 0.1,
"max": 8.0,
"step": 0.1,
"display": "slider",
"tooltip": "Factor to scale the resolution by "
"(e.g., 2.0 for 2x upscale)",
"(e.g., 2.0 for 2x, 0.5 for half scale)",
},
),
},
@@ -140,38 +140,46 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
f"scale_factor must be a number, got {type(scale_factor).__name__}"
)
# Additional tensor validation
# Validate tensors using helper methods
if image is not None:
if not isinstance(image, torch.Tensor):
raise ValueError(
f"image must be a torch.Tensor, got {type(image).__name__}"
)
if len(image.shape) != 4:
raise ValueError(
f"image tensor must have 4 dimensions "
f"[batch, height, width, channels], got {len(image.shape)}"
)
self._validate_image_tensor(image)
if latent is not None:
if not isinstance(latent, dict):
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
self._validate_latent_dict(latent)
if "samples" not in latent:
raise ValueError("latent dict must contain 'samples' key")
def _validate_image_tensor(self, image: torch.Tensor) -> None:
"""Validate image tensor format"""
if not isinstance(image, torch.Tensor):
raise ValueError(
f"image must be a torch.Tensor, got {type(image).__name__}"
)
samples = latent["samples"]
if not isinstance(samples, torch.Tensor):
raise ValueError(
f"latent['samples'] must be a torch.Tensor, "
f"got {type(samples).__name__}"
)
if len(image.shape) != 4:
raise ValueError(
f"image tensor must have 4 dimensions "
f"[batch, height, width, channels], got {len(image.shape)}"
)
if len(samples.shape) != 4:
raise ValueError(
f"latent samples tensor must have 4 dimensions "
f"[batch, channels, height, width], got {len(samples.shape)}"
)
def _validate_latent_dict(self, latent: Dict[str, torch.Tensor]) -> None:
"""Validate latent dictionary format"""
if not isinstance(latent, dict):
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
if "samples" not in latent:
raise ValueError("latent dict must contain 'samples' key")
samples = latent["samples"]
if not isinstance(samples, torch.Tensor):
raise ValueError(
f"latent['samples'] must be a torch.Tensor, "
f"got {type(samples).__name__}"
)
if len(samples.shape) != 4:
raise ValueError(
f"latent samples tensor must have 4 dimensions "
f"[batch, channels, height, width], got {len(samples.shape)}"
)
# Node class mappings for ComfyUI registration
+20 -5
View File
@@ -60,14 +60,14 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
}
}
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
FUNCTION = "get_combo"
CATEGORY = "ComfyAssets"
def get_combo(
self, sampler: str, sched: str, steps: int, cfg: float
) -> Tuple[str, str, int, float]:
) -> Tuple[object, str, int, float]:
"""
Get compact sampler combo configuration.
@@ -78,17 +78,32 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
cfg: CFG scale value
Returns:
Tuple of (sampler, scheduler, steps, cfg)
Tuple of (sampler_object, scheduler, steps, cfg)
"""
try:
# Use the same validation logic but with compact interface
result = get_sampler_combo(sampler, sched, steps, cfg)
return result
# Create the sampler object
try:
import comfy.samplers
sampler_obj = comfy.samplers.sampler_object(result[0])
except ImportError:
# Return sampler name for testing
sampler_obj = result[0]
return (sampler_obj, result[1], result[2], result[3])
except Exception as e:
# Graceful fallback
self.handle_error(f"Error in compact combo: {str(e)}")
return ("euler", "normal", 20, 7.0)
try:
import comfy.samplers
sampler_obj = comfy.samplers.sampler_object("euler")
except ImportError:
# Return sampler name for testing
sampler_obj = "euler"
return (sampler_obj, "normal", 20, 7.0)
def __str__(self) -> str:
"""String representation of the compact node."""
+29 -6
View File
@@ -65,14 +65,14 @@ class SamplerComboNode(ComfyAssetsBaseNode):
}
}
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
FUNCTION = "get_sampler_combo"
CATEGORY = "ComfyAssets"
def get_sampler_combo(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
) -> Tuple[str, str, int, float]:
) -> Tuple[object, str, int, float]:
"""
Get sampler combo configuration.
@@ -83,7 +83,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
cfg: CFG scale value
Returns:
Tuple of (sampler_name, scheduler, steps, cfg)
Tuple of (sampler_object, scheduler, steps, cfg)
"""
try:
# Validate inputs
@@ -98,17 +98,33 @@ class SamplerComboNode(ComfyAssetsBaseNode):
f"steps={steps}, cfg={cfg}. "
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
)
return ("euler", "normal", 20, 7.0)
try:
import comfy.samplers
sampler = comfy.samplers.sampler_object("euler")
except ImportError:
# Return mock object for testing
sampler = "euler"
return (sampler, "normal", 20, 7.0)
# Process and return the combo
result = get_sampler_combo(sampler_name, scheduler, steps, cfg)
# Create the sampler object
try:
import comfy.samplers
sampler = comfy.samplers.sampler_object(result[0])
except ImportError:
# Return sampler name for testing
sampler = result[0]
self.log_info(
f"Configured sampler combo: {result[0]}, {result[1]}, "
f"{result[2]} steps, CFG {result[3]}"
)
return result
return (sampler, result[1], result[2], result[3])
except Exception as e:
# Handle any unexpected errors gracefully
@@ -119,7 +135,14 @@ class SamplerComboNode(ComfyAssetsBaseNode):
f"{self.__class__.__name__}: Error processing sampler combo: {str(e)}. "
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
)
return ("euler", "normal", 20, 7.0)
try:
import comfy.samplers
sampler = comfy.samplers.sampler_object("euler")
except ImportError:
# Return mock object for testing
sampler = "euler"
return (sampler, "normal", 20, 7.0)
def validate_inputs(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
+29 -3
View File
@@ -1,14 +1,40 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.1"
license = {file = "LICENSE"}
dependencies = ["# Development dependencies for ComfyUI-KikoTools", "# Testing framework", "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "# Code quality", "black>=23.0.0", "flake8>=6.0.0", "mypy>=1.0.0", "# Development utilities", "pre-commit>=3.0.0", "# ComfyUI testing (mock dependencies for unit tests)", "torch>=2.0.0", "numpy>=1.24.0", "pillow>=9.0.0"]
version = "1.0.7"
license = {text = "MIT"}
dependencies = []
[project.optional-dependencies]
dev = [
# Testing framework
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
# Code quality
"black>=23.0.0",
"flake8>=6.0.0",
"mypy>=1.0.0",
# Development utilities
"pre-commit>=3.0.0",
# ComfyUI testing (mock dependencies for unit tests)
"torch>=2.0.0",
"numpy>=1.24.0",
"pillow>=9.0.0"
]
[project.urls]
Repository = "https://github.com/ComfyAssets/ComfyUI-KikoTools"
# Used by Comfy Registry https://registry.comfy.org
[tool.setuptools.packages.find]
include = ["kikotools*"]
exclude = ["tests*", "web*"]
[tool.comfy]
PublisherId = "kiko9"
DisplayName = "ComfyUI-KikoTools"
+219
View File
@@ -0,0 +1,219 @@
"""Tests for Empty Latent Batch node and logic."""
import pytest
import torch
from kikotools.tools.empty_latent_batch.node import EmptyLatentBatchNode
from kikotools.tools.empty_latent_batch.logic import (
create_empty_latent_batch,
validate_dimensions,
sanitize_dimensions,
)
class TestEmptyLatentBatchLogic:
"""Test the logic functions for empty latent batch creation."""
def test_create_empty_latent_batch_basic(self):
"""Test basic empty latent creation."""
result = create_empty_latent_batch(512, 512, 1)
assert "samples" in result
samples = result["samples"]
assert isinstance(samples, torch.Tensor)
assert samples.shape == (1, 4, 64, 64) # 512/8 = 64
assert torch.all(samples == 0) # Should be all zeros
def test_create_empty_latent_batch_with_batch_size(self):
"""Test empty latent creation with larger batch size."""
batch_size = 4
result = create_empty_latent_batch(1024, 768, batch_size)
assert "samples" in result
samples = result["samples"]
assert isinstance(samples, torch.Tensor)
assert samples.shape == (4, 4, 96, 128) # 768/8=96, 1024/8=128
assert torch.all(samples == 0)
def test_create_empty_latent_batch_invalid_dimensions(self):
"""Test error handling for invalid dimensions."""
with pytest.raises(ValueError, match="Width and height must be positive"):
create_empty_latent_batch(0, 512, 1)
with pytest.raises(ValueError, match="Width and height must be positive"):
create_empty_latent_batch(512, -100, 1)
def test_create_empty_latent_batch_not_divisible_by_8(self):
"""Test error handling for dimensions not divisible by 8."""
with pytest.raises(ValueError, match="must be divisible by 8"):
create_empty_latent_batch(513, 512, 1)
with pytest.raises(ValueError, match="must be divisible by 8"):
create_empty_latent_batch(512, 515, 1)
def test_create_empty_latent_batch_invalid_batch_size(self):
"""Test error handling for invalid batch size."""
with pytest.raises(ValueError, match="Batch size must be positive"):
create_empty_latent_batch(512, 512, 0)
with pytest.raises(ValueError, match="Batch size must be positive"):
create_empty_latent_batch(512, 512, -1)
def test_validate_dimensions_valid(self):
"""Test dimension validation with valid inputs."""
assert validate_dimensions(512, 512) is True
assert validate_dimensions(1024, 768) is True
assert validate_dimensions(64, 64) is True # Minimum size
assert validate_dimensions(8192, 8192) is True # Maximum size
def test_validate_dimensions_invalid(self):
"""Test dimension validation with invalid inputs."""
assert validate_dimensions(0, 512) is False # Zero dimension
assert validate_dimensions(512, -100) is False # Negative dimension
assert validate_dimensions(513, 512) is False # Not divisible by 8
assert validate_dimensions(32, 32) is False # Too small
assert validate_dimensions(8200, 8200) is False # Too large
def test_sanitize_dimensions_basic(self):
"""Test basic dimension sanitization."""
width, height = sanitize_dimensions(512, 512)
assert width == 512
assert height == 512
def test_sanitize_dimensions_not_divisible_by_8(self):
"""Test sanitization of dimensions not divisible by 8."""
width, height = sanitize_dimensions(513, 515)
assert width == 512 # Rounds down to nearest multiple of 8
assert height == 512
width, height = sanitize_dimensions(517, 519)
assert width == 520 # Rounds up to nearest multiple of 8
assert height == 520
def test_sanitize_dimensions_too_small(self):
"""Test sanitization of dimensions that are too small."""
width, height = sanitize_dimensions(32, 16)
assert width == 64 # Minimum size
assert height == 64
def test_sanitize_dimensions_too_large(self):
"""Test sanitization of dimensions that are too large."""
width, height = sanitize_dimensions(10000, 9000)
assert width == 8192 # Maximum size
assert height == 8192
class TestEmptyLatentBatchNode:
"""Test the EmptyLatentBatchNode ComfyUI node."""
def setup_method(self):
"""Set up test fixtures."""
self.node = EmptyLatentBatchNode()
def test_input_types_structure(self):
"""Test that INPUT_TYPES returns proper structure."""
input_types = EmptyLatentBatchNode.INPUT_TYPES()
assert "required" in input_types
required = input_types["required"]
assert "width" in required
assert "height" in required
assert "batch_size" in required
# Check width parameter
width_spec = required["width"]
assert width_spec[0] == "INT"
assert width_spec[1]["default"] == 1024
assert width_spec[1]["min"] == 64
assert width_spec[1]["max"] == 8192
assert width_spec[1]["step"] == 8
def test_node_attributes(self):
"""Test node class attributes."""
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT",)
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent",)
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
assert EmptyLatentBatchNode.CATEGORY == "ComfyAssets"
def test_create_empty_latent_basic(self):
"""Test basic empty latent creation through node."""
result = self.node.create_empty_latent(512, 512, 1)
assert isinstance(result, tuple)
assert len(result) == 1
latent_dict = result[0]
assert isinstance(latent_dict, dict)
assert "samples" in latent_dict
samples = latent_dict["samples"]
assert isinstance(samples, torch.Tensor)
assert samples.shape == (1, 4, 64, 64)
def test_create_empty_latent_with_batch(self):
"""Test empty latent creation with batch size."""
batch_size = 3
result = self.node.create_empty_latent(1024, 768, batch_size)
latent_dict = result[0]
samples = latent_dict["samples"]
assert samples.shape == (3, 4, 96, 128) # batch=3, 768/8=96, 1024/8=128
def test_create_empty_latent_dimension_adjustment(self):
"""Test that dimensions are adjusted when not divisible by 8."""
# Input dimensions not divisible by 8
result = self.node.create_empty_latent(513, 515, 1)
latent_dict = result[0]
samples = latent_dict["samples"]
# Should be adjusted to 512x512 -> 64x64 latent
assert samples.shape == (1, 4, 64, 64)
def test_validate_inputs_valid(self):
"""Test input validation with valid parameters."""
assert self.node.validate_inputs(512, 512, 1) is True
assert self.node.validate_inputs(1024, 768, 4) is True
def test_validate_inputs_invalid_batch_size(self):
"""Test input validation with invalid batch size."""
assert self.node.validate_inputs(512, 512, 0) is False
assert self.node.validate_inputs(512, 512, 100) is False # Too large
def test_get_latent_info(self):
"""Test latent info generation."""
info = self.node.get_latent_info(512, 512, 2)
assert "Empty latent batch" in info
assert "2 × 4 × 64 × 64" in info
assert "512×512" in info
def test_get_memory_estimate(self):
"""Test memory estimation."""
estimate = self.node.get_memory_estimate(512, 512, 1)
assert "KB" in estimate or "MB" in estimate
# Larger batch should show larger estimate
large_estimate = self.node.get_memory_estimate(1024, 1024, 8)
assert "MB" in large_estimate
def test_node_registration_mappings(self):
"""Test that node registration mappings are properly defined."""
from kikotools.tools.empty_latent_batch.node import (
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
)
assert "EmptyLatentBatch" in NODE_CLASS_MAPPINGS
assert NODE_CLASS_MAPPINGS["EmptyLatentBatch"] == EmptyLatentBatchNode
assert "EmptyLatentBatch" in NODE_DISPLAY_NAME_MAPPINGS
assert NODE_DISPLAY_NAME_MAPPINGS["EmptyLatentBatch"] == "Empty Latent Batch"
def test_node_inheritance(self):
"""Test that node properly inherits from base class."""
from kikotools.base.base_node import ComfyAssetsBaseNode
assert isinstance(self.node, ComfyAssetsBaseNode)
assert hasattr(self.node, "handle_error")
assert hasattr(self.node, "log_info")
assert hasattr(self.node, "validate_inputs")
@@ -0,0 +1,193 @@
"""Unit tests for ImageToMultipleOf tool."""
import pytest
import torch
import sys
from pathlib import Path
# Add the project root to the Python path for tests
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from kikotools.tools.image_to_multiple_of.logic import (
calculate_dimensions_to_multiple,
process_image_to_multiple_of,
)
from kikotools.tools.image_to_multiple_of.node import ImageToMultipleOfNode
class TestImageToMultipleOfLogic:
"""Test core logic functions."""
def test_calculate_dimensions_to_multiple(self):
"""Test dimension calculation for various inputs."""
# Test exact multiples
assert calculate_dimensions_to_multiple(256, 512, 64) == (256, 512)
# Test non-exact multiples
assert calculate_dimensions_to_multiple(300, 400, 64) == (256, 384)
assert calculate_dimensions_to_multiple(150, 200, 32) == (128, 192)
# Test small values
assert calculate_dimensions_to_multiple(10, 20, 8) == (8, 16)
# Test with multiple_of = 1 (should return original)
assert calculate_dimensions_to_multiple(123, 456, 1) == (123, 456)
def test_process_image_center_crop(self):
"""Test center crop processing."""
# Create test image (batch=1, height=300, width=400, channels=3)
image = torch.rand(1, 300, 400, 3)
# Process with center crop
result = process_image_to_multiple_of(image, 64, "center crop")
# Check dimensions
assert result.shape == (1, 256, 384, 3)
# Check that center portion is preserved
# The crop should start at (22, 8) and end at (278, 392)
# This is a rough check that values are from the center
assert result.dtype == image.dtype
def test_process_image_rescale(self):
"""Test rescale processing."""
# Create test image
image = torch.rand(1, 300, 400, 3)
# Process with rescale
result = process_image_to_multiple_of(image, 64, "rescale")
# Check dimensions
assert result.shape == (1, 256, 384, 3)
assert result.dtype == image.dtype
def test_process_image_batch(self):
"""Test processing with batch of images."""
# Create batch of images
batch_size = 4
image = torch.rand(batch_size, 300, 400, 3)
# Process with center crop
result_crop = process_image_to_multiple_of(image, 32, "center crop")
assert result_crop.shape == (batch_size, 288, 384, 3)
# Process with rescale
result_rescale = process_image_to_multiple_of(image, 32, "rescale")
assert result_rescale.shape == (batch_size, 288, 384, 3)
def test_process_image_different_channels(self):
"""Test with different channel counts."""
# Test with 1 channel (grayscale)
image_gray = torch.rand(1, 256, 256, 1)
result = process_image_to_multiple_of(image_gray, 64, "center crop")
assert result.shape == (1, 256, 256, 1)
# Test with 4 channels (RGBA)
image_rgba = torch.rand(1, 300, 400, 4)
result = process_image_to_multiple_of(image_rgba, 64, "rescale")
assert result.shape == (1, 256, 384, 4)
class TestImageToMultipleOfNode:
"""Test ComfyUI node implementation."""
def test_node_input_types(self):
"""Test node input type definitions."""
input_types = ImageToMultipleOfNode.INPUT_TYPES()
assert "required" in input_types
assert "image" in input_types["required"]
assert "multiple_of" in input_types["required"]
assert "method" in input_types["required"]
# Check multiple_of configuration
multiple_config = input_types["required"]["multiple_of"][1]
assert multiple_config["default"] == 64
assert multiple_config["min"] == 1
assert multiple_config["max"] == 256
assert multiple_config["step"] == 16
# Check method options
methods = input_types["required"]["method"][0]
assert "center crop" in methods
assert "rescale" in methods
def test_node_metadata(self):
"""Test node metadata."""
assert ImageToMultipleOfNode.RETURN_TYPES == ("IMAGE",)
assert ImageToMultipleOfNode.RETURN_NAMES == ("image",)
assert ImageToMultipleOfNode.FUNCTION == "process"
assert ImageToMultipleOfNode.CATEGORY == "ComfyAssets"
def test_node_process_center_crop(self):
"""Test node processing with center crop."""
node = ImageToMultipleOfNode()
image = torch.rand(1, 300, 400, 3)
result = node.process(image, 64, "center crop")
assert isinstance(result, tuple)
assert len(result) == 1
assert result[0].shape == (1, 256, 384, 3)
def test_node_process_rescale(self):
"""Test node processing with rescale."""
node = ImageToMultipleOfNode()
image = torch.rand(1, 300, 400, 3)
result = node.process(image, 32, "rescale")
assert isinstance(result, tuple)
assert len(result) == 1
assert result[0].shape == (1, 288, 384, 3)
def test_node_validation_errors(self):
"""Test input validation error handling."""
node = ImageToMultipleOfNode()
# Test with None image
with pytest.raises(ValueError, match="Image input is required"):
node.validate_inputs(image=None, multiple_of=64, method="center crop")
# Test with invalid image shape
invalid_image = torch.rand(300, 400, 3) # Missing batch dimension
with pytest.raises(ValueError, match="Expected image tensor with shape"):
node.validate_inputs(
image=invalid_image, multiple_of=64, method="center crop"
)
# Test with negative multiple_of
image = torch.rand(1, 300, 400, 3)
with pytest.raises(ValueError, match="multiple_of must be positive"):
node.validate_inputs(image=image, multiple_of=-64, method="center crop")
# Test with invalid method
with pytest.raises(ValueError, match="Invalid method"):
node.validate_inputs(image=image, multiple_of=64, method="invalid")
# Test with image too small
small_image = torch.rand(1, 30, 40, 3)
with pytest.raises(ValueError, match="too small to be adjusted"):
node.validate_inputs(
image=small_image, multiple_of=64, method="center crop"
)
def test_node_edge_cases(self):
"""Test edge cases."""
node = ImageToMultipleOfNode()
# Test with already multiple dimensions
image = torch.rand(1, 256, 512, 3)
result = node.process(image, 64, "center crop")
assert result[0].shape == image.shape
# Test with multiple_of = 1
image = torch.rand(1, 123, 456, 3)
result = node.process(image, 1, "center crop")
assert result[0].shape == image.shape
# Test with very large multiple_of
image = torch.rand(1, 1024, 1024, 3)
result = node.process(image, 256, "rescale")
assert result[0].shape == (1, 1024, 1024, 3)
+546
View File
@@ -0,0 +1,546 @@
"""
Unit tests for KikoSaveImage tool
Tests image saving functionality with multiple formats and quality settings
"""
import pytest
import torch
import tempfile
import os
from PIL import Image
from unittest.mock import patch
from kikotools.tools.kiko_save_image.node import KikoSaveImageNode
from kikotools.tools.kiko_save_image.logic import (
convert_tensor_to_pil,
process_image_batch,
validate_save_inputs,
save_image_with_format,
get_save_image_path,
create_png_metadata,
)
class TestKikoSaveImageLogic:
"""Test core logic functions"""
def test_convert_tensor_to_pil(self):
"""Test tensor to PIL conversion"""
# Create test tensor [height, width, channels] with values 0-1
tensor = torch.rand(64, 64, 3)
# Convert to PIL
pil_image = convert_tensor_to_pil(tensor)
# Verify conversion
assert isinstance(pil_image, Image.Image)
assert pil_image.size == (64, 64) # PIL uses (width, height)
assert pil_image.mode in ["RGB", "RGBA"]
def test_convert_tensor_to_pil_rgba(self):
"""Test tensor to PIL conversion with alpha channel"""
# Create RGBA tensor
tensor = torch.rand(32, 32, 4)
pil_image = convert_tensor_to_pil(tensor)
assert isinstance(pil_image, Image.Image)
assert pil_image.size == (32, 32)
assert pil_image.mode == "RGBA"
def test_get_save_image_path(self):
"""Test save path generation"""
with tempfile.TemporaryDirectory() as temp_dir:
# Test basic path generation
full_path, filename = get_save_image_path(
"test_prefix", 0, ".png", temp_dir
)
assert full_path.startswith(temp_dir)
assert filename.startswith("test_prefix_")
assert filename.endswith("_00000.png")
# Test with empty subfolder (standard behavior)
full_path, filename = get_save_image_path("test", 1, ".jpg", temp_dir, "")
assert full_path.startswith(temp_dir)
assert filename.startswith("test_")
assert filename.endswith("_00001.jpg")
def test_create_png_metadata(self):
"""Test PNG metadata creation"""
# Test with no metadata
metadata = create_png_metadata()
assert metadata is None
# Test with prompt data
prompt_data = {"test": "value"}
metadata = create_png_metadata(prompt=prompt_data)
assert metadata is not None
# Check that metadata contains our data (implementation detail)
assert hasattr(metadata, "text")
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_process_image_batch_png(self, mock_folder_paths):
"""Test batch processing with PNG format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
# Create test image batch [batch, height, width, channels]
images = torch.rand(2, 32, 32, 3)
# Process batch
results, enhanced_data = process_image_batch(
images=images,
filename_prefix="test_batch",
format_type="PNG",
png_compress_level=6,
)
# Verify results (clean data)
assert len(results) == 2
for i, result in enumerate(results):
assert "filename" in result
assert "subfolder" in result
assert "type" in result
assert result["type"] == "output"
# Verify enhanced data
assert len(enhanced_data) == 2
for i, enhanced in enumerate(enhanced_data):
assert enhanced["format"] == "PNG"
assert enhanced["compress_level"] == 6
assert enhanced["dimensions"] == "32x32"
assert enhanced["popup"] is True # Default popup value
assert "file_size" in enhanced
# Verify file was saved
filepath = os.path.join(temp_dir, enhanced["filename"])
assert os.path.exists(filepath)
# Verify image can be loaded
saved_img = Image.open(filepath)
assert saved_img.size == (32, 32)
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_process_image_batch_jpeg(self, mock_folder_paths):
"""Test batch processing with JPEG format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
# Create test image batch
images = torch.rand(1, 64, 64, 3)
# Process batch
results, enhanced_data = process_image_batch(
images=images,
filename_prefix="test_jpeg",
format_type="JPEG",
quality=85,
)
# Verify results
assert len(results) == 1
assert len(enhanced_data) == 1
enhanced = enhanced_data[0]
assert enhanced["format"] == "JPEG"
assert enhanced["quality"] == 85
assert enhanced["filename"].endswith(".jpg")
# Verify file exists and can be loaded
filepath = os.path.join(temp_dir, results[0]["filename"])
assert os.path.exists(filepath)
saved_img = Image.open(filepath)
assert saved_img.size == (64, 64)
assert saved_img.mode == "RGB" # JPEG converts to RGB
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_process_image_batch_webp(self, mock_folder_paths):
"""Test batch processing with WebP format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
# Create test image batch
images = torch.rand(1, 48, 48, 3)
# Test lossless WebP
results = process_image_batch(
images=images,
filename_prefix="test_webp",
format_type="WEBP",
quality=90,
webp_lossless=True,
)
assert len(results) == 1
result = results[0]
assert result["format"] == "WEBP"
assert result["lossless"] is True
assert result["filename"].endswith(".webp")
def test_validate_save_inputs_valid(self):
"""Test input validation with valid inputs"""
images = torch.rand(2, 64, 64, 3)
# Should not raise exception
validate_save_inputs(images, "PNG", 90, 4)
validate_save_inputs(images, "JPEG", 85, 4)
validate_save_inputs(images, "WEBP", 95, 6)
def test_validate_save_inputs_invalid_tensor(self):
"""Test validation with invalid tensor"""
# Wrong tensor dimensions
invalid_tensor = torch.rand(64, 64) # Missing batch and channel dims
with pytest.raises(ValueError, match="4 dimensions"):
validate_save_inputs(invalid_tensor, "PNG", 90, 4)
# Non-tensor input
with pytest.raises(ValueError, match="torch.Tensor"):
validate_save_inputs("not_a_tensor", "PNG", 90, 4)
def test_validate_save_inputs_invalid_format(self):
"""Test validation with invalid format"""
images = torch.rand(1, 32, 32, 3)
with pytest.raises(ValueError, match="format must be one of"):
validate_save_inputs(images, "BMP", 90, 4)
def test_validate_save_inputs_invalid_quality(self):
"""Test validation with invalid quality"""
images = torch.rand(1, 32, 32, 3)
# Quality out of range
with pytest.raises(
ValueError, match="quality must be an integer between 1 and 100"
):
validate_save_inputs(images, "JPEG", 0, 4)
with pytest.raises(
ValueError, match="quality must be an integer between 1 and 100"
):
validate_save_inputs(images, "JPEG", 101, 4)
def test_validate_save_inputs_invalid_compress_level(self):
"""Test validation with invalid PNG compression level"""
images = torch.rand(1, 32, 32, 3)
with pytest.raises(
ValueError, match="png_compress_level must be an integer between 0 and 9"
):
validate_save_inputs(images, "PNG", 90, -1)
with pytest.raises(
ValueError, match="png_compress_level must be an integer between 0 and 9"
):
validate_save_inputs(images, "PNG", 90, 10)
def test_save_image_with_format_png(self):
"""Test saving with PNG format"""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
temp_path = temp_file.name
try:
# Create test PIL image
img = Image.new("RGB", (32, 32), color="red")
# Save with PNG format
result = save_image_with_format(img, temp_path, "PNG", png_compress_level=8)
assert result["format"] == "PNG"
assert result["compress_level"] == 8
assert os.path.exists(temp_path)
# Verify saved image
saved_img = Image.open(temp_path)
assert saved_img.size == (32, 32)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def test_save_image_with_format_jpeg_rgba_conversion(self):
"""Test JPEG saving with RGBA to RGB conversion"""
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file:
temp_path = temp_file.name
try:
# Create RGBA image
img = Image.new("RGBA", (32, 32), color=(255, 0, 0, 128))
# Save as JPEG (should convert to RGB)
result = save_image_with_format(img, temp_path, "JPEG", quality=95)
assert result["format"] == "JPEG"
assert result["quality"] == 95
# Verify saved image is RGB
saved_img = Image.open(temp_path)
assert saved_img.mode == "RGB"
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
class TestKikoSaveImageNode:
"""Test KikoSaveImageNode class"""
def setup_method(self):
"""Setup test fixtures"""
self.node = KikoSaveImageNode()
def test_input_types(self):
"""Test INPUT_TYPES class method"""
input_types = KikoSaveImageNode.INPUT_TYPES()
# Check required inputs
required = input_types["required"]
assert "images" in required
assert "filename_prefix" in required
assert "format" in required
# Check format options
format_options = required["format"][0]
assert "PNG" in format_options
assert "JPEG" in format_options
assert "WEBP" in format_options
# Check optional inputs
optional = input_types["optional"]
assert "quality" in optional
assert "png_compress_level" in optional
assert "webp_lossless" in optional
assert "popup" in optional
# Check hidden inputs
hidden = input_types["hidden"]
assert "prompt" in hidden
assert "extra_pnginfo" in hidden
def test_node_attributes(self):
"""Test node class attributes"""
assert KikoSaveImageNode.RETURN_TYPES == ()
assert KikoSaveImageNode.FUNCTION == "save_images"
assert KikoSaveImageNode.OUTPUT_NODE is True
assert KikoSaveImageNode.CATEGORY == "ComfyAssets"
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
def test_save_images_success(self, mock_process):
"""Test successful image saving"""
# Setup mock - new return format (results, enhanced_data)
mock_results = [
{
"filename": "test_00001_00000.png",
"subfolder": "",
"type": "output",
}
]
mock_enhanced = [
{
"filename": "test_00001_00000.png",
"popup": True,
"type": "output",
"format": "PNG",
"file_size": 1024,
"dimensions": "64x64",
}
]
mock_process.return_value = (mock_results, mock_enhanced)
# Create test input
images = torch.rand(1, 64, 64, 3)
# Call save_images
result = self.node.save_images(
images=images,
filename_prefix="test",
format="PNG",
quality=90,
png_compress_level=4,
)
# Verify mock was called
mock_process.assert_called_once()
# Verify result format
assert "ui" in result
assert "images" in result["ui"]
assert "kiko_enhanced" in result["ui"]
assert result["ui"]["images"] == mock_results
assert result["ui"]["kiko_enhanced"] == mock_enhanced
def test_validate_inputs_success(self):
"""Test input validation with valid inputs"""
images = torch.rand(1, 32, 32, 3)
# Should not raise exception
self.node.validate_inputs(
images=images,
format="PNG",
quality=90,
png_compress_level=4,
webp_lossless=False,
popup=True,
)
def test_validate_inputs_invalid_webp_lossless(self):
"""Test validation with invalid webp_lossless type"""
images = torch.rand(1, 32, 32, 3)
with pytest.raises(ValueError, match="webp_lossless must be a boolean"):
self.node.validate_inputs(
images=images,
format="PNG",
quality=90,
png_compress_level=4,
webp_lossless="not_boolean",
popup=True,
)
def test_validate_inputs_invalid_popup(self):
"""Test validation with invalid popup"""
images = torch.rand(1, 32, 32, 3)
# Non-boolean popup
with pytest.raises(ValueError, match="popup must be a boolean"):
self.node.validate_inputs(
images=images,
format="PNG",
quality=90,
png_compress_level=4,
webp_lossless=False,
popup="not_boolean",
)
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
def test_save_images_error_handling(self, mock_process):
"""Test error handling in save_images method"""
# Setup mock to raise exception
mock_process.side_effect = Exception("Test error")
images = torch.rand(1, 32, 32, 3)
# Should handle error and re-raise with context
with pytest.raises(ValueError, match="Failed to save images"):
self.node.save_images(images=images)
def test_node_info(self):
"""Test get_node_info method"""
info = self.node.get_node_info()
assert info["class_name"] == "KikoSaveImageNode"
assert info["category"] == "ComfyAssets"
assert info["function"] == "save_images"
class TestNodeRegistration:
"""Test node registration mappings"""
def test_node_class_mappings(self):
"""Test NODE_CLASS_MAPPINGS contains KikoSaveImage"""
from kikotools.tools.kiko_save_image.node import NODE_CLASS_MAPPINGS
assert "KikoSaveImage" in NODE_CLASS_MAPPINGS
assert NODE_CLASS_MAPPINGS["KikoSaveImage"] is KikoSaveImageNode
def test_node_display_name_mappings(self):
"""Test NODE_DISPLAY_NAME_MAPPINGS contains KikoSaveImage"""
from kikotools.tools.kiko_save_image.node import NODE_DISPLAY_NAME_MAPPINGS
assert "KikoSaveImage" in NODE_DISPLAY_NAME_MAPPINGS
assert NODE_DISPLAY_NAME_MAPPINGS["KikoSaveImage"] == "Kiko Save Image"
# Integration test fixtures
@pytest.fixture
def sample_image_tensor():
"""Create sample image tensor for testing"""
# Create a colorful test image [batch, height, width, channels]
batch_size, height, width, channels = 2, 64, 64, 3
# Create gradient pattern
tensor = torch.zeros(batch_size, height, width, channels)
for b in range(batch_size):
for h in range(height):
for w in range(width):
# Create RGB gradient pattern
tensor[b, h, w, 0] = h / height # Red gradient
tensor[b, h, w, 1] = w / width # Green gradient
tensor[b, h, w, 2] = (b + 1) * 0.5 # Blue varies by batch
return tensor
class TestIntegration:
"""Integration tests using sample data"""
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_full_pipeline_png(self, mock_folder_paths, sample_image_tensor):
"""Test complete pipeline with PNG format"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
node = KikoSaveImageNode()
# Save images
result = node.save_images(
images=sample_image_tensor,
filename_prefix="integration_test",
format="PNG",
png_compress_level=6,
)
# Verify result structure
assert "ui" in result
assert "images" in result["ui"]
assert len(result["ui"]["images"]) == 2
# Verify files were created
for image_info in result["ui"]["images"]:
filepath = os.path.join(temp_dir, image_info["filename"])
assert os.path.exists(filepath)
# Verify image properties
img = Image.open(filepath)
assert img.size == (64, 64)
assert img.format == "PNG"
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
def test_full_pipeline_all_formats(self, mock_folder_paths, sample_image_tensor):
"""Test complete pipeline with all supported formats"""
with tempfile.TemporaryDirectory() as temp_dir:
mock_folder_paths.get_output_directory.return_value = temp_dir
node = KikoSaveImageNode()
# Test each format
formats_to_test = [
("PNG", {"png_compress_level": 8}),
("JPEG", {"quality": 85}),
("WEBP", {"quality": 90, "webp_lossless": False}),
("WEBP", {"quality": 100, "webp_lossless": True}),
]
for format_type, kwargs in formats_to_test:
result = node.save_images(
images=sample_image_tensor,
filename_prefix=f"test_{format_type.lower()}",
format=format_type,
**kwargs,
)
# Verify results
assert len(result["ui"]["images"]) == 2
for image_info in result["ui"]["images"]:
assert image_info["format"] == format_type
# Verify file exists and can be opened
filepath = os.path.join(temp_dir, image_info["filename"])
assert os.path.exists(filepath)
img = Image.open(filepath)
assert img.size == (64, 64)
+3 -3
View File
@@ -168,17 +168,17 @@ class TestSamplerComboNode:
steps_input = required["steps"]
assert steps_input[0] == "INT"
assert steps_input[1]["min"] == 1
assert steps_input[1]["max"] == 1000
assert steps_input[1]["max"] == 100
# Check CFG input structure
cfg_input = required["cfg"]
assert cfg_input[0] == "FLOAT"
assert cfg_input[1]["min"] == 0.0
assert cfg_input[1]["max"] == 30.0
assert cfg_input[1]["max"] == 20.0
def test_return_types_structure(self):
"""Test that return types are correctly defined."""
assert SamplerComboNode.RETURN_TYPES == (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_TYPES == ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_NAMES == (
"sampler_name",
"scheduler",
+393
View File
@@ -0,0 +1,393 @@
// ComfyUI-KikoTools - Empty Latent Batch with Swap Button
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "comfyassets.EmptyLatentBatch",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "EmptyLatentBatch") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Track button click state for visual feedback
this.swapButtonPressed = false;
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function (presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
};
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function (
value,
graphcanvas,
node,
pos,
event,
) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find((w) => w.name === "width");
const heightWidget = node.widgets.find((w) => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024],
"896×1152": [896, 1152],
"832×1216": [832, 1216],
"768×1344": [768, 1344],
"640×1536": [640, 1536],
"1152×896": [1152, 896],
"1216×832": [1216, 832],
"1344×768": [1344, 768],
"1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080],
"1536×1536": [1536, 1536],
"1280×768": [1280, 768],
"768×1280": [768, 1280],
"1440×1080": [1440, 1080],
"1080×1440": [1080, 1440],
"1728×1152": [1728, 1152],
"1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080],
"2048×768": [2048, 768],
"1792×768": [1792, 768],
"2304×768": [2304, 768],
"1080×2560": [1080, 2560],
"768×2048": [768, 2048],
"768×1792": [768, 1792],
"768×2304": [768, 2304],
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function () {
const widthWidget = this.widgets.find((w) => w.name === "width");
const heightWidget = this.widgets.find((w) => w.name === "height");
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution =
this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes("×")) {
[w, h] = rawResolution.split("×").map((v) => parseInt(v));
} else if (rawResolution.includes("x")) {
[w, h] = rawResolution.split("x").map((v) => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets =
presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(
swappedFormattedPreset,
this,
presetWidget,
);
}
if (widthWidget.callback) {
widthWidget.callback(h, this, widthWidget);
}
if (heightWidget.callback) {
heightWidget.callback(w, this, heightWidget);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", this, presetWidget);
}
if (widthWidget.callback) {
widthWidget.callback(h, this, widthWidget);
}
if (heightWidget.callback) {
heightWidget.callback(w, this, heightWidget);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, this, widthWidget);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, this, heightWidget);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
// Override onResize to refresh button position
const originalOnResize = this.onResize;
this.onResize = function (size) {
if (originalOnResize) {
originalOnResize.call(this, size);
}
// Force redraw to update button position
this.setDirtyCanvas(true, true);
// Also mark the graph as dirty
if (this.graph) {
this.graph.setDirtyCanvas(true, true);
}
};
// Override onBounding to ensure proper updates
const originalOnBounding = this.onBounding;
this.onBounding = function (out) {
if (originalOnBounding) {
originalOnBounding.call(this, out);
}
// Force redraw when bounds change
this.setDirtyCanvas(true, true);
};
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) {
onDrawForeground.apply(this, arguments);
}
if (this.flags.collapsed) return;
// Draw swap button with consistent spacing from widgets
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.size[0] - swapButtonSize - margin;
// Calculate button position based on widget spacing rather than bottom margin
// Estimate widget area height and add consistent spacing
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
const topMargin = 35; // Space from top to first widget
const buttonSpacing = 40; // Space between last widget and button (moved down 5)
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
// Button background - change color based on pressed state
if (this.swapButtonPressed) {
// Darker when pressed
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
} else {
// Normal state
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
}
ctx.beginPath();
ctx.roundRect(
swapButtonX,
swapButtonY,
swapButtonSize,
swapButtonSize,
4,
);
ctx.fill();
// Button border with subtle highlight
ctx.strokeStyle = this.swapButtonPressed
? "rgba(20, 100, 180, 1.0)"
: "rgba(33, 150, 243, 0.9)";
ctx.lineWidth = 1;
ctx.stroke();
// Draw swap icon - modern double arrow design
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = 2;
ctx.lineCap = "round";
const centerX = swapButtonX + 12;
const centerY = swapButtonY + 12;
// Top arrow (pointing right) - width to height
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY - 3);
ctx.lineTo(centerX + 5, centerY - 3);
ctx.stroke();
// Top arrow head
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 5);
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 1);
ctx.stroke();
// Bottom arrow (pointing left) - height to width
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY + 3);
ctx.lineTo(centerX - 7, centerY + 3);
ctx.stroke();
// Bottom arrow head
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 1);
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 5);
ctx.stroke();
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e) {
// Check if click is on swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX =
this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 40;
const swapButtonY =
this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
if (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
) {
// Visual feedback - set button as pressed
this.swapButtonPressed = true;
this.setDirtyCanvas(true, true);
// Execute swap
this.swapDimensions();
// Reset button state after a short delay for visual feedback
setTimeout(() => {
this.swapButtonPressed = false;
this.setDirtyCanvas(true, true);
}, 150);
return true; // Consume the event
}
// Call original onMouseDown if not clicking swap button
if (onMouseDown) {
return onMouseDown.apply(this, arguments);
}
};
// Optional: Add hover effect for better user feedback
const onMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function (e) {
// Check if hovering over swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX =
this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 40;
const swapButtonY =
this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
const isHovering =
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize;
// Update cursor style for better UX (safely)
if (
isHovering &&
this.graph &&
this.graph.canvas &&
this.graph.canvas.canvas
) {
this.graph.canvas.canvas.style.cursor = "pointer";
} else if (
this.graph &&
this.graph.canvas &&
this.graph.canvas.canvas
) {
this.graph.canvas.canvas.style.cursor = "default";
}
// Call original onMouseMove
if (onMouseMove) {
return onMouseMove.apply(this, arguments);
}
};
}
},
});
File diff suppressed because it is too large Load Diff