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
- 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
- **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)
**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
<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) |
| **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 |
| **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)
- 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
### Prerequisites
@@ -298,9 +398,10 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 2 (Resolution Calculator, Width Height Selector)
- **Nodes**: 4 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo)
- **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+
- **ComfyUI Compatibility**: Latest
- **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.width_height_selector import WidthHeightSelectorNode
from .tools.seed_history import SeedHistoryNode
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
"ResolutionCalculator": ResolutionCalculatorNode,
"WidthHeightSelector": WidthHeightSelectorNode,
"SeedHistory": SeedHistoryNode,
"SamplerCombo": SamplerComboNode,
"SamplerComboCompact": SamplerComboCompactNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector",
"SeedHistory": "Seed History",
"SamplerCombo": "Sampler Combo",
"SamplerComboCompact": "Sampler Combo (Compact)",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
@@ -4,7 +4,7 @@ Pure functions for dimension extraction and scaling calculations
"""
import torch
from typing import Tuple, Optional, Union, Dict, Any
from typing import Tuple, Optional, Dict
def extract_dimensions(
@@ -16,7 +16,8 @@ def extract_dimensions(
Args:
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:
Tuple of (width, height) as integers
@@ -42,7 +43,8 @@ def extract_dimensions(
samples = latent["samples"]
if len(samples.shape) != 4:
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
+10 -5
View File
@@ -42,7 +42,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
"max": 8.0,
"step": 0.1,
"display": "slider",
"tooltip": "Factor to scale the resolution by (e.g., 2.0 for 2x upscale)",
"tooltip": "Factor to scale the resolution by "
"(e.g., 2.0 for 2x upscale)",
},
),
},
@@ -93,7 +94,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
else "LATENT" if latent is not None else "NONE"
)
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
@@ -147,7 +149,8 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
if len(image.shape) != 4:
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:
@@ -160,12 +163,14 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
samples = latent["samples"]
if not isinstance(samples, torch.Tensor):
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:
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,
{
"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.",
"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": (
@@ -48,7 +49,8 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
"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.",
"Used when preset is 'custom' or as fallback for invalid "
"presets.",
},
),
"height": (
@@ -59,7 +61,8 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
"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.",
"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 torch
import numpy as np
from typing import Dict, Any
from unittest.mock import MagicMock
+1 -2
View File
@@ -4,8 +4,7 @@ Tests the shared functionality for all ComfyAssets tools
"""
import pytest
import logging
from unittest.mock import patch, MagicMock
from unittest.mock import patch
from kikotools.base import ComfyAssetsBaseNode
@@ -5,7 +5,6 @@ Following TDD principles - these tests define the expected behavior
import pytest
import torch
from unittest.mock import patch, MagicMock
# Import the modules we're going to test (they don't exist yet - TDD!)
from kikotools.tools.resolution_calculator.logic import (
@@ -172,8 +171,6 @@ class TestResolutionCalculatorNode:
def test_node_has_correct_comfyui_attributes(self):
"""Test node has all required ComfyUI attributes"""
node = ResolutionCalculatorNode()
# Check class attributes exist
assert hasattr(ResolutionCalculatorNode, "INPUT_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."""
import pytest
from unittest.mock import Mock
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
from kikotools.tools.width_height_selector.logic import (
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);
};
}
},
});