Compare commits

...
Author SHA1 Message Date
Vito Sansevero 3a3a6ebab9 feat: add follow execution and custom colors UI features
- Add follow execution feature with settings integration
  - Automatically centers canvas on currently executing node
  - Configurable via ComfyUI settings panel
  - Supports auto-start on workflow execution
  - Adds right-click menu options when enabled

- Add custom colors feature with extended options
  - Based on ComfyUI-Custom-Scripts with enhancements from PR #433
  - Three color picker modes: Full, Title only, Background only
  - Multi-node selection support
  - Configurable via settings to enable/disable individual options
  - Auto-shading option for better contrast

Both features are disabled by default and can be enabled in ComfyUI settings under the 🫶 KikoTools sections.
2025-08-10 11:35:22 -07:00
Vito Sansevero 886917d95c chore: bump version to 1.0.15 in pyproject.toml 2025-08-10 06:58:30 -07:00
Vito Sansevero bf55338b61 fix: update all remaining category assertions to handle emoji prefixes
- Change startswith checks to contains checks for ComfyAssets/
- All nodes use '🫶 ComfyAssets/...' format with emoji prefix
2025-08-10 06:51:43 -07:00
Vito Sansevero c561e59650 fix: update test assertions to match emoji prefixes in node categories 2025-08-10 06:50:30 -07:00
Vito Sansevero 9e67f06a21 fix: update base node test to handle emoji prefix in category 2025-08-10 06:44:51 -07:00
Vito Sansevero 90e4b57dc6 style: add missing newline to any_type.py for black formatting 2025-08-10 06:33:12 -07:00
Vito Sansevero 386e48c2e1 feat: add Kiko Purge VRAM node for intelligent memory management
- Add comprehensive VRAM management tool with 4 purge modes (soft, aggressive, models_only, cache_only)
- Implement smart memory threshold triggering to avoid unnecessary purges
- Add detailed memory reporting showing before/after stats and freed MB
- Support passthrough design for seamless workflow integration
- Include graceful CPU fallback for non-CUDA environments
- Add comprehensive test suite with 14 tests covering all functionality
- Update documentation with detailed usage examples and parameters
- Create reusable AnyType class for wildcard input matching

The node provides essential memory management capabilities for complex workflows,
helping prevent OOM errors and optimize multi-model processing pipelines.
2025-08-10 06:25:48 -07:00
Vito Sansevero 624ba92723 feat: add Kiko Purge VRAM node for intelligent memory management
- Add comprehensive VRAM management tool with 4 purge modes (soft, aggressive, models_only, cache_only)
- Implement smart memory threshold triggering to avoid unnecessary purges
- Add detailed memory reporting showing before/after stats and freed MB
- Support passthrough design for seamless workflow integration
- Include graceful CPU fallback for non-CUDA environments
- Add comprehensive test suite with 14 tests covering all functionality
- Update documentation with detailed usage examples and parameters
- Create reusable AnyType class for wildcard input matching

The node provides essential memory management capabilities for complex workflows,
helping prevent OOM errors and optimize multi-model processing pipelines.
2025-08-10 06:17:42 -07:00
Vito Sansevero 0ae29f576a chore: bump version to 1.0.14 in pyproject.toml 2025-08-08 21:02:43 -07:00
Vito Sansevero ca9fd5153a style(kikotools): Update category names and refactor nodes 2025-08-08 21:01:48 -07:00
Vito c0d239f31f Update README.md
Update into.
2025-08-08 20:34:20 -07:00
Vito Sansevero 6d27520641 docs: Update README with new image tools added 2025-08-08 18:33:55 -07:00
Vito Sansevero 7cde5a5ceb docs: update image URLs in README.md 2025-08-08 18:31:02 -07:00
Vito Sansevero ecdde6bbd7 chore: bump version to 1.0.13 in pyproject.toml 2025-08-08 18:28:29 -07:00
Vito 0557c040f8 Merge pull request #31 from ComfyAssets/feature/embedding-autocomplete
Feature/embedding autocomplete
2025-08-08 18:28:04 -07:00
32 changed files with 1088 additions and 39 deletions
+7 -7
View File
@@ -53,7 +53,7 @@ jobs:
print('✓ All imports successful')
# Test base node
assert ComfyAssetsBaseNode.CATEGORY.startswith('ComfyAssets')
assert 'ComfyAssets' in ComfyAssetsBaseNode.CATEGORY
print('✓ Base node tests passed')
# Test dimension extraction
@@ -168,7 +168,7 @@ jobs:
assert node.RETURN_TYPES[2] == 'INT'
assert node.RETURN_TYPES[3] == 'FLOAT'
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
assert node.CATEGORY == 'ComfyAssets/🌀 Samplers'
assert node.CATEGORY == '🫶 ComfyAssets/🌀 Samplers'
print('✓ Sampler Combo return types tests passed')
# Test sampler combo functionality
@@ -217,7 +217,7 @@ jobs:
# Test return types
assert node.RETURN_TYPES == ('INT',)
assert node.RETURN_NAMES == ('seed',)
assert node.CATEGORY == 'ComfyAssets/🌱 Seeds'
assert node.CATEGORY == '🫶 ComfyAssets/🌱 Seeds'
print('✓ Seed History return types tests passed')
# Test seed output functionality
@@ -333,7 +333,7 @@ jobs:
assert res_class.RETURN_TYPES == ('INT', 'INT')
assert res_class.RETURN_NAMES == ('width', 'height')
assert res_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in res_class.CATEGORY
print('✓ Resolution Calculator ComfyUI integration passed')
# Test Width Height Selector
@@ -354,7 +354,7 @@ jobs:
assert wh_class.RETURN_TYPES == ('INT', 'INT')
assert wh_class.RETURN_NAMES == ('width', 'height')
assert wh_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in wh_class.CATEGORY
print('✓ Width Height Selector ComfyUI integration passed')
# Test Sampler Combo
@@ -374,7 +374,7 @@ jobs:
assert 'steps' in input_types['required']
assert 'cfg' in input_types['required']
assert sampler_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in sampler_class.CATEGORY
print('✓ Sampler Combo ComfyUI integration passed')
# Test Seed History
@@ -393,7 +393,7 @@ jobs:
assert seed_class.RETURN_TYPES == ('INT',)
assert seed_class.RETURN_NAMES == ('seed',)
assert seed_class.CATEGORY.startswith('ComfyAssets/')
assert 'ComfyAssets/' in seed_class.CATEGORY
print('✓ Seed History ComfyUI integration passed')
print('🎉 All tools ComfyUI integration readiness tests passed!')
+95 -7
View File
@@ -8,7 +8,14 @@
> A modular collection of essential custom ComfyUI nodes missing from the standard release.
ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped under the **"ComfyAssets"** category. Each tool is designed with clean interfaces, comprehensive testing, and optimized performance for SDXL and FLUX workflows.
ComfyUI-KikoTools provides carefully crafted, production-ready nodes under the "ComfyAssets" category.
Each tool is built with clean interfaces, thorough testing, and optimized performance for SDXL and FLUX workflows.
This project started out of frustration with keeping ComfyUI up to date and waiting for dozens of custom nodes to update—most of which I didn’t even use. After taking a hard look at my workflow, I realized I only needed one or two features from these nodes, many of which were abandoned or stuck in maintenance mode.
I tried forking, patching, and submitting merge requests, but eventually decided to create my own curated collection of tools—fully supported and maintained by me. That’s how Kiko’s Tools was born.
I’m sharing them here with the community, and I hope you find them as useful as I do.
## 🚀 Features
@@ -26,7 +33,10 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | ✍️ Text |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
| [🎬 Film Grain](#-film-grain) | Add realistic film grain effects to images | 💾 Images |
| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | 🔧 Utils |
| [🧹 Kiko Purge VRAM](#-kiko-purge-vram) | Intelligent VRAM management with detailed reporting | 🛠️ Utils |
### 🧰 xyz-helpers Tools
@@ -219,6 +229,41 @@ Adjusts image dimensions to be multiples of a specified value for model compatib
![Image to Multiple Of Example](examples/workflows/image_to_multiple_of_example.png)
#### 📉 Image Scale Down By
Efficiently scale images down by a specified factor with quality preservation.
- **Proportional Scaling**: Reduces both width and height by the same factor
- **Quality Preservation**: Uses bilinear interpolation with antialiasing
- **Batch Support**: Process multiple images simultaneously
- **Memory Efficient**: Optimized for large image batches
- **Flexible Factor**: Scale from 0.01x to 1.0x with 0.01 precision
**Use Cases:**
- Create thumbnails or preview images
- Reduce memory usage for large workflows
- Generate image pyramids for multi-scale processing
- Quick downsampling for performance optimization
- Prepare images for web display or transmission
#### 🎬 Film Grain
Add realistic analog film grain effects to generated images.
- **Realistic Grain Simulation**: Mimics actual film photography characteristics
- **Grain Size Control**: Fine to coarse grain patterns (0.25x to 2.0x)
- **Intensity Adjustment**: Variable strength from subtle to pronounced (0-10)
- **Color Saturation**: Monochrome to full color grain (0-2)
- **Shadow Lifting (Toe)**: Film-like shadow response curves
- **Red Multiplier**: Adjust red channel independently for vintage looks
- **Alpha Preservation**: Maintains transparency when present
- **ITU-R BT.709 Color Space**: Professional color handling
**Use Cases:**
- Add vintage film aesthetic to AI-generated images
- Create cinematic looks with authentic grain patterns
- Simulate different film stocks (35mm, 16mm, etc.)
- Add texture to overly smooth AI renders
- Match grain from reference photography
#### 🎛️ Flux Sampler Params
FLUX-optimized parameter generator with intelligent batch processing capabilities.
@@ -317,9 +362,9 @@ Unified interface for text encoding and sampler parameter management.
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
<div align="center">
<img src="ac-emb.png" width="30%" alt="Embedding Autocomplete" />
<img src="ac-lora.png" width="30%" alt="LoRA Autocomplete" />
<img src="ac-tag.png" width="30%" alt="Tag Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-emb.png?raw=true" width="30%" alt="Embedding Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-lora.png?raw=true" width="30%" alt="LoRA Autocomplete" />
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-tag.png?raw=true" width="30%" alt="Tag Autocomplete" />
</div>
This feature is an enhanced fork of the autocomplete functionality from [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss. We've modernized the codebase, fixed existing bugs, and added robust security features.
@@ -351,6 +396,46 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
- Original autocomplete concept by [pythongosssss](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
- Enhanced and modernized by KikoTools team
### 🧹 Kiko Purge VRAM
**Intelligent GPU memory management with threshold-based triggering and detailed reporting.**
**Key Features:**
- **4 Purge Modes**:
- `soft`: Basic garbage collection and cache clearing
- `aggressive`: Multiple GC passes with full CUDA cache clearing
- `models_only`: Unload all models and clear model cache
- `cache_only`: Clear CUDA cache without garbage collection
- **Smart Thresholds**: Only purge when memory usage exceeds specified MB limit
- **Detailed Reporting**: Shows before/after memory usage, freed MB, and timing
- **Passthrough Design**: Acts as workflow checkpoint without disrupting data flow
- **CPU Fallback**: Gracefully handles non-CUDA environments
**Use Cases:**
- Free memory between heavy processing stages
- Prevent OOM errors in complex workflows
- Debug memory usage patterns
- Optimize multi-model workflows
- Clean up after batch processing
**Parameters:**
- **anything**: Any input (passed through unchanged)
- **mode**: Purge strategy selection
- **report_memory**: Generate detailed memory statistics
- **memory_threshold_mb**: Only purge if usage exceeds (0 = always purge)
**Example Output:**
```
Memory usage (5000.0 MB) exceeds threshold (4000 MB)
Memory Purge Report
-------------------
Mode: soft
Memory Freed: 2500.0 MB
Before: 5000.0 MB used (62.5%)
After: 2500.0 MB used (31.3%)
Time: 150.0ms
```
### 💾 Kiko Save Image Features
**Use Cases:**
@@ -585,6 +670,8 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) |
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
@@ -884,8 +971,9 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 16 (10 core tools + 6 xyz-helpers)
- **Categories**: 8 emoji-based categories for better organization
- **Nodes**: 19 (13 core tools + 6 xyz-helpers)
- **Features**: Embedding Autocomplete (settings-based, not a node)
- **Categories**: 9 emoji-based categories for better organization
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
+7 -3
View File
@@ -11,6 +11,7 @@ from .tools.gemini_prompt import GeminiPromptNode
from .tools.image_scale_down_by import ImageScaleDownByNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.kiko_film_grain import KikoFilmGrainNode
from .tools.kiko_purge_vram import KikoPurgeVRAM
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
@@ -40,13 +41,15 @@ NODE_CLASS_MAPPINGS = {
"DisplayAny": DisplayAnyNode,
"DisplayText": DisplayTextNode,
"KikoFilmGrain": KikoFilmGrainNode,
"KikoPurgeVRAM": KikoPurgeVRAM,
"SamplerSelectHelper": SamplerSelectHelperNode,
"SchedulerSelectHelper": SchedulerSelectHelperNode,
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"KikoEmbeddingAutocomplete": KikoEmbeddingAutocomplete,
# Note: KikoEmbeddingAutocomplete is not registered as a node
# It's a settings-only feature accessed through ComfyUI settings menu
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -62,14 +65,15 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GeminiPrompt": "Gemini Prompt Engineer",
"DisplayAny": "Display Any",
"DisplayText": "Display Text",
"KikoFilmGrain": "Kiko Film Grain",
"KikoFilmGrain": "Film Grain",
"KikoPurgeVRAM": "Kiko Purge VRAM",
"SamplerSelectHelper": "Sampler Select Helper",
"SchedulerSelectHelper": "Scheduler Select Helper",
"TextEncodeSamplerParams": "Text Encode for Sampler Params",
"FluxSamplerParams": "Flux Sampler Parameters",
"PlotParameters+": "Plot Parameters",
"LoRAFolderBatch": "LoRA Folder Batch",
"KikoEmbeddingAutocomplete": "🫶 Embedding Autocomplete Configuration",
# KikoEmbeddingAutocomplete removed - settings only, not a node
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+8
View File
@@ -0,0 +1,8 @@
"""AnyType for wildcard input matching in ComfyUI nodes."""
class AnyType(str):
"""A special type that matches any input type in ComfyUI."""
def __ne__(self, other):
return False
+1 -1
View File
@@ -20,7 +20,7 @@ class ComfyAssetsBaseNode:
- Consistent return type handling
"""
CATEGORY = "ComfyAssets"
CATEGORY = "🫶 ComfyAssets"
def validate_inputs(self, **kwargs) -> None:
"""
+1 -1
View File
@@ -38,7 +38,7 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
return True
RETURN_TYPES = ("STRING",)
CATEGORY = "ComfyAssets/👁️ Display"
CATEGORY = "🫶 ComfyAssets/👁️ Display"
RETURN_NAMES = ("display_text",)
FUNCTION = "display"
OUTPUT_NODE = True # This node displays output in the UI
+1 -1
View File
@@ -19,7 +19,7 @@ class DisplayTextNode(ComfyAssetsBaseNode):
RETURN_NAMES = ("text",)
OUTPUT_NODE = True
FUNCTION = "display_text"
CATEGORY = "ComfyAssets/👁️ Display"
CATEGORY = "🫶 ComfyAssets/👁️ Display"
DESCRIPTION = """
Displays text in the UI with a copy-to-clipboard feature.
@@ -17,7 +17,7 @@ class KikoEmbeddingAutocomplete:
"""Node that provides embedding autocomplete functionality."""
DISPLAY_NAME = "🫶 Embedding Autocomplete Settings"
CATEGORY = "ComfyAssets"
CATEGORY = "🫶 ComfyAssets"
# Settings definition for the settings registry
SETTINGS = {
+1 -1
View File
@@ -96,7 +96,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
FUNCTION = "create_empty_latent"
CATEGORY = "ComfyAssets/📦 Latents"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def create_empty_latent(
self, preset: str, width: int, height: int, batch_size: int
+1 -1
View File
@@ -51,7 +51,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "negative_prompt")
FUNCTION = "generate_prompt"
CATEGORY = "ComfyAssets/🧠 Prompts"
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = """
Analyzes images using Google's Gemini AI to generate optimized prompts.
+1 -1
View File
@@ -35,7 +35,7 @@ class ImageScaleDownByNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("IMAGE",)
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("images",)
FUNCTION = "scale_down"
+1 -1
View File
@@ -36,7 +36,7 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("IMAGE",)
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("image",)
FUNCTION = "process"
+1 -1
View File
@@ -85,7 +85,7 @@ class KikoFilmGrainNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "apply_grain"
CATEGORY = "ComfyAssets/image"
CATEGORY = "🫶 ComfyAssets/💾 Images"
DESCRIPTION = "Apply realistic film grain effect with customizable parameters"
def apply_grain(
@@ -0,0 +1,3 @@
from .node import KikoPurgeVRAM
__all__ = ["KikoPurgeVRAM"]
+130
View File
@@ -0,0 +1,130 @@
import gc
from typing import Dict, Tuple
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
try:
import comfy.model_management as mm
COMFY_AVAILABLE = True
except ImportError:
COMFY_AVAILABLE = False
def get_memory_stats() -> Dict[str, float]:
stats = {
"cuda_available": False,
"free_mb": 0,
"total_mb": 0,
"used_mb": 0,
"used_percent": 0,
}
if TORCH_AVAILABLE and torch.cuda.is_available():
stats["cuda_available"] = True
free, total = torch.cuda.mem_get_info()
free_mb = free / (1024 * 1024)
total_mb = total / (1024 * 1024)
used_mb = total_mb - free_mb
stats["free_mb"] = free_mb
stats["total_mb"] = total_mb
stats["used_mb"] = used_mb
stats["used_percent"] = (used_mb / total_mb) * 100 if total_mb > 0 else 0
return stats
def purge_memory(mode: str = "soft", unload_models: bool = False) -> float:
before_stats = get_memory_stats()
if mode == "soft":
# Basic garbage collection and cache clearing
gc.collect()
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.empty_cache()
elif mode == "aggressive":
# Multiple passes of garbage collection with full cache clearing
gc.collect()
gc.collect()
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
elif mode == "models_only":
# Only unload models
if COMFY_AVAILABLE:
mm.unload_all_models()
mm.soft_empty_cache()
gc.collect()
elif mode == "cache_only":
# Only clear cache without garbage collection
if TORCH_AVAILABLE and torch.cuda.is_available():
torch.cuda.empty_cache()
# Handle model unloading for non-model-specific modes
if unload_models and mode not in ["models_only"]:
if COMFY_AVAILABLE:
mm.unload_all_models()
mm.soft_empty_cache()
after_stats = get_memory_stats()
freed_mb = before_stats["used_mb"] - after_stats["used_mb"]
return max(0, freed_mb)
def format_memory_report(
before: Dict[str, float], after: Dict[str, float], mode: str, elapsed_ms: float
) -> str:
if not before.get("cuda_available", True):
return (
"Memory Purge Report\n"
"-------------------\n"
"CUDA not available - CPU memory management only\n"
f"Mode: {mode}\n"
f"Time: {elapsed_ms:.1f}ms"
)
freed_mb = before["used_mb"] - after["used_mb"]
report = [
"Memory Purge Report",
"-------------------",
f"Mode: {mode}",
f"Memory Freed: {freed_mb:.1f} MB",
f"Before: {before['used_mb']:.1f} MB used ({before['used_percent']:.1f}%)",
f"After: {after['used_mb']:.1f} MB used ({after['used_percent']:.1f}%)",
f"Time: {elapsed_ms:.1f}ms",
]
return "\n".join(report)
def should_purge(threshold_mb: int) -> Tuple[bool, str]:
if threshold_mb <= 0:
return True, ""
stats = get_memory_stats()
if not stats["cuda_available"]:
return True, "CUDA not available, proceeding with CPU memory management"
if stats["used_mb"] >= threshold_mb:
return (
True,
f"Memory usage ({stats['used_mb']:.1f} MB) exceeds threshold ({threshold_mb} MB)",
)
else:
return (
False,
f"Memory usage ({stats['used_mb']:.1f} MB) below threshold ({threshold_mb} MB)",
)
+102
View File
@@ -0,0 +1,102 @@
import time
from typing import Any, Dict, Tuple
try:
from ...base.base_node import ComfyAssetsBaseNode as BaseNode
from ...base.any_type import AnyType
except ImportError:
# Fallback for testing environment
from kikotools.base.base_node import ComfyAssetsBaseNode as BaseNode
from kikotools.base.any_type import AnyType
from .logic import get_memory_stats, purge_memory, format_memory_report, should_purge
any_type = AnyType("*")
class KikoPurgeVRAM(BaseNode):
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"anything": (any_type, {}),
"mode": (
["soft", "aggressive", "models_only", "cache_only"],
{
"default": "soft",
"tooltip": "Purge mode: soft (basic), aggressive (thorough), models_only (unload models), cache_only (clear cache)",
},
),
"report_memory": (
"BOOLEAN",
{
"default": True,
"tooltip": "Generate detailed memory usage report",
},
),
},
"optional": {
"memory_threshold_mb": (
"INT",
{
"default": 0,
"min": 0,
"max": 48000,
"step": 100,
"tooltip": "Only purge if memory usage exceeds this threshold (0 = always purge)",
},
),
},
}
RETURN_TYPES = (any_type, "STRING")
RETURN_NAMES = ("passthrough", "memory_report")
FUNCTION = "purge_vram"
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
OUTPUT_NODE = True
DESCRIPTION = "Purge VRAM to free up GPU memory during workflow execution. Passes through any input unchanged."
def purge_vram(
self,
anything: Any,
mode: str,
report_memory: bool,
memory_threshold_mb: int = 0,
) -> Tuple[Any, str]:
# Check if we should purge based on threshold
should_run, threshold_msg = should_purge(memory_threshold_mb)
if not should_run:
if report_memory:
return anything, f"Memory purge skipped: {threshold_msg}"
else:
return anything, ""
# Get before stats
before_stats = get_memory_stats() if report_memory else None
start_time = time.time()
# Determine if we should unload models
unload_models = mode in ["models_only", "aggressive"]
# Perform memory purge
purge_memory(mode=mode, unload_models=unload_models)
# Calculate elapsed time
elapsed_ms = (time.time() - start_time) * 1000
# Generate report if requested
if report_memory:
after_stats = get_memory_stats()
report = format_memory_report(before_stats, after_stats, mode, elapsed_ms)
if threshold_msg and memory_threshold_mb > 0:
report = f"{threshold_msg}\n\n{report}"
else:
report = ""
# Pass through the input unchanged
return anything, report
NODE_CLASS_MAPPINGS = {"KikoPurgeVRAM": KikoPurgeVRAM}
NODE_DISPLAY_NAME_MAPPINGS = {"KikoPurgeVRAM": "Kiko Purge VRAM"}
+1 -1
View File
@@ -95,7 +95,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ()
CATEGORY = "ComfyAssets/💾 Images"
CATEGORY = "🫶 ComfyAssets/💾 Images"
FUNCTION = "save_images"
OUTPUT_NODE = True
@@ -60,7 +60,7 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("INT", "INT")
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
RETURN_NAMES = ("width", "height")
FUNCTION = "calculate_resolution"
@@ -63,7 +63,7 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
FUNCTION = "get_combo"
CATEGORY = "ComfyAssets/🌀 Samplers"
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
def get_combo(
self, sampler: str, sched: str, steps: int, cfg: float
+1 -1
View File
@@ -68,7 +68,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
FUNCTION = "get_sampler_combo"
CATEGORY = "ComfyAssets/🌀 Samplers"
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
def get_sampler_combo(
self, sampler_name: str, scheduler: str, steps: int, cfg: float
+1 -1
View File
@@ -38,7 +38,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "output_seed"
CATEGORY = "ComfyAssets/🌱 Seeds"
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
def output_seed(self, seed: int) -> Tuple[int]:
"""
@@ -85,7 +85,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "get_dimensions"
CATEGORY = "ComfyAssets/🖼️ Resolution"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
def get_dimensions(self, preset: str, width: int, height: int) -> Tuple[int, int]:
"""
@@ -125,7 +125,7 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("LATENT", "SAMPLER_PARAMS")
RETURN_NAMES = ("latent", "params")
FUNCTION = "process_batch"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def process_batch(
self,
@@ -80,7 +80,7 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("LORA_PARAMS", "STRING", "INT")
RETURN_NAMES = ("lora_params", "lora_list", "lora_count")
FUNCTION = "batch_loras"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def batch_loras(
self,
@@ -113,7 +113,7 @@ class PlotParametersNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "plot_parameters"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def plot_parameters(
self,
@@ -30,7 +30,7 @@ class SamplerSelectHelperNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_samplers",)
FUNCTION = "select_samplers"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def select_samplers(self, **sampler_flags) -> Tuple[str]:
"""
@@ -30,7 +30,7 @@ class SchedulerSelectHelperNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_schedulers",)
FUNCTION = "select_schedulers"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def select_schedulers(self, **scheduler_flags) -> Tuple[str]:
"""
@@ -40,7 +40,7 @@ class TextEncodeSamplerParamsNode(ComfyAssetsBaseNode):
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditioning",)
FUNCTION = "encode_prompts"
CATEGORY = "ComfyAssets/🧰 xyz-helpers"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def encode_prompts(self, text: str, clip: Any) -> Tuple[Any]:
"""
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.12"
version = "1.0.15"
license = {text = "MIT"}
dependencies = []
+299
View File
@@ -0,0 +1,299 @@
import sys
from unittest.mock import patch, MagicMock
import pytest
# Mock comfy modules
sys.modules["comfy"] = MagicMock()
sys.modules["comfy.model_management"] = MagicMock()
from kikotools.tools.kiko_purge_vram.logic import (
purge_memory,
get_memory_stats,
format_memory_report,
)
class TestMemoryStats:
@patch("torch.cuda.is_available")
@patch("torch.cuda.mem_get_info")
def test_get_memory_stats_with_cuda(self, mock_mem_info, mock_cuda_available):
mock_cuda_available.return_value = True
mock_mem_info.return_value = (4000000000, 8000000000) # 4GB free, 8GB total
stats = get_memory_stats()
assert stats["cuda_available"] is True
assert stats["free_mb"] == pytest.approx(3814.7, rel=0.1)
assert stats["total_mb"] == pytest.approx(7629.4, rel=0.1)
assert stats["used_mb"] == pytest.approx(3814.7, rel=0.1)
assert stats["used_percent"] == pytest.approx(50.0, rel=0.1)
@patch("torch.cuda.is_available")
def test_get_memory_stats_without_cuda(self, mock_cuda_available):
mock_cuda_available.return_value = False
stats = get_memory_stats()
assert stats["cuda_available"] is False
assert stats["free_mb"] == 0
assert stats["total_mb"] == 0
assert stats["used_mb"] == 0
assert stats["used_percent"] == 0
class TestMemoryPurge:
@patch("torch.cuda.is_available")
@patch("torch.cuda.empty_cache")
@patch("torch.cuda.ipc_collect")
@patch("gc.collect")
def test_purge_memory_soft_mode(
self, mock_gc, mock_ipc, mock_empty_cache, mock_cuda
):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 4000, "free_mb": 4000},
{"used_mb": 2000, "free_mb": 6000},
]
freed_mb = purge_memory(mode="soft", unload_models=False)
mock_gc.assert_called_once()
mock_empty_cache.assert_called_once()
mock_ipc.assert_not_called()
assert freed_mb == 2000
@patch("torch.cuda.is_available")
@patch("torch.cuda.empty_cache")
@patch("torch.cuda.ipc_collect")
@patch("torch.cuda.synchronize")
@patch("gc.collect")
def test_purge_memory_aggressive_mode(
self, mock_gc, mock_sync, mock_ipc, mock_empty_cache, mock_cuda
):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 4000, "free_mb": 4000},
{"used_mb": 1500, "free_mb": 6500},
]
freed_mb = purge_memory(mode="aggressive", unload_models=False)
assert mock_gc.call_count == 2
mock_empty_cache.assert_called()
mock_ipc.assert_called_once()
mock_sync.assert_called_once()
assert freed_mb == 2500
@patch("kikotools.tools.kiko_purge_vram.logic.COMFY_AVAILABLE", True)
@patch("kikotools.tools.kiko_purge_vram.logic.mm.unload_all_models")
@patch("kikotools.tools.kiko_purge_vram.logic.mm.soft_empty_cache")
@patch("torch.cuda.is_available")
@patch("gc.collect")
def test_purge_memory_models_only(
self, mock_gc, mock_cuda, mock_soft_empty, mock_unload
):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 6000, "free_mb": 2000},
{"used_mb": 1000, "free_mb": 7000},
]
freed_mb = purge_memory(mode="models_only", unload_models=True)
mock_unload.assert_called_once()
mock_soft_empty.assert_called_once()
mock_gc.assert_called()
assert freed_mb == 5000
@patch("torch.cuda.is_available")
@patch("torch.cuda.empty_cache")
@patch("gc.collect")
def test_purge_memory_cache_only(self, mock_gc, mock_empty_cache, mock_cuda):
mock_cuda.return_value = True
with patch(
"kikotools.tools.kiko_purge_vram.logic.get_memory_stats"
) as mock_stats:
mock_stats.side_effect = [
{"used_mb": 3000, "free_mb": 5000},
{"used_mb": 2500, "free_mb": 5500},
]
freed_mb = purge_memory(mode="cache_only", unload_models=False)
mock_gc.assert_not_called()
mock_empty_cache.assert_called_once()
assert freed_mb == 500
@patch("torch.cuda.is_available")
def test_purge_memory_no_cuda(self, mock_cuda):
mock_cuda.return_value = False
with patch("gc.collect") as mock_gc:
freed_mb = purge_memory(mode="soft", unload_models=False)
mock_gc.assert_called_once()
assert freed_mb == 0
class TestMemoryReport:
def test_format_memory_report_with_improvement(self):
before = {
"used_mb": 4000,
"free_mb": 4000,
"total_mb": 8000,
"used_percent": 50,
}
after = {"used_mb": 2000, "free_mb": 6000, "total_mb": 8000, "used_percent": 25}
report = format_memory_report(before, after, mode="soft", elapsed_ms=150)
assert "Memory Purge Report" in report
assert "Mode: soft" in report
assert "Memory Freed: 2000.0 MB" in report
assert "Before: 4000.0 MB used (50.0%)" in report
assert "After: 2000.0 MB used (25.0%)" in report
assert "Time: 150.0ms" in report
def test_format_memory_report_no_improvement(self):
before = {
"used_mb": 2000,
"free_mb": 6000,
"total_mb": 8000,
"used_percent": 25,
}
after = {"used_mb": 2000, "free_mb": 6000, "total_mb": 8000, "used_percent": 25}
report = format_memory_report(before, after, mode="cache_only", elapsed_ms=50)
assert "Memory Freed: 0.0 MB" in report
assert "Time: 50.0ms" in report
def test_format_memory_report_no_cuda(self):
before = {
"used_mb": 0,
"free_mb": 0,
"total_mb": 0,
"used_percent": 0,
"cuda_available": False,
}
after = {
"used_mb": 0,
"free_mb": 0,
"total_mb": 0,
"used_percent": 0,
"cuda_available": False,
}
report = format_memory_report(before, after, mode="soft", elapsed_ms=10)
assert "CUDA not available" in report
class TestKikoPurgeVRAMNode:
@patch("kikotools.tools.kiko_purge_vram.node.format_memory_report")
@patch("kikotools.tools.kiko_purge_vram.node.purge_memory")
@patch("kikotools.tools.kiko_purge_vram.node.get_memory_stats")
@patch("kikotools.tools.kiko_purge_vram.node.should_purge")
def test_node_execute_with_threshold(
self, mock_should_purge, mock_stats, mock_purge, mock_format
):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
mock_should_purge.return_value = (
True,
"Memory usage (5000.0 MB) exceeds threshold (4000 MB)",
)
mock_stats.side_effect = [
{
"used_mb": 5000,
"free_mb": 3000,
"total_mb": 8000,
"used_percent": 62.5,
"cuda_available": True,
},
{
"used_mb": 2000,
"free_mb": 6000,
"total_mb": 8000,
"used_percent": 25,
"cuda_available": True,
},
]
mock_purge.return_value = 3000
mock_format.return_value = "Memory Purge Report\n-------------------\nMode: soft\nMemory Freed: 3000.0 MB"
node = KikoPurgeVRAM()
test_input = "test_data"
result, report = node.purge_vram(
anything=test_input,
mode="soft",
report_memory=True,
memory_threshold_mb=4000,
)
assert result == test_input
assert "Memory Freed: 3000.0 MB" in report
mock_purge.assert_called_once_with(mode="soft", unload_models=False)
@patch("kikotools.tools.kiko_purge_vram.logic.get_memory_stats")
def test_node_skip_below_threshold(self, mock_stats):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
mock_stats.return_value = {
"used_mb": 2000,
"free_mb": 6000,
"total_mb": 8000,
"used_percent": 25,
"cuda_available": True,
}
node = KikoPurgeVRAM()
test_input = "test_data"
with patch("kikotools.tools.kiko_purge_vram.logic.purge_memory") as mock_purge:
result, report = node.purge_vram(
anything=test_input,
mode="soft",
report_memory=True,
memory_threshold_mb=3000,
)
assert result == test_input
assert "below threshold" in report.lower()
mock_purge.assert_not_called()
def test_node_input_types(self):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
input_types = KikoPurgeVRAM.INPUT_TYPES()
assert "required" in input_types
assert "optional" in input_types
assert "anything" in input_types["required"]
assert "mode" in input_types["required"]
assert "report_memory" in input_types["required"]
assert "memory_threshold_mb" in input_types["optional"]
def test_node_properties(self):
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
assert KikoPurgeVRAM.FUNCTION == "purge_vram"
assert KikoPurgeVRAM.CATEGORY == "🫶 ComfyAssets/🛠️ Utils"
assert KikoPurgeVRAM.OUTPUT_NODE is True
assert len(KikoPurgeVRAM.RETURN_TYPES) == 2
assert KikoPurgeVRAM.RETURN_NAMES == ("passthrough", "memory_report")
+258
View File
@@ -0,0 +1,258 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
// Custom colors feature with extended options based on PR #433
// Adds custom color pickers for nodes with full, title, and background options
const colorShade = (col, amt) => {
col = col.replace(/^#/, "");
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
let [r, g, b] = col.match(/.{2}/g);
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};
app.registerExtension({
name: "kikotools.customColors",
async init() {
// Register settings
app.ui.settings.addSetting({
id: "kikotools.custom_colors.enabled",
name: "🫶 Custom Colors: Enable",
type: "boolean",
defaultValue: false,
tooltip: "Enable custom color picker options in node context menu",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.show_full",
name: "🫶 Custom Colors: Show Full Color Option",
type: "boolean",
defaultValue: true,
tooltip: "Show option to change both title and background colors",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.show_title",
name: "🫶 Custom Colors: Show Title Color Option",
type: "boolean",
defaultValue: true,
tooltip: "Show option to change only title color",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.show_bg",
name: "🫶 Custom Colors: Show Background Color Option",
type: "boolean",
defaultValue: true,
tooltip: "Show option to change only background color",
});
app.ui.settings.addSetting({
id: "kikotools.custom_colors.auto_shade",
name: "🫶 Custom Colors: Auto-shade Title",
type: "boolean",
defaultValue: true,
tooltip: "Automatically apply shading to title color for better contrast",
});
},
setup() {
let pickerFull, pickerTitle, pickerBG;
let activeNode;
// Check if feature is enabled
const isEnabled = () => {
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
return setting !== undefined ? setting : false;
};
const getSettings = () => ({
showFull: app.ui.settings.getSettingValue("kikotools.custom_colors.show_full") !== false,
showTitle: app.ui.settings.getSettingValue("kikotools.custom_colors.show_title") !== false,
showBG: app.ui.settings.getSettingValue("kikotools.custom_colors.show_bg") !== false,
autoShade: app.ui.settings.getSettingValue("kikotools.custom_colors.auto_shade") !== false,
});
// Helper function to apply color to node(s)
const applyColorToNodes = (colorValue, colorType, node) => {
const settings = getSettings();
const graphcanvas = LGraphCanvas.active_canvas;
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
? [node]
: Object.values(graphcanvas.selected_nodes);
nodes.forEach(n => {
if (colorValue) {
if (n.constructor === LiteGraph.LGraphGroup) {
// For groups, only set the main color
if (colorType === 'full' || colorType === 'bg') {
n.color = colorValue;
}
} else {
// For regular nodes
switch(colorType) {
case 'full':
n.color = settings.autoShade ? colorShade(colorValue, 20) : colorValue;
n.bgcolor = colorValue;
break;
case 'title':
n.color = colorValue;
break;
case 'bg':
n.bgcolor = colorValue;
break;
}
}
}
});
node.setDirtyCanvas(true, true);
};
// Create color picker input if not exists
const createPicker = (type) => {
const picker = $el("input", {
type: "color",
parent: document.body,
style: {
display: "none",
},
});
picker.onchange = () => {
if (activeNode) {
applyColorToNodes(picker.value, type, activeNode);
}
};
return picker;
};
// Hook into the node colors menu
const onMenuNodeColors = LGraphCanvas.onMenuNodeColors;
LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) {
const r = onMenuNodeColors.apply(this, arguments);
// Only add custom options if enabled
if (!isEnabled()) return r;
const settings = getSettings();
requestAnimationFrame(() => {
const menus = document.querySelectorAll(".litecontextmenu");
for (let i = menus.length - 1; i >= 0; i--) {
if (menus[i].firstElementChild.textContent.includes("No color") ||
menus[i].firstElementChild.value?.content?.includes("No color")) {
// Add Custom Full option
if (settings.showFull) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!pickerFull) {
pickerFull = createPicker('full');
}
activeNode = null;
pickerFull.value = node.bgcolor || "#000000";
activeNode = node;
pickerFull.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🫶 Custom Full",
}),
]
);
}
// Add Custom Title option
if (settings.showTitle) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!pickerTitle) {
pickerTitle = createPicker('title');
}
activeNode = null;
pickerTitle.value = node.color || "#000000";
activeNode = node;
pickerTitle.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🫶 Custom Title",
}),
]
);
}
// Add Custom BG option
if (settings.showBG) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!pickerBG) {
pickerBG = createPicker('bg');
}
activeNode = null;
pickerBG.value = node.bgcolor || "#000000";
activeNode = node;
pickerBG.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🫶 Custom BG",
}),
]
);
}
break;
}
}
});
return r;
};
},
});
+157
View File
@@ -0,0 +1,157 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Adds follow execution feature when enabled in settings
// Adds menu options to toggle follow execution and go to executing node
app.registerExtension({
name: "kikotools.followExecution",
async init() {
// Register settings in ComfyUI's settings panel
app.ui.settings.addSetting({
id: "kikotools.follow_execution.enabled",
name: "🫶 Follow Execution: Enable",
type: "boolean",
defaultValue: false,
tooltip: "Enable follow execution feature in canvas right-click menu",
});
app.ui.settings.addSetting({
id: "kikotools.follow_execution.show_goto_node",
name: "🫶 Follow Execution: Show 'Go to node' menu",
type: "boolean",
defaultValue: true,
tooltip: "Show 'Go to node' submenu in canvas menu",
});
app.ui.settings.addSetting({
id: "kikotools.follow_execution.auto_start",
name: "🫶 Follow Execution: Auto-start",
type: "boolean",
defaultValue: false,
tooltip: "Automatically start following execution when workflow starts",
});
},
async setup() {
let followExecution = false;
let isEnabled = false;
// Check if the feature is enabled in settings
const checkEnabled = () => {
const setting = app.ui.settings.getSettingValue("kikotools.follow_execution.enabled");
isEnabled = setting !== undefined ? setting : false;
// If disabled, turn off follow execution
if (!isEnabled && followExecution) {
followExecution = false;
}
};
// Check for auto-start setting
const checkAutoStart = () => {
const autoStart = app.ui.settings.getSettingValue("kikotools.follow_execution.auto_start");
if (autoStart && isEnabled) {
followExecution = true;
}
};
// Initialize settings on startup
checkEnabled();
checkAutoStart();
// Center on the executing node
const centerNode = (id) => {
if (!followExecution || !id || !isEnabled) return;
const node = app.graph.getNodeById(id);
if (!node) return;
app.canvas.centerOnNode(node);
};
// Listen for execution events
api.addEventListener("executing", ({ detail }) => centerNode(detail));
// Listen for execution start to handle auto-start
api.addEventListener("execution_start", () => {
checkEnabled();
checkAutoStart();
});
// Extend canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
// Check if feature is enabled before adding menu items
checkEnabled();
if (!isEnabled) return options;
// Add separator
options.push(null);
// Add follow execution toggle
options.push({
content: followExecution ? "🫶 Stop following execution" : "🫶 Follow execution",
callback: () => {
followExecution = !followExecution;
if (followExecution) {
centerNode(app.runningNodeId);
}
},
});
// Add go to executing node option if a node is currently executing
if (app.runningNodeId) {
options.push({
content: "🫶 Show executing node",
callback: () => {
const node = app.graph.getNodeById(app.runningNodeId);
if (!node) return;
app.canvas.centerOnNode(node);
},
});
}
// Add go to node by type submenu
const showGoToNode = app.ui.settings.getSettingValue("kikotools.follow_execution.show_goto_node");
if (showGoToNode !== false) { // Default to true if not set
const nodes = app.graph._nodes;
const types = nodes.reduce((p, n) => {
if (n.type in p) {
p[n.type].push(n);
} else {
p[n.type] = [n];
}
return p;
}, {});
options.push({
content: "🫶 Go to node",
has_submenu: true,
submenu: {
options: Object.keys(types)
.sort()
.map((t) => ({
content: t,
has_submenu: true,
submenu: {
options: types[t]
.sort((a, b) => {
return a.pos[0] - b.pos[0];
})
.map((n) => ({
content: `${n.getTitle()} - #${n.id} (${Math.round(n.pos[0])}, ${Math.round(n.pos[1])})`,
callback: () => {
app.canvas.centerOnNode(n);
},
})),
},
})),
},
});
}
return options;
};
},
});