Compare commits

..
8 Commits
10 changed files with 1322 additions and 3 deletions
+64 -2
View File
@@ -76,6 +76,22 @@ 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
### 🔧 Architecture Highlights
- **Modular Design**: Each tool is self-contained and independently testable
@@ -152,6 +168,20 @@ 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 → Save Image
📦 preset: "1024×1024" ↘ batch latents ↗
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
### Common Workflows
<details>
@@ -192,6 +222,7 @@ 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) |
| **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 +309,37 @@ 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
## 🛠️ Development
### Prerequisites
@@ -398,9 +460,9 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 4 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo)
- **Nodes**: 5 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 2 (Swap Button, History UI)
- **Interactive Features**: 3 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button)
- **Test Coverage**: 100% (180+ comprehensive tests)
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
+2
View File
@@ -28,9 +28,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
+3
View File
@@ -7,6 +7,7 @@ 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
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
@@ -15,6 +16,7 @@ NODE_CLASS_MAPPINGS = {
"SeedHistory": SeedHistoryNode,
"SamplerCombo": SamplerComboNode,
"SamplerComboCompact": SamplerComboCompactNode,
"EmptyLatentBatch": EmptyLatentBatchNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -23,6 +25,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SeedHistory": "Seed History",
"SamplerCombo": "Sampler Combo",
"SamplerComboCompact": "Sampler Combo (Compact)",
"EmptyLatentBatch": "Empty Latent Batch",
}
__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, Any
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
+312
View File
@@ -0,0 +1,312 @@
"""Empty Latent Batch node for ComfyUI."""
import torch
from typing import Dict, Any, 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,
get_model_recommendation,
get_preset_metadata,
get_presets_by_model_group,
)
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",
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.1"
version = "1.0.2"
license = {file = "LICENSE"}
dependencies = ["# Development dependencies for ComfyUI-KikoTools", "# Testing framework", "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "# Code quality", "black>=23.0.0", "flake8>=6.0.0", "mypy>=1.0.0", "# Development utilities", "pre-commit>=3.0.0", "# ComfyUI testing (mock dependencies for unit tests)", "torch>=2.0.0", "numpy>=1.24.0", "pillow>=9.0.0"]
+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")
+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);
}
};
}
},
});