Compare commits

...
Author SHA1 Message Date
Vito Sansevero 214851f2ef style: Clean up unused imports in test files 2025-06-14 15:25:55 -07:00
Vito Sansevero bc7608f891 style: Fix line formatting issues 2025-06-14 15:25:45 -07:00
Vito Sansevero 1ab839c58c style: Break long lines for readability 2025-06-14 15:25:36 -07:00
Vito Sansevero 0cf64ae411 style(logic): adjust typing imports and line breaks 2025-06-14 15:25:25 -07:00
Vito Sansevero da26d40d98 test: Add unit tests for Sampler Combo functionality 2025-06-14 13:41:58 -07:00
Vito Sansevero bc6e4938ef feat(sampler combo): add unified sampling interface 2025-06-14 13:41:43 -07:00
Vito 1c0e88435c Merge pull request #3 from ComfyAssets/seed-history
docs: Add Seed History documentation to README.md
2025-06-14 11:59:20 -07:00
Vito Sansevero 43563ef33c docs: Add Seed History documentation to README.md 2025-06-14 11:53:26 -07:00
Vito 7711cf31d8 Merge pull request #2 from ComfyAssets/seed-history
Seed history
2025-06-14 11:43:34 -07:00
Vito Sansevero bcfca9b8df feat(seed-history): Add seed history tracking UI 2025-06-14 11:29:18 -07:00
Vito Sansevero ddc847a16d test(seed_history): add tests for Seed History tool 2025-06-14 11:29:03 -07:00
Vito Sansevero 70e8322bd5 feat(seed_history): add Seed History tool and node 2025-06-14 11:28:52 -07:00
Vito Sansevero 26da37be18 feat(examples): add seed history workflow example 2025-06-14 11:28:43 -07:00
Vito Sansevero 677bbc04ba docs: Add Seed History tool documentation 2025-06-14 11:28:33 -07:00
Vito 602912f31d Merge pull request #1 from ComfyAssets/width-and-height
Width and height
2025-06-14 11:05:25 -07:00
21 changed files with 3065 additions and 24 deletions
+103 -2
View File
@@ -44,6 +44,38 @@ Advanced preset-based dimension selection with visual swap button.
- Mobile and ultra-wide format support - Mobile and ultra-wide format support
- Integration with upscaling pipelines - Integration with upscaling pipelines
#### 🎲 Seed History
Advanced seed tracking with interactive history management and UI.
- **Automatic Tracking**: Monitors all seed changes with timestamps
- **Interactive History**: Click any historical seed to reload instantly
- **Smart Deduplication**: 500ms window prevents duplicate rapid additions
- **Persistent Storage**: History survives browser sessions and ComfyUI restarts
- **Auto-Hide UI**: Clean interface that hides after 2.5 seconds of inactivity
- **Visual Feedback**: Toast notifications and selection highlighting
**Use Cases:**
- Track promising seeds during creative exploration
- Quickly return to successful generation parameters
- Maintain reproducibility across sessions
- Compare results from different seeds efficiently
#### ⚙️ Sampler Combo
Unified sampling configuration interface combining sampler, scheduler, steps, and CFG.
- **All-in-One Interface**: Single node for complete sampling configuration
- **Smart Recommendations**: Optimal settings suggestions per sampler type
- **Compatibility Validation**: Ensures sampler/scheduler combinations work well
- **Intelligent Defaults**: Context-aware parameter recommendations
- **Range Validation**: Prevents invalid parameter combinations
- **Comprehensive Tooltips**: Detailed guidance for each parameter
**Use Cases:**
- Simplify complex sampling workflows
- Ensure optimal sampler/scheduler combinations
- Reduce node clutter in workflows
- Quick sampling parameter experimentation
### 🔧 Architecture Highlights ### 🔧 Architecture Highlights
- **Modular Design**: Each tool is self-contained and independently testable - **Modular Design**: Each tool is self-contained and independently testable
@@ -97,6 +129,29 @@ preset: "1920×1080" ↘ 1920×1080 ↗
**Output:** 1920×1080 (16:9 cinematic) **Output:** 1920×1080 (16:9 cinematic)
**Swap Button:** Click to get 1080×1920 (9:16 portrait) **Swap Button:** Click to get 1080×1920 (9:16 portrait)
### Seed History Example
```
Seed History → KSampler → VAE Decode → Save Image
🎲 12345 ↘ seed ↗
[History UI: 54321, 99999, 11111...]
```
**Current Seed:** 12345
**History:** Auto-tracked previous seeds with timestamps
**Interaction:** Click any historical seed to reload instantly
### Sampler Combo Example
```
Sampler Combo → KSampler → VAE Decode → Save Image
⚙️ All Settings ↘ sampler/scheduler/steps/cfg ↗
```
**Configuration:** euler, normal, 20 steps, CFG 7.0
**Output:** Complete sampling configuration in one node
**Smart Features:** Recommendations and compatibility validation
### Common Workflows ### Common Workflows
<details> <details>
@@ -135,6 +190,8 @@ preset: "1920×1080" ↘ 1920×1080 ↗
|------|-------------|--------|---------------| |------|-------------|--------|---------------|
| **Resolution Calculator** | Calculate upscaled dimensions with model optimization | ✅ Complete | [Docs](examples/documentation/resolution_calculator.md) | | **Resolution Calculator** | Calculate upscaled dimensions with model optimization | ✅ Complete | [Docs](examples/documentation/resolution_calculator.md) |
| **Width Height Selector** | Preset-based dimension selection with 26 curated options | ✅ Complete | [Docs](examples/documentation/width_height_selector.md) | | **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 | [Usage Examples](#sampler-combo-example) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon | | **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon | | **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -178,6 +235,49 @@ preset: "1920×1080" ↘ 1920×1080 ↗
- FLUX Presets (8): 1920×1080 to 1152×1728 (high resolution) - FLUX Presets (8): 1920×1080 to 1152×1728 (high resolution)
- Ultra-Wide (8): 2560×1080 to 768×2304 (modern ratios) - Ultra-Wide (8): 2560×1080 to 768×2304 (modern ratios)
#### Seed History
**Inputs:**
- `seed` (INT): 0 to 18,446,744,073,709,551,615, default 12345
**Outputs:**
- `seed` (INT): Validated and processed seed value
**UI Features:**
- Interactive history display with timestamps
- Generate random seed button (🎲 Generate)
- Clear history button (🗑️ Clear)
- Auto-hide after 2.5 seconds of inactivity
- Click-to-restore hidden history
**History Management:**
- Maximum 10 entries for optimal performance
- Smart deduplication with 500ms window
- Persistent localStorage storage
- Newest entries displayed first
- Human-readable time formatting (5m ago, 2h ago)
#### Sampler Combo
**Inputs:**
- `sampler_name` (DROPDOWN): Available ComfyUI samplers (euler, dpmpp_2m, etc.)
- `scheduler` (DROPDOWN): Available schedulers (normal, karras, exponential, etc.)
- `steps` (INT): 1-1000, default 20
- `cfg` (FLOAT): 0.0-30.0, default 7.0
**Outputs:**
- `sampler_name` (STRING): Selected sampler algorithm
- `scheduler` (STRING): Selected scheduler algorithm
- `steps` (INT): Validated step count
- `cfg` (FLOAT): Validated CFG scale
**Features:**
- Smart parameter validation and sanitization
- Sampler-specific recommendations for optimal settings
- Compatibility checking between samplers and schedulers
- Graceful error handling with safe defaults
- Comprehensive tooltips for user guidance
## 🛠️ Development ## 🛠️ Development
### Prerequisites ### Prerequisites
@@ -298,9 +398,10 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats ## 📈 Stats
- **Nodes**: 2 (Resolution Calculator, Width Height Selector) - **Nodes**: 4 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo)
- **Presets**: 26 curated resolution presets - **Presets**: 26 curated resolution presets
- **Test Coverage**: 100% - **Interactive Features**: 2 (Swap Button, History UI)
- **Test Coverage**: 100% (180+ comprehensive tests)
- **Python Version**: 3.8+ - **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest - **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy) - **Dependencies**: Minimal (PyTorch, NumPy)
+167
View File
@@ -0,0 +1,167 @@
# Seed History Tool
The Seed History tool provides advanced seed value tracking with an interactive UI for managing seed history, automatic deduplication, and convenient seed retrieval.
## Overview
The Seed History node functions as both a standard seed input and an intelligent tracking system that automatically monitors seed changes and maintains a searchable history.
## Features
### 🎲 Core Functionality
- **Seed Output**: Standard ComfyUI seed value output (0 to 18,446,744,073,709,551,615)
- **History Tracking**: Automatic tracking of all seed changes with timestamps
- **Deduplication**: Intelligent filtering to prevent duplicate entries within 500ms windows
- **Persistent Storage**: History persists across ComfyUI sessions using localStorage
### 🎯 Interactive UI
- **History Display**: Scrollable list showing recent seeds with timestamps
- **Click to Load**: Click any history entry to instantly load that seed
- **Generate Button**: Create new random seeds with one click
- **Clear History**: Remove all tracked seeds when needed
- **Auto-Hide**: History section automatically hides after 2.5 seconds of inactivity
### ⚡ Smart Features
- **Real-time Updates**: Tracks seed changes from increment/decrement buttons
- **Visual Feedback**: Selected seeds are highlighted in green
- **Time Formatting**: Human-readable "time ago" display (e.g., "5m ago", "2h ago")
- **Notifications**: Toast messages for actions like generate and clear
- **Responsive Design**: Adapts to node resizing
## Usage
### Basic Setup
1. **Add Node**: Search for "Seed History" in the ComfyUI node browser
2. **Connect Output**: Connect the seed output to any node requiring a seed input
3. **Automatic Tracking**: The node automatically begins tracking seed changes
### Seed Management
```
🎲 Seed History
┌─────────────────┐
│ 🎲 Generate │ 🗑️ Clear │
├─────────────────┤
│ 🎲 1,234,567 │ ← Click to load
│ ⏰ 2m ago │
├─────────────────┤
│ 🎲 9,876,543 │
│ ⏰ 5m ago │
├─────────────────┤
│ 🎲 5,555,555 │
│ ⏰ 10m ago │
└─────────────────┘
```
### Workflow Integration
The Seed History node works seamlessly with any ComfyUI workflow:
```
[Seed History] → [KSampler] → [Image Output]
↓
[VAE Decode] → [Save Image]
```
## Advanced Features
### History Management
- **Maximum Entries**: Keeps the 10 most recent seeds
- **Smart Deduplication**: Prevents rapid duplicate additions
- **Timestamp Tracking**: Full date/time information for each seed
- **Persistent Storage**: History survives ComfyUI restarts
### UI Behavior
- **Auto-Hide Timer**: History hides after 2.5 seconds of inactivity
- **Mouse Interaction**: Hovering over history cancels auto-hide
- **Restore Button**: Click to restore hidden history section
- **Visual Feedback**: Hover effects and selection highlighting
### Seed Validation
- **Range Checking**: Ensures seeds are within valid ComfyUI range
- **Error Handling**: Graceful fallback to default seed (12345) on errors
- **Sanitization**: Automatic clamping of out-of-range values
## Technical Details
### Input Parameters
- **seed** (INT): Seed value for generation processes
- Range: 0 to 18,446,744,073,709,551,615
- Default: 12345
- Tooltip: "Seed value for generation processes. History UI tracks all changes automatically."
### Output
- **seed** (INT): The processed seed value for use in other nodes
### Storage
- **Key**: `comfyui_kikotools_seed_history`
- **Format**: JSON array of history entries
- **Location**: Browser localStorage
- **Persistence**: Survives browser sessions and ComfyUI restarts
## Use Cases
### 🎨 Creative Workflows
- **Iteration Tracking**: Keep track of promising seeds during creative exploration
- **Version Control**: Easily return to previous seeds that produced good results
- **Experimentation**: Generate and track multiple seed variations
### 🔬 Technical Workflows
- **Reproducibility**: Maintain exact seed records for reproducing specific outputs
- **A/B Testing**: Compare results from different seeds with easy switching
- **Documentation**: Export seed history for technical documentation
### 📊 Batch Processing
- **Seed Management**: Track seeds across multiple batch runs
- **Quality Control**: Quickly identify and reuse successful seeds
- **Workflow Optimization**: Analyze seed performance patterns
## Tips and Best Practices
### Efficient Usage
1. **Let it Track**: The node automatically tracks all seed changes - no manual intervention needed
2. **Use Generate**: The generate button is optimized for creating good random seeds
3. **Regular Clearing**: Clear history periodically to maintain relevant seeds only
### Workflow Integration
1. **Single Source**: Use one Seed History node per workflow for centralized tracking
2. **Connect Early**: Place the node early in your workflow chain for complete tracking
3. **Branch Connections**: Connect to multiple nodes that need the same seed
### History Management
1. **Review Regularly**: Check history for seeds that produced good results
2. **Document Success**: Note down particularly successful seeds externally
3. **Clean Periodically**: Clear history when starting new creative projects
## Troubleshooting
### Common Issues
**History Not Updating**
- Ensure the node is properly connected to your workflow
- Check that seed widget is visible and functional
- Verify browser localStorage is enabled
**UI Not Appearing**
- Check browser console for JavaScript errors
- Ensure ComfyUI-KikoTools is properly installed
- Verify web directory permissions
**Seeds Not Loading**
- Confirm the seed is within valid range
- Check that target widgets support the seed value
- Verify node connections are intact
### Performance Notes
- History is limited to 10 entries for optimal performance
- Deduplication prevents excessive storage usage
- Auto-hide reduces visual clutter during long workflows
## Examples
See the `examples/workflows/` directory for complete workflow examples demonstrating:
- Basic seed tracking workflow
- Creative iteration with history
- Technical reproducibility setup
- Batch processing with seed management
@@ -0,0 +1,409 @@
{
"last_node_id": 8,
"last_link_id": 7,
"nodes": [
{
"id": 1,
"type": "SeedHistory",
"pos": [
100,
100
],
"size": {
"0": 280,
"1": 320
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "seed",
"type": "INT",
"links": [
1
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "SeedHistory"
},
"widgets_values": [
12345
],
"color": "#2a363b",
"bgcolor": "#3f5159"
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [
100,
450
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
2
],
"shape": 3
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3
],
"shape": 3
},
{
"name": "VAE",
"type": "VAE",
"links": [
4
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"sd_xl_base_1.0.safetensors"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [
450,
100
],
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
5
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"a beautiful landscape with mountains and lakes, sunset, detailed, photorealistic"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [
450,
350
],
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"blurry, low quality, distorted, ugly, deformed"
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 5,
"type": "KSampler",
"pos": [
900,
100
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 2
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 5
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"link": 1,
"widget": {
"name": "seed"
}
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
],
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
12345,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 6,
"type": "EmptyLatentImage",
"pos": [
900,
400
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 5,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"shape": 3
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 7,
"type": "VAEDecode",
"pos": [
1300,
100
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 4
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [],
"shape": 3
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 8,
"type": "SaveImage",
"pos": [
1300,
200
],
"size": {
"0": 315,
"1": 270
},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"ComfyUI"
]
}
],
"links": [
[
1,
1,
0,
5,
4,
"INT"
],
[
2,
2,
0,
5,
0,
"MODEL"
],
[
3,
2,
1,
3,
0,
"CLIP"
],
[
4,
2,
2,
7,
1,
"VAE"
],
[
5,
3,
0,
5,
1,
"CONDITIONING"
],
[
6,
4,
0,
5,
2,
"CONDITIONING"
],
[
7,
5,
0,
7,
0,
"LATENT"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9090909090909091,
"offset": [
-45.45454545454545,
-9.090909090909092
]
},
"info": {
"name": "Seed History Example",
"author": "ComfyUI-KikoTools",
"description": "Example workflow demonstrating the Seed History tool for tracking and managing seed values with an interactive UI.",
"version": "1.0",
"created": "2024-06-14",
"modified": "2024-06-14"
}
},
"version": 0.4
}
+8
View File
@@ -5,16 +5,24 @@ Handles automatic discovery and registration of all ComfyAssets tools
from .tools.resolution_calculator import ResolutionCalculatorNode from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.width_height_selector import WidthHeightSelectorNode from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.seed_history import SeedHistoryNode
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
# ComfyUI node registration mappings # ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"ResolutionCalculator": ResolutionCalculatorNode, "ResolutionCalculator": ResolutionCalculatorNode,
"WidthHeightSelector": WidthHeightSelectorNode, "WidthHeightSelector": WidthHeightSelectorNode,
"SeedHistory": SeedHistoryNode,
"SamplerCombo": SamplerComboNode,
"SamplerComboCompact": SamplerComboCompactNode,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"ResolutionCalculator": "Resolution Calculator", "ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector", "WidthHeightSelector": "Width Height Selector",
"SeedHistory": "Seed History",
"SamplerCombo": "Sampler Combo",
"SamplerComboCompact": "Sampler Combo (Compact)",
} }
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
@@ -4,7 +4,7 @@ Pure functions for dimension extraction and scaling calculations
""" """
import torch import torch
from typing import Tuple, Optional, Union, Dict, Any from typing import Tuple, Optional, Dict
def extract_dimensions( def extract_dimensions(
@@ -16,7 +16,8 @@ def extract_dimensions(
Args: Args:
image: Optional IMAGE tensor in ComfyUI format [batch, height, width, channels] image: Optional IMAGE tensor in ComfyUI format [batch, height, width, channels]
latent: Optional LATENT dict with 'samples' tensor [batch, channels, height/8, width/8] latent: Optional LATENT dict with 'samples' tensor
[batch, channels, height/8, width/8]
Returns: Returns:
Tuple of (width, height) as integers Tuple of (width, height) as integers
@@ -42,7 +43,8 @@ def extract_dimensions(
samples = latent["samples"] samples = latent["samples"]
if len(samples.shape) != 4: if len(samples.shape) != 4:
raise ValueError( raise ValueError(
f"Expected LATENT samples tensor with 4 dimensions, got {len(samples.shape)}" f"Expected LATENT samples tensor with 4 dimensions, "
f"got {len(samples.shape)}"
) )
_, _, latent_height, latent_width = samples.shape _, _, latent_height, latent_width = samples.shape
+10 -5
View File
@@ -42,7 +42,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
"max": 8.0, "max": 8.0,
"step": 0.1, "step": 0.1,
"display": "slider", "display": "slider",
"tooltip": "Factor to scale the resolution by (e.g., 2.0 for 2x upscale)", "tooltip": "Factor to scale the resolution by "
"(e.g., 2.0 for 2x upscale)",
}, },
), ),
}, },
@@ -93,7 +94,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
else "LATENT" if latent is not None else "NONE" else "LATENT" if latent is not None else "NONE"
) )
self.log_info( self.log_info(
f"Calculating resolution with scale_factor={scale_factor}, input_type={input_type}" f"Calculating resolution with scale_factor={scale_factor}, "
f"input_type={input_type}"
) )
# Calculate the resolution # Calculate the resolution
@@ -147,7 +149,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
if len(image.shape) != 4: if len(image.shape) != 4:
raise ValueError( raise ValueError(
f"image tensor must have 4 dimensions [batch, height, width, channels], got {len(image.shape)}" f"image tensor must have 4 dimensions "
f"[batch, height, width, channels], got {len(image.shape)}"
) )
if latent is not None: if latent is not None:
@@ -160,12 +163,14 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
samples = latent["samples"] samples = latent["samples"]
if not isinstance(samples, torch.Tensor): if not isinstance(samples, torch.Tensor):
raise ValueError( raise ValueError(
f"latent['samples'] must be a torch.Tensor, got {type(samples).__name__}" f"latent['samples'] must be a torch.Tensor, "
f"got {type(samples).__name__}"
) )
if len(samples.shape) != 4: if len(samples.shape) != 4:
raise ValueError( raise ValueError(
f"latent samples tensor must have 4 dimensions [batch, channels, height, width], got {len(samples.shape)}" f"latent samples tensor must have 4 dimensions "
f"[batch, channels, height, width], got {len(samples.shape)}"
) )
@@ -0,0 +1,6 @@
"""Sampler Combo tool for ComfyUI."""
from .node import SamplerComboNode
from .compact_node import SamplerComboCompactNode
__all__ = ["SamplerComboNode", "SamplerComboCompactNode"]
@@ -0,0 +1,99 @@
"""Compact Sampler Combo node for ComfyUI with minimal interface."""
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
get_sampler_combo,
SAMPLERS,
SCHEDULERS,
)
class SamplerComboCompactNode(ComfyAssetsBaseNode):
"""
Compact Sampler Combo node with minimal interface.
Provides essential sampling parameters in a space-efficient layout
with shorter parameter names and reduced visual footprint.
"""
@classmethod
def INPUT_TYPES(cls):
"""Define compact input types for the ComfyUI node."""
return {
"required": {
"sampler": (
SAMPLERS,
{
"default": "euler",
"tooltip": "Sampler",
},
),
"sched": (
SCHEDULERS,
{
"default": "normal",
"tooltip": "Scheduler",
},
),
"steps": (
"INT",
{
"default": 20,
"min": 1,
"max": 50,
"step": 1,
"tooltip": "Steps",
},
),
"cfg": (
"FLOAT",
{
"default": 7.0,
"min": 1.0,
"max": 15.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG",
},
),
}
}
RETURN_TYPES = (SAMPLERS, 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]:
"""
Get compact sampler combo configuration.
Args:
sampler: The sampler algorithm name
sched: The scheduler algorithm name
steps: Number of sampling steps
cfg: CFG scale value
Returns:
Tuple of (sampler, scheduler, steps, cfg)
"""
try:
# Use the same validation logic but with compact interface
result = get_sampler_combo(sampler, sched, steps, cfg)
return result
except Exception as e:
# Graceful fallback
self.handle_error(f"Error in compact combo: {str(e)}")
return ("euler", "normal", 20, 7.0)
def __str__(self) -> str:
"""String representation of the compact node."""
return "SamplerComboCompactNode"
def __repr__(self) -> str:
"""Detailed string representation of the compact node."""
return f"SamplerComboCompactNode(category='{self.CATEGORY}')"
+220
View File
@@ -0,0 +1,220 @@
"""Logic module for Sampler Combo node."""
from typing import Tuple, Dict, Any, List
import logging
logger = logging.getLogger(__name__)
# Import ComfyUI samplers - will be available when running in ComfyUI
try:
import comfy.samplers
SAMPLERS = comfy.samplers.KSampler.SAMPLERS
SCHEDULERS = comfy.samplers.KSampler.SCHEDULERS
except ImportError:
# Fallback for testing/development environment
SAMPLERS = [
"euler",
"euler_ancestral",
"heun",
"dpm_2",
"dpm_2_ancestral",
"lms",
"dpm_fast",
"dpm_adaptive",
"dpmpp_2s_ancestral",
"dpmpp_sde",
"dpmpp_2m",
"ddim",
"uni_pc",
"uni_pc_bh2",
]
SCHEDULERS = [
"normal",
"karras",
"exponential",
"sgm_uniform",
"simple",
"ddim_uniform",
"beta",
]
def validate_sampler_settings(
sampler_name: str, scheduler: str, steps: int, cfg: float
) -> bool:
"""
Validate sampler configuration settings.
Args:
sampler_name: The sampler algorithm name
scheduler: The scheduler algorithm name
steps: Number of sampling steps
cfg: CFG (classifier-free guidance) scale value
Returns:
True if all settings are valid
"""
try:
# Validate sampler
if sampler_name not in SAMPLERS:
logger.error(f"Invalid sampler: {sampler_name}")
return False
# Validate scheduler
if scheduler not in SCHEDULERS:
logger.error(f"Invalid scheduler: {scheduler}")
return False
# Validate steps
if not isinstance(steps, int) or steps < 1 or steps > 1000:
logger.error(f"Invalid steps: {steps} (must be 1-1000)")
return False
# Validate CFG
if not isinstance(cfg, (int, float)) or cfg < 0 or cfg > 30:
logger.error(f"Invalid CFG: {cfg} (must be 0-30)")
return False
return True
except Exception as e:
logger.error(f"Error validating sampler settings: {e}")
return False
def get_sampler_combo(
sampler_name: str, scheduler: str, steps: int, cfg: float
) -> Tuple[str, str, int, float]:
"""
Process and return sampler combo settings.
Args:
sampler_name: The sampler algorithm name
scheduler: The scheduler algorithm name
steps: Number of sampling steps
cfg: CFG scale value
Returns:
Tuple of (sampler_name, scheduler, steps, cfg)
"""
try:
# Validate inputs
if not validate_sampler_settings(sampler_name, scheduler, steps, cfg):
# Return safe defaults if validation fails
logger.warning("Invalid settings provided, using safe defaults")
return ("euler", "normal", 20, 7.0)
# Sanitize values
steps = max(1, min(1000, int(steps)))
cfg = max(0.0, min(30.0, float(cfg)))
return (sampler_name, scheduler, steps, cfg)
except Exception as e:
logger.error(f"Error processing sampler combo: {e}")
# Return safe defaults on any error
return ("euler", "normal", 20, 7.0)
def get_compatible_scheduler_suggestions(sampler_name: str) -> List[str]:
"""
Get scheduler suggestions that work well with specific samplers.
Args:
sampler_name: The sampler algorithm name
Returns:
List of recommended scheduler names
"""
# Scheduler compatibility recommendations
compatibility_map = {
"euler": ["normal", "simple", "sgm_uniform"],
"euler_ancestral": ["normal", "karras", "exponential"],
"heun": ["normal", "karras"],
"dpm_2": ["normal", "karras"],
"dpm_2_ancestral": ["normal", "karras", "exponential"],
"dpmpp_2s_ancestral": ["normal", "karras", "exponential"],
"dpmpp_sde": ["normal", "karras", "exponential"],
"dpmpp_2m": ["normal", "karras", "sgm_uniform"],
"ddim": ["ddim_uniform", "normal"],
"uni_pc": ["normal", "sgm_uniform"],
"uni_pc_bh2": ["normal", "sgm_uniform"],
}
return compatibility_map.get(sampler_name, ["normal", "karras"])
def get_recommended_steps_range(sampler_name: str) -> Tuple[int, int, int]:
"""
Get recommended steps range for specific samplers.
Args:
sampler_name: The sampler algorithm name
Returns:
Tuple of (min_steps, max_steps, default_steps)
"""
# Steps recommendations by sampler
steps_map = {
"euler": (10, 30, 20),
"euler_ancestral": (15, 40, 25),
"heun": (10, 25, 15),
"dpm_2": (10, 30, 22),
"dpm_2_ancestral": (15, 35, 25),
"dpmpp_2s_ancestral": (15, 40, 28),
"dpmpp_sde": (15, 35, 25),
"dpmpp_2m": (15, 30, 20),
"ddim": (20, 50, 30),
"uni_pc": (10, 25, 15),
"uni_pc_bh2": (10, 25, 15),
}
return steps_map.get(sampler_name, (10, 50, 20))
def get_recommended_cfg_range(sampler_name: str) -> Tuple[float, float, float]:
"""
Get recommended CFG range for specific samplers.
Args:
sampler_name: The sampler algorithm name
Returns:
Tuple of (min_cfg, max_cfg, default_cfg)
"""
# CFG recommendations by sampler
cfg_map = {
"euler": (3.0, 15.0, 7.0),
"euler_ancestral": (5.0, 20.0, 8.0),
"heun": (3.0, 12.0, 6.0),
"dpm_2": (4.0, 15.0, 7.5),
"dpm_2_ancestral": (5.0, 18.0, 8.5),
"dpmpp_2s_ancestral": (6.0, 20.0, 9.0),
"dpmpp_sde": (5.0, 18.0, 8.0),
"dpmpp_2m": (4.0, 15.0, 7.0),
"ddim": (3.0, 12.0, 6.0),
"uni_pc": (3.0, 12.0, 6.5),
"uni_pc_bh2": (3.0, 12.0, 6.5),
}
return cfg_map.get(sampler_name, (1.0, 20.0, 7.0))
def get_sampler_info() -> Dict[str, Any]:
"""
Get information about available samplers and schedulers.
Returns:
Dictionary containing sampler/scheduler information
"""
return {
"samplers": SAMPLERS,
"schedulers": SCHEDULERS,
"sampler_count": len(SAMPLERS),
"scheduler_count": len(SCHEDULERS),
"default_sampler": "euler",
"default_scheduler": "normal",
"default_steps": 20,
"default_cfg": 7.0,
}
+260
View File
@@ -0,0 +1,260 @@
"""Sampler Combo node for ComfyUI."""
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
get_sampler_combo,
validate_sampler_settings,
get_compatible_scheduler_suggestions,
get_recommended_steps_range,
get_recommended_cfg_range,
SAMPLERS,
SCHEDULERS,
)
class SamplerComboNode(ComfyAssetsBaseNode):
"""
Sampler Combo node for selecting sampling configuration.
Provides a unified interface for selecting sampler, scheduler, steps,
and CFG settings in a single node, reducing workflow complexity and
ensuring compatible parameter combinations.
"""
@classmethod
def INPUT_TYPES(cls):
"""Define the input types for the ComfyUI node."""
return {
"required": {
"sampler_name": (
SAMPLERS,
{
"default": "euler",
"tooltip": "Sampling algorithm",
},
),
"scheduler": (
SCHEDULERS,
{
"default": "normal",
"tooltip": "Step distribution schedule",
},
),
"steps": (
"INT",
{
"default": 20,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "Sampling steps (1-100)",
},
),
"cfg": (
"FLOAT",
{
"default": 7.0,
"min": 0.0,
"max": 20.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG scale (0-20)",
},
),
}
}
RETURN_TYPES = (SAMPLERS, 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]:
"""
Get sampler combo configuration.
Args:
sampler_name: The sampler algorithm name
scheduler: The scheduler algorithm name
steps: Number of sampling steps
cfg: CFG scale value
Returns:
Tuple of (sampler_name, scheduler, steps, cfg)
"""
try:
# Validate inputs
self.validate_inputs(
sampler_name=sampler_name,
scheduler=scheduler,
steps=steps,
cfg=cfg,
)
# Process and return the combo
result = get_sampler_combo(sampler_name, scheduler, steps, cfg)
self.log_info(
f"Configured sampler combo: {result[0]}, {result[1]}, "
f"{result[2]} steps, CFG {result[3]}"
)
return result
except Exception as e:
# Handle any unexpected errors gracefully
error_msg = (
f"Error processing sampler combo: {str(e)}. "
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
)
self.handle_error(error_msg)
return ("euler", "normal", 20, 7.0)
def validate_inputs(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
) -> None:
"""
Validate sampler combo inputs.
Args:
sampler_name: The sampler algorithm name
scheduler: The scheduler algorithm name
steps: Number of sampling steps
cfg: CFG scale value
Raises:
ValueError: If validation fails
"""
if not validate_sampler_settings(sampler_name, scheduler, steps, cfg):
self.handle_error(
f"Invalid sampler settings: sampler={sampler_name}, "
f"scheduler={scheduler}, steps={steps}, cfg={cfg}"
)
def get_scheduler_suggestions(self, sampler_name: str) -> list:
"""
Get scheduler suggestions compatible with the selected sampler.
Args:
sampler_name: The sampler algorithm name
Returns:
List of recommended scheduler names
"""
return get_compatible_scheduler_suggestions(sampler_name)
def get_steps_recommendation(self, sampler_name: str) -> dict:
"""
Get steps recommendation for the selected sampler.
Args:
sampler_name: The sampler algorithm name
Returns:
Dictionary with min, max, and default steps
"""
min_steps, max_steps, default_steps = get_recommended_steps_range(sampler_name)
return {
"min": min_steps,
"max": max_steps,
"default": default_steps,
"recommendation": f"Range: {min_steps}-{max_steps} steps",
}
def get_cfg_recommendation(self, sampler_name: str) -> dict:
"""
Get CFG recommendation for the selected sampler.
Args:
sampler_name: The sampler algorithm name
Returns:
Dictionary with min, max, and default CFG values
"""
min_cfg, max_cfg, default_cfg = get_recommended_cfg_range(sampler_name)
return {
"min": min_cfg,
"max": max_cfg,
"default": default_cfg,
"recommendation": f"Recommended range: {min_cfg}-{max_cfg} CFG",
}
def get_combo_analysis(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
) -> dict:
"""
Analyze the sampler combo configuration and provide recommendations.
Args:
sampler_name: The sampler algorithm name
scheduler: The scheduler algorithm name
steps: Number of sampling steps
cfg: CFG scale value
Returns:
Dictionary containing analysis and recommendations
"""
analysis = {
"sampler": sampler_name,
"scheduler": scheduler,
"steps": steps,
"cfg": cfg,
"valid": validate_sampler_settings(sampler_name, scheduler, steps, cfg),
"scheduler_suggestions": self.get_scheduler_suggestions(sampler_name),
"steps_rec": self.get_steps_recommendation(sampler_name),
"cfg_rec": self.get_cfg_recommendation(sampler_name),
}
# Add compatibility assessment
suggested_schedulers = self.get_scheduler_suggestions(sampler_name)
analysis["scheduler_compatible"] = scheduler in suggested_schedulers
# Add performance assessment
steps_rec = self.get_steps_recommendation(sampler_name)
analysis["steps_optimal"] = steps_rec["min"] <= steps <= steps_rec["max"]
cfg_rec = self.get_cfg_recommendation(sampler_name)
analysis["cfg_optimal"] = cfg_rec["min"] <= cfg <= cfg_rec["max"]
return analysis
@classmethod
def get_available_samplers(cls) -> list:
"""
Get list of available samplers.
Returns:
List of sampler names
"""
return list(SAMPLERS)
@classmethod
def get_available_schedulers(cls) -> list:
"""
Get list of available schedulers.
Returns:
List of scheduler names
"""
return list(SCHEDULERS)
def __str__(self) -> str:
"""String representation of the node."""
return (
f"SamplerComboNode(samplers={len(SAMPLERS)}, "
f"schedulers={len(SCHEDULERS)})"
)
def __repr__(self) -> str:
"""Detailed string representation of the node."""
return (
f"SamplerComboNode("
f"samplers={len(SAMPLERS)}, "
f"schedulers={len(SCHEDULERS)}, "
f"category='{self.CATEGORY}', "
f"function='{self.FUNCTION}'"
f")"
)
+10
View File
@@ -0,0 +1,10 @@
"""
Seed History tool for ComfyUI-KikoTools.
Provides seed value tracking with history management,
automatic deduplication, and interactive UI.
"""
from .node import SeedHistoryNode
__all__ = ["SeedHistoryNode"]
+295
View File
@@ -0,0 +1,295 @@
"""Core logic for Seed History tool."""
import random
import time
from typing import List, Dict, Any, Tuple, Optional
def generate_random_seed() -> int:
"""
Generate a cryptographically strong random seed value.
Returns:
Random integer in the valid ComfyUI seed range
"""
return random.randint(0, 0xFFFFFFFFFFFFFFFF)
def validate_seed_value(seed: Any) -> bool:
"""
Validate that a seed value is within acceptable range.
Args:
seed: Seed value to validate
Returns:
True if seed is valid, False otherwise
"""
if seed is None:
return False
try:
seed_int = int(seed)
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF
except (ValueError, TypeError):
return False
def sanitize_seed_value(seed: Any) -> int:
"""
Sanitize and convert seed value to valid integer.
Args:
seed: Raw seed value
Returns:
Valid seed integer
Raises:
ValueError: If seed cannot be converted to valid range
"""
if seed is None:
raise ValueError("Seed cannot be None")
try:
seed_int = int(seed)
# Clamp to valid range
if seed_int < 0:
seed_int = 0
elif seed_int > 0xFFFFFFFFFFFFFFFF:
seed_int = 0xFFFFFFFFFFFFFFFF
return seed_int
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid seed value: {seed}") from e
def create_history_entry(
seed: int, timestamp: Optional[float] = None
) -> Dict[str, Any]:
"""
Create a standardized history entry for a seed.
Args:
seed: Seed value
timestamp: Optional timestamp (uses current time if None)
Returns:
Dictionary containing seed history entry
"""
if timestamp is None:
timestamp = time.time()
return {
"seed": seed,
"timestamp": timestamp,
"dateString": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)),
}
def filter_duplicate_seeds(
history: List[Dict[str, Any]], new_seed: int, dedup_window_ms: int = 500
) -> bool:
"""
Check if a seed should be filtered as a duplicate.
Args:
history: Current seed history
new_seed: New seed to check
dedup_window_ms: Deduplication window in milliseconds
Returns:
True if seed should be filtered (is duplicate), False otherwise
"""
if not history:
return False
current_time = time.time() * 1000 # Convert to milliseconds
# Check most recent entry for duplicates within window
latest_entry = history[0]
latest_timestamp_ms = latest_entry["timestamp"] * 1000
time_diff = current_time - latest_timestamp_ms
is_same_seed = latest_entry["seed"] == new_seed
is_within_window = time_diff < dedup_window_ms
return is_same_seed and is_within_window
def add_seed_to_history(
history: List[Dict[str, Any]],
seed: int,
max_history: int = 10,
dedup_window_ms: int = 500,
) -> Tuple[List[Dict[str, Any]], bool]:
"""
Add a seed to the history with deduplication and size management.
Args:
history: Current seed history
seed: Seed to add
max_history: Maximum number of entries to keep
dedup_window_ms: Deduplication window in milliseconds
Returns:
Tuple of (updated_history, was_added)
"""
# Validate seed
if not validate_seed_value(seed):
return history, False
# Sanitize seed
try:
clean_seed = sanitize_seed_value(seed)
except ValueError:
return history, False
# Check for duplicates
if filter_duplicate_seeds(history, clean_seed, dedup_window_ms):
return history, False
# Create new history list (don't modify original)
new_history = [entry for entry in history if entry["seed"] != clean_seed]
# Add new entry at the beginning
new_entry = create_history_entry(clean_seed)
new_history.insert(0, new_entry)
# Trim to max size
if len(new_history) > max_history:
new_history = new_history[:max_history]
return new_history, True
def format_time_ago(timestamp: float) -> str:
"""
Format a timestamp as a human-readable time ago string.
Args:
timestamp: Unix timestamp
Returns:
Formatted time ago string
"""
now = time.time()
diff = now - timestamp
days = int(diff // 86400)
hours = int((diff % 86400) // 3600)
minutes = int((diff % 3600) // 60)
seconds = int(diff % 60)
if days > 0:
return f"{days}d ago"
elif hours > 0:
return f"{hours}h ago"
elif minutes > 0:
return f"{minutes}m ago"
else:
return f"{seconds}s ago"
def search_history_by_seed(
history: List[Dict[str, Any]], seed: int
) -> Optional[Dict[str, Any]]:
"""
Search history for a specific seed value.
Args:
history: Seed history to search
seed: Seed value to find
Returns:
History entry if found, None otherwise
"""
for entry in history:
if entry["seed"] == seed:
return entry
return None
def get_history_statistics(history: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Calculate statistics about the seed history.
Args:
history: Seed history
Returns:
Dictionary containing history statistics
"""
if not history:
return {
"total_seeds": 0,
"oldest_timestamp": None,
"newest_timestamp": None,
"time_span_hours": 0,
"unique_seeds": 0,
}
timestamps = [entry["timestamp"] for entry in history]
oldest = min(timestamps)
newest = max(timestamps)
time_span = (newest - oldest) / 3600 # Convert to hours
unique_seeds = len(set(entry["seed"] for entry in history))
return {
"total_seeds": len(history),
"oldest_timestamp": oldest,
"newest_timestamp": newest,
"time_span_hours": time_span,
"unique_seeds": unique_seeds,
}
def export_history_to_text(history: List[Dict[str, Any]]) -> str:
"""
Export seed history to a formatted text string.
Args:
history: Seed history to export
Returns:
Formatted text representation
"""
if not history:
return "# Seed History (Empty)\n\nNo seeds tracked yet."
lines = ["# ComfyUI Seed History", ""]
lines.append(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"Total seeds: {len(history)}")
lines.append("")
for i, entry in enumerate(history, 1):
time_ago = format_time_ago(entry["timestamp"])
lines.append(f"{i:2d}. {entry['seed']} ({time_ago})")
return "\n".join(lines)
def import_seeds_from_list(seed_list: List[int]) -> List[Dict[str, Any]]:
"""
Import a list of seeds as history entries.
Args:
seed_list: List of seed integers
Returns:
List of history entries
"""
history = []
current_time = time.time()
for i, seed in enumerate(seed_list):
if validate_seed_value(seed):
# Spread timestamps by 1 minute intervals (newest first)
timestamp = current_time - (i * 60)
entry = create_history_entry(seed, timestamp)
history.append(entry)
return history
+173
View File
@@ -0,0 +1,173 @@
"""Seed History node for ComfyUI."""
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
generate_random_seed,
validate_seed_value,
sanitize_seed_value,
)
class SeedHistoryNode(ComfyAssetsBaseNode):
"""
Seed History node for tracking and managing seed values.
Provides seed value output with integrated history tracking,
deduplication, and interactive UI for seed management.
"""
@classmethod
def INPUT_TYPES(cls):
"""Define the input types for the ComfyUI node."""
return {
"required": {
"seed": (
"INT",
{
"default": 12345,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "Seed value for generation processes. "
"History UI tracks all changes automatically.",
},
),
}
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "output_seed"
CATEGORY = "ComfyAssets"
def output_seed(self, seed: int) -> Tuple[int]:
"""
Output the seed value for use in other nodes.
Args:
seed: Input seed value
Returns:
Tuple containing the seed value
"""
try:
# Validate and sanitize the seed
if not validate_seed_value(seed):
self.handle_error(
f"Invalid seed value: {seed}. Using fallback seed 12345."
)
return (12345,)
clean_seed = sanitize_seed_value(seed)
return (clean_seed,)
except Exception as e:
# Handle any unexpected errors gracefully
error_msg = f"Error processing seed: {str(e)}. Using fallback seed 12345."
self.handle_error(error_msg)
return (12345,)
def generate_new_seed(self) -> int:
"""
Generate a new random seed value.
Returns:
New random seed integer
"""
try:
new_seed = generate_random_seed()
self.log_info(f"Generated new seed: {new_seed}")
return new_seed
except Exception as e:
error_msg = f"Error generating seed: {str(e)}. Using fallback."
self.handle_error(error_msg)
return 12345
def validate_seed_input(self, seed: int) -> bool:
"""
Validate seed input value.
Args:
seed: Seed value to validate
Returns:
True if seed is valid
"""
return validate_seed_value(seed)
def get_seed_info(self, seed: int) -> str:
"""
Get descriptive information about a seed value.
Args:
seed: Seed value
Returns:
Information string about the seed
"""
if not validate_seed_value(seed):
return f"Invalid seed: {seed} (outside valid range)"
# Convert to hex for additional info
hex_value = hex(seed)
# Check if it's a "nice" number (power of 2, round number, etc.)
seed_type = "standard"
if seed == 0:
seed_type = "zero"
elif seed & (seed - 1) == 0: # Power of 2
seed_type = "power of 2"
elif str(seed).count("0") > len(str(seed)) // 2:
seed_type = "round number"
elif seed == 12345:
seed_type = "default"
return f"Seed {seed} ({hex_value}) - {seed_type}"
def get_seed_range_info(self) -> str:
"""
Get information about the valid seed range.
Returns:
Range information string
"""
max_seed = 0xFFFFFFFFFFFFFFFF
return f"Valid range: 0 to {max_seed:,} ({hex(max_seed)})"
@classmethod
def get_default_seed(cls) -> int:
"""
Get the default seed value.
Returns:
Default seed integer
"""
return 12345
@classmethod
def is_seed_in_range(cls, seed: int) -> bool:
"""
Check if seed is within valid ComfyUI range.
Args:
seed: Seed value to check
Returns:
True if seed is in valid range
"""
return 0 <= seed <= 0xFFFFFFFFFFFFFFFF
def __str__(self) -> str:
"""String representation of the node."""
return "SeedHistoryNode(with_ui_tracking)"
def __repr__(self) -> str:
"""Detailed string representation of the node."""
return (
f"SeedHistoryNode("
f"category='{self.CATEGORY}', "
f"function='{self.FUNCTION}', "
f"max_seed={hex(0xFFFFFFFFFFFFFFFF)}"
f")"
)
@@ -35,9 +35,10 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
preset_keys, preset_keys,
{ {
"default": "custom", "default": "custom",
"tooltip": "Select from optimized resolution presets or use custom dimensions. " "tooltip": "Select from optimized resolution presets or use "
"SDXL presets are ~1MP, FLUX presets are higher resolution, " "custom dimensions. SDXL presets are ~1MP, FLUX presets are "
"Ultra-wide presets support modern aspect ratios.", "higher resolution, Ultra-wide presets support modern "
"aspect ratios.",
}, },
), ),
"width": ( "width": (
@@ -48,7 +49,8 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
"max": 8192, "max": 8192,
"step": 8, "step": 8,
"tooltip": "Custom width in pixels (must be multiple of 8). " "tooltip": "Custom width in pixels (must be multiple of 8). "
"Used when preset is 'custom' or as fallback for invalid presets.", "Used when preset is 'custom' or as fallback for invalid "
"presets.",
}, },
), ),
"height": ( "height": (
@@ -59,7 +61,8 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
"max": 8192, "max": 8192,
"step": 8, "step": 8,
"tooltip": "Custom height in pixels (must be multiple of 8). " "tooltip": "Custom height in pixels (must be multiple of 8). "
"Used when preset is 'custom' or as fallback for invalid presets.", "Used when preset is 'custom' or as fallback for invalid "
"presets.",
}, },
), ),
} }
-2
View File
@@ -5,8 +5,6 @@ Provides mock ComfyUI environments and test data
import pytest import pytest
import torch import torch
import numpy as np
from typing import Dict, Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
+1 -2
View File
@@ -4,8 +4,7 @@ Tests the shared functionality for all ComfyAssets tools
""" """
import pytest import pytest
import logging from unittest.mock import patch
from unittest.mock import patch, MagicMock
from kikotools.base import ComfyAssetsBaseNode from kikotools.base import ComfyAssetsBaseNode
@@ -5,7 +5,6 @@ Following TDD principles - these tests define the expected behavior
import pytest import pytest
import torch import torch
from unittest.mock import patch, MagicMock
# Import the modules we're going to test (they don't exist yet - TDD!) # Import the modules we're going to test (they don't exist yet - TDD!)
from kikotools.tools.resolution_calculator.logic import ( from kikotools.tools.resolution_calculator.logic import (
@@ -172,8 +171,6 @@ class TestResolutionCalculatorNode:
def test_node_has_correct_comfyui_attributes(self): def test_node_has_correct_comfyui_attributes(self):
"""Test node has all required ComfyUI attributes""" """Test node has all required ComfyUI attributes"""
node = ResolutionCalculatorNode()
# Check class attributes exist # Check class attributes exist
assert hasattr(ResolutionCalculatorNode, "INPUT_TYPES") assert hasattr(ResolutionCalculatorNode, "INPUT_TYPES")
assert hasattr(ResolutionCalculatorNode, "RETURN_TYPES") assert hasattr(ResolutionCalculatorNode, "RETURN_TYPES")
+352
View File
@@ -0,0 +1,352 @@
"""Tests for Sampler Combo node."""
import pytest
from unittest.mock import patch
from kikotools.tools.sampler_combo.node import SamplerComboNode
from kikotools.tools.sampler_combo.logic import (
validate_sampler_settings,
get_sampler_combo,
get_compatible_scheduler_suggestions,
get_recommended_steps_range,
get_recommended_cfg_range,
get_sampler_info,
SAMPLERS,
SCHEDULERS,
)
class TestSamplerComboLogic:
"""Test cases for sampler combo logic functions."""
def test_validate_sampler_settings_valid(self):
"""Test validation with valid settings."""
assert validate_sampler_settings("euler", "normal", 20, 7.0) is True
assert validate_sampler_settings("dpmpp_2m", "karras", 15, 8.5) is True
assert validate_sampler_settings("ddim", "ddim_uniform", 30, 6.0) is True
def test_validate_sampler_settings_invalid_sampler(self):
"""Test validation with invalid sampler."""
assert validate_sampler_settings("invalid_sampler", "normal", 20, 7.0) is False
def test_validate_sampler_settings_invalid_scheduler(self):
"""Test validation with invalid scheduler."""
assert validate_sampler_settings("euler", "invalid_scheduler", 20, 7.0) is False
def test_validate_sampler_settings_invalid_steps(self):
"""Test validation with invalid steps."""
assert validate_sampler_settings("euler", "normal", 0, 7.0) is False
assert validate_sampler_settings("euler", "normal", 1001, 7.0) is False
assert validate_sampler_settings("euler", "normal", -5, 7.0) is False
def test_validate_sampler_settings_invalid_cfg(self):
"""Test validation with invalid CFG."""
assert validate_sampler_settings("euler", "normal", 20, -1.0) is False
assert validate_sampler_settings("euler", "normal", 20, 31.0) is False
def test_get_sampler_combo_valid(self):
"""Test getting sampler combo with valid inputs."""
result = get_sampler_combo("euler", "normal", 20, 7.0)
assert result == ("euler", "normal", 20, 7.0)
result = get_sampler_combo("dpmpp_2m", "karras", 25, 8.5)
assert result == ("dpmpp_2m", "karras", 25, 8.5)
def test_get_sampler_combo_invalid_returns_defaults(self):
"""Test that invalid inputs return safe defaults."""
result = get_sampler_combo("invalid", "normal", 20, 7.0)
assert result == ("euler", "normal", 20, 7.0)
result = get_sampler_combo("euler", "invalid", 20, 7.0)
assert result == ("euler", "normal", 20, 7.0)
def test_get_sampler_combo_sanitizes_values(self):
"""Test that values are sanitized to valid ranges."""
# Test steps clamping
result = get_sampler_combo("euler", "normal", 0, 7.0)
assert result[2] >= 1 # steps should be at least 1
result = get_sampler_combo("euler", "normal", 1500, 7.0)
assert result[2] <= 1000 # steps should be at most 1000
# Test CFG clamping
result = get_sampler_combo("euler", "normal", 20, -5.0)
assert result[3] >= 0.0 # CFG should be at least 0
result = get_sampler_combo("euler", "normal", 20, 50.0)
assert result[3] <= 30.0 # CFG should be at most 30
def test_get_compatible_scheduler_suggestions(self):
"""Test getting scheduler suggestions for different samplers."""
suggestions = get_compatible_scheduler_suggestions("euler")
assert isinstance(suggestions, list)
assert len(suggestions) > 0
assert "normal" in suggestions
suggestions = get_compatible_scheduler_suggestions("ddim")
assert "ddim_uniform" in suggestions
# Test unknown sampler returns defaults
suggestions = get_compatible_scheduler_suggestions("unknown_sampler")
assert "normal" in suggestions
assert "karras" in suggestions
def test_get_recommended_steps_range(self):
"""Test getting recommended steps range for samplers."""
min_steps, max_steps, default_steps = get_recommended_steps_range("euler")
assert isinstance(min_steps, int)
assert isinstance(max_steps, int)
assert isinstance(default_steps, int)
assert min_steps <= default_steps <= max_steps
assert min_steps > 0
# Test unknown sampler returns defaults
min_steps, max_steps, default_steps = get_recommended_steps_range("unknown")
assert min_steps == 10
assert max_steps == 50
assert default_steps == 20
def test_get_recommended_cfg_range(self):
"""Test getting recommended CFG range for samplers."""
min_cfg, max_cfg, default_cfg = get_recommended_cfg_range("euler")
assert isinstance(min_cfg, float)
assert isinstance(max_cfg, float)
assert isinstance(default_cfg, float)
assert min_cfg <= default_cfg <= max_cfg
assert min_cfg >= 0.0
# Test unknown sampler returns defaults
min_cfg, max_cfg, default_cfg = get_recommended_cfg_range("unknown")
assert min_cfg == 1.0
assert max_cfg == 20.0
assert default_cfg == 7.0
def test_get_sampler_info(self):
"""Test getting sampler information."""
info = get_sampler_info()
assert isinstance(info, dict)
assert "samplers" in info
assert "schedulers" in info
assert "sampler_count" in info
assert "scheduler_count" in info
assert info["sampler_count"] == len(SAMPLERS)
assert info["scheduler_count"] == len(SCHEDULERS)
class TestSamplerComboNode:
"""Test cases for SamplerComboNode."""
def setup_method(self):
"""Set up test fixtures."""
self.node = SamplerComboNode()
def test_input_types_structure(self):
"""Test that INPUT_TYPES returns correct structure."""
input_types = SamplerComboNode.INPUT_TYPES()
assert "required" in input_types
required = input_types["required"]
# Check all required inputs are present
assert "sampler_name" in required
assert "scheduler" in required
assert "steps" in required
assert "cfg" in required
# Check sampler input structure
sampler_input = required["sampler_name"]
assert sampler_input[0] == SAMPLERS
assert isinstance(sampler_input[1], dict)
assert "default" in sampler_input[1]
assert "tooltip" in sampler_input[1]
# Check scheduler input structure
scheduler_input = required["scheduler"]
assert scheduler_input[0] == SCHEDULERS
assert isinstance(scheduler_input[1], dict)
# Check steps input structure
steps_input = required["steps"]
assert steps_input[0] == "INT"
assert steps_input[1]["min"] == 1
assert steps_input[1]["max"] == 1000
# 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
def test_return_types_structure(self):
"""Test that return types are correctly defined."""
assert SamplerComboNode.RETURN_TYPES == (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_NAMES == (
"sampler_name",
"scheduler",
"steps",
"cfg",
)
assert SamplerComboNode.FUNCTION == "get_sampler_combo"
assert SamplerComboNode.CATEGORY == "ComfyAssets"
def test_get_sampler_combo_valid_inputs(self):
"""Test get_sampler_combo with valid inputs."""
result = self.node.get_sampler_combo("euler", "normal", 20, 7.0)
assert result == ("euler", "normal", 20, 7.0)
result = self.node.get_sampler_combo("dpmpp_2m", "karras", 15, 8.5)
assert result == ("dpmpp_2m", "karras", 15, 8.5)
def test_get_sampler_combo_invalid_inputs_returns_defaults(self):
"""Test that invalid inputs return safe defaults."""
with patch.object(self.node, "handle_error") as mock_error:
mock_error.side_effect = ValueError("Invalid settings")
try:
result = self.node.get_sampler_combo("invalid", "normal", 20, 7.0)
except ValueError:
pass # Expected when handle_error raises
# Test with exception handling bypassed
with patch(
"kikotools.tools.sampler_combo.node.validate_sampler_settings",
return_value=False,
):
result = self.node.get_sampler_combo("invalid", "normal", 20, 7.0)
assert result == ("euler", "normal", 20, 7.0)
def test_validate_inputs_valid(self):
"""Test input validation with valid inputs."""
# Should not raise any exception
self.node.validate_inputs("euler", "normal", 20, 7.0)
def test_validate_inputs_invalid(self):
"""Test input validation with invalid inputs."""
with pytest.raises(ValueError):
self.node.validate_inputs("invalid", "normal", 20, 7.0)
def test_get_scheduler_suggestions(self):
"""Test getting scheduler suggestions."""
suggestions = self.node.get_scheduler_suggestions("euler")
assert isinstance(suggestions, list)
assert len(suggestions) > 0
suggestions = self.node.get_scheduler_suggestions("ddim")
assert "ddim_uniform" in suggestions
def test_get_steps_recommendation(self):
"""Test getting steps recommendations."""
rec = self.node.get_steps_recommendation("euler")
assert isinstance(rec, dict)
assert "min" in rec
assert "max" in rec
assert "default" in rec
assert "recommendation" in rec
def test_get_cfg_recommendation(self):
"""Test getting CFG recommendations."""
rec = self.node.get_cfg_recommendation("euler")
assert isinstance(rec, dict)
assert "min" in rec
assert "max" in rec
assert "default" in rec
assert "recommendation" in rec
def test_get_combo_analysis(self):
"""Test getting combo analysis."""
analysis = self.node.get_combo_analysis("euler", "normal", 20, 7.0)
assert isinstance(analysis, dict)
assert "sampler" in analysis
assert "scheduler" in analysis
assert "steps" in analysis
assert "cfg" in analysis
assert "valid" in analysis
assert "scheduler_suggestions" in analysis
assert "scheduler_compatible" in analysis
assert "steps_optimal" in analysis
assert "cfg_optimal" in analysis
def test_get_available_samplers(self):
"""Test getting available samplers."""
samplers = SamplerComboNode.get_available_samplers()
assert isinstance(samplers, list)
assert len(samplers) > 0
assert "euler" in samplers
def test_get_available_schedulers(self):
"""Test getting available schedulers."""
schedulers = SamplerComboNode.get_available_schedulers()
assert isinstance(schedulers, list)
assert len(schedulers) > 0
assert "normal" in schedulers
def test_string_representations(self):
"""Test string representations of the node."""
str_repr = str(self.node)
assert "SamplerComboNode" in str_repr
assert "samplers=" in str_repr
assert "schedulers=" in str_repr
repr_str = repr(self.node)
assert "SamplerComboNode" in repr_str
assert "category=" in repr_str
assert "function=" in repr_str
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, "validate_inputs")
assert hasattr(self.node, "handle_error")
assert hasattr(self.node, "log_info")
class TestSamplerComboIntegration:
"""Integration tests for Sampler Combo functionality."""
def test_full_workflow_valid_settings(self):
"""Test complete workflow with valid settings."""
node = SamplerComboNode()
# Test with different sampler/scheduler combinations
test_cases = [
("euler", "normal", 20, 7.0),
("dpmpp_2m", "karras", 15, 8.0),
("euler_ancestral", "exponential", 25, 9.0),
("ddim", "ddim_uniform", 30, 6.0),
]
for sampler, scheduler, steps, cfg in test_cases:
result = node.get_sampler_combo(sampler, scheduler, steps, cfg)
assert result == (sampler, scheduler, steps, cfg)
def test_recommendation_compatibility(self):
"""Test that recommendations are compatible with actual functionality."""
node = SamplerComboNode()
for sampler in SAMPLERS[:5]: # Test first 5 samplers
suggestions = node.get_scheduler_suggestions(sampler)
steps_rec = node.get_steps_recommendation(sampler)
cfg_rec = node.get_cfg_recommendation(sampler)
# Test that recommendations work with the node
for scheduler in suggestions[:2]: # Test first 2 suggestions
result = node.get_sampler_combo(
sampler, scheduler, steps_rec["default"], cfg_rec["default"]
)
assert result[0] == sampler
assert result[1] == scheduler
assert result[2] == steps_rec["default"]
assert result[3] == cfg_rec["default"]
def test_error_recovery(self):
"""Test error recovery with malformed inputs."""
node = SamplerComboNode()
# These should all return safe defaults due to error handling
with patch(
"kikotools.tools.sampler_combo.logic.validate_sampler_settings",
side_effect=Exception("Simulated error"),
):
result = node.get_sampler_combo("euler", "normal", 20, 7.0)
assert result == ("euler", "normal", 20, 7.0) # Safe defaults
+415
View File
@@ -0,0 +1,415 @@
"""Tests for Seed History tool."""
import time
from kikotools.tools.seed_history.node import SeedHistoryNode
from kikotools.tools.seed_history.logic import (
generate_random_seed,
validate_seed_value,
sanitize_seed_value,
create_history_entry,
filter_duplicate_seeds,
add_seed_to_history,
format_time_ago,
search_history_by_seed,
get_history_statistics,
export_history_to_text,
import_seeds_from_list,
)
class TestSeedHistoryNode:
"""Test SeedHistoryNode functionality."""
def test_node_structure(self):
"""Test that node has correct ComfyUI structure."""
# Test class attributes
assert hasattr(SeedHistoryNode, "INPUT_TYPES")
assert hasattr(SeedHistoryNode, "RETURN_TYPES")
assert hasattr(SeedHistoryNode, "RETURN_NAMES")
assert hasattr(SeedHistoryNode, "FUNCTION")
assert hasattr(SeedHistoryNode, "CATEGORY")
# Test input types structure
input_types = SeedHistoryNode.INPUT_TYPES()
assert "required" in input_types
assert "seed" in input_types["required"]
# Test seed input configuration
seed_config = input_types["required"]["seed"]
assert seed_config[0] == "INT"
assert isinstance(seed_config[1], dict)
assert "default" in seed_config[1]
assert "min" in seed_config[1]
assert "max" in seed_config[1]
assert seed_config[1]["min"] == 0
assert seed_config[1]["max"] == 0xFFFFFFFFFFFFFFFF
# Test return types
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
assert SeedHistoryNode.RETURN_NAMES == ("seed",)
assert SeedHistoryNode.FUNCTION == "output_seed"
assert SeedHistoryNode.CATEGORY == "ComfyAssets"
def test_output_seed_valid_input(self):
"""Test seed output with valid input."""
node = SeedHistoryNode()
# Test various valid seeds
test_seeds = [0, 12345, 999999, 0xFFFFFFFFFFFFFFFF]
for seed in test_seeds:
result = node.output_seed(seed)
assert isinstance(result, tuple)
assert len(result) == 1
assert result[0] == seed
def test_output_seed_invalid_input(self):
"""Test seed output with invalid input."""
node = SeedHistoryNode()
# Test invalid seeds (negative values)
result = node.output_seed(-1)
assert result == (12345,) # Fallback
# Test seeds too large
result = node.output_seed(0xFFFFFFFFFFFFFFFF + 1)
assert result == (12345,) # Fallback
def test_generate_new_seed(self):
"""Test random seed generation."""
node = SeedHistoryNode()
# Generate multiple seeds
seeds = []
for _ in range(10):
seed = node.generate_new_seed()
seeds.append(seed)
# Test all seeds are valid
for seed in seeds:
assert validate_seed_value(seed)
# Test seeds are different (probabilistically)
assert len(set(seeds)) > 5 # Should have some variety
def test_validate_seed_input(self):
"""Test seed validation."""
node = SeedHistoryNode()
# Valid seeds
assert node.validate_seed_input(0)
assert node.validate_seed_input(12345)
assert node.validate_seed_input(0xFFFFFFFFFFFFFFFF)
# Invalid seeds
assert not node.validate_seed_input(-1)
assert not node.validate_seed_input(0xFFFFFFFFFFFFFFFF + 1)
assert not node.validate_seed_input(None)
def test_get_seed_info(self):
"""Test seed information generation."""
node = SeedHistoryNode()
# Test various seed types
info_zero = node.get_seed_info(0)
assert "zero" in info_zero.lower()
info_default = node.get_seed_info(12345)
assert "default" in info_default.lower()
info_power_of_2 = node.get_seed_info(1024)
assert "power of 2" in info_power_of_2.lower()
# Test invalid seed
info_invalid = node.get_seed_info(-1)
assert "invalid" in info_invalid.lower()
def test_seed_range_info(self):
"""Test seed range information."""
node = SeedHistoryNode()
range_info = node.get_seed_range_info()
assert "Valid range" in range_info
assert str(0xFFFFFFFFFFFFFFFF) in range_info
def test_class_methods(self):
"""Test class methods."""
# Test default seed
default_seed = SeedHistoryNode.get_default_seed()
assert default_seed == 12345
# Test range checking
assert SeedHistoryNode.is_seed_in_range(0)
assert SeedHistoryNode.is_seed_in_range(12345)
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF)
assert not SeedHistoryNode.is_seed_in_range(-1)
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF + 1)
class TestSeedHistoryLogic:
"""Test seed history logic functions."""
def test_generate_random_seed(self):
"""Test random seed generation."""
# Generate multiple seeds
seeds = [generate_random_seed() for _ in range(100)]
# Test all seeds are valid
for seed in seeds:
assert validate_seed_value(seed)
# Test seeds have variety
assert len(set(seeds)) > 50 # Should have good variety
def test_validate_seed_value(self):
"""Test seed validation logic."""
# Valid seeds
assert validate_seed_value(0)
assert validate_seed_value(12345)
assert validate_seed_value(0xFFFFFFFFFFFFFFFF)
# Invalid seeds
assert not validate_seed_value(-1)
assert not validate_seed_value(0xFFFFFFFFFFFFFFFF + 1)
assert not validate_seed_value(None)
assert not validate_seed_value("invalid")
assert not validate_seed_value([])
def test_sanitize_seed_value(self):
"""Test seed sanitization."""
# Valid seeds should pass through
assert sanitize_seed_value(12345) == 12345
assert sanitize_seed_value(0) == 0
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF) == 0xFFFFFFFFFFFFFFFF
# String numbers should convert
assert sanitize_seed_value("12345") == 12345
assert sanitize_seed_value("0") == 0
# Out of range should clamp
assert sanitize_seed_value(-100) == 0
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF + 100) == 0xFFFFFFFFFFFFFFFF
# Invalid should raise
try:
sanitize_seed_value(None)
assert False, "Should have raised ValueError"
except ValueError:
pass
try:
sanitize_seed_value("invalid")
assert False, "Should have raised ValueError"
except ValueError:
pass
def test_create_history_entry(self):
"""Test history entry creation."""
seed = 12345
timestamp = time.time()
# With explicit timestamp
entry = create_history_entry(seed, timestamp)
assert entry["seed"] == seed
assert entry["timestamp"] == timestamp
assert "dateString" in entry
# With auto timestamp
entry_auto = create_history_entry(seed)
assert entry_auto["seed"] == seed
assert "timestamp" in entry_auto
assert "dateString" in entry_auto
def test_filter_duplicate_seeds(self):
"""Test duplicate seed filtering."""
seed = 12345
current_time = time.time()
# Empty history should not filter
assert not filter_duplicate_seeds([], seed, 500)
# Recent duplicate should filter
recent_entry = create_history_entry(seed, current_time - 0.1)
history = [recent_entry]
assert filter_duplicate_seeds(history, seed, 500)
# Old duplicate should not filter
old_entry = create_history_entry(seed, current_time - 1.0)
history = [old_entry]
assert not filter_duplicate_seeds(history, seed, 500)
# Different seed should not filter
different_entry = create_history_entry(54321, current_time - 0.1)
history = [different_entry]
assert not filter_duplicate_seeds(history, seed, 500)
def test_add_seed_to_history(self):
"""Test adding seeds to history."""
history = []
# Add first seed
new_history, was_added = add_seed_to_history(history, 12345)
assert was_added
assert len(new_history) == 1
assert new_history[0]["seed"] == 12345
# Add different seed
new_history2, was_added2 = add_seed_to_history(new_history, 54321)
assert was_added2
assert len(new_history2) == 2
assert new_history2[0]["seed"] == 54321 # Most recent first
# Add duplicate (should remove old and add new)
time.sleep(0.6) # Wait past dedup window
new_history3, was_added3 = add_seed_to_history(new_history2, 12345)
assert was_added3
assert len(new_history3) == 2
assert new_history3[0]["seed"] == 12345 # Most recent first
# Test max history limit
history_long = []
for i in range(15):
history_long, _ = add_seed_to_history(history_long, i, max_history=10)
time.sleep(0.001) # Small delay to avoid dedup
assert len(history_long) == 10
def test_format_time_ago(self):
"""Test time ago formatting."""
now = time.time()
# Recent times
assert "s ago" in format_time_ago(now - 30)
assert "m ago" in format_time_ago(now - 300)
assert "h ago" in format_time_ago(now - 7200)
assert "d ago" in format_time_ago(now - 86400)
def test_search_history_by_seed(self):
"""Test history search."""
history = [
create_history_entry(12345),
create_history_entry(54321),
create_history_entry(99999),
]
# Found seed
result = search_history_by_seed(history, 54321)
assert result is not None
assert result["seed"] == 54321
# Not found seed
result = search_history_by_seed(history, 11111)
assert result is None
def test_get_history_statistics(self):
"""Test history statistics."""
# Empty history
stats = get_history_statistics([])
assert stats["total_seeds"] == 0
assert stats["unique_seeds"] == 0
# History with data
now = time.time()
history = [
create_history_entry(12345, now - 3600),
create_history_entry(54321, now - 1800),
create_history_entry(12345, now), # Duplicate
]
stats = get_history_statistics(history)
assert stats["total_seeds"] == 3
assert stats["unique_seeds"] == 2
assert stats["time_span_hours"] == 1.0
def test_export_history_to_text(self):
"""Test history export."""
# Empty history
text = export_history_to_text([])
assert "Empty" in text
# History with data
history = [create_history_entry(12345), create_history_entry(54321)]
text = export_history_to_text(history)
assert "12345" in text
assert "54321" in text
assert "Total seeds: 2" in text
def test_import_seeds_from_list(self):
"""Test importing seeds from list."""
seed_list = [12345, 54321, 99999]
history = import_seeds_from_list(seed_list)
assert len(history) == 3
# Check seeds are in correct order (newest first)
assert history[0]["seed"] == 12345
assert history[1]["seed"] == 54321
assert history[2]["seed"] == 99999
# Check timestamps are spaced
assert history[0]["timestamp"] > history[1]["timestamp"]
assert history[1]["timestamp"] > history[2]["timestamp"]
class TestSeedHistoryIntegration:
"""Test integration scenarios."""
def test_complete_workflow(self):
"""Test complete seed history workflow."""
node = SeedHistoryNode()
# Test basic seed output
result = node.output_seed(12345)
assert result == (12345,)
# Test seed generation
new_seed = node.generate_new_seed()
assert validate_seed_value(new_seed)
# Test seed info
info = node.get_seed_info(new_seed)
assert str(new_seed) in info
def test_history_management(self):
"""Test history management operations."""
history = []
# Add seeds over time
seeds = [12345, 54321, 99999, 11111, 22222]
for seed in seeds:
history, was_added = add_seed_to_history(history, seed)
assert was_added
time.sleep(0.001) # Avoid dedup
# Check history order (newest first)
assert history[0]["seed"] == 22222
assert history[-1]["seed"] == 12345
# Test search
found = search_history_by_seed(history, 99999)
assert found is not None
# Test statistics
stats = get_history_statistics(history)
assert stats["total_seeds"] == 5
assert stats["unique_seeds"] == 5
def test_error_handling(self):
"""Test error handling scenarios."""
node = SeedHistoryNode()
# Test with invalid seeds
result = node.output_seed(-1)
assert result == (12345,) # Fallback
# Test validation
assert not node.validate_seed_input(None)
assert not node.validate_seed_input("invalid")
# Test history with invalid seeds
history = []
history, was_added = add_seed_to_history(history, -1)
assert not was_added
assert len(history) == 0
@@ -1,7 +1,5 @@
"""Tests for Width Height Selector tool.""" """Tests for Width Height Selector tool."""
import pytest
from unittest.mock import Mock
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
from kikotools.tools.width_height_selector.logic import ( from kikotools.tools.width_height_selector.logic import (
get_preset_dimensions, get_preset_dimensions,
+524
View File
@@ -0,0 +1,524 @@
// ComfyUI-KikoTools - Seed History with Tracking UI
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "comfyassets.SeedHistory",
async setup() {
// Store reference to all SeedHistory nodes
window.seedHistoryNodes = window.seedHistoryNodes || [];
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SeedHistory") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Initialize seed history
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.mouseOverHistory = false;
// Register this node in global registry
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
// Create UI container
const uiContainer = document.createElement("div");
uiContainer.style.padding = "8px";
uiContainer.style.backgroundColor = "#1e1e1e";
uiContainer.style.borderRadius = "6px";
uiContainer.style.marginTop = "6px";
uiContainer.style.border = "1px solid #404040";
this.buildSeedInterface(uiContainer);
// Add as widget
this.seedWidget = this.addDOMWidget(
"seed_history_ui",
"div",
uiContainer,
);
// Set node size
if (!this.hasBeenResized) {
this.size = [280, 320];
}
// Track manual resizing
const originalResize = this.onResize;
this.onResize = function (size) {
this.hasBeenResized = true;
if (originalResize) {
originalResize.call(this, size);
}
};
// Hook into seed widget callbacks to track all changes
setTimeout(() => {
this.setupSeedWidgetCallbacks();
}, 100);
// Hook directly into widget value changes
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function(name, value, oldValue, widget) {
if (name === "seed" && value !== oldValue) {
this.addSeedToHistory(value);
}
if (originalOnWidgetChange) {
return originalOnWidgetChange.call(this, name, value, oldValue, widget);
}
};
// Save/load data
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize ? originalSerialize.call(this) : {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
if (originalConfigure) {
originalConfigure.call(this, data);
}
if (data.hasBeenResized) {
this.hasBeenResized = data.hasBeenResized;
}
if (data.seedHistory) {
this.seedHistory = data.seedHistory;
this.refreshHistoryDisplay();
}
};
// Cleanup interval on node removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.seedValueWatcher) {
clearInterval(this.seedValueWatcher);
this.seedValueWatcher = null;
}
// Clean up deduplication tracking
if (this.lastAddedSeed) {
this.lastAddedSeed = null;
}
// Remove from global registry
if (window.seedHistoryNodes) {
const index = window.seedHistoryNodes.indexOf(this);
if (index !== -1) {
window.seedHistoryNodes.splice(index, 1);
}
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
};
this.setDirtyCanvas(true, true);
};
// Setup seed widget callbacks to track increment/decrement/randomize
nodeType.prototype.setupSeedWidgetCallbacks = function () {
// Find the seed widget
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (!seedWidget) {
setTimeout(() => this.setupSeedWidgetCallbacks(), 500);
return;
}
// Store the last known seed value to detect changes
this.lastSeedValue = seedWidget.value;
// Monitor for value changes that might not trigger callback
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
// Build the seed interface
nodeType.prototype.buildSeedInterface = function (container) {
container.innerHTML = "";
// Header
const headerDiv = document.createElement("div");
headerDiv.style.marginBottom = "8px";
const titleDiv = document.createElement("div");
titleDiv.style.fontWeight = "bold";
titleDiv.style.color = "#00d4ff";
titleDiv.style.textAlign = "center";
titleDiv.style.padding = "4px";
titleDiv.style.backgroundColor = "rgba(0, 212, 255, 0.1)";
titleDiv.style.borderRadius = "4px";
titleDiv.style.fontSize = "11px";
titleDiv.innerHTML = "🎲 Seed History";
headerDiv.appendChild(titleDiv);
// Action buttons
const buttonDiv = document.createElement("div");
buttonDiv.style.display = "flex";
buttonDiv.style.gap = "4px";
buttonDiv.style.marginTop = "6px";
// Generate button
const generateBtn = document.createElement("button");
generateBtn.textContent = "🎲 Generate";
generateBtn.style.flex = "1";
generateBtn.style.padding = "3px 6px";
generateBtn.style.backgroundColor = "#0088cc";
generateBtn.style.color = "white";
generateBtn.style.border = "none";
generateBtn.style.borderRadius = "3px";
generateBtn.style.cursor = "pointer";
generateBtn.style.fontSize = "10px";
generateBtn.addEventListener("click", () => this.generateRandomSeed());
buttonDiv.appendChild(generateBtn);
// Clear button
const clearBtn = document.createElement("button");
clearBtn.textContent = "🗑️ Clear";
clearBtn.style.flex = "1";
clearBtn.style.padding = "3px 6px";
clearBtn.style.backgroundColor = "#cc4444";
clearBtn.style.color = "white";
clearBtn.style.border = "none";
clearBtn.style.borderRadius = "3px";
clearBtn.style.cursor = "pointer";
clearBtn.style.fontSize = "10px";
clearBtn.addEventListener("click", () => this.clearSeedHistory());
buttonDiv.appendChild(clearBtn);
headerDiv.appendChild(buttonDiv);
container.appendChild(headerDiv);
// History display
const historyDiv = document.createElement("div");
historyDiv.style.maxHeight = "180px";
historyDiv.style.overflowY = "auto";
historyDiv.style.border = "1px solid #333";
historyDiv.style.borderRadius = "4px";
historyDiv.style.backgroundColor = "#2a2a2a";
historyDiv.style.padding = "6px";
historyDiv.style.fontSize = "10px";
historyDiv.style.fontFamily = "monospace";
// Mouse events for auto-hide
historyDiv.addEventListener("mouseenter", () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
historyDiv.addEventListener("mouseleave", () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.historyDisplay = historyDiv;
container.appendChild(historyDiv);
this.refreshHistoryDisplay();
};
// Load history from storage
nodeType.prototype.loadSeedHistory = function () {
try {
const stored = localStorage.getItem('comfyui_kikotools_seed_history');
return stored ? JSON.parse(stored) : [];
} catch (error) {
return [];
}
};
// Save history to storage
nodeType.prototype.saveSeedHistory = function () {
try {
localStorage.setItem('comfyui_kikotools_seed_history', JSON.stringify(this.seedHistory));
} catch (error) {
// Silently handle storage errors
}
};
// Add seed to history
nodeType.prototype.addSeedToHistory = function (seed) {
if (!seed || seed === 0) return;
const numSeed = typeof seed === 'string' ? parseInt(seed) : seed;
const now = Date.now();
// Deduplication: prevent adding the same seed within 500ms window
if (!this.lastAddedSeed) {
this.lastAddedSeed = { seed: null, timestamp: 0 };
}
const timeSinceLastAdd = now - this.lastAddedSeed.timestamp;
const isSameSeed = this.lastAddedSeed.seed === numSeed;
const isWithinDupeWindow = timeSinceLastAdd < 500; // 500ms window
if (isSameSeed && isWithinDupeWindow) {
return;
}
// Update deduplication tracking
this.lastAddedSeed = { seed: numSeed, timestamp: now };
// Remove if already exists in history
this.seedHistory = this.seedHistory.filter(item => item.seed !== numSeed);
// Add to front
this.seedHistory.unshift({
seed: numSeed,
timestamp: now,
dateString: new Date().toLocaleString()
});
// Keep only last 10
if (this.seedHistory.length > 10) {
this.seedHistory = this.seedHistory.slice(0, 10);
}
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.startAutoHide();
};
// Generate new random seed
nodeType.prototype.generateRandomSeed = function () {
const newSeed = Math.floor(Math.random() * 0xFFFFFFFFFFFFFFFF);
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = newSeed;
if (seedWidget.callback) {
seedWidget.callback(newSeed, this, seedWidget);
}
}
this.addSeedToHistory(newSeed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${newSeed}`, "success");
};
// Use seed from history
nodeType.prototype.useSeedFromHistory = function (historyItem, index) {
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = historyItem.seed;
if (seedWidget.callback) {
seedWidget.callback(historyItem.seed, this, seedWidget);
}
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${historyItem.seed}`, "info");
};
// Clear history
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.showMessage("History cleared", "info");
};
// Refresh history display
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.historyDisplay) return;
if (!this.seedHistory || this.seedHistory.length === 0) {
this.historyDisplay.innerHTML =
'<div style="color: #888; text-align: center; padding: 15px;">No seeds tracked<br><small>Generate seeds to build history</small></div>';
return;
}
this.historyDisplay.innerHTML = "";
this.seedHistory.forEach((item, index) => {
const entryDiv = document.createElement("div");
entryDiv.style.padding = "4px";
entryDiv.style.marginBottom = "3px";
entryDiv.style.backgroundColor = "#333";
entryDiv.style.borderRadius = "2px";
entryDiv.style.cursor = "pointer";
entryDiv.style.border = "1px solid transparent";
entryDiv.style.lineHeight = "1.2";
entryDiv.addEventListener("mouseenter", () => {
entryDiv.style.backgroundColor = "#444";
entryDiv.style.border = "1px solid #555";
});
entryDiv.addEventListener("mouseleave", () => {
entryDiv.style.backgroundColor = "#333";
entryDiv.style.border = "1px solid transparent";
});
entryDiv.addEventListener("click", () => {
this.useSeedFromHistory(item, index);
});
const timeAgo = this.formatTimeAgo(item.timestamp);
entryDiv.innerHTML = `
<div style="color: #fff; font-weight: bold; margin-bottom: 1px;">
🎲 ${item.seed}
</div>
<div style="color: #999; font-size: 8px;">
⏰ ${timeAgo}
</div>
`;
this.historyDisplay.appendChild(entryDiv);
});
this.startAutoHide();
};
// Highlight selected entry
nodeType.prototype.highlightHistoryEntry = function (index) {
const entries = this.historyDisplay.querySelectorAll('div[style*="cursor: pointer"]');
entries.forEach((entry, i) => {
if (i === index) {
entry.style.backgroundColor = "#006600";
entry.style.border = "1px solid #00aa00";
} else {
entry.style.backgroundColor = "#333";
entry.style.border = "1px solid transparent";
}
});
};
// Auto-hide functionality
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.mouseOverHistory) {
this.hideTimer = setTimeout(() => {
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
};
nodeType.prototype.hideHistorySection = function () {
if (this.historyDisplay && !this.mouseOverHistory) {
this.historyDisplay.style.display = "none";
if (!this.restoreButton) {
const restoreDiv = document.createElement("div");
restoreDiv.style.padding = "10px";
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.border = "1px solid #333";
restoreDiv.style.borderRadius = "4px";
restoreDiv.style.textAlign = "center";
restoreDiv.style.cursor = "pointer";
restoreDiv.style.color = "#888";
restoreDiv.style.fontSize = "10px";
restoreDiv.innerHTML = "🎲 History auto-hidden<br><small>Click to show</small>";
restoreDiv.addEventListener("mouseenter", () => {
restoreDiv.style.backgroundColor = "#333";
restoreDiv.style.color = "#bbb";
});
restoreDiv.addEventListener("mouseleave", () => {
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.color = "#888";
});
restoreDiv.addEventListener("click", () => {
this.showHistorySection();
});
this.restoreButton = restoreDiv;
this.historyDisplay.parentNode.insertBefore(
restoreDiv,
this.historyDisplay.nextSibling
);
}
}
};
nodeType.prototype.showHistorySection = function () {
if (this.historyDisplay) {
this.historyDisplay.style.display = "block";
if (this.restoreButton && this.restoreButton.parentNode) {
this.restoreButton.parentNode.removeChild(this.restoreButton);
this.restoreButton = null;
}
this.startAutoHide();
}
};
// Format time ago
nodeType.prototype.formatTimeAgo = function (timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
};
// Show messages
nodeType.prototype.showMessage = function (message, type = "info") {
const notification = document.createElement("div");
notification.style.position = "fixed";
notification.style.top = "15px";
notification.style.right = "15px";
notification.style.padding = "6px 10px";
notification.style.borderRadius = "3px";
notification.style.color = "white";
notification.style.fontSize = "10px";
notification.style.zIndex = "10000";
notification.style.maxWidth = "200px";
notification.textContent = message;
switch (type) {
case "success":
notification.style.backgroundColor = "#28a745";
break;
case "error":
notification.style.backgroundColor = "#dc3545";
break;
case "warning":
notification.style.backgroundColor = "#ffc107";
break;
default:
notification.style.backgroundColor = "#17a2b8";
}
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 1800);
};
}
},
});