Compare commits

...
Author SHA1 Message Date
Vito Sansevero f7d007d8c4 style: Fix line break in test assertion 2025-08-08 18:16:08 -07:00
Vito Sansevero 80045954a5 refactor(node): handle missing folder_paths module 2025-08-08 18:07:38 -07:00
Vito Sansevero 6f176e7b78 test: Refactor folder_paths mocking setup 2025-08-08 18:00:35 -07:00
Vito Sansevero 576efbba41 feat: complete embedding autocomplete implementation with all features
- Remove debug code and console.log statements
- Fix test suite to properly mock folder_paths module
- Update test expectations to match actual implementation
- Add comprehensive README documentation with feature list
- Add placeholder images for documentation screenshots
- Include diagnostic scripts for testing embedding paths
- All tests passing (338 passed, 2 skipped)

Features implemented:
- Autocomplete for embeddings, LoRAs, and custom tags
- Custom word list loading from URL (with security validation)
- Configurable triggers and settings
- Auto-insert comma, replace underscores, Tab/Enter selection
- Smart scrolling in suggestion list
- Secure content validation to prevent XSS attacks

Credits to pythongosssss/ComfyUI-Custom-Scripts for inspiration
2025-08-08 17:45:21 -07:00
Vito Sansevero 593bd4de79 fix: support both 'embedding:' and 'embeddings:' triggers
- Accept both singular and plural forms
- Common user expectation to use plural
- Regex pattern now matches embeddings?:
- Works with 120 loaded embeddings
2025-08-08 15:19:52 -07:00
Vito Sansevero a8fb5ddaa7 fix: use file_name property for embedding names
- Extract embedding names from file_name property
- Filter out null/undefined entries
- Successfully processes 120 embeddings with proper names
- Cleaner extraction logic based on actual API response structure
2025-08-08 15:17:49 -07:00
Vito Sansevero f67e619930 fix: properly extract embedding names from object items
- Add comprehensive object property checking
- Try multiple property names (name, filename, title, id, embedding_name)
- Log item structure to understand format
- Handle both string and object item formats
2025-08-08 15:14:37 -07:00
Vito Sansevero f9e2f5bcc5 fix: improve embedding pagination with api method
- Try api.getEmbeddings(page) for pagination
- Add better logging to see item format
- Gracefully fall back to first page if pagination fails
- Log sample items to understand structure
2025-08-08 15:09:45 -07:00
Vito Sansevero 2cfeab9e76 fix: handle paginated embeddings API response
- Detect and parse paginated response format (items array)
- Fetch all pages to get complete embeddings list (113 total)
- Support both paginated and object formats for compatibility
- Extract actual embedding names from items array
- Debug shows successful trigger detection for 'embedding:'
2025-08-08 15:06:31 -07:00
Vito Sansevero f5ce23ad12 fix: handle ComfyUI's embeddings object response format
- Parse embeddings from object keys instead of expecting array
- Remove file extensions from embedding names
- Add fallback method for LoRAs using /object_info API
- Delay widget attachment to catch dynamically created widgets
- Better detection of textarea widgets regardless of type
2025-08-08 15:03:40 -07:00
Vito Sansevero 05a9520436 debug: add comprehensive logging to diagnose autocomplete issues
- Add console logging to JS for resource fetching and widget attachment
- Add debug mode with window.kikoDebug for inspection
- Log Python API endpoint registration and file discovery
- Track widget creation and event handling
- Show first 5 items when loading resources
2025-08-08 15:00:28 -07:00
Vito Sansevero c1fcc4566b fix: improve embedding autocomplete detection and triggers
- Use ComfyUI's native api.getEmbeddings() for proper embedding detection
- Add dedicated /kikotools/autocomplete/loras endpoint for LoRA files
- Improve trigger detection for "embedding:" and "<lora:" patterns
- Context-aware suggestions based on trigger type
- Better insertion logic that maintains correct syntax
- Sort suggestions by relevance (exact match, starts with, alphabetical)
- Fix character matching patterns to include underscores and hyphens
2025-08-08 14:55:55 -07:00
Vito Sansevero 40b91fc44f feat: enhance embedding autocomplete with interactive test panel
- Add debug/test panel to the node with helpful usage hints
- Display counts of available embeddings and LoRAs
- Show sample items and status information
- Include clear instructions for triggering autocomplete
- Update display name with 🫶 branding
- Make node an OUTPUT_NODE to display information
2025-08-08 13:58:45 -07:00
Vito Sansevero 3e7734bbc3 feat: add KikoEmbeddingAutocomplete with settings registry system
- Implement centralized settings registry for all KikoTools
- Create KikoEmbeddingAutocomplete node with backend API
- Add frontend JavaScript autocomplete widget with ComfyUI integration
- Support for embeddings and LoRAs with smart filtering
- Configurable settings in ComfyUI UI with 🫶 branding
- Include keyboard navigation and real-time suggestions
2025-08-08 13:20:09 -07:00
Vito 17400d88f7 Merge pull request #30 from ComfyAssets/feature/kiko-film-grain
feat: add KikoFilmGrain node for realistic film grain effects
2025-08-07 18:50:48 -07:00
Vito Sansevero 0f79601065 feat: add KikoFilmGrain node for realistic film grain effects
- Implement film grain effect with customizable parameters (scale, strength, saturation, toe, seed)
- Use pure PyTorch operations for better GPU utilization (no OpenCV dependencies)
- Apply ITU-R BT.709 color space conversion for accurate grain distribution
- Implement screen blend mode for better highlight preservation
- Add channel-specific weighting matching real film characteristics (3x blue, 2x red)
- Preserve alpha channel when present
- Add comprehensive test suite (20 tests covering all functionality)
- Include documentation and example workflow
- Register node under ComfyAssets/image category

Improvements over reference implementation:
- More efficient memory management avoiding numpy/OpenCV conversions
- Better grain mixing algorithm with proper color science
- Improved performance through PyTorch-native operations
2025-08-07 18:42:37 -07:00
Vito Sansevero df60457929 chore: bump version to 1.0.12 in pyproject.toml 2025-08-07 08:01:18 -07:00
Vito 5d0e8194a1 Merge pull request #29 from ComfyAssets/fix/display-nodes-scrolling
Fix/display nodes scrolling
2025-08-07 08:00:50 -07:00
Vito Sansevero 218a208bf0 feat(display): add copy buttons as ComfyUI widgets
- Add copy buttons using addCustomWidget for proper integration
- Display Text: separate copy buttons for positive/negative prompts
- Display Any: single copy button for entire value
- Visual feedback shows "✓ Copied\!" for 1.5 seconds
- Buttons work with ComfyUI's widget system

Restores copy functionality while maintaining scrolling fixes
2025-08-07 07:25:21 -07:00
Vito Sansevero a7b612d799 fix(display): handle DOM not ready for appendChild operations
- Add proper null checks before appendChild calls
- Use requestAnimationFrame to ensure DOM elements exist
- Check both inputEl and parentNode before adding buttons
- Prevent "Cannot read properties of null" errors on load

Fixes workflow loading errors with display nodes
2025-08-07 07:05:09 -07:00
Vito Sansevero df3d4c19cb fix(display): use ComfyUI's native STRING widgets for proper scrolling
- Replace custom draw implementations with ComfyWidgets["STRING"]
- Fix cursor display issues (was showing + instead of text cursor)
- Enable native scrolling behavior for both Display Text and Display Any
- Maintain all existing features (copy buttons, split view for prompts)
- Add proper widget cleanup and state management
- Reference: ShowText implementation from ComfyUI-Custom-Scripts

Fixes scrolling and cursor issues in display nodes
2025-08-07 06:45:35 -07:00
Vito Sansevero 938d0d93de chore(pyproject): bump version to 1.0.11 2025-08-07 06:35:34 -07:00
Vito e65bf123b5 Merge pull request #28 from ComfyAssets/feat/add-comfyui-essentials-nodes
Feat/add comfyui essentials nodes
2025-08-07 06:25:36 -07:00
26 changed files with 3581 additions and 757 deletions
+1
View File
@@ -162,3 +162,4 @@ experiments/
# Gemini model cache
.gemini_models_cache.json
referance/
+40
View File
@@ -26,6 +26,7 @@ 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 |
### 🧰 xyz-helpers Tools
@@ -311,6 +312,45 @@ Unified interface for text encoding and sampler parameter management.
- Quick template-based generation
- Batch prompt processing
### 🔤 Embedding Autocomplete
**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" />
</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.
**Key Features:**
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
**Settings Include:**
- Enable/disable autocomplete for embeddings, LoRAs, and custom tags
- Configurable trigger phrases (e.g., `emb:`, `lora:`, custom shortcuts)
- Auto-insert comma after completion
- Replace underscores with spaces in tags
- Choose insertion keys (Tab, Enter, or both)
- Load custom word lists from URLs with security validation
**Security Features:**
- Validates all loaded content to prevent script injection
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
- Safe character whitelist for tags
- File size limits to prevent memory exhaustion
- Clear error messages for rejected content
**Credits:**
- Original autocomplete concept by [pythongosssss](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
- Enhanced and modernized by KikoTools team
### 💾 Kiko Save Image Features
**Use Cases:**
+85 -1
View File
@@ -13,7 +13,91 @@ except ImportError:
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# Tell ComfyUI where to find our JavaScript extensions
WEB_DIRECTORY = "./web"
import os
WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
# Import server components at module level to ensure they're available
try:
from aiohttp import web
from server import PromptServer
import folder_paths
print("[KikoTools] Server imports successful")
# Register autocomplete endpoints directly
@PromptServer.instance.routes.get("/kikotools/autocomplete/embeddings")
async def get_embeddings(request):
"""API endpoint for getting list of embeddings with full paths."""
print("[KikoTools] Embeddings endpoint called")
try:
embedding_files = folder_paths.get_filename_list("embeddings")
print(f"[KikoTools] Found {len(embedding_files)} embedding files")
# Return embeddings with their subdirectory paths, without extensions
embeddings = []
for f in embedding_files:
# Remove extension but keep subdirectory path
clean_path = os.path.splitext(f)[0]
embeddings.append(
{
"file_name": clean_path,
"model_name": clean_path,
"name": os.path.basename(clean_path),
"path": clean_path,
}
)
if len(embeddings) > 0:
print(f"[KikoTools] Sample embedding: {embeddings[0]}")
print(f"[KikoTools] Returning {len(embeddings)} embeddings with paths")
return web.json_response(embeddings)
except Exception as e:
print(f"[KikoTools] Error getting embeddings: {e}")
import traceback
traceback.print_exc()
return web.json_response([])
@PromptServer.instance.routes.get("/kikotools/autocomplete/loras")
async def get_loras(request):
"""API endpoint for getting list of LoRAs."""
print("[KikoTools] LoRA endpoint called")
try:
lora_files = folder_paths.get_filename_list("loras")
print(f"[KikoTools] Found {len(lora_files)} LoRA files")
# Return LoRAs with paths
loras = []
for f in lora_files:
clean_path = os.path.splitext(f)[0]
loras.append(
{
"name": os.path.basename(clean_path),
"path": clean_path,
"file": f,
}
)
print(f"[KikoTools] Returning {len(loras)} LoRAs")
return web.json_response(loras)
except Exception as e:
print(f"[KikoTools] Error getting LoRAs: {e}")
import traceback
traceback.print_exc()
return web.json_response([])
print("[KikoTools] Autocomplete API endpoints registered successfully")
print(
"[KikoTools] Routes available: /kikotools/autocomplete/embeddings and /kikotools/autocomplete/loras"
)
except ImportError as e:
print(f"[KikoTools] Could not import server components: {e}")
except Exception as e:
print(f"[KikoTools] Unexpected error setting up API: {e}")
import traceback
traceback.print_exc()
# API endpoints are registered above at module import time
def get_version():
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+125
View File
@@ -0,0 +1,125 @@
# Kiko Film Grain
## Overview
The **Kiko Film Grain** node applies realistic film grain effects to images, simulating the aesthetic of analog film photography. It provides comprehensive controls for grain size, intensity, color saturation, and shadow lifting to achieve various film looks.
## Node Details
- **Category**: ComfyAssets/image
- **Node Name**: KikoFilmGrain
- **Display Name**: Kiko Film Grain
## Inputs
### Required
- **image** (`IMAGE`)
- The input image to apply film grain to
- Supports batch processing
- Preserves alpha channel if present
### Parameters
- **scale** (`FLOAT`)
- Controls the size of the grain pattern
- Range: 0.25 to 2.0
- Default: 0.5
- Lower values = finer grain, higher values = coarser grain
- **strength** (`FLOAT`)
- Intensity of the grain effect
- Range: 0.0 to 10.0
- Default: 0.5
- 0.0 = no grain, higher values = more pronounced grain
- **saturation** (`FLOAT`)
- Color saturation of the grain
- Range: 0.0 to 2.0
- Default: 0.7
- 0.0 = monochrome grain, 1.0 = full color, >1.0 = oversaturated
- **toe** (`FLOAT`)
- Lifts blacks/shadows for a film-like look
- Range: -0.2 to 0.5
- Default: 0.0
- Positive values lift shadows, negative values crush blacks
- **seed** (`INT`)
- Random seed for grain pattern generation
- Range: 0 to maximum integer
- Default: 0
- Use for reproducible grain patterns
## Outputs
- **image** (`IMAGE`)
- The processed image with film grain applied
- Same dimensions and batch size as input
- Alpha channel preserved if present
## Usage Examples
### Subtle Film Look
```
Scale: 0.5
Strength: 0.3
Saturation: 0.8
Toe: 0.05
```
Creates a subtle, fine-grained film aesthetic suitable for portraits.
### Vintage Film
```
Scale: 1.0
Strength: 0.8
Saturation: 0.5
Toe: 0.15
```
Simulates vintage film with moderate grain and lifted shadows.
### High ISO Film
```
Scale: 0.75
Strength: 1.5
Saturation: 0.6
Toe: 0.1
```
Emulates high ISO film stock with pronounced grain.
### Black & White Film
```
Scale: 0.6
Strength: 0.6
Saturation: 0.0
Toe: 0.08
```
Creates monochrome grain perfect for black and white photography.
## Technical Details
### Improvements Over Standard Implementations
1. **Pure PyTorch Operations**: No OpenCV dependencies, better GPU utilization
2. **ITU-R BT.709 Color Space**: Accurate color conversion for grain application
3. **Screen Blend Mode**: Preserves highlights better than multiply blending
4. **Channel-Specific Weighting**: Film grain is stronger in blue channel (3x), moderate in red (2x), matching real film characteristics
5. **Efficient Memory Management**: Minimizes tensor copies and conversions
### Algorithm Overview
1. Generate random noise at specified scale
2. Convert to YCbCr color space for realistic grain distribution
3. Apply different blur kernels to each channel:
- Y (luminance): 3x3 kernel for fine detail
- Cb (blue-yellow): 15x15 kernel for color noise
- Cr (red-green): 11x11 kernel for color noise
4. Convert back to RGB and apply strength/saturation
5. Use screen blend mode to combine with original image
6. Apply toe adjustment for film-like shadow response
## Tips
- Start with low strength values (0.2-0.5) and adjust upward
- For color images, saturation between 0.5-0.8 looks most natural
- Combine with color grading nodes for complete film emulation
- Use consistent seed values across batch for uniform grain
- Scale parameter affects both grain size and render performance (smaller scale = more computation)
## Compatibility
- Works with any image format supported by ComfyUI
- Preserves image properties (alpha channel, batch size)
- Compatible with both RGB and RGBA images
- Efficient batch processing support
@@ -0,0 +1,165 @@
{
"id": "kiko-film-grain-example",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [
50,
100
],
"size": [
350,
450
],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"shape": 3,
"label": "IMAGE"
},
{
"name": "MASK",
"type": "MASK",
"links": null,
"shape": 3,
"label": "MASK"
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png"
]
},
{
"id": 2,
"type": "KikoFilmGrain",
"pos": [
450,
100
],
"size": [
315,
202
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [2],
"shape": 3,
"label": "image",
"slot_index": 0
}
],
"properties": {
"cnr_id": "kikotools",
"Node name for S&R": "KikoFilmGrain"
},
"widgets_values": [
0.5,
0.5,
0.7,
0.0,
0
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 3,
"type": "PreviewImage",
"pos": [
850,
100
],
"size": [
350,
450
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"properties": {
"Node name for S&R": "PreviewImage"
}
},
{
"id": 4,
"type": "Note",
"pos": [
450,
350
],
"size": [
315,
150
],
"flags": {},
"order": 3,
"mode": 0,
"properties": {
"text": ""
},
"widgets_values": [
"Kiko Film Grain Example\n\nThis workflow demonstrates the film grain effect.\n\nAdjust parameters:\n- Scale: Grain size (0.25-2.0)\n- Strength: Intensity (0.0-10.0)\n- Saturation: Color amount (0.0-2.0)\n- Toe: Shadow lifting (-0.2-0.5)\n- Seed: Random pattern"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.0,
"offset": [0, 0]
}
},
"version": 0.4
}
+18 -12
View File
@@ -3,24 +3,26 @@ KikoTools package initialization and node registry
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
from .tools.empty_latent_batch import EmptyLatentBatchNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.image_scale_down_by import ImageScaleDownByNode
from .tools.gemini_prompt import GeminiPromptNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
from .tools.empty_latent_batch import EmptyLatentBatchNode
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_save_image import KikoSaveImageNode
from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
from .tools.seed_history import SeedHistoryNode
from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.xyz_helpers import (
FluxSamplerParamsNode,
LoRAFolderBatchNode,
PlotParametersNode,
SamplerSelectHelperNode,
SchedulerSelectHelperNode,
TextEncodeSamplerParamsNode,
FluxSamplerParamsNode,
PlotParametersNode,
LoRAFolderBatchNode,
)
# ComfyUI node registration mappings
@@ -37,12 +39,14 @@ NODE_CLASS_MAPPINGS = {
"GeminiPrompt": GeminiPromptNode,
"DisplayAny": DisplayAnyNode,
"DisplayText": DisplayTextNode,
"KikoFilmGrain": KikoFilmGrainNode,
"SamplerSelectHelper": SamplerSelectHelperNode,
"SchedulerSelectHelper": SchedulerSelectHelperNode,
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"KikoEmbeddingAutocomplete": KikoEmbeddingAutocomplete,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -58,12 +62,14 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GeminiPrompt": "Gemini Prompt Engineer",
"DisplayAny": "Display Any",
"DisplayText": "Display Text",
"KikoFilmGrain": "Kiko Film Grain",
"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",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
View File
+96
View File
@@ -0,0 +1,96 @@
"""Tool registry for KikoTools.
This module provides the central registration system for all KikoTools nodes.
"""
import importlib
import os
from typing import Dict, List, Any, Optional
from pathlib import Path
class ToolRegistry:
"""Central registry for all KikoTools."""
def __init__(self):
self.tools: Dict[str, Any] = {}
self.node_classes: Dict[str, Any] = {}
def register_tool(self, tool_name: str, node_class: Any) -> None:
"""Register a tool and its node class.
Args:
tool_name: Name of the tool
node_class: The ComfyUI node class
"""
self.tools[tool_name] = node_class
# Also register by class name for ComfyUI
class_name = node_class.__name__
self.node_classes[class_name] = node_class
def discover_tools(self) -> None:
"""Automatically discover and load all tools in the tools directory."""
tools_dir = Path(__file__).parent.parent / "tools"
if not tools_dir.exists():
return
for tool_dir in tools_dir.iterdir():
if tool_dir.is_dir() and not tool_dir.name.startswith("_"):
self._load_tool(tool_dir.name)
def _load_tool(self, tool_name: str) -> None:
"""Load a single tool module.
Args:
tool_name: Name of the tool directory
"""
try:
# Try to import the tool's node module
module = importlib.import_module(f"kikotools.tools.{tool_name}.node")
# Look for node classes (classes with ComfyUI node attributes)
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and hasattr(attr, "INPUT_TYPES")
and hasattr(attr, "FUNCTION")
):
self.register_tool(tool_name, attr)
# If the tool has settings, register them
if hasattr(attr, "SETTINGS"):
from .settings import settings_registry
settings_registry.register_tool_settings(
tool_name,
getattr(
attr,
"DISPLAY_NAME",
tool_name.replace("_", " ").title(),
),
attr.SETTINGS,
)
except ImportError as e:
# Tool might not have a node.py file yet
pass
def get_node_class_mappings(self) -> Dict[str, Any]:
"""Get node class mappings for ComfyUI registration."""
return self.node_classes.copy()
def get_node_display_name_mappings(self) -> Dict[str, str]:
"""Get display name mappings for ComfyUI."""
mappings = {}
for class_name, node_class in self.node_classes.items():
if hasattr(node_class, "DISPLAY_NAME"):
mappings[class_name] = node_class.DISPLAY_NAME
else:
# Generate a display name from class name
mappings[class_name] = class_name.replace("Kiko", "").replace(
"Node", ""
)
return mappings
+201
View File
@@ -0,0 +1,201 @@
"""Settings registry for KikoTools.
This module provides a centralized settings management system for all KikoTools.
Tools can register their settings, which are then exposed in ComfyUI's settings UI.
"""
import json
import os
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass, field
@dataclass
class SettingDefinition:
"""Definition of a single setting."""
id: str
name: str
type: str # "boolean", "combo", "number", "string", "custom"
default: Any
description: Optional[str] = None
options: Optional[Union[List[Any], Dict[str, Any]]] = None
min_value: Optional[float] = None
max_value: Optional[float] = None
step: Optional[float] = None
on_change: Optional[str] = None # JavaScript callback as string
@dataclass
class ToolSettings:
"""Settings collection for a single tool."""
tool_name: str
display_name: str
settings: List[SettingDefinition] = field(default_factory=list)
class SettingsRegistry:
"""Central registry for all KikoTools settings."""
def __init__(self):
self.tools: Dict[str, ToolSettings] = {}
self.settings_by_id: Dict[str, SettingDefinition] = {}
def register_tool_settings(
self, tool_name: str, display_name: str, settings: Dict[str, Dict[str, Any]]
) -> None:
"""Register settings for a tool.
Args:
tool_name: Internal tool identifier (e.g., "embedding_autocomplete")
display_name: Display name for the tool (e.g., "Embedding Autocomplete")
settings: Dictionary of setting configurations
{
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable embedding autocomplete"
},
"max_suggestions": {
"type": "combo",
"default": 20,
"options": [10, 20, 50],
"description": "Maximum number of suggestions"
}
}
"""
tool_settings = ToolSettings(tool_name, display_name)
for setting_key, config in settings.items():
# Generate fully qualified setting ID
setting_id = f"kikotools.{tool_name}.{setting_key}"
# Create display name with branding
setting_name = f"🫶 {display_name}: {setting_key.replace('_', ' ').title()}"
setting_def = SettingDefinition(
id=setting_id,
name=setting_name,
type=config.get("type", "string"),
default=config.get("default"),
description=config.get("description"),
options=config.get("options"),
min_value=config.get("min"),
max_value=config.get("max"),
step=config.get("step"),
on_change=config.get("on_change"),
)
tool_settings.settings.append(setting_def)
self.settings_by_id[setting_id] = setting_def
self.tools[tool_name] = tool_settings
def get_setting(self, setting_id: str) -> Optional[SettingDefinition]:
"""Get a setting definition by ID."""
return self.settings_by_id.get(setting_id)
def get_tool_settings(self, tool_name: str) -> Optional[ToolSettings]:
"""Get all settings for a tool."""
return self.tools.get(tool_name)
def generate_frontend_registration(self) -> str:
"""Generate JavaScript code for frontend settings registration."""
js_lines = [
"// Auto-generated KikoTools settings registration",
"// This file is automatically generated by the settings registry",
"",
"import { app } from '../../scripts/app.js';",
"",
"app.registerExtension({",
" name: 'kikotools.settings',",
" async init() {",
" // Register all KikoTools settings",
]
for tool_name, tool_settings in self.tools.items():
js_lines.append(f" // {tool_settings.display_name} settings")
for setting in tool_settings.settings:
js_lines.append(f" app.ui.settings.addSetting({{")
js_lines.append(f' id: "{setting.id}",')
js_lines.append(f' name: "{setting.name}",')
js_lines.append(
f" defaultValue: {self._js_value(setting.default)},"
)
js_lines.append(f' type: "{setting.type}",')
if setting.description:
js_lines.append(f' tooltip: "{setting.description}",')
if setting.type == "combo" and setting.options:
js_lines.append(f" options: (value) => {{")
js_lines.append(
f" const options = {json.dumps(setting.options)};"
)
js_lines.append(f" return options.map(opt => ({{")
js_lines.append(f" value: opt,")
js_lines.append(f" text: String(opt),")
js_lines.append(f" selected: opt === value")
js_lines.append(f" }}));")
js_lines.append(f" }},")
if setting.type == "number":
if setting.min_value is not None:
js_lines.append(f" min: {setting.min_value},")
if setting.max_value is not None:
js_lines.append(f" max: {setting.max_value},")
if setting.step is not None:
js_lines.append(f" step: {setting.step},")
if setting.on_change:
js_lines.append(f" onChange(value) {{")
js_lines.append(f" {setting.on_change}")
js_lines.append(f" }}")
js_lines.append(f" }});")
js_lines.append("")
js_lines.extend([" }", "});", ""])
return "\n".join(js_lines)
def _js_value(self, value: Any) -> str:
"""Convert Python value to JavaScript literal."""
if isinstance(value, bool):
return "true" if value else "false"
elif isinstance(value, str):
return f'"{value}"'
elif value is None:
return "null"
else:
return str(value)
def save_frontend_settings(
self, output_path: str = "web/js/kikoSettings.js"
) -> None:
"""Save the generated frontend settings to a file."""
js_content = self.generate_frontend_registration()
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f:
f.write(js_content)
def get_all_settings(self) -> Dict[str, Any]:
"""Get all registered settings as a dictionary."""
result = {}
for tool_name, tool_settings in self.tools.items():
result[tool_name] = {
"display_name": tool_settings.display_name,
"settings": {
setting.id.split(".")[-1]: {
"type": setting.type,
"default": setting.default,
"description": setting.description,
"options": setting.options,
}
for setting in tool_settings.settings
},
}
return result
@@ -0,0 +1,5 @@
"""Embedding Autocomplete tool for KikoTools."""
from .node import KikoEmbeddingAutocomplete
__all__ = ["KikoEmbeddingAutocomplete"]
@@ -0,0 +1,291 @@
"""KikoEmbeddingAutocomplete node for ComfyUI.
Provides autocomplete functionality for embeddings and LoRAs in text inputs.
"""
import os
from typing import Dict, List, Any
try:
import folder_paths
except ImportError:
# For testing outside ComfyUI environment
folder_paths = None
class KikoEmbeddingAutocomplete:
"""Node that provides embedding autocomplete functionality."""
DISPLAY_NAME = "🫶 Embedding Autocomplete Settings"
CATEGORY = "ComfyAssets"
# Settings definition for the settings registry
SETTINGS = {
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable autocomplete",
},
"show_embeddings": {
"type": "boolean",
"default": True,
"description": "Show embeddings in autocomplete",
},
"show_loras": {
"type": "boolean",
"default": True,
"description": "Show LoRAs in autocomplete",
},
"embedding_trigger": {
"type": "text",
"default": "embedding:",
"description": "Trigger text for embeddings (e.g., 'embedding:', 'emb:', or custom)",
},
"lora_trigger": {
"type": "text",
"default": "<lora:",
"description": "Trigger text for LoRAs (e.g., '<lora:', 'lora:', or custom)",
},
"quick_trigger": {
"type": "text",
"default": "em",
"description": "Quick trigger to show embeddings (e.g., 'em', 'emb', or disabled with '')",
},
"min_chars": {
"type": "combo",
"default": 2,
"options": [1, 2, 3, 4, 5],
"description": "Minimum characters before showing suggestions",
},
"max_suggestions": {
"type": "combo",
"default": 20,
"options": [5, 10, 15, 20, 30, 50, 100],
"description": "Maximum number of suggestions to display",
},
"sort_by_directory": {
"type": "boolean",
"default": True,
"description": "Group suggestions by directory",
},
}
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
return {
"required": {},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "update_settings"
OUTPUT_NODE = True
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def __init__(self):
self.embeddings_cache = None
self.loras_cache = None
def update_settings(self, unique_id=None):
"""Update settings display.
This node serves as a settings indicator.
Actual settings are configured in ComfyUI Settings menu.
"""
# This node doesn't actually process anything
# It's just a visual indicator that autocomplete is available
return ()
def refresh_cache(self):
"""Refresh the cache of embeddings and LoRAs."""
print("[KikoEmbeddingAutocomplete] Refreshing cache...")
self.embeddings_cache = self.get_embeddings()
self.loras_cache = self.get_loras()
print(
f"[KikoEmbeddingAutocomplete] Cached {len(self.embeddings_cache)} embeddings, {len(self.loras_cache)} LoRAs"
)
def get_embeddings(self) -> List[Dict[str, Any]]:
"""Get list of available embeddings."""
embeddings = []
# Get embedding files from ComfyUI's folder system
try:
print("[KikoEmbeddingAutocomplete] Getting embeddings list...")
if folder_paths is None:
return embeddings
embedding_files = folder_paths.get_filename_list("embeddings")
print(
f"[KikoEmbeddingAutocomplete] Found {len(embedding_files)} embedding files"
)
for file in embedding_files:
name = os.path.splitext(file)[0]
embeddings.append(
{
"name": name,
"file": file,
"type": "embedding",
"display": f"embedding:{name}",
"value": f"embedding:{name}",
}
)
except Exception as e:
print(f"Error loading embeddings: {e}")
return embeddings
def get_loras(self) -> List[Dict[str, Any]]:
"""Get list of available LoRAs."""
loras = []
# Get LoRA files from ComfyUI's folder system
try:
if folder_paths is None:
return loras
lora_files = folder_paths.get_filename_list("loras")
for file in lora_files:
name = os.path.splitext(file)[0]
loras.append(
{
"name": name,
"file": file,
"type": "lora",
"display": f"<lora:{name}:1.0>",
"value": f"<lora:{name}:1.0>",
}
)
except Exception as e:
print(f"Error loading LoRAs: {e}")
return loras
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Check if the node needs to be re-executed."""
# Always re-execute if refresh is True
if kwargs.get("refresh", False):
return float("NaN")
# Check if embeddings/loras folders have changed
try:
if folder_paths is None:
return 0
embeddings_path = folder_paths.get_folder_paths("embeddings")[0]
loras_path = folder_paths.get_folder_paths("loras")[0]
# Return combined modification time
return os.path.getmtime(embeddings_path) + os.path.getmtime(loras_path)
except Exception:
return 0
class KikoEmbeddingAutocompleteAPI:
"""API endpoints for embedding autocomplete."""
@staticmethod
def get_suggestions(
prefix: str,
max_results: int = 20,
include_embeddings: bool = True,
include_loras: bool = True,
case_sensitive: bool = False,
) -> List[Dict[str, Any]]:
"""Get autocomplete suggestions for a given prefix.
Args:
prefix: The text prefix to match
max_results: Maximum number of results to return
include_embeddings: Include embeddings in results
include_loras: Include LoRAs in results
case_sensitive: Use case-sensitive matching
Returns:
List of suggestion dictionaries
"""
suggestions = []
# Normalize prefix for matching
match_prefix = prefix if case_sensitive else prefix.lower()
# Get embeddings
if include_embeddings:
try:
if folder_paths is None:
embedding_files = []
else:
embedding_files = folder_paths.get_filename_list("embeddings")
for file in embedding_files:
name = os.path.splitext(file)[0]
match_name = name if case_sensitive else name.lower()
# Check for match
if match_name.startswith(match_prefix):
suggestions.append(
{
"name": name,
"type": "embedding",
"display": f"embedding:{name}",
"value": f"embedding:{name}",
"priority": 1 if match_name == match_prefix else 0,
}
)
elif match_prefix in match_name:
suggestions.append(
{
"name": name,
"type": "embedding",
"display": f"embedding:{name}",
"value": f"embedding:{name}",
"priority": -1,
}
)
except Exception as e:
print(f"Error loading embeddings: {e}")
# Get LoRAs
if include_loras:
try:
if folder_paths is None:
lora_files = []
else:
lora_files = folder_paths.get_filename_list("loras")
for file in lora_files:
name = os.path.splitext(file)[0]
match_name = name if case_sensitive else name.lower()
# Check for match
if match_name.startswith(match_prefix):
suggestions.append(
{
"name": name,
"type": "lora",
"display": f"<lora:{name}:1.0>",
"value": f"<lora:{name}:1.0>",
"priority": 1 if match_name == match_prefix else 0,
}
)
elif match_prefix in match_name:
suggestions.append(
{
"name": name,
"type": "lora",
"display": f"<lora:{name}:1.0>",
"value": f"<lora:{name}:1.0>",
"priority": -1,
}
)
except Exception as e:
print(f"Error loading LoRAs: {e}")
# Sort by priority and name
suggestions.sort(key=lambda x: (-x["priority"], x["name"]))
# Limit results
return suggestions[:max_results]
@@ -0,0 +1,3 @@
from .node import KikoFilmGrainNode
__all__ = ["KikoFilmGrainNode"]
+221
View File
@@ -0,0 +1,221 @@
import torch
import torch.nn.functional as F
def rgb_to_ycbcr(rgb: torch.Tensor) -> torch.Tensor:
"""
Convert RGB tensor to YCbCr color space.
Args:
rgb: Tensor of shape [B, H, W, C] in range [0, 1]
Returns:
YCbCr tensor of same shape
"""
ycbcr = rgb.detach().clone()
r, g, b = rgb[:, :, :, 0], rgb[:, :, :, 1], rgb[:, :, :, 2]
# ITU-R BT.709 coefficients
ycbcr[:, :, :, 0] = 0.2126 * r + 0.7152 * g + 0.0722 * b # Y
ycbcr[:, :, :, 1] = -0.1146 * r - 0.3854 * g + 0.5 * b # Cb
ycbcr[:, :, :, 2] = 0.5 * r - 0.4542 * g - 0.0458 * b # Cr
return ycbcr
def ycbcr_to_rgb(ycbcr: torch.Tensor) -> torch.Tensor:
"""
Convert YCbCr tensor to RGB color space.
Args:
ycbcr: Tensor of shape [B, H, W, C]
Returns:
RGB tensor of same shape in range [0, 1]
"""
rgb = ycbcr.detach().clone()
y, cb, cr = ycbcr[:, :, :, 0], ycbcr[:, :, :, 1], ycbcr[:, :, :, 2]
rgb[:, :, :, 0] = y + 1.5748 * cr # R
rgb[:, :, :, 1] = y - 0.1873 * cb - 0.4681 * cr # G
rgb[:, :, :, 2] = y + 1.8556 * cb # B
return torch.clamp(rgb, 0, 1)
def apply_gaussian_blur(tensor: torch.Tensor, kernel_size: int) -> torch.Tensor:
"""
Apply Gaussian blur to a tensor using PyTorch operations.
Args:
tensor: Tensor of shape [B, H, W, C]
kernel_size: Size of the Gaussian kernel (must be odd)
Returns:
Blurred tensor of same shape
"""
if kernel_size <= 1:
return tensor
# Ensure kernel size is odd
kernel_size = kernel_size if kernel_size % 2 == 1 else kernel_size + 1
# Create Gaussian kernel
sigma = kernel_size / 3.0
x = torch.arange(kernel_size, dtype=torch.float32) - kernel_size // 2
gauss = torch.exp(-x.pow(2) / (2 * sigma**2))
gauss = gauss / gauss.sum()
# Create 2D kernel
kernel = gauss.unsqueeze(0) * gauss.unsqueeze(1)
kernel = kernel.unsqueeze(0).unsqueeze(0)
# Apply blur per channel
batch_size, h, w, channels = tensor.shape
tensor_reshaped = tensor.permute(0, 3, 1, 2) # [B, C, H, W]
# Expand kernel for all channels
kernel = kernel.repeat(channels, 1, 1, 1)
# Apply convolution with padding
padding = kernel_size // 2
blurred = F.conv2d(tensor_reshaped, kernel, padding=padding, groups=channels)
return blurred.permute(0, 2, 3, 1) # Back to [B, H, W, C]
def generate_grain_texture(
batch_size: int, height: int, width: int, scale: float, seed: int
) -> torch.Tensor:
"""
Generate base grain texture at specified scale.
Args:
batch_size: Number of images in batch
height: Target height
width: Target width
scale: Scale factor for grain size (larger = coarser grain)
seed: Random seed for reproducibility
Returns:
Grain texture tensor of shape [B, H/scale, W/scale, 3]
"""
torch.manual_seed(seed)
grain_height = max(1, int(height / scale))
grain_width = max(1, int(width / scale))
# Generate random noise
grain = torch.rand(batch_size, grain_height, grain_width, 3)
return grain
def apply_film_grain(
image: torch.Tensor,
scale: float = 0.5,
strength: float = 0.5,
saturation: float = 0.7,
toe: float = 0.0,
seed: int = 0,
) -> torch.Tensor:
"""
Apply film grain effect to an image with improved algorithms.
Improvements over original:
- Better color space conversion using ITU-R BT.709 coefficients
- More efficient Gaussian blur using PyTorch convolutions
- Improved grain mixing with better channel weighting
- Preserves alpha channel if present
- Better memory efficiency
Args:
image: Input tensor of shape [B, H, W, C] in range [0, 1]
scale: Grain size (0.25-2.0, higher = coarser grain)
strength: Grain intensity (0.0-10.0)
saturation: Color saturation of grain (0.0-2.0)
toe: Lift blacks/shadows (-0.2-0.5)
seed: Random seed for reproducibility
Returns:
Image with film grain applied
"""
if strength == 0.0:
return image
# Handle empty batch
if image.shape[0] == 0:
return image
result = image.detach().clone()
has_alpha = image.shape[-1] == 4
# Generate grain texture
grain = generate_grain_texture(
image.shape[0], image.shape[1], image.shape[2], scale, seed
)
# Convert to YCbCr for better grain application
grain_ycbcr = rgb_to_ycbcr(grain)
# Apply different blur kernels to each channel for more realistic grain
# Y channel - fine detail
grain_ycbcr[:, :, :, 0] = apply_gaussian_blur(
grain_ycbcr[:, :, :, 0:1], kernel_size=3
).squeeze(-1)
# Cb channel - medium blur for color noise
grain_ycbcr[:, :, :, 1] = apply_gaussian_blur(
grain_ycbcr[:, :, :, 1:2], kernel_size=15
).squeeze(-1)
# Cr channel - slightly less blur
grain_ycbcr[:, :, :, 2] = apply_gaussian_blur(
grain_ycbcr[:, :, :, 2:3], kernel_size=11
).squeeze(-1)
# Convert back to RGB
grain = ycbcr_to_rgb(grain_ycbcr)
# Center grain around 0 and apply strength
grain = (grain - 0.5) * strength
# Apply channel-specific weighting for more realistic film grain
# Film grain is typically stronger in blue channel, moderate in red
grain[:, :, :, 0] *= 2.0 # Red channel
grain[:, :, :, 1] *= 1.0 # Green channel (reference)
grain[:, :, :, 2] *= 3.0 # Blue channel
# Add 1 to make it multiplicative
grain = grain + 1.0
# Apply saturation control
# Extract luminance for desaturation mixing
luminance = grain[:, :, :, 1:2] # Use green channel as approximation
grain = grain * saturation + luminance * (1 - saturation)
# Interpolate grain to match image size if needed
if grain.shape[1] != image.shape[1] or grain.shape[2] != image.shape[2]:
grain = F.interpolate(
grain.permute(0, 3, 1, 2),
size=(image.shape[1], image.shape[2]),
mode="bilinear",
align_corners=False,
).permute(0, 2, 3, 1)
# Apply grain using screen blend mode: 1 - (1 - image) * grain
# This preserves highlights better than multiply
if has_alpha:
# Only apply to RGB channels
result[:, :, :, :3] = 1 - (1 - result[:, :, :, :3]) * grain
else:
result = 1 - (1 - result[:, :, :, :3]) * grain
# Apply toe adjustment (lift blacks)
if has_alpha:
result[:, :, :, :3] = result[:, :, :, :3] * (1 - toe) + toe
else:
result = result * (1 - toe) + toe
# Ensure output is in valid range
return torch.clamp(result, 0, 1)
+123
View File
@@ -0,0 +1,123 @@
import torch
from typing import Dict, Any, Tuple
from ...base import ComfyAssetsBaseNode
from .logic import apply_film_grain
class KikoFilmGrainNode(ComfyAssetsBaseNode):
"""
Apply realistic film grain effect to images.
This node simulates the grain patterns found in analog film photography.
It provides controls for grain size, intensity, color saturation, and
shadow lifting (toe) to achieve various film looks.
Improvements over reference implementation:
- More efficient PyTorch-based blur operations
- Better memory management for large batches
- Preserves alpha channel when present
- Improved grain mixing algorithm
- ITU-R BT.709 color space conversion
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"image": ("IMAGE",),
"scale": (
"FLOAT",
{
"default": 0.5,
"min": 0.25,
"max": 2.0,
"step": 0.05,
"display": "slider",
"description": "Grain size - smaller values create finer grain",
},
),
"strength": (
"FLOAT",
{
"default": 0.5,
"min": 0.0,
"max": 10.0,
"step": 0.01,
"display": "slider",
"description": "Intensity of the grain effect",
},
),
"saturation": (
"FLOAT",
{
"default": 0.7,
"min": 0.0,
"max": 2.0,
"step": 0.01,
"display": "slider",
"description": "Color saturation of the grain (0=monochrome)",
},
),
"toe": (
"FLOAT",
{
"default": 0.0,
"min": -0.2,
"max": 0.5,
"step": 0.001,
"display": "slider",
"description": "Lift blacks/shadows for a film-like look",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"description": "Random seed for grain pattern generation",
},
),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "apply_grain"
CATEGORY = "ComfyAssets/image"
DESCRIPTION = "Apply realistic film grain effect with customizable parameters"
def apply_grain(
self,
image: torch.Tensor,
scale: float,
strength: float,
saturation: float,
toe: float,
seed: int,
) -> Tuple[torch.Tensor]:
"""
Apply film grain effect to the input image.
Args:
image: Input image tensor [B, H, W, C]
scale: Grain size factor (0.25-2.0)
strength: Grain intensity (0.0-10.0)
saturation: Color saturation of grain (0.0-2.0)
toe: Shadow lifting amount (-0.2-0.5)
seed: Random seed for reproducibility
Returns:
Tuple containing the processed image tensor
"""
result = apply_film_grain(
image=image,
scale=scale,
strength=strength,
saturation=saturation,
toe=toe,
seed=seed,
)
return (result,)
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.10"
version = "1.0.12"
license = {text = "MIT"}
dependencies = []
+7
View File
@@ -3,10 +3,17 @@ pytest configuration and fixtures for ComfyUI-KikoTools testing
Provides mock ComfyUI environments and test data
"""
import sys
import pytest
import torch
from unittest.mock import MagicMock
# Mock folder_paths module before any imports that might use it
sys.modules["folder_paths"] = MagicMock()
sys.modules["folder_paths"].get_filename_list = MagicMock(return_value=[])
sys.modules["folder_paths"].get_folder_paths = MagicMock(return_value=["/mock/path"])
sys.modules["folder_paths"].base_path = "/mock/base"
@pytest.fixture
def mock_image_tensor():
+92
View File
@@ -0,0 +1,92 @@
"""Basic tests for KikoEmbeddingAutocomplete."""
import sys
from unittest.mock import MagicMock, patch
def test_import():
"""Test that the module can be imported."""
from kikotools.tools.embedding_autocomplete import KikoEmbeddingAutocomplete
assert KikoEmbeddingAutocomplete is not None
assert (
KikoEmbeddingAutocomplete.DISPLAY_NAME == "🫶 Embedding Autocomplete Settings"
)
assert KikoEmbeddingAutocomplete.CATEGORY == "ComfyAssets"
def test_settings_defined():
"""Test that settings are properly defined."""
from kikotools.tools.embedding_autocomplete import KikoEmbeddingAutocomplete
settings = KikoEmbeddingAutocomplete.SETTINGS
assert "enabled" in settings
assert "min_chars" in settings # Changed from trigger_chars
assert "max_suggestions" in settings
assert "show_embeddings" in settings
assert "show_loras" in settings
assert "embedding_trigger" in settings
assert "lora_trigger" in settings
assert "quick_trigger" in settings
assert "sort_by_directory" in settings
# Check settings structure
assert settings["enabled"]["type"] == "boolean"
assert settings["enabled"]["default"] is True
assert settings["min_chars"]["type"] == "combo"
assert settings["min_chars"]["options"] == [1, 2, 3, 4, 5]
def test_input_types():
"""Test INPUT_TYPES class method."""
from kikotools.tools.embedding_autocomplete import KikoEmbeddingAutocomplete
input_types = KikoEmbeddingAutocomplete.INPUT_TYPES()
assert "required" in input_types
assert "hidden" in input_types
assert input_types["required"] == {} # No required inputs
assert "unique_id" in input_types["hidden"]
def test_api_suggestions():
"""Test the API suggestions method."""
from kikotools.tools.embedding_autocomplete.node import (
KikoEmbeddingAutocompleteAPI,
folder_paths,
)
# Mock folder_paths if it exists (will be None in tests)
with patch("kikotools.tools.embedding_autocomplete.node.folder_paths") as mock_fp:
mock_fp.get_filename_list = MagicMock(
side_effect=lambda x: (
["test1.pt", "test2.safetensors"]
if x == "embeddings"
else ["lora1.pt", "lora2.safetensors"]
)
)
# Test with embeddings
suggestions = KikoEmbeddingAutocompleteAPI.get_suggestions(
prefix="test", include_embeddings=True, include_loras=False
)
assert len(suggestions) == 2
assert suggestions[0]["type"] == "embedding"
assert suggestions[0]["name"] == "test1"
# Test with LoRAs
suggestions = KikoEmbeddingAutocompleteAPI.get_suggestions(
prefix="lora", include_embeddings=False, include_loras=True
)
assert len(suggestions) == 2
assert suggestions[0]["type"] == "lora"
assert "<lora:" in suggestions[0]["value"]
if __name__ == "__main__":
test_import()
test_settings_defined()
test_input_types()
test_api_suggestions()
print("All tests passed!")
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Test script to check how ComfyUI returns embedding paths."""
import sys
import os
# Add ComfyUI to path if available
comfyui_path = os.path.expanduser("~/ComfyUI")
if os.path.exists(comfyui_path):
sys.path.insert(0, comfyui_path)
try:
import folder_paths
print("Testing embedding paths...")
print("=" * 50)
# Get embeddings
embeddings = folder_paths.get_filename_list("embeddings")
print(f"Total embeddings found: {len(embeddings)}")
print("\nFirst 20 embeddings:")
for i, emb in enumerate(embeddings[:20]):
print(f" {i+1}. '{emb}'")
print("\n" + "=" * 50)
print("Checking for path separators...")
has_paths = any("/" in emb or "\\" in emb for emb in embeddings)
print(f"Contains path separators: {has_paths}")
if has_paths:
print("\nEmbeddings with paths:")
for emb in embeddings[:10]:
if "/" in emb or "\\" in emb:
print(f" - {emb}")
except ImportError as e:
print(f"Could not import folder_paths: {e}")
print("\nThis script should be run from within ComfyUI environment")
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Test what folder_paths.get_filename_list actually returns."""
import sys
import os
# Add ComfyUI to path
comfyui_path = "/home/vito/ai-apps/ComfyUI-3.12"
if os.path.exists(comfyui_path):
sys.path.insert(0, comfyui_path)
# Set the working directory for folder_paths
os.environ["COMFYUI_PATH"] = comfyui_path
try:
import folder_paths
print("Testing folder_paths.get_filename_list('embeddings')...")
print("=" * 60)
embeddings = folder_paths.get_filename_list("embeddings")
print(f"Total embeddings: {len(embeddings)}")
print("\nFirst 10 embeddings:")
for i, emb in enumerate(embeddings[:10]):
print(f" {i+1}. '{emb}'")
# Check if any have paths
with_paths = [e for e in embeddings if "/" in e or "\\" in e]
print(f"\nEmbeddings with path separators: {len(with_paths)}")
if with_paths:
print("Examples:")
for e in with_paths[:5]:
print(f" - '{e}'")
# Check the actual folder structure
print("\n" + "=" * 60)
print("Checking actual folder structure...")
emb_folders = folder_paths.get_folder_paths("embeddings")
print(f"Embedding folders: {emb_folders}")
if emb_folders:
emb_dir = emb_folders[0]
print(f"\nContents of {emb_dir}:")
for root, dirs, files in os.walk(emb_dir):
rel_root = os.path.relpath(root, emb_dir)
if rel_root == ".":
rel_root = ""
for f in files[:5]: # Show first 5 files in each dir
if f.endswith((".pt", ".safetensors", ".ckpt")):
full_path = os.path.join(rel_root, f) if rel_root else f
print(f" - '{full_path}'")
if len(files) > 5:
print(f" ... and {len(files)-5} more files")
if dirs:
print(f" Subdirectories: {dirs}")
except ImportError as e:
print(f"Could not import folder_paths: {e}")
else:
print(f"ComfyUI not found at {comfyui_path}")
+249
View File
@@ -0,0 +1,249 @@
import pytest
import torch
import numpy as np
from unittest.mock import MagicMock
from kikotools.tools.kiko_film_grain.logic import (
apply_film_grain,
generate_grain_texture,
rgb_to_ycbcr,
ycbcr_to_rgb,
apply_gaussian_blur,
)
class TestColorSpaceConversion:
def test_rgb_to_ycbcr_conversion(self):
rgb = torch.tensor([[[[1.0, 0.0, 0.0]]]]) # Pure red
ycbcr = rgb_to_ycbcr(rgb)
assert ycbcr.shape == rgb.shape
assert 0.0 <= ycbcr[0, 0, 0, 0] <= 1.0 # Y channel
def test_ycbcr_to_rgb_conversion(self):
ycbcr = torch.tensor([[[[0.5, 0.0, 0.0]]]])
rgb = ycbcr_to_rgb(ycbcr)
assert rgb.shape == ycbcr.shape
assert rgb.min() >= 0.0
assert rgb.max() <= 1.0
def test_rgb_ycbcr_round_trip(self):
original = torch.rand(1, 4, 4, 3)
converted = ycbcr_to_rgb(rgb_to_ycbcr(original))
# Should be approximately equal after round trip
assert torch.allclose(original, converted, atol=0.01)
class TestGaussianBlur:
def test_apply_gaussian_blur_no_blur(self):
image = torch.rand(1, 10, 10, 3)
blurred = apply_gaussian_blur(image, kernel_size=1)
# Kernel size 1 should not blur
assert torch.allclose(image, blurred, atol=0.001)
def test_apply_gaussian_blur_with_blur(self):
# Create sharp edge image
image = torch.zeros(1, 10, 10, 1)
image[:, :5, :, :] = 1.0
blurred = apply_gaussian_blur(image, kernel_size=3)
# Edge should be smoothed
edge_original = image[0, 4:6, 5, 0]
edge_blurred = blurred[0, 4:6, 5, 0]
# White side near edge should be darker due to blur
assert edge_blurred[0] < edge_original[0]
# Black side near edge should be lighter due to blur
assert edge_blurred[1] > edge_original[1]
def test_apply_gaussian_blur_preserves_shape(self):
for shape in [(1, 32, 32, 3), (2, 64, 128, 1), (4, 16, 16, 3)]:
image = torch.rand(*shape)
blurred = apply_gaussian_blur(image, kernel_size=5)
assert blurred.shape == image.shape
class TestGrainGeneration:
def test_generate_grain_texture_shape(self):
batch_size = 2
height = 64
width = 128
scale = 2.0
grain = generate_grain_texture(batch_size, height, width, scale, seed=42)
expected_height = int(height / scale)
expected_width = int(width / scale)
assert grain.shape == (batch_size, expected_height, expected_width, 3)
def test_generate_grain_texture_deterministic(self):
grain1 = generate_grain_texture(1, 32, 32, 1.0, seed=123)
grain2 = generate_grain_texture(1, 32, 32, 1.0, seed=123)
assert torch.allclose(grain1, grain2)
def test_generate_grain_texture_different_seeds(self):
grain1 = generate_grain_texture(1, 32, 32, 1.0, seed=123)
grain2 = generate_grain_texture(1, 32, 32, 1.0, seed=456)
assert not torch.allclose(grain1, grain2)
def test_generate_grain_texture_scale_factor(self):
height, width = 64, 64
grain_1x = generate_grain_texture(1, height, width, 1.0, seed=42)
grain_2x = generate_grain_texture(1, height, width, 2.0, seed=42)
assert grain_1x.shape[1] == height
assert grain_2x.shape[1] == height // 2
class TestFilmGrainApplication:
def test_apply_film_grain_no_effect(self):
image = torch.rand(1, 32, 32, 3)
# Zero strength should have no effect
result = apply_film_grain(
image, scale=1.0, strength=0.0, saturation=1.0, toe=0.0, seed=42
)
assert torch.allclose(image, result, atol=0.001)
def test_apply_film_grain_with_strength(self):
image = torch.ones(1, 32, 32, 3) * 0.5
result = apply_film_grain(
image, scale=1.0, strength=1.0, saturation=1.0, toe=0.0, seed=42
)
# Should add variation
assert not torch.allclose(image, result)
# Should remain in valid range
assert result.min() >= 0.0
assert result.max() <= 1.0
def test_apply_film_grain_saturation_effect(self):
image = torch.ones(1, 32, 32, 3) * 0.5
# Full saturation
result_saturated = apply_film_grain(
image, scale=1.0, strength=1.0, saturation=1.0, toe=0.0, seed=42
)
# No saturation (monochrome grain)
result_desaturated = apply_film_grain(
image, scale=1.0, strength=1.0, saturation=0.0, toe=0.0, seed=42
)
# Calculate color variance
var_saturated = torch.var(result_saturated, dim=-1).mean()
var_desaturated = torch.var(result_desaturated, dim=-1).mean()
# Desaturated should have less color variance
assert var_desaturated < var_saturated
def test_apply_film_grain_toe_effect(self):
image = torch.ones(1, 32, 32, 3) * 0.5
# No toe
result_no_toe = apply_film_grain(
image, scale=1.0, strength=0.5, saturation=1.0, toe=0.0, seed=42
)
# With toe (lifts blacks)
result_with_toe = apply_film_grain(
image, scale=1.0, strength=0.5, saturation=1.0, toe=0.2, seed=42
)
# Toe should generally lift the overall brightness
assert result_with_toe.mean() > result_no_toe.mean()
def test_apply_film_grain_batch_processing(self):
batch_size = 4
image = torch.rand(batch_size, 32, 32, 3)
result = apply_film_grain(
image, scale=1.5, strength=0.5, saturation=0.8, toe=0.1, seed=42
)
assert result.shape == image.shape
# Each image in batch should be different (due to grain)
for i in range(batch_size - 1):
assert not torch.allclose(result[i], result[i + 1])
def test_apply_film_grain_preserves_alpha(self):
# Image with alpha channel
image = torch.rand(1, 32, 32, 4)
original_alpha = image[:, :, :, 3:4].clone()
result = apply_film_grain(
image, scale=1.0, strength=1.0, saturation=1.0, toe=0.0, seed=42
)
# Alpha channel should be unchanged
assert torch.allclose(original_alpha, result[:, :, :, 3:4])
def test_apply_film_grain_scale_interpolation(self):
image = torch.ones(1, 64, 64, 3) * 0.5
# Different scales should produce different sized grain
result_fine = apply_film_grain(
image, scale=0.5, strength=0.5, saturation=1.0, toe=0.0, seed=42
)
result_coarse = apply_film_grain(
image, scale=2.0, strength=0.5, saturation=1.0, toe=0.0, seed=42
)
# Compute local variance to measure grain size
def compute_local_variance(img, window=3):
unfold = torch.nn.Unfold(kernel_size=window, stride=1, padding=1)
img_reshaped = img.permute(0, 3, 1, 2)
patches = unfold(img_reshaped)
var = torch.var(patches, dim=1)
return var.mean()
var_fine = compute_local_variance(result_fine)
var_coarse = compute_local_variance(result_coarse)
# Fine grain should have higher local variance than coarse grain
# (more rapid changes)
assert var_fine != var_coarse # They should be different
class TestEdgeCases:
def test_handles_empty_batch(self):
image = torch.rand(0, 32, 32, 3)
result = apply_film_grain(
image, scale=1.0, strength=0.5, saturation=1.0, toe=0.0, seed=42
)
assert result.shape == image.shape
def test_handles_single_pixel(self):
image = torch.rand(1, 1, 1, 3)
result = apply_film_grain(
image, scale=1.0, strength=0.5, saturation=1.0, toe=0.0, seed=42
)
assert result.shape == image.shape
assert result.min() >= 0.0
assert result.max() <= 1.0
def test_handles_extreme_parameters(self):
image = torch.rand(1, 32, 32, 3)
# Maximum strength
result = apply_film_grain(
image, scale=2.0, strength=10.0, saturation=2.0, toe=0.5, seed=42
)
assert result.min() >= 0.0
assert result.max() <= 1.0
# Minimum values
result = apply_film_grain(
image, scale=0.25, strength=0.0, saturation=0.0, toe=-0.2, seed=42
)
assert result.min() >= 0.0
assert result.max() <= 1.0
+102 -164
View File
@@ -1,12 +1,87 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.DisplayAny",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayAny") {
function populate(text) {
// Remove existing display widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("display_") || this.widgets[i].name === "copy_value") {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Split text into multiple widgets if it's too long or has multiple lines
const lines = text ? text.split('\n') : [''];
const maxLinesPerWidget = 20;
const chunks = [];
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
}
// Create a widget for each chunk
chunks.forEach((chunk, index) => {
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.8;
w.inputEl.style.fontFamily = "monospace";
w.value = chunk;
});
// Add copy button widget
const copyWidget = {
type: "button",
name: "copy_value",
text: "📋 Copy Value",
callback: () => {
navigator.clipboard.writeText(text || '').then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy Value";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
// Update node title with condensed info
const firstLine = text ? text.split('\n')[0] : '';
const condensed = firstLine.length > 50
? firstLine.substring(0, 50) + "..."
: firstLine;
this.title = `DisplayAny: ${condensed}`;
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the display text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
console.log("DisplayAny onExecuted message:", message);
@@ -14,181 +89,44 @@ app.registerExtension({
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
console.log("DisplayAny displayText:", displayText);
// Update the display widget with the value
this.updateDisplay(displayText);
// Also show a condensed version in the title
const firstLine = displayText.split('\n')[0];
const condensed = firstLine.length > 50
? firstLine.substring(0, 50) + "..."
: firstLine;
this.title = `DisplayAny: ${condensed}`;
populate.call(this, displayText);
} else {
console.log("DisplayAny no text in message");
}
};
nodeType.prototype.updateDisplay = function(text) {
// Remove existing display widget if any
const existingWidget = this.widgets?.find(w => w.name === "display_value");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Create display widget
const widget = {
type: "custom_display",
name: "display_value",
size: [this.size[0] - 20, 80],
displayText: text || "",
scrollY: 0,
maxScrollHeight: 0,
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 16;
const minHeight = 60;
// Fixed viewport height for scrollable area
const viewportHeight = 200; // Fixed height for display area
ctx.font = "12px monospace";
const lines = this.displayText ? this.displayText.split('\n') : [""];
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, viewportHeight);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, viewportHeight);
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, viewportHeight - 2);
// Save context for clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + 1, y + 1, widget_width - margin * 2 - 2, viewportHeight - 2);
ctx.clip();
// Prepare text
ctx.fillStyle = "#ddd";
ctx.textAlign = "left";
ctx.textBaseline = "top";
// Draw each line with scrolling
const maxWidth = widget_width - margin * 2 - padding * 2;
let currentY = y + padding - this.scrollY;
// Process text - wrap long lines for JSON
let processedLines = [];
for (const line of lines) {
if (line.length > 0) {
// Split long lines into chunks that fit
let remaining = line;
while (remaining.length > 0) {
let chunkSize = remaining.length;
while (chunkSize > 0 && ctx.measureText(remaining.substring(0, chunkSize)).width > maxWidth) {
chunkSize--;
}
if (chunkSize === 0) chunkSize = 1; // At least one character
processedLines.push(remaining.substring(0, chunkSize));
remaining = remaining.substring(chunkSize);
}
} else {
processedLines.push(line);
}
}
// Calculate total content height for scrolling
const totalContentHeight = processedLines.length * lineHeight + padding * 2;
this.maxScrollHeight = Math.max(0, totalContentHeight - viewportHeight);
// Draw all visible lines
for (let i = 0; i < processedLines.length; i++) {
// Only draw if line is in viewport
if (currentY > y - lineHeight && currentY < y + viewportHeight) {
ctx.fillText(processedLines[i], margin + padding, currentY);
}
currentY += lineHeight;
}
// Restore context
ctx.restore();
// Draw scrollbar if needed
if (this.maxScrollHeight > 0) {
const scrollbarWidth = 6;
const scrollbarX = margin + widget_width - margin * 2 - scrollbarWidth - 2;
const scrollbarHeight = Math.max(20, (viewportHeight / totalContentHeight) * viewportHeight);
const scrollbarY = y + 2 + (this.scrollY / this.maxScrollHeight) * (viewportHeight - scrollbarHeight - 4);
// Scrollbar track
ctx.fillStyle = "#333";
ctx.fillRect(scrollbarX, y + 2, scrollbarWidth, viewportHeight - 4);
// Scrollbar thumb
ctx.fillStyle = "#666";
ctx.fillRect(scrollbarX, scrollbarY, scrollbarWidth, scrollbarHeight);
}
return viewportHeight;
},
computeSize: function(width) {
// Fixed height for scrollable viewport
const viewportHeight = 200;
return [width, viewportHeight];
}
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size
this.computeSize();
this.setDirtyCanvas(true);
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size - make it wider for better JSON display
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 250); // Increased for viewport
this.size[0] = Math.max(this.size[0], 450);
this.size[1] = Math.max(this.size[1], 250);
// Add placeholder text
this.updateDisplay("Value will appear here...");
// Mark this node as having a scrollable widget
this.flags = this.flags || {};
this.flags.allow_interaction = true;
};
// Handle mouse wheel events on the node
const onMouseWheel = nodeType.prototype.onMouseWheel;
nodeType.prototype.onMouseWheel = function(event, local_pos, delta) {
// Check if we have a display widget
const displayWidget = this.widgets?.find(w => w.name === "display_value");
if (displayWidget && displayWidget.maxScrollHeight > 0) {
// Scroll by 3 lines at a time
const scrollStep = 48; // 3 lines * 16px
displayWidget.scrollY = Math.max(0, Math.min(displayWidget.maxScrollHeight, displayWidget.scrollY - delta[1] * scrollStep));
this.setDirtyCanvas(true);
return true; // Consume the event
}
// Call original handler if exists
return onMouseWheel?.apply(this, arguments) || false;
populate.call(this, "Value will appear here...");
};
}
}
+165 -579
View File
@@ -5,608 +5,194 @@ app.registerExtension({
name: "ComfyAssets.DisplayText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
// Create or update the text widget
this.updateTextDisplay(message.text[0]);
function populate(text) {
// Remove existing widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("text_") || this.widgets[i].name?.startsWith("copy_")) {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
};
nodeType.prototype.updateTextDisplay = function(text) {
// Remove existing text widget if any
const existingWidget = this.widgets?.find(w => w.name === "displayed_text");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Parse the text to detect positive/negative prompt format
function parsePrompts(text) {
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract prompt content
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
if (posMatch && negMatch) {
// Extract just the prompt content, stopping at the first ** marker
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers and everything after them
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
return {
type: 'prompts',
positive: positiveText,
negative: negativeText
};
// Remove any trailing ** markers
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
return {
type: 'text',
content: text
};
}
const parsedContent = parsePrompts(text);
// Create custom widget for text display
const widget = {
type: "custom_text_display",
name: "displayed_text",
size: [this.size[0] - 20, this.size[1] - 60], // Adjust for node chrome
parsedContent: parsedContent,
scrollOffset: 0,
posScrollOffset: 0,
negScrollOffset: 0,
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 20;
const buttonHeight = 30;
const buttonWidth = 90;
// Use the actual widget height from node size
const availableHeight = node.size[1] - 60; // Account for node header and margins
this.size[1] = Math.max(100, availableHeight);
// Calculate available width for text
const availableWidth = widget_width - margin * 2 - padding * 2;
// Word wrap function with better performance
function wrapText(text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
ctx.font = "14px monospace";
for (const word of words) {
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, this.size[1]);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, this.size[1]);
if (this.parsedContent.type === 'prompts') {
// Simple split view for positive/negative prompts
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
// Draw positive prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#8f8";
ctx.font = "12px sans-serif";
ctx.fillText("✓ Positive Prompt", margin + padding, y + headerHeight - 7);
// Positive prompt text area
const posTextY = y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, posTextY, widget_width - margin * 2 - 2, posTextHeight);
// Draw separator
const separatorY = y + halfHeight;
ctx.strokeStyle = "#555";
ctx.beginPath();
ctx.moveTo(margin, separatorY);
ctx.lineTo(widget_width - margin, separatorY);
ctx.stroke();
// Draw negative prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, separatorY + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#f88";
ctx.font = "12px sans-serif";
ctx.fillText("✗ Negative Prompt", margin + padding, separatorY + headerHeight - 7);
// Negative prompt text area
const negTextY = separatorY + headerHeight;
const negTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, negTextY, widget_width - margin * 2 - 2, negTextHeight);
// Draw text for both sections
ctx.font = "14px monospace";
ctx.fillStyle = "#ddd";
// Wrap text for both prompts
const posLines = [];
const posParagraphs = this.parsedContent.positive.split('\n');
for (const para of posParagraphs) {
if (para.trim() === '') {
posLines.push('');
} else {
posLines.push(...wrapText(para, availableWidth - 10));
}
}
const negLines = [];
const negParagraphs = this.parsedContent.negative.split('\n');
for (const para of negParagraphs) {
if (para.trim() === '') {
negLines.push('');
} else {
negLines.push(...wrapText(para, availableWidth - 10));
}
}
// Draw positive prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, posTextY + padding, availableWidth - 10, posTextHeight - padding * 2);
ctx.clip();
let currentY = posTextY + padding + lineHeight - 5;
const posVisibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const posStartLine = Math.floor(this.posScrollOffset);
const posEndLine = Math.min(posStartLine + posVisibleLines, posLines.length);
for (let i = posStartLine; i < posEndLine; i++) {
ctx.fillText(posLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw positive scroll indicator if needed
if (posLines.length > posVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = posTextHeight - padding * 2;
const maxScroll = posLines.length - posVisibleLines;
const scrollRatio = this.posScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (posVisibleLines / posLines.length) * scrollBarHeight);
const thumbY = posTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, posTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw negative prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, negTextY + padding, availableWidth - 10, negTextHeight - padding * 2);
ctx.clip();
ctx.fillStyle = "#ddd";
currentY = negTextY + padding + lineHeight - 5;
const negVisibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const negStartLine = Math.floor(this.negScrollOffset);
const negEndLine = Math.min(negStartLine + negVisibleLines, negLines.length);
for (let i = negStartLine; i < negEndLine; i++) {
ctx.fillText(negLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw negative scroll indicator if needed
if (negLines.length > negVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = negTextHeight - padding * 2;
const maxScroll = negLines.length - negVisibleLines;
const scrollRatio = this.negScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (negVisibleLines / negLines.length) * scrollBarHeight);
const thumbY = negTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, negTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy buttons
const buttonY = y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (widget_width - margin * 2) / 2;
// Positive copy button
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.posCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.posCopySuccess ? "✓ Copied!" : "📋 Positive", posButtonX + buttonWidth/2, buttonY + buttonHeight/2);
// Negative copy button
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.negCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(negButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(negButtonX, buttonY, buttonWidth, buttonHeight);
// Ensure text color and alignment are set
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.negCopySuccess ? "✓ Copied!" : "📋 Negative", negButtonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
} else {
// Regular text display
const textAreaHeight = this.size[1] - buttonHeight - padding;
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, textAreaHeight);
// Process text
ctx.font = "14px monospace";
const paragraphs = this.parsedContent.content.split('\n');
const allLines = [];
for (const paragraph of paragraphs) {
if (paragraph.trim() === '') {
allLines.push('');
} else {
const wrappedLines = wrapText(paragraph, availableWidth);
allLines.push(...wrappedLines);
}
}
// Draw text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, y + padding, availableWidth, textAreaHeight - padding * 2);
ctx.clip();
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const maxScroll = Math.max(0, allLines.length - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
ctx.fillStyle = "#ddd";
let currentY = y + padding + lineHeight - 5 - (this.scrollOffset * lineHeight);
for (let i = 0; i < allLines.length; i++) {
if (currentY > y && currentY < y + textAreaHeight) {
ctx.fillText(allLines[i], margin + padding, currentY);
}
currentY += lineHeight;
}
ctx.restore();
// Draw scroll indicator if needed
if (allLines.length > visibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = textAreaHeight - 4;
const thumbHeight = Math.max(20, (visibleLines / allLines.length) * scrollBarHeight);
const thumbY = y + 2 + (this.scrollOffset / maxScroll) * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, y + 2, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy button
const buttonX = widget_width - margin - buttonWidth - padding;
const buttonY = y + textAreaHeight + padding / 2;
ctx.fillStyle = this.copyButtonHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.copySuccess ? "✓ Copied!" : "📋 Copy", buttonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
return this.size[1];
},
// Create header widget for positive prompt
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
posHeader.inputEl.readOnly = true;
posHeader.inputEl.style.opacity = 0.7;
posHeader.inputEl.style.backgroundColor = "#2a4a2a";
posHeader.inputEl.style.color = "#8f8";
posHeader.inputEl.style.fontWeight = "bold";
posHeader.value = "✓ Positive Prompt";
mouse: function(event, pos, node) {
const margin = 10;
const padding = 10;
const buttonWidth = 90;
const buttonHeight = 30;
const lineHeight = 20;
// Check if mouse is over the widget
const isOver = pos[1] > this.last_y && pos[1] < this.last_y + this.size[1];
if (!isOver) return false;
if (this.parsedContent.type === 'prompts') {
// Handle split view
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
const posTextY = this.last_y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
const negTextY = this.last_y + halfHeight + headerHeight;
const negTextHeight = halfHeight - headerHeight;
const buttonY = this.last_y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (node.size[0] - margin * 2) / 2;
// Check which section for scrolling
const inPosSection = pos[1] > posTextY && pos[1] < posTextY + posTextHeight;
const inNegSection = pos[1] > negTextY && pos[1] < negTextY + negTextHeight;
// Handle scrolling
if (event.type === "wheel") {
if (inPosSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.posScrollOffset = (this.posScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.positive.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.posScrollOffset = Math.max(0, Math.min(this.posScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
} else if (inNegSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.negScrollOffset = (this.negScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.negative.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.negScrollOffset = Math.max(0, Math.min(this.negScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
}
// Check button hovers
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
const oldPosHover = this.posCopyHovered;
const oldNegHover = this.negCopyHovered;
this.posCopyHovered = pos[0] > posButtonX && pos[0] < posButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
this.negCopyHovered = pos[0] > negButtonX && pos[0] < negButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldPosHover !== this.posCopyHovered || oldNegHover !== this.negCopyHovered) {
node.setDirtyCanvas(true);
}
// Handle button clicks
if (event.type === "pointerdown") {
if (this.posCopyHovered) {
this.copyToClipboard(this.parsedContent.positive, 'positive');
return true;
} else if (this.negCopyHovered) {
this.copyToClipboard(this.parsedContent.negative, 'negative');
return true;
}
}
} else {
// Regular text handling
const textAreaHeight = this.size[1] - buttonHeight - padding;
const buttonX = node.size[0] - margin - buttonWidth - padding;
const buttonY = this.last_y + textAreaHeight + padding / 2;
// Handle scrolling
if (event.type === "wheel" && pos[1] < this.last_y + textAreaHeight) {
const delta = event.deltaY > 0 ? 1 : -1;
this.scrollOffset = (this.scrollOffset || 0) + delta;
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.content.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
// Check button hover
const oldHover = this.copyButtonHovered;
this.copyButtonHovered = pos[0] > buttonX && pos[0] < buttonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldHover !== this.copyButtonHovered) {
node.setDirtyCanvas(true);
}
// Handle button click
if (event.type === "pointerdown" && this.copyButtonHovered) {
this.copyToClipboard(this.parsedContent.content, 'regular');
return true;
}
}
return false;
},
// Create positive prompt widget
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
posWidget.inputEl.readOnly = true;
posWidget.inputEl.style.opacity = 0.9;
posWidget.value = positiveText;
copyToClipboard: function(text, type) {
const node = this._node;
navigator.clipboard.writeText(text).then(() => {
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback copy method
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
// Create copy button widget for positive prompt
const posCopyWidget = {
type: "button",
name: "copy_positive",
text: "📋 Copy Positive",
callback: () => {
navigator.clipboard.writeText(positiveText).then(() => {
posCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
posCopyWidget.text = "📋 Copy Positive";
this.setDirtyCanvas(true);
}, 1500);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
});
},
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(posCopyWidget);
computeSize: function(width) {
return [width, this.size[1]];
// Create header widget for negative prompt
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
negHeader.inputEl.readOnly = true;
negHeader.inputEl.style.opacity = 0.7;
negHeader.inputEl.style.backgroundColor = "#4a2a2a";
negHeader.inputEl.style.color = "#f88";
negHeader.inputEl.style.fontWeight = "bold";
negHeader.value = "✗ Negative Prompt";
// Create negative prompt widget
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
negWidget.inputEl.readOnly = true;
negWidget.inputEl.style.opacity = 0.9;
negWidget.value = negativeText;
// Create copy button widget for negative prompt
const negCopyWidget = {
type: "button",
name: "copy_negative",
text: "📋 Copy Negative",
callback: () => {
navigator.clipboard.writeText(negativeText).then(() => {
negCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
negCopyWidget.text = "📋 Copy Negative";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(negCopyWidget);
} else {
// Single text display with ComfyUI's standard STRING widget
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.9;
w.value = text;
// Create copy button widget
const copyWidget = {
type: "button",
name: "copy_text",
text: "📋 Copy",
callback: () => {
navigator.clipboard.writeText(text).then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
};
// Store reference to node for callbacks
widget._node = this;
// Store the last y position for mouse detection
const originalDraw = widget.draw;
widget.draw = function(ctx, node, widget_width, y, H) {
this.last_y = y;
return originalDraw.call(this, ctx, node, widget_width, y, H);
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size to accommodate the widget
this.computeSize();
};
// Handle node resizing
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
onResize?.apply(this, arguments);
// Update widget size when node is resized
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
if (textWidget) {
textWidget.size[0] = size[0] - 20;
textWidget.size[1] = size[1] - 60;
this.setDirtyCanvas(true);
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
populate.call(this, message.text[0]);
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 350);
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
this.updateTextDisplay("Text will appear here after execution...");
// Mark this node as having a scrollable widget
this.flags = this.flags || {};
this.flags.allow_interaction = true;
};
// Override mouse wheel handler at node level
const onMouseWheel = nodeType.prototype.onMouseWheel;
nodeType.prototype.onMouseWheel = function(event, local_pos, delta) {
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
if (textWidget) {
// Let the widget handle the mouse event
const fakeEvent = { type: "wheel", deltaY: -delta[1] * 100 };
if (textWidget.mouse && textWidget.mouse.call(textWidget, fakeEvent, local_pos, this)) {
return true;
}
}
return onMouseWheel?.apply(this, arguments) || false;
populate.call(this, "Text will appear here after execution...");
};
}
}
File diff suppressed because it is too large Load Diff