Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f6846c3cb | ||
|
|
5ae7985bc2 | ||
|
|
b37bc763dc | ||
|
|
0439763614 | ||
|
|
2e1f563298 | ||
|
|
4b63cb7176 | ||
|
|
1f6a148538 | ||
|
|
d9a7879c45 | ||
|
|
c6b5dc4b54 | ||
|
|
a4d1169c63 | ||
|
|
ff6f397dd2 | ||
|
|
97913deae3 | ||
|
|
e6c8dd583f | ||
|
|
f9db6f8635 | ||
|
|
1b18873e65 | ||
|
|
d2b30f0a78 | ||
|
|
917421529b | ||
|
|
d29dcb8564 | ||
|
|
b95bf8e64a | ||
|
|
b8622e21da | ||
|
|
4c8f20ef88 | ||
|
|
26d135e106 | ||
|
|
db7d0dc86e | ||
|
|
b5e24dbe57 | ||
|
|
eb1e646453 | ||
|
|
793579a1dd | ||
|
|
eba899b30d | ||
|
|
914ba8e003 | ||
|
|
51b057982e | ||
|
|
3a3a6ebab9 | ||
|
|
886917d95c | ||
|
|
bf55338b61 | ||
|
|
c561e59650 | ||
|
|
9e67f06a21 | ||
|
|
90e4b57dc6 | ||
|
|
386e48c2e1 | ||
|
|
624ba92723 | ||
|
|
0ae29f576a | ||
|
|
ca9fd5153a | ||
|
|
c0d239f31f | ||
|
|
6d27520641 | ||
|
|
7cde5a5ceb | ||
|
|
ecdde6bbd7 | ||
|
|
0557c040f8 | ||
|
|
f7d007d8c4 | ||
|
|
80045954a5 | ||
|
|
6f176e7b78 | ||
|
|
576efbba41 | ||
|
|
593bd4de79 | ||
|
|
a8fb5ddaa7 | ||
|
|
f67e619930 | ||
|
|
f9e2f5bcc5 | ||
|
|
2cfeab9e76 | ||
|
|
f5ce23ad12 | ||
|
|
05a9520436 | ||
|
|
c1fcc4566b | ||
|
|
40b91fc44f | ||
|
|
3e7734bbc3 | ||
|
|
17400d88f7 | ||
|
|
0f79601065 | ||
|
|
df60457929 | ||
|
|
5d0e8194a1 |
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
+10
-10
@@ -17,7 +17,7 @@ jobs:
|
||||
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
@@ -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!')
|
||||
@@ -402,7 +402,7 @@ jobs:
|
||||
test-package-structure:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
@@ -458,7 +458,7 @@ jobs:
|
||||
test-documentation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Test documentation completeness
|
||||
run: |
|
||||
|
||||
@@ -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,6 +33,11 @@ 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 |
|
||||
| [📉 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 |
|
||||
| [📂 Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | 💾 Images |
|
||||
|
||||
### 🧰 xyz-helpers Tools
|
||||
|
||||
@@ -218,6 +230,41 @@ Adjusts image dimensions to be multiples of a specified value for model compatib
|
||||
|
||||

|
||||
|
||||
#### 📉 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.
|
||||
|
||||
@@ -311,6 +358,105 @@ Unified interface for text encoding and sampler parameter management.
|
||||
- Quick template-based generation
|
||||
- Batch prompt processing
|
||||
|
||||
#### 📂 Local Image Loader
|
||||
Visual gallery browser for loading local images, videos, and audio files directly into ComfyUI workflows.
|
||||
|
||||
- **Visual Gallery Interface**: Browse files with thumbnail previews in a masonry layout
|
||||
- **Multi-Media Support**: Load images (JPG, PNG, GIF, WebP), videos (MP4, WebM, MOV), and audio files (MP3, WAV, OGG, FLAC)
|
||||
- **Quick Navigation**: Navigate folders with breadcrumb path and parent directory button
|
||||
- **Responsive Layout**: Automatically adjusts thumbnail grid to available space
|
||||
- **Metadata Extraction**: Reads embedded prompt and workflow data from generated images
|
||||
- **Saved Paths**: Remember frequently used directories for quick access
|
||||
- **Double-Click Preview**: Open full-size media in new browser tab
|
||||
- **Smart Sorting**: Sort by name, date, or file size in ascending or descending order
|
||||
- **Pagination Support**: Efficiently browse large directories with page controls
|
||||
|
||||
**Use Cases:**
|
||||
- Load reference images from local folders for img2img workflows
|
||||
- Browse and select from collections of generated images
|
||||
- Quickly access frequently used asset directories
|
||||
- Extract prompts and settings from previously generated images
|
||||
- Preview media files before loading into workflow
|
||||
|
||||
### 🔤 Embedding Autocomplete
|
||||
|
||||
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
|
||||
|
||||
<div align="center">
|
||||
<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.
|
||||
|
||||
**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 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:**
|
||||
@@ -545,6 +691,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) |
|
||||
@@ -844,8 +992,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)
|
||||
|
||||
+85
-1
@@ -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 |
@@ -0,0 +1,106 @@
|
||||
# Batch Prompts Node
|
||||
|
||||
The **Batch Prompts** node loads and processes prompts from text files for batch generation workflows. It automatically cycles through prompts with each execution, making it perfect for testing multiple prompts in queue batches.
|
||||
|
||||
## Features
|
||||
|
||||
- **File-based prompt loading** - Load prompts from text files with `---` separators
|
||||
- **Auto-increment mode** - Automatically advance to the next prompt with each execution
|
||||
- **Positive/Negative splitting** - Automatically splits prompts at "Negative:" markers
|
||||
- **Persistent state** - Maintains position across ComfyUI restarts
|
||||
- **Wrap-around support** - Loop back to the first prompt after the last one
|
||||
- **Progress tracking** - Shows current position and total prompts
|
||||
|
||||
## Input Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `prompt_file` | STRING | "prompts.txt" | Path to text file containing prompts |
|
||||
| `index` | INT | 0 | Manual prompt index (when auto_increment is off) |
|
||||
| `auto_increment` | BOOLEAN | True | Automatically advance to next prompt |
|
||||
| `wrap_around` | BOOLEAN | True | Loop back to start after last prompt |
|
||||
| `split_negative` | BOOLEAN | True | Split prompts at "Negative:" marker |
|
||||
| `reload_file` | BOOLEAN | False | Force reload file from disk |
|
||||
| `show_preview` | BOOLEAN | True | Show prompt preview in console |
|
||||
|
||||
## Output Values
|
||||
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `positive` | STRING | The positive prompt text |
|
||||
| `negative` | STRING | The negative prompt text (if split) |
|
||||
| `full_prompt` | STRING | Complete prompt including negative |
|
||||
| `next_prompt` | STRING | Preview of the next prompt |
|
||||
| `current_index` | INT | Current prompt index (0-based) |
|
||||
| `total_prompts` | INT | Total number of prompts |
|
||||
| `batch_info` | STRING | Progress information string |
|
||||
|
||||
## Prompt File Format
|
||||
|
||||
Create a text file with prompts separated by `---` on its own line:
|
||||
|
||||
```
|
||||
A beautiful sunset over the ocean
|
||||
Negative: blurry, dark, low quality
|
||||
---
|
||||
Mountain landscape with snow peaks
|
||||
Negative: foggy, unclear
|
||||
---
|
||||
Futuristic city at night
|
||||
Negative: old, vintage, sepia
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Queue Batch Processing
|
||||
|
||||
1. Create a prompt file in your ComfyUI `input` folder
|
||||
2. Add the Batch Prompts node to your workflow
|
||||
3. Set `prompt_file` to your file name
|
||||
4. Enable `auto_increment` and `wrap_around`
|
||||
5. Connect `positive` to your text encoder
|
||||
6. Connect `negative` to your negative text encoder
|
||||
7. Set Queue Batch to desired number (e.g., 10)
|
||||
8. Run the queue - prompts will cycle automatically
|
||||
|
||||
### Manual Index Control
|
||||
|
||||
For manual control over which prompt to use:
|
||||
|
||||
1. Set `auto_increment` to False
|
||||
2. Control the `index` parameter manually
|
||||
3. Use with other nodes that provide index values
|
||||
|
||||
### Monitoring Progress
|
||||
|
||||
The node provides several ways to track progress:
|
||||
|
||||
- `batch_info` output shows "Prompt X of Y (Z% complete)"
|
||||
- Console logging shows current prompt preview (when `show_preview` is True)
|
||||
- `current_index` and `total_prompts` for custom progress displays
|
||||
|
||||
## Tips
|
||||
|
||||
- Place prompt files in the ComfyUI `input` folder for easy access
|
||||
- Use relative paths like "prompts.txt" for files in the input folder
|
||||
- Use absolute paths for files elsewhere on your system
|
||||
- The node maintains state across ComfyUI restarts
|
||||
- Set `reload_file` to True to force re-reading after editing the file
|
||||
- Empty sections (between `---` markers) are automatically skipped
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Prompts not changing in queue batch
|
||||
- Ensure `auto_increment` is set to True
|
||||
- Check console for "[BatchPrompts] Auto-increment" messages
|
||||
- Restart ComfyUI after installing/updating the node
|
||||
|
||||
### File not found errors
|
||||
- Check that the file exists in the ComfyUI `input` folder
|
||||
- Try using an absolute path to test
|
||||
- Ensure file has read permissions
|
||||
|
||||
### State persistence
|
||||
- State is stored in your system's temp directory
|
||||
- Clear `/tmp/comfyui_batch_prompts/` to reset all counters
|
||||
- Use `reload_file` to reset counter for a specific 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,158 @@
|
||||
# Local Image Loader
|
||||
|
||||
## Overview
|
||||
|
||||
The Local Image Loader node provides a visual gallery interface for browsing and selecting images, videos, and audio files from your local filesystem directly within ComfyUI. This streamlined version focuses on essential functionality without the complexity of tagging or metadata management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Visual Gallery Browser**: Browse local directories with thumbnail previews
|
||||
- **Multi-Media Support**: Load images, videos, and audio files
|
||||
- **Directory Navigation**: Navigate through folders with ease
|
||||
- **Sorting Options**: Sort by name, date, or file size
|
||||
- **Saved Paths**: Save frequently used directory paths for quick access
|
||||
- **Pagination**: Handle large directories with paginated display
|
||||
- **Lightbox Preview**: Full-size preview with zoom and pan capabilities
|
||||
|
||||
## Node Inputs
|
||||
|
||||
### Required Inputs
|
||||
None - The node uses a visual interface for file selection
|
||||
|
||||
### Hidden Inputs
|
||||
- `unique_id`: Automatically assigned node identifier
|
||||
|
||||
## Node Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `image` | IMAGE | The selected image as a tensor |
|
||||
| `video_path` | STRING | Path to the selected video file |
|
||||
| `audio_path` | STRING | Path to the selected audio file |
|
||||
| `info` | STRING | JSON metadata about the selected image |
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
1. **Add the Node**: Search for "Local Image Loader" in the node menu
|
||||
2. **Browse Directory**: Enter a directory path or use saved paths
|
||||
3. **Select Media**: Click on thumbnails to select files
|
||||
4. **Connect Outputs**: Use the outputs in your workflow
|
||||
|
||||
### Interface Controls
|
||||
|
||||
#### Path Management
|
||||
- **Directory Input**: Enter or paste a directory path
|
||||
- **Saved Paths Dropdown**: Quick access to saved directories
|
||||
- **Save Path Button** (💾): Save current directory to favorites
|
||||
- **Browse Button** (📁): Load the entered directory
|
||||
|
||||
#### View Options
|
||||
- **Videos Checkbox**: Show/hide video files
|
||||
- **Audio Checkbox**: Show/hide audio files
|
||||
- **Sort By**: Choose between Name, Date, or Size
|
||||
- **Sort Order**: Ascending (↑) or Descending (↓)
|
||||
- **Refresh Button** (🔄): Reload current directory
|
||||
|
||||
#### Gallery Display
|
||||
- **Thumbnail Grid**: Visual preview of files
|
||||
- **Blue Border**: Selected items are highlighted
|
||||
- **Folder Icons**: Navigate into subdirectories
|
||||
- **Video Overlay**: Visual indicator for video files
|
||||
- **Pagination**: Navigate through pages of results
|
||||
|
||||
## File Support
|
||||
|
||||
### Supported Image Formats
|
||||
- `.jpg`, `.jpeg`
|
||||
- `.png`
|
||||
- `.bmp`
|
||||
- `.gif`
|
||||
- `.webp`
|
||||
|
||||
### Supported Video Formats
|
||||
- `.mp4`
|
||||
- `.webm`
|
||||
- `.mov`
|
||||
- `.mkv`
|
||||
- `.avi`
|
||||
|
||||
### Supported Audio Formats
|
||||
- `.mp3`
|
||||
- `.wav`
|
||||
- `.ogg`
|
||||
- `.flac`
|
||||
|
||||
## Image Metadata
|
||||
|
||||
When an image is selected, the node extracts and returns metadata including:
|
||||
- **Basic Info**: Filename, width, height, format, mode
|
||||
- **Embedded Parameters**: Generation parameters if present
|
||||
- **Workflow Data**: Embedded ComfyUI workflow if present
|
||||
- **Prompt Data**: Embedded prompt information if present
|
||||
|
||||
## Examples
|
||||
|
||||
### Loading an Image for Processing
|
||||
```
|
||||
Local Image Loader → Load Image → Image Processing Node
|
||||
↓
|
||||
[info] → Display Text (to show metadata)
|
||||
```
|
||||
|
||||
### Setting Up a Multi-Media Workflow
|
||||
```
|
||||
Local Image Loader → [image] → Image Preview
|
||||
↓
|
||||
[video_path] → Video Player Node
|
||||
↓
|
||||
[audio_path] → Audio Player Node
|
||||
```
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Save Frequently Used Paths**: Use the save button to bookmark directories you use often
|
||||
2. **Use Sorting**: Sort by date to find recent files quickly
|
||||
3. **Keyboard Navigation**: Press Enter in the path field to load a directory
|
||||
4. **Performance**: For directories with thousands of files, use pagination to navigate efficiently
|
||||
5. **Thumbnail Generation**: Thumbnails are generated on-demand and cached for performance
|
||||
|
||||
## Differences from Original
|
||||
|
||||
This version simplifies the original ComfyUI_Local_Image_Gallery by removing:
|
||||
- Tag filtering and management
|
||||
- Rating system
|
||||
- Global tag search
|
||||
- Metadata editing capabilities
|
||||
|
||||
These features were removed to focus on the core functionality of browsing and selecting files, making the tool simpler and more straightforward to use.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Directory Not Loading**
|
||||
- Verify the path exists and you have read permissions
|
||||
- Check for special characters in the path
|
||||
- Try using absolute paths instead of relative ones
|
||||
|
||||
**Thumbnails Not Showing**
|
||||
- Ensure the files are in supported formats
|
||||
- Check if the images are corrupted
|
||||
- Try refreshing the gallery
|
||||
|
||||
**Large Directories Slow to Load**
|
||||
- Use sorting and pagination to manage large folders
|
||||
- Consider organizing files into subdirectories
|
||||
- Enable only the media types you need (images, videos, audio)
|
||||
|
||||
## Technical Details
|
||||
|
||||
The node creates a visual widget that runs in the ComfyUI interface and communicates with the backend through API endpoints to:
|
||||
- List directory contents
|
||||
- Generate thumbnails
|
||||
- Save user preferences
|
||||
- Handle file selection
|
||||
|
||||
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
|
||||
@@ -13,6 +13,7 @@ This node is based on work from [comfyui-essentials-nodes](https://github.com/cu
|
||||
- **Flexible Strength Control**: Single, multiple, or range-based strength values
|
||||
- **Batch Modes**: Sequential or combinatorial strength application
|
||||
- **Epoch Detection**: Automatically extracts epoch numbers from filenames
|
||||
- **Auto-Batching**: Automatically splits large LoRA collections into manageable chunks to prevent UI disconnection
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
@@ -33,6 +34,11 @@ This node is based on work from [comfyui-essentials-nodes](https://github.com/cu
|
||||
|-----------|------|---------|-------------|
|
||||
| `include_pattern` | STRING | "" | Regex pattern to include files |
|
||||
| `exclude_pattern` | STRING | "" | Regex pattern to exclude files |
|
||||
| `max_loras` | INT | 50 | Maximum LoRAs to process (when auto_batch disabled) |
|
||||
| `sort_order` | DROPDOWN | natural | Sorting method [natural, alphabetical, newest, oldest] |
|
||||
| `auto_batch` | DROPDOWN | disabled | Enable auto-batching for large collections [disabled, enabled] |
|
||||
| `batch_size` | INT | 25 | Number of LoRAs per batch when auto-batching |
|
||||
| `batch_index` | INT | 0 | Which batch to output (0-based) when auto-batching |
|
||||
|
||||
### Strength Format Options
|
||||
- **Single**: `"1.0"` - Apply same strength to all LoRAs
|
||||
@@ -72,6 +78,16 @@ LoRAFolderBatch → Processing Pipeline
|
||||
strength: "0.8...1.2+0.1"
|
||||
```
|
||||
|
||||
### Auto-Batch Large Collections
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
folder_path: "massive_lora_collection" # 100+ files
|
||||
strength: "1.0"
|
||||
auto_batch: enabled
|
||||
batch_size: 25
|
||||
batch_index: 0 # Change to 1, 2, 3... for subsequent batches
|
||||
```
|
||||
|
||||
## Batch Modes Explained
|
||||
|
||||
### Sequential Mode
|
||||
@@ -86,6 +102,41 @@ Each LoRA is tested with ALL strength values:
|
||||
- LoRA2 → [0.5, 0.75, 1.0]
|
||||
- LoRA3 → [0.5, 0.75, 1.0]
|
||||
|
||||
## Auto-Batching for Large Collections
|
||||
|
||||
### Overview
|
||||
When testing large numbers of LoRAs (e.g., 75+ files), ComfyUI can experience UI disconnections or memory issues. Auto-batching solves this by automatically splitting your LoRA collection into smaller, manageable chunks.
|
||||
|
||||
### How It Works
|
||||
1. **Enable Auto-Batching**: Set `auto_batch` to "enabled"
|
||||
2. **Set Batch Size**: Configure `batch_size` (default: 25, range: 5-100)
|
||||
3. **Select Batch**: Use `batch_index` to choose which batch to process
|
||||
|
||||
### Example: Testing 75 LoRAs
|
||||
With 75 LoRAs and batch_size=25, the system creates 3 batches:
|
||||
- **Batch 0**: LoRAs 1-25 (set batch_index=0)
|
||||
- **Batch 1**: LoRAs 26-50 (set batch_index=1)
|
||||
- **Batch 2**: LoRAs 51-75 (set batch_index=2)
|
||||
|
||||
Run your workflow 3 times, changing only the `batch_index` each time.
|
||||
|
||||
### Visual Feedback
|
||||
When auto-batching is enabled, the `lora_list` output includes batch information:
|
||||
```
|
||||
=== Batch 1/3 (LoRAs 1-25) ===
|
||||
|
||||
style-epoch-001
|
||||
style-epoch-002
|
||||
...
|
||||
```
|
||||
|
||||
### Best Practices for Auto-Batching
|
||||
1. **Start with Default**: Use batch_size=25 for most scenarios
|
||||
2. **Adjust for Memory**: Decrease batch_size if you still experience issues
|
||||
3. **Combinatorial Mode**: Be extra careful - 25 LoRAs × 3 strengths = 75 combinations
|
||||
4. **Save Between Batches**: Save your results after each batch to avoid data loss
|
||||
5. **Use Plot Parameters**: The batch info appears in plot visualizations for easy tracking
|
||||
|
||||
## File Naming Patterns
|
||||
|
||||
### Supported Epoch Formats
|
||||
@@ -207,6 +258,7 @@ batch_mode: sequential
|
||||
- **1.0.1**: Added natural sorting for epochs
|
||||
- **1.0.2**: Enhanced pattern filtering
|
||||
- **1.0.3**: Improved batch modes and strength parsing
|
||||
- **1.0.4**: Added auto-batching for large LoRA collections
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -0,0 +1,14 @@
|
||||
A beautiful sunset over the ocean, golden hour lighting, professional photography, vibrant colors, high detail
|
||||
Negative: blurry, dark, low quality, distorted, oversaturated
|
||||
---
|
||||
Majestic mountain landscape with snow-capped peaks, dramatic clouds, alpine scenery, crystal clear air, epic composition
|
||||
Negative: foggy, flat lighting, boring composition, low contrast
|
||||
---
|
||||
Futuristic cityscape at night, neon lights, cyberpunk aesthetic, rain-slicked streets, atmospheric, blade runner style
|
||||
Negative: daylight, rural, old fashioned, low tech, empty streets
|
||||
---
|
||||
Enchanted forest with magical glowing mushrooms, fairy lights, mystical atmosphere, ancient trees, fantasy art style
|
||||
Negative: desert, urban, modern, realistic, mundane
|
||||
---
|
||||
Space station orbiting Earth, detailed mechanical structures, astronauts performing spacewalk, realistic sci-fi, NASA photography
|
||||
Negative: fantasy, medieval, underwater, cartoon style
|
||||
@@ -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
|
||||
}
|
||||
+28
-12
@@ -3,28 +3,34 @@ 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.batch_prompts import BatchPromptsNode
|
||||
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_purge_vram import KikoPurgeVRAM
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.local_image_loader import LocalImageLoaderNode
|
||||
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
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BatchPrompts": BatchPromptsNode,
|
||||
"ResolutionCalculator": ResolutionCalculatorNode,
|
||||
"WidthHeightSelector": WidthHeightSelectorNode,
|
||||
"SeedHistory": SeedHistoryNode,
|
||||
@@ -37,15 +43,21 @@ NODE_CLASS_MAPPINGS = {
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
"DisplayText": DisplayTextNode,
|
||||
"KikoFilmGrain": KikoFilmGrainNode,
|
||||
"KikoPurgeVRAM": KikoPurgeVRAM,
|
||||
"KikoLocalImageLoader": LocalImageLoaderNode,
|
||||
"SamplerSelectHelper": SamplerSelectHelperNode,
|
||||
"SchedulerSelectHelper": SchedulerSelectHelperNode,
|
||||
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
|
||||
"FluxSamplerParams": FluxSamplerParamsNode,
|
||||
"PlotParameters+": PlotParametersNode,
|
||||
"LoRAFolderBatch": LoRAFolderBatchNode,
|
||||
# Note: KikoEmbeddingAutocomplete is not registered as a node
|
||||
# It's a settings-only feature accessed through ComfyUI settings menu
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BatchPrompts": "Batch Prompts",
|
||||
"ResolutionCalculator": "Resolution Calculator",
|
||||
"WidthHeightSelector": "Width Height Selector",
|
||||
"SeedHistory": "Seed History",
|
||||
@@ -58,12 +70,16 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"GeminiPrompt": "Gemini Prompt Engineer",
|
||||
"DisplayAny": "Display Any",
|
||||
"DisplayText": "Display Text",
|
||||
"KikoFilmGrain": "Film Grain",
|
||||
"KikoPurgeVRAM": "Kiko Purge VRAM",
|
||||
"KikoLocalImageLoader": "Local Image Loader",
|
||||
"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 removed - settings only, not a node
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,7 @@ class ComfyAssetsBaseNode:
|
||||
- Consistent return type handling
|
||||
"""
|
||||
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets"
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tool registry for KikoTools.
|
||||
|
||||
This module provides the central registration system for all KikoTools nodes.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Dict, Any
|
||||
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:
|
||||
# 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
|
||||
@@ -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(" 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(" options: (value) => {")
|
||||
js_lines.append(
|
||||
f" const options = {json.dumps(setting.options)};"
|
||||
)
|
||||
js_lines.append(" return options.map(opt => ({")
|
||||
js_lines.append(" value: opt,")
|
||||
js_lines.append(" text: String(opt),")
|
||||
js_lines.append(" selected: opt === value")
|
||||
js_lines.append(" }));")
|
||||
js_lines.append(" }},")
|
||||
|
||||
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(" onChange(value) {")
|
||||
js_lines.append(f" {setting.on_change}")
|
||||
js_lines.append(" }")
|
||||
|
||||
js_lines.append(" }});")
|
||||
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 @@
|
||||
"""Batch Prompts node for loading and processing prompts from text files."""
|
||||
|
||||
from .node import BatchPromptsNode
|
||||
|
||||
__all__ = ["BatchPromptsNode"]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Logic module for Batch Prompts node."""
|
||||
|
||||
import os
|
||||
from typing import List, Tuple, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_prompts_from_file(file_path: str) -> List[str]:
|
||||
"""
|
||||
Load prompts from a text file where prompts are separated by '---'.
|
||||
|
||||
Args:
|
||||
file_path: Path to the text file containing prompts
|
||||
|
||||
Returns:
|
||||
List of prompts (each prompt may be multi-line)
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Split by --- separator
|
||||
prompts = content.split("---")
|
||||
|
||||
# Clean up prompts - remove leading/trailing whitespace but preserve internal formatting
|
||||
cleaned_prompts = []
|
||||
for prompt in prompts:
|
||||
prompt = prompt.strip()
|
||||
if prompt: # Only add non-empty prompts
|
||||
cleaned_prompts.append(prompt)
|
||||
|
||||
logger.info(f"Loaded {len(cleaned_prompts)} prompts from {file_path}")
|
||||
return cleaned_prompts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading prompts from {file_path}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_prompt_at_index(
|
||||
prompts: List[str], index: int, wrap: bool = True
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Get prompt at specified index with optional wrapping.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
index: Index to retrieve
|
||||
wrap: Whether to wrap around to beginning when index exceeds list length
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt text, actual index used)
|
||||
"""
|
||||
if not prompts:
|
||||
return ("", 0)
|
||||
|
||||
if wrap:
|
||||
actual_index = index % len(prompts)
|
||||
else:
|
||||
actual_index = min(index, len(prompts) - 1)
|
||||
|
||||
return (prompts[actual_index], actual_index)
|
||||
|
||||
|
||||
def get_next_prompt(
|
||||
prompts: List[str], current_index: int, wrap: bool = True
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Get the next prompt in sequence.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
current_index: Current prompt index
|
||||
wrap: Whether to wrap around to beginning
|
||||
|
||||
Returns:
|
||||
Tuple of (next prompt text, next index)
|
||||
"""
|
||||
if not prompts:
|
||||
return ("", 0)
|
||||
|
||||
next_index = current_index + 1
|
||||
|
||||
if wrap:
|
||||
next_index = next_index % len(prompts)
|
||||
else:
|
||||
next_index = min(next_index, len(prompts) - 1)
|
||||
|
||||
return (prompts[next_index], next_index)
|
||||
|
||||
|
||||
def get_prompt_preview(prompt: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Get a preview of a prompt, truncated if necessary.
|
||||
|
||||
Args:
|
||||
prompt: Full prompt text
|
||||
max_length: Maximum length for preview
|
||||
|
||||
Returns:
|
||||
Preview string
|
||||
"""
|
||||
if len(prompt) <= max_length:
|
||||
return prompt
|
||||
|
||||
return prompt[:max_length] + "..."
|
||||
|
||||
|
||||
def parse_prompt_file_list(file_list_str: str) -> List[str]:
|
||||
"""
|
||||
Parse a comma-separated list of prompt file paths.
|
||||
|
||||
Args:
|
||||
file_list_str: Comma-separated file paths
|
||||
|
||||
Returns:
|
||||
List of file paths
|
||||
"""
|
||||
if not file_list_str:
|
||||
return []
|
||||
|
||||
files = []
|
||||
for file_path in file_list_str.split(","):
|
||||
file_path = file_path.strip()
|
||||
if file_path:
|
||||
files.append(file_path)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def merge_prompts_from_multiple_files(file_paths: List[str]) -> List[str]:
|
||||
"""
|
||||
Load and merge prompts from multiple files.
|
||||
|
||||
Args:
|
||||
file_paths: List of file paths
|
||||
|
||||
Returns:
|
||||
Combined list of all prompts
|
||||
"""
|
||||
all_prompts = []
|
||||
|
||||
for file_path in file_paths:
|
||||
prompts = load_prompts_from_file(file_path)
|
||||
all_prompts.extend(prompts)
|
||||
|
||||
logger.info(f"Merged {len(all_prompts)} prompts from {len(file_paths)} files")
|
||||
return all_prompts
|
||||
|
||||
|
||||
def get_batch_info(prompts: List[str], current_index: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about current batch processing state.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
current_index: Current prompt index
|
||||
|
||||
Returns:
|
||||
Dictionary with batch information
|
||||
"""
|
||||
total = len(prompts)
|
||||
|
||||
return {
|
||||
"current_index": current_index,
|
||||
"total_prompts": total,
|
||||
"progress": f"{current_index + 1}/{total}" if total > 0 else "0/0",
|
||||
"percentage": (current_index / total * 100) if total > 0 else 0,
|
||||
"remaining": total - current_index - 1 if total > 0 else 0,
|
||||
"is_complete": current_index >= total - 1 if total > 0 else True,
|
||||
}
|
||||
|
||||
|
||||
def validate_prompt_file(file_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate that a prompt file exists and is readable.
|
||||
|
||||
Args:
|
||||
file_path: Path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not file_path:
|
||||
return (False, "No file path provided")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return (False, f"File not found: {file_path}")
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return (False, f"Path is not a file: {file_path}")
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
f.read(1) # Try to read one character
|
||||
return (True, "")
|
||||
except Exception as e:
|
||||
return (False, f"Cannot read file: {str(e)}")
|
||||
|
||||
|
||||
def format_prompt_for_display(prompt: str, index: int, total: int) -> str:
|
||||
"""
|
||||
Format a prompt for display with index information.
|
||||
|
||||
Args:
|
||||
prompt: Prompt text
|
||||
index: Current index
|
||||
total: Total number of prompts
|
||||
|
||||
Returns:
|
||||
Formatted display string
|
||||
"""
|
||||
header = f"[Prompt {index + 1}/{total}]"
|
||||
separator = "-" * len(header)
|
||||
|
||||
return f"{header}\n{separator}\n{prompt}"
|
||||
|
||||
|
||||
def split_prompt_into_positive_negative(
|
||||
prompt: str, negative_prefix: str = "Negative:"
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Split a prompt into positive and negative parts.
|
||||
|
||||
Args:
|
||||
prompt: Full prompt text
|
||||
negative_prefix: Prefix that marks the negative prompt section
|
||||
|
||||
Returns:
|
||||
Tuple of (positive_prompt, negative_prompt)
|
||||
"""
|
||||
# Look for negative prompt marker
|
||||
negative_lower = negative_prefix.lower()
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
if negative_lower in prompt_lower:
|
||||
# Find the actual position (case-insensitive search)
|
||||
idx = prompt_lower.index(negative_lower)
|
||||
positive = prompt[:idx].strip()
|
||||
negative = prompt[idx + len(negative_prefix) :].strip()
|
||||
return (positive, negative)
|
||||
|
||||
# No negative prompt found
|
||||
return (prompt, "")
|
||||
|
||||
|
||||
def create_batch_queue(
|
||||
prompts: List[str], batch_size: int = 1, randomize: bool = False
|
||||
) -> List[List[int]]:
|
||||
"""
|
||||
Create a queue of prompt indices for batch processing.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
batch_size: Number of prompts per batch
|
||||
randomize: Whether to randomize the order
|
||||
|
||||
Returns:
|
||||
List of batches, where each batch is a list of prompt indices
|
||||
"""
|
||||
if not prompts:
|
||||
return []
|
||||
|
||||
indices = list(range(len(prompts)))
|
||||
|
||||
if randomize:
|
||||
import random
|
||||
|
||||
random.shuffle(indices)
|
||||
|
||||
batches = []
|
||||
for i in range(0, len(indices), batch_size):
|
||||
batch = indices[i : i + batch_size]
|
||||
batches.append(batch)
|
||||
|
||||
return batches
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Batch Prompts node for ComfyUI."""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
load_prompts_from_file,
|
||||
get_prompt_at_index,
|
||||
get_next_prompt,
|
||||
get_prompt_preview,
|
||||
get_batch_info,
|
||||
validate_prompt_file,
|
||||
split_prompt_into_positive_negative,
|
||||
)
|
||||
from .state_manager import STATE_MANAGER
|
||||
|
||||
|
||||
class BatchPromptsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Batch Prompts node for loading and iterating through prompts from text files.
|
||||
|
||||
Loads prompts from a text file where prompts are separated by '---' markers,
|
||||
provides iteration control, and outputs both current and next prompts with
|
||||
optional positive/negative splitting.
|
||||
"""
|
||||
|
||||
# Class variable to cache loaded prompts
|
||||
_prompt_cache = {}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
# Try to get input folder path
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
folder_paths.get_input_directory()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"prompt_file": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "prompts.txt",
|
||||
"multiline": False,
|
||||
"tooltip": "Path to text file containing prompts separated by '---'",
|
||||
},
|
||||
),
|
||||
"index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 9999,
|
||||
"step": 1,
|
||||
"tooltip": "Current prompt index (0-based)",
|
||||
},
|
||||
),
|
||||
"auto_increment": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Automatically increment index after each execution",
|
||||
},
|
||||
),
|
||||
"wrap_around": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Wrap to first prompt after reaching the end",
|
||||
},
|
||||
),
|
||||
"split_negative": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Split prompts into positive/negative at 'Negative:' marker",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"reload_file": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": "Force reload file from disk"},
|
||||
),
|
||||
"show_preview": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "Show prompt preview in console"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"positive",
|
||||
"negative",
|
||||
"full_prompt",
|
||||
"next_prompt",
|
||||
"current_index",
|
||||
"total_prompts",
|
||||
"batch_info",
|
||||
)
|
||||
FUNCTION = "process_batch_prompts"
|
||||
CATEGORY = "🫶 ComfyAssets/📝 Text"
|
||||
|
||||
def process_batch_prompts(
|
||||
self,
|
||||
prompt_file: str,
|
||||
index: int,
|
||||
auto_increment: bool,
|
||||
wrap_around: bool,
|
||||
split_negative: bool,
|
||||
reload_file: bool = False,
|
||||
show_preview: bool = True,
|
||||
) -> Tuple[str, str, str, str, int, int, str]:
|
||||
"""
|
||||
Process batch prompts from file.
|
||||
|
||||
Args:
|
||||
prompt_file: Path to prompt file
|
||||
index: Current prompt index
|
||||
auto_increment: Whether to auto-increment index
|
||||
wrap_around: Whether to wrap around at end
|
||||
split_negative: Whether to split positive/negative prompts
|
||||
reload_file: Force reload from disk
|
||||
show_preview: Show prompt preview in console
|
||||
|
||||
Returns:
|
||||
Tuple of (positive, negative, full_prompt, next_prompt, current_index, total_prompts, batch_info)
|
||||
"""
|
||||
try:
|
||||
# Handle file path first to get a consistent key
|
||||
if not os.path.isabs(prompt_file):
|
||||
# Try to resolve relative to ComfyUI input directory
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
full_path = os.path.join(input_dir, prompt_file)
|
||||
except Exception:
|
||||
# Fallback to current directory
|
||||
full_path = os.path.abspath(prompt_file)
|
||||
else:
|
||||
full_path = prompt_file
|
||||
|
||||
# Use persistent state manager for tracking execution
|
||||
if auto_increment:
|
||||
# Use file-based persistent state
|
||||
actual_index = STATE_MANAGER.increment_execution_count(full_path)
|
||||
print(
|
||||
f"[BatchPrompts] Auto-increment: using index {actual_index} for {os.path.basename(prompt_file)}"
|
||||
)
|
||||
else:
|
||||
actual_index = index
|
||||
print(f"[BatchPrompts] Manual mode: using index {actual_index}")
|
||||
|
||||
# Validate file
|
||||
is_valid, error_msg = validate_prompt_file(full_path)
|
||||
if not is_valid:
|
||||
self.handle_error(f"Invalid prompt file: {error_msg}")
|
||||
|
||||
# Load prompts (with caching)
|
||||
cache_key = full_path
|
||||
if reload_file or cache_key not in self._prompt_cache:
|
||||
prompts = load_prompts_from_file(full_path)
|
||||
if not prompts:
|
||||
self.handle_error(f"No prompts found in file: {prompt_file}")
|
||||
self._prompt_cache[cache_key] = prompts
|
||||
# Reset execution count when reloading file
|
||||
if reload_file:
|
||||
STATE_MANAGER.reset_execution_count(full_path)
|
||||
self.log_info(f"Loaded {len(prompts)} prompts from {prompt_file}")
|
||||
else:
|
||||
prompts = self._prompt_cache[cache_key]
|
||||
|
||||
# Get current prompt using the determined index
|
||||
current_prompt, used_index = get_prompt_at_index(
|
||||
prompts, actual_index, wrap_around
|
||||
)
|
||||
|
||||
# Get next prompt
|
||||
next_prompt_text, next_index = get_next_prompt(
|
||||
prompts, used_index, wrap_around
|
||||
)
|
||||
|
||||
# Split positive/negative if requested
|
||||
if split_negative:
|
||||
positive, negative = split_prompt_into_positive_negative(current_prompt)
|
||||
else:
|
||||
positive = current_prompt
|
||||
negative = ""
|
||||
|
||||
# Get batch info
|
||||
batch_info_dict = get_batch_info(prompts, used_index)
|
||||
batch_info_str = (
|
||||
f"Prompt {batch_info_dict['current_index'] + 1} of {batch_info_dict['total_prompts']} "
|
||||
f"({batch_info_dict['percentage']:.1f}% complete)"
|
||||
)
|
||||
|
||||
# Show preview if requested
|
||||
if show_preview:
|
||||
preview = get_prompt_preview(positive, 80)
|
||||
self.log_info(
|
||||
f"Current prompt [{used_index + 1}/{len(prompts)}]: {preview}"
|
||||
)
|
||||
|
||||
# No need to manually reset - the modulo operation in get_prompt_at_index handles wrapping
|
||||
|
||||
return (
|
||||
positive,
|
||||
negative,
|
||||
current_prompt,
|
||||
next_prompt_text,
|
||||
used_index,
|
||||
len(prompts),
|
||||
batch_info_str,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error processing batch prompts: {str(e)}")
|
||||
# Return empty values on error
|
||||
return ("", "", "", "", 0, 0, "Error")
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""
|
||||
Check if node inputs have changed.
|
||||
This ensures the node re-executes when needed.
|
||||
"""
|
||||
# Import time to ensure unique value each check
|
||||
import time
|
||||
|
||||
# Return current timestamp to guarantee the node is seen as changed
|
||||
# This forces re-execution on every workflow run
|
||||
return str(time.time())
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Simple Batch Prompts node for ComfyUI - debugging version."""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
load_prompts_from_file,
|
||||
get_prompt_at_index,
|
||||
split_prompt_into_positive_negative,
|
||||
)
|
||||
|
||||
# Global counter that persists across all executions
|
||||
GLOBAL_COUNTER = {"count": 0}
|
||||
|
||||
|
||||
class SimpleBatchPromptsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Simplified Batch Prompts node for debugging.
|
||||
Uses a global counter to ensure prompts change.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"prompt_file": ("STRING", {"default": "prompts.txt"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("positive", "negative", "index")
|
||||
FUNCTION = "get_next_prompt"
|
||||
CATEGORY = "🫶 ComfyAssets/📝 Text"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution every time."""
|
||||
GLOBAL_COUNTER["count"] += 1
|
||||
return GLOBAL_COUNTER["count"]
|
||||
|
||||
def get_next_prompt(self, prompt_file: str) -> Tuple[str, str, int]:
|
||||
"""Get the next prompt in sequence."""
|
||||
# Resolve file path
|
||||
if not os.path.isabs(prompt_file):
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
full_path = os.path.join(input_dir, prompt_file)
|
||||
except ImportError:
|
||||
full_path = os.path.abspath(prompt_file)
|
||||
else:
|
||||
full_path = prompt_file
|
||||
|
||||
# Load prompts
|
||||
prompts = load_prompts_from_file(full_path)
|
||||
if not prompts:
|
||||
return ("No prompts found", "", 0)
|
||||
|
||||
# Get current prompt based on global counter
|
||||
index = GLOBAL_COUNTER["count"] % len(prompts)
|
||||
current_prompt, _ = get_prompt_at_index(prompts, index, wrap=True)
|
||||
|
||||
# Split positive/negative
|
||||
positive, negative = split_prompt_into_positive_negative(current_prompt)
|
||||
|
||||
print(
|
||||
f"[SimpleBatchPrompts] Counter={GLOBAL_COUNTER['count']}, Index={index}, Prompt={positive[:30]}..."
|
||||
)
|
||||
|
||||
return (positive, negative, index)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""State management for batch prompts using file persistence."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Manages persistent state for batch prompt execution."""
|
||||
|
||||
def __init__(self):
|
||||
# Use temp directory for state files
|
||||
self.state_dir = Path(tempfile.gettempdir()) / "comfyui_batch_prompts"
|
||||
self.state_dir.mkdir(exist_ok=True)
|
||||
self.state_file = self.state_dir / "execution_state.json"
|
||||
|
||||
def get_state(self) -> Dict[str, Any]:
|
||||
"""Load state from file."""
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_state(self, state: Dict[str, Any]):
|
||||
"""Save state to file."""
|
||||
try:
|
||||
with open(self.state_file, "w") as f:
|
||||
json.dump(state, f)
|
||||
except Exception as e:
|
||||
print(f"[BatchPrompts] Failed to save state: {e}")
|
||||
|
||||
def get_execution_count(self, file_path: str) -> int:
|
||||
"""Get execution count for a specific file."""
|
||||
state = self.get_state()
|
||||
counts = state.get("execution_counts", {})
|
||||
return counts.get(file_path, 0)
|
||||
|
||||
def increment_execution_count(self, file_path: str) -> int:
|
||||
"""Increment and return execution count for a file."""
|
||||
state = self.get_state()
|
||||
counts = state.get("execution_counts", {})
|
||||
current = counts.get(file_path, 0)
|
||||
counts[file_path] = current + 1
|
||||
state["execution_counts"] = counts
|
||||
self.save_state(state)
|
||||
return current
|
||||
|
||||
def reset_execution_count(self, file_path: str):
|
||||
"""Reset execution count for a file."""
|
||||
state = self.get_state()
|
||||
counts = state.get("execution_counts", {})
|
||||
counts[file_path] = 0
|
||||
state["execution_counts"] = counts
|
||||
self.save_state(state)
|
||||
|
||||
|
||||
# Global state manager instance
|
||||
STATE_MANAGER = StateManager()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Logic for DisplayAny node - displays any input value or tensor shape."""
|
||||
|
||||
from typing import Any, List, Union
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
def get_tensor_shapes(input_value: Any) -> List[List[int]]:
|
||||
@@ -54,7 +54,7 @@ def format_display_value(input_value: Any, mode: str = "raw value") -> str:
|
||||
|
||||
if isinstance(input_value, (dict, list)):
|
||||
return json.dumps(input_value, indent=2)
|
||||
except:
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return str(input_value)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""DisplayAny node for ComfyUI - displays any input value or tensor information."""
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import format_display_value, validate_display_mode
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -35,7 +35,7 @@ class ImageScaleDownByNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "scale_down"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .node import KikoFilmGrainNode
|
||||
|
||||
__all__ = ["KikoFilmGrainNode"]
|
||||
@@ -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)
|
||||
@@ -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/💾 Images"
|
||||
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,)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .node import KikoPurgeVRAM
|
||||
|
||||
__all__ = ["KikoPurgeVRAM"]
|
||||
@@ -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)",
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -95,7 +95,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "ComfyAssets/💾 Images"
|
||||
CATEGORY = "🫶 ComfyAssets/💾 Images"
|
||||
FUNCTION = "save_images"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Local Image Loader tool for KikoTools."""
|
||||
|
||||
from .node import LocalImageLoaderNode
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KikoLocalImageLoader": LocalImageLoaderNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KikoLocalImageLoader": "Local Image Loader",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"last_path": "/home/vito/ai-apps/ComfyUI-3.12/output",
|
||||
"saved_paths": [
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Core logic for Local Image Loader."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from typing import Tuple, Dict, Any, List
|
||||
|
||||
|
||||
def get_supported_extensions() -> Dict[str, List[str]]:
|
||||
"""Get supported file extensions by type."""
|
||||
return {
|
||||
"image": [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"],
|
||||
"video": [".mp4", ".webm", ".mov", ".mkv", ".avi"],
|
||||
"audio": [".mp3", ".wav", ".ogg", ".flac"],
|
||||
}
|
||||
|
||||
|
||||
def load_image_from_path(path: str) -> Tuple[torch.Tensor, Dict[str, Any]]:
|
||||
"""
|
||||
Load an image from the given path and convert it to a tensor.
|
||||
|
||||
Args:
|
||||
path: Path to the image file
|
||||
|
||||
Returns:
|
||||
Tuple of (image tensor, metadata dict)
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
with Image.open(path) as img:
|
||||
# Convert to appropriate format
|
||||
if "A" in img.getbands():
|
||||
img_out = img.convert("RGBA")
|
||||
else:
|
||||
img_out = img.convert("RGB")
|
||||
|
||||
# Convert to tensor
|
||||
img_array = np.array(img_out).astype(np.float32) / 255.0
|
||||
image_tensor = torch.from_numpy(img_array)[None,]
|
||||
|
||||
# Collect metadata
|
||||
metadata = {
|
||||
"filename": os.path.basename(path),
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"mode": img.mode,
|
||||
"format": img.format,
|
||||
}
|
||||
|
||||
# Check for embedded metadata
|
||||
if "parameters" in img.info:
|
||||
metadata["parameters"] = img.info["parameters"]
|
||||
if "prompt" in img.info:
|
||||
try:
|
||||
metadata["prompt"] = json.loads(img.info["prompt"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata["prompt"] = img.info["prompt"]
|
||||
if "workflow" in img.info:
|
||||
try:
|
||||
metadata["workflow"] = json.loads(img.info["workflow"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata["workflow"] = img.info["workflow"]
|
||||
|
||||
return image_tensor, metadata
|
||||
|
||||
|
||||
def scan_directory(
|
||||
directory: str,
|
||||
show_videos: bool = False,
|
||||
show_audio: bool = False,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Scan a directory for supported media files.
|
||||
|
||||
Args:
|
||||
directory: Directory path to scan
|
||||
show_videos: Include video files
|
||||
show_audio: Include audio files
|
||||
sort_by: Sort criteria ('name', 'date', 'size')
|
||||
sort_order: Sort order ('asc', 'desc')
|
||||
|
||||
Returns:
|
||||
List of file information dictionaries
|
||||
"""
|
||||
if not os.path.isdir(directory):
|
||||
raise NotADirectoryError(f"Not a directory: {directory}")
|
||||
|
||||
extensions = get_supported_extensions()
|
||||
items = []
|
||||
|
||||
for item in os.listdir(directory):
|
||||
full_path = os.path.join(directory, item)
|
||||
|
||||
try:
|
||||
stats = os.stat(full_path)
|
||||
item_data = {
|
||||
"path": full_path,
|
||||
"name": item,
|
||||
"mtime": stats.st_mtime,
|
||||
"size": stats.st_size,
|
||||
}
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
items.append({**item_data, "type": "dir"})
|
||||
else:
|
||||
ext = os.path.splitext(item)[1].lower()
|
||||
item_type = None
|
||||
|
||||
if ext in extensions["image"]:
|
||||
item_type = "image"
|
||||
elif show_videos and ext in extensions["video"]:
|
||||
item_type = "video"
|
||||
elif show_audio and ext in extensions["audio"]:
|
||||
item_type = "audio"
|
||||
|
||||
if item_type:
|
||||
items.append({**item_data, "type": item_type})
|
||||
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
# Sort items
|
||||
reverse = sort_order == "desc"
|
||||
if sort_by == "date":
|
||||
items.sort(key=lambda x: x["mtime"], reverse=reverse)
|
||||
elif sort_by == "size":
|
||||
items.sort(key=lambda x: x.get("size", 0), reverse=reverse)
|
||||
else: # name
|
||||
items.sort(key=lambda x: x["name"].lower(), reverse=reverse)
|
||||
|
||||
# Directories first
|
||||
items.sort(key=lambda x: x["type"] != "dir")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def create_empty_tensor() -> torch.Tensor:
|
||||
"""Create an empty tensor for when no image is selected."""
|
||||
return torch.zeros(1, 1, 1, 4)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Local Image Loader node for ComfyUI."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import torch
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import load_image_from_path, create_empty_tensor
|
||||
|
||||
|
||||
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SELECTIONS_FILE = os.path.join(NODE_DIR, "selections.json")
|
||||
CONFIG_FILE = os.path.join(NODE_DIR, "config.json")
|
||||
|
||||
|
||||
def load_selections() -> Dict[str, Any]:
|
||||
"""Load node selections from file."""
|
||||
if not os.path.exists(SELECTIONS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(SELECTIONS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_selections(data: Dict[str, Any]) -> None:
|
||||
"""Save node selections to file."""
|
||||
try:
|
||||
with open(SELECTIONS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error saving selections: {e}")
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(data: Dict[str, Any]) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error saving config: {e}")
|
||||
|
||||
|
||||
class LocalImageLoaderNode(ComfyAssetsBaseNode):
|
||||
"""Node for loading images from local filesystem with a visual gallery interface."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {"unique_id": "UNIQUE_ID"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (
|
||||
"IMAGE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image",
|
||||
"video_path",
|
||||
"audio_path",
|
||||
"info",
|
||||
)
|
||||
FUNCTION = "load_media"
|
||||
CATEGORY = "🫶 ComfyAssets/💾 Images"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Check if node state has changed."""
|
||||
if os.path.exists(SELECTIONS_FILE):
|
||||
return os.path.getmtime(SELECTIONS_FILE)
|
||||
return float("inf")
|
||||
|
||||
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str, str, str]:
|
||||
"""
|
||||
Load selected media based on node's unique ID.
|
||||
|
||||
Args:
|
||||
unique_id: Unique identifier for this node instance
|
||||
|
||||
Returns:
|
||||
Tuple of (image tensor, video path, audio path, info string)
|
||||
"""
|
||||
image_tensor = create_empty_tensor()
|
||||
video_path = ""
|
||||
audio_path = ""
|
||||
info_string = ""
|
||||
|
||||
selections = load_selections()
|
||||
node_selections = selections.get(str(unique_id), {})
|
||||
|
||||
# Load image if selected
|
||||
image_selection = node_selections.get("image")
|
||||
if image_selection and image_selection.get("path"):
|
||||
image_path = image_selection["path"]
|
||||
if os.path.exists(image_path):
|
||||
try:
|
||||
image_tensor, metadata = load_image_from_path(image_path)
|
||||
info_string = json.dumps(metadata, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error loading image: {e}")
|
||||
|
||||
# Get video path if selected
|
||||
video_selection = node_selections.get("video")
|
||||
if video_selection and video_selection.get("path"):
|
||||
if os.path.exists(video_selection["path"]):
|
||||
video_path = video_selection["path"]
|
||||
|
||||
# Get audio path if selected
|
||||
audio_selection = node_selections.get("audio")
|
||||
if audio_selection and audio_selection.get("path"):
|
||||
if os.path.exists(audio_selection["path"]):
|
||||
audio_path = audio_selection["path"]
|
||||
|
||||
return (image_tensor, video_path, audio_path, info_string)
|
||||
|
||||
|
||||
# Setup API routes
|
||||
try:
|
||||
import server
|
||||
from aiohttp import web
|
||||
import urllib.parse
|
||||
import io
|
||||
from PIL import Image
|
||||
from .logic import scan_directory
|
||||
|
||||
prompt_server = server.PromptServer.instance
|
||||
|
||||
@prompt_server.routes.post("/kiko_local_image_loader/set_node_selection")
|
||||
async def set_node_selection(request):
|
||||
"""API endpoint to set node selection."""
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id"))
|
||||
path = data.get("path")
|
||||
media_type = data.get("type")
|
||||
|
||||
if not all([node_id, path, media_type]):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Missing required data."}, status=400
|
||||
)
|
||||
|
||||
selections = load_selections()
|
||||
if node_id not in selections:
|
||||
selections[node_id] = {}
|
||||
|
||||
selections[node_id][media_type] = {"path": path}
|
||||
save_selections(selections)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_saved_paths")
|
||||
async def get_saved_paths(request):
|
||||
"""API endpoint to get saved directory paths."""
|
||||
config = load_config()
|
||||
return web.json_response({"saved_paths": config.get("saved_paths", [])})
|
||||
|
||||
@prompt_server.routes.post("/kiko_local_image_loader/save_paths")
|
||||
async def save_paths(request):
|
||||
"""API endpoint to save directory paths."""
|
||||
try:
|
||||
data = await request.json()
|
||||
paths = data.get("paths", [])
|
||||
config = load_config()
|
||||
config["saved_paths"] = paths
|
||||
save_config(config)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/images")
|
||||
async def get_local_images(request):
|
||||
"""API endpoint to get images from a directory."""
|
||||
directory = request.query.get("directory", "")
|
||||
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return web.json_response({"error": "Directory not found."}, status=404)
|
||||
|
||||
# Save last path
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
|
||||
show_videos = request.query.get("show_videos", "false").lower() == "true"
|
||||
show_audio = request.query.get("show_audio", "false").lower() == "true"
|
||||
|
||||
page = int(request.query.get("page", 1))
|
||||
per_page = int(request.query.get("per_page", 50))
|
||||
sort_by = request.query.get("sort_by", "name")
|
||||
sort_order = request.query.get("sort_order", "asc")
|
||||
|
||||
try:
|
||||
items = scan_directory(
|
||||
directory, show_videos, show_audio, sort_by, sort_order
|
||||
)
|
||||
|
||||
# Get parent directory
|
||||
parent_directory = os.path.dirname(directory)
|
||||
if parent_directory == directory:
|
||||
parent_directory = None
|
||||
|
||||
# Paginate results
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_items = items[start:end]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"items": paginated_items,
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"current_page": page,
|
||||
"current_directory": directory,
|
||||
"parent_directory": parent_directory,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_last_path")
|
||||
async def get_last_path(request):
|
||||
"""API endpoint to get last used directory path."""
|
||||
return web.json_response({"last_path": load_config().get("last_path", "")})
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
|
||||
async def get_thumbnail(request):
|
||||
"""API endpoint to get image thumbnail."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
img = Image.open(filepath)
|
||||
has_alpha = img.mode == "RGBA" or (
|
||||
img.mode == "P" and "transparency" in img.info
|
||||
)
|
||||
img = img.convert("RGBA") if has_alpha else img.convert("RGB")
|
||||
img.thumbnail([320, 320], Image.LANCZOS)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
format, content_type = (
|
||||
("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg")
|
||||
)
|
||||
img.save(buffer, format=format, quality=90 if format == "JPEG" else None)
|
||||
buffer.seek(0)
|
||||
|
||||
return web.Response(body=buffer.read(), content_type=content_type)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error generating thumbnail: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/view")
|
||||
async def view_image(request):
|
||||
"""API endpoint to view full image."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
return web.FileResponse(filepath)
|
||||
except Exception:
|
||||
return web.Response(status=500)
|
||||
|
||||
except ImportError:
|
||||
# Server not available during testing
|
||||
pass
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"57": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01/HiDream_00001_.png"
|
||||
}
|
||||
},
|
||||
"58": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
"min": 1.0,
|
||||
"max": 15.0,
|
||||
"step": 0.5,
|
||||
"display": "slider",
|
||||
"tooltip": "CFG",
|
||||
},
|
||||
),
|
||||
@@ -63,7 +62,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
|
||||
|
||||
@@ -58,7 +58,6 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"display": "slider",
|
||||
"tooltip": "CFG scale (0-20)",
|
||||
},
|
||||
),
|
||||
@@ -68,7 +67,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
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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,
|
||||
@@ -352,6 +352,12 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
< len(lora_strength[lora_file_idx])
|
||||
else 0
|
||||
)
|
||||
# Add batch info if available
|
||||
if "batch_info" in loras:
|
||||
param_record["lora_batch"] = (
|
||||
f"Batch {loras['batch_info']['index'] + 1}/"
|
||||
f"{loras['batch_info']['total']}"
|
||||
)
|
||||
|
||||
out_params.append(param_record)
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,7 +35,7 @@ def get_lora_folders() -> List[str]:
|
||||
return [".", "flux", "sdxl", "sd15"]
|
||||
|
||||
|
||||
def scan_folder_for_loras(folder_path: str) -> List[str]:
|
||||
def scan_folder_for_loras(folder_path: str) -> List[str]: # noqa: C901
|
||||
"""
|
||||
Scan a folder for LoRA files (.safetensors).
|
||||
|
||||
@@ -52,70 +51,85 @@ def scan_folder_for_loras(folder_path: str) -> List[str]:
|
||||
# Get all LoRA paths from ComfyUI (includes extra_model_paths)
|
||||
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
|
||||
|
||||
# Determine the full path and base lora path
|
||||
full_path = None
|
||||
base_lora_path = None
|
||||
|
||||
# Check if this is an absolute path
|
||||
if os.path.isabs(folder_path):
|
||||
full_path = folder_path
|
||||
|
||||
# Try to find which lora base path this belongs to
|
||||
rel_folder = None
|
||||
# Check if this path is inside any of the known lora directories
|
||||
for lora_base in lora_paths:
|
||||
try:
|
||||
potential_rel = os.path.relpath(full_path, lora_base)
|
||||
if not potential_rel.startswith(".."):
|
||||
# This path is inside this lora base
|
||||
rel_folder = potential_rel
|
||||
break
|
||||
except ValueError:
|
||||
# Different drives on Windows
|
||||
continue
|
||||
# Normalize paths for comparison
|
||||
norm_full = os.path.normpath(full_path)
|
||||
norm_base = os.path.normpath(lora_base)
|
||||
|
||||
if rel_folder is None:
|
||||
# Path is outside all known lora directories
|
||||
# Try to extract a relative path that might work
|
||||
# Check if path contains common lora folder structures
|
||||
path_parts = full_path.replace("\\", "/").split("/")
|
||||
if "lora" in path_parts or "loras" in path_parts:
|
||||
# Find index after lora/loras
|
||||
# Check if full_path starts with this lora_base
|
||||
if norm_full.startswith(norm_base):
|
||||
base_lora_path = lora_base
|
||||
break
|
||||
|
||||
# Also check if the path is a subdirectory under lora/loras
|
||||
if "lora" in norm_full.lower():
|
||||
# Find the lora or loras directory in the path
|
||||
path_parts = norm_full.replace("\\", "/").split("/")
|
||||
for i, part in enumerate(path_parts):
|
||||
if part in ["lora", "loras"]:
|
||||
# Use everything after lora/loras as relative path
|
||||
rel_folder = "/".join(path_parts[i + 1 :])
|
||||
break
|
||||
|
||||
if rel_folder is None:
|
||||
# Last resort: use last two directories as relative path
|
||||
rel_folder = (
|
||||
"/".join(path_parts[-2:])
|
||||
if len(path_parts) >= 2
|
||||
else path_parts[-1]
|
||||
)
|
||||
if part.lower() in ["lora", "loras"]:
|
||||
# Check if this matches our lora_base
|
||||
potential_base = "/".join(path_parts[: i + 1])
|
||||
if os.path.normpath(potential_base) == norm_base:
|
||||
base_lora_path = lora_base
|
||||
break
|
||||
if base_lora_path:
|
||||
break
|
||||
else:
|
||||
# Relative path provided
|
||||
full_path = (
|
||||
os.path.join(lora_paths[0], folder_path) if lora_paths else folder_path
|
||||
)
|
||||
rel_folder = folder_path if folder_path != "." else ""
|
||||
base_lora_path = lora_paths[0] if lora_paths else ""
|
||||
full_path = os.path.join(base_lora_path, folder_path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
logger.warning(f"Folder does not exist: {full_path}")
|
||||
return []
|
||||
|
||||
# Scan for .safetensors files
|
||||
# Scan for .safetensors files recursively
|
||||
lora_files = []
|
||||
for file in os.listdir(full_path):
|
||||
if file.endswith(".safetensors"):
|
||||
# Store relative path from lora base
|
||||
if rel_folder and rel_folder != ".":
|
||||
lora_files.append(os.path.join(rel_folder, file).replace("\\", "/"))
|
||||
else:
|
||||
lora_files.append(file)
|
||||
for root, _, files in os.walk(full_path):
|
||||
for file in files:
|
||||
if file.endswith(".safetensors"):
|
||||
# Get the full path to the file
|
||||
file_full_path = os.path.join(root, file)
|
||||
|
||||
# Calculate the correct relative path for ComfyUI
|
||||
if base_lora_path:
|
||||
# Path is inside a known lora directory
|
||||
try:
|
||||
rel_path = os.path.relpath(file_full_path, base_lora_path)
|
||||
lora_files.append(rel_path.replace("\\", "/"))
|
||||
except ValueError:
|
||||
# Different drives on Windows, use path relative to scan folder
|
||||
rel_path = os.path.relpath(file_full_path, full_path)
|
||||
if rel_path == ".":
|
||||
lora_files.append(file)
|
||||
else:
|
||||
lora_files.append(rel_path.replace("\\", "/"))
|
||||
else:
|
||||
# Path is outside known lora directories
|
||||
# Return path relative to the scanned folder
|
||||
rel_path = os.path.relpath(file_full_path, full_path)
|
||||
if rel_path == ".":
|
||||
lora_files.append(file)
|
||||
else:
|
||||
lora_files.append(rel_path.replace("\\", "/"))
|
||||
|
||||
# Sort naturally (handles epoch numbers properly)
|
||||
lora_files = natural_sort(lora_files)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(lora_files)} LoRA files in {folder_path}, returning paths relative to lora base"
|
||||
)
|
||||
logger.info(f"Found {len(lora_files)} LoRA files in {folder_path}")
|
||||
if lora_files and logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Base lora path: {base_lora_path}")
|
||||
logger.debug(f"Full scan path: {full_path}")
|
||||
logger.debug(f"First few LoRA paths returned: {lora_files[:3]}")
|
||||
return lora_files
|
||||
|
||||
except Exception as e:
|
||||
@@ -123,6 +137,34 @@ def scan_folder_for_loras(folder_path: str) -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
def sort_lora_files(lora_files: List[str], sort_order: str) -> List[str]:
|
||||
"""
|
||||
Sort LoRA files based on the specified order.
|
||||
|
||||
Args:
|
||||
lora_files: List of LoRA file paths
|
||||
sort_order: Type of sorting ("natural", "alphabetical", "newest", "oldest")
|
||||
|
||||
Returns:
|
||||
Sorted list of LoRA files
|
||||
"""
|
||||
if sort_order == "natural":
|
||||
return natural_sort(lora_files)
|
||||
elif sort_order == "alphabetical":
|
||||
return sorted(lora_files)
|
||||
elif sort_order in ["newest", "oldest"]:
|
||||
# For time-based sorting, we need the actual file stats
|
||||
# Since we only have relative paths, we'll sort by name for now
|
||||
# This could be enhanced if we have access to file stats
|
||||
sorted_files = natural_sort(lora_files)
|
||||
if sort_order == "oldest":
|
||||
return sorted_files
|
||||
else: # newest
|
||||
return sorted_files[::-1]
|
||||
else:
|
||||
return lora_files
|
||||
|
||||
|
||||
def natural_sort(items: List[str]) -> List[str]:
|
||||
"""
|
||||
Sort strings naturally, handling numbers properly.
|
||||
@@ -140,10 +182,17 @@ def natural_sort(items: List[str]) -> List[str]:
|
||||
|
||||
# Split on digits and filter out empty strings
|
||||
parts = [atoi(c) for c in re.split(r"(\d+)", text) if c]
|
||||
# Put files without numbers first
|
||||
if not any(isinstance(p, int) for p in parts):
|
||||
return [0] + parts
|
||||
return parts
|
||||
|
||||
# Convert to tuple of (type_order, value) to ensure consistent comparison
|
||||
# Integers get type_order 0, strings get type_order 1
|
||||
typed_parts = []
|
||||
for part in parts:
|
||||
if isinstance(part, int):
|
||||
typed_parts.append((0, part))
|
||||
else:
|
||||
typed_parts.append((1, part))
|
||||
|
||||
return typed_parts
|
||||
|
||||
return sorted(items, key=natural_key)
|
||||
|
||||
@@ -183,7 +232,7 @@ def filter_loras_by_pattern(
|
||||
return filtered
|
||||
|
||||
|
||||
def parse_strength_string(strength_str: str) -> List[float]:
|
||||
def parse_strength_string(strength_str: str) -> List[float]: # noqa: C901
|
||||
"""
|
||||
Parse strength string into list of values.
|
||||
|
||||
@@ -278,6 +327,57 @@ def create_lora_params(
|
||||
return {"loras": lora_files, "strengths": strength_lists}
|
||||
|
||||
|
||||
def create_lora_params_batched(
|
||||
lora_files: List[str],
|
||||
strengths: List[float],
|
||||
batch_mode: str = "sequential",
|
||||
batch_size: int = 25,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Create multiple LORA_PARAMS structures for FluxSamplerParams, batched for stability.
|
||||
|
||||
Args:
|
||||
lora_files: List of LoRA file paths
|
||||
strengths: List of strength values to test
|
||||
batch_mode: How to batch ("sequential" or "combinatorial")
|
||||
batch_size: Maximum number of LoRAs per batch
|
||||
|
||||
Returns:
|
||||
List of LORA_PARAMS dictionaries, each with batch info
|
||||
"""
|
||||
if not lora_files:
|
||||
logger.warning("No LoRA files provided")
|
||||
return [{"loras": [], "strengths": [], "batch_info": {"index": 0, "total": 0}}]
|
||||
|
||||
# Split lora_files into batches
|
||||
batches = []
|
||||
total_batches = (len(lora_files) + batch_size - 1) // batch_size
|
||||
|
||||
for i in range(0, len(lora_files), batch_size):
|
||||
batch_loras = lora_files[i : i + batch_size]
|
||||
batch_index = i // batch_size
|
||||
|
||||
# Create params for this batch
|
||||
params = create_lora_params(batch_loras, strengths, batch_mode)
|
||||
|
||||
# Add batch tracking info
|
||||
params["batch_info"] = {
|
||||
"index": batch_index,
|
||||
"total": total_batches,
|
||||
"start_idx": i,
|
||||
"end_idx": min(i + batch_size, len(lora_files)),
|
||||
"size": len(batch_loras),
|
||||
}
|
||||
|
||||
batches.append(params)
|
||||
|
||||
logger.info(f"Created {total_batches} batches of LoRAs (batch size: {batch_size})")
|
||||
for i, batch in enumerate(batches):
|
||||
logger.info(f" Batch {i}: {batch['batch_info']['size']} LoRAs")
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def get_lora_info(lora_file: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract information from LoRA filename.
|
||||
@@ -313,15 +413,25 @@ def validate_folder_path(folder_path: str) -> bool:
|
||||
Validate that the folder path exists and is accessible.
|
||||
|
||||
Args:
|
||||
folder_path: Folder path to validate
|
||||
folder_path: Folder path to validate (absolute or relative)
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
"""
|
||||
try:
|
||||
# Handle absolute paths
|
||||
if os.path.isabs(folder_path):
|
||||
return os.path.exists(folder_path) and os.path.isdir(folder_path)
|
||||
|
||||
# Handle relative paths
|
||||
import folder_paths
|
||||
|
||||
lora_base_path = folder_paths.folder_names_and_paths["loras"][0][0]
|
||||
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
|
||||
|
||||
if not lora_paths:
|
||||
return False
|
||||
|
||||
lora_base_path = lora_paths[0]
|
||||
|
||||
if folder_path == ".":
|
||||
full_path = lora_base_path
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""LoRA Folder Batch node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any, Dict, List
|
||||
import os
|
||||
from typing import Tuple, Any, Dict
|
||||
import logging
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
get_lora_folders,
|
||||
scan_folder_for_loras,
|
||||
filter_loras_by_pattern,
|
||||
parse_strength_string,
|
||||
create_lora_params,
|
||||
create_lora_params_batched,
|
||||
get_lora_info,
|
||||
validate_folder_path,
|
||||
)
|
||||
@@ -74,21 +73,67 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
|
||||
"tooltip": "Regex pattern to exclude files (e.g., 'test|backup')",
|
||||
},
|
||||
),
|
||||
"max_loras": (
|
||||
"INT",
|
||||
{
|
||||
"default": 50,
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"tooltip": "Maximum number of LoRAs to process (to prevent UI disconnection)",
|
||||
},
|
||||
),
|
||||
"auto_batch": (
|
||||
["disabled", "enabled"],
|
||||
{
|
||||
"default": "disabled",
|
||||
"tooltip": "Auto-batch large sets into chunks of 25 LoRAs",
|
||||
},
|
||||
),
|
||||
"batch_size": (
|
||||
"INT",
|
||||
{
|
||||
"default": 25,
|
||||
"min": 5,
|
||||
"max": 100,
|
||||
"tooltip": "Number of LoRAs per batch when auto-batching",
|
||||
},
|
||||
),
|
||||
"batch_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"tooltip": "Which batch to output (0-based index)",
|
||||
},
|
||||
),
|
||||
"sort_order": (
|
||||
["natural", "alphabetical", "newest", "oldest"],
|
||||
{
|
||||
"default": "natural",
|
||||
"tooltip": "How to sort the LoRA files",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
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(
|
||||
def batch_loras( # noqa: C901
|
||||
self,
|
||||
folder_path: str,
|
||||
strength: str,
|
||||
batch_mode: str,
|
||||
include_pattern: str = "",
|
||||
exclude_pattern: str = "",
|
||||
max_loras: int = 50,
|
||||
sort_order: str = "natural",
|
||||
auto_batch: str = "disabled",
|
||||
batch_size: int = 25,
|
||||
batch_index: int = 0,
|
||||
) -> Tuple[Dict[str, Any], str, int]:
|
||||
"""
|
||||
Batch process LoRAs from a folder.
|
||||
@@ -137,37 +182,98 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
|
||||
self.log_info("No LoRAs left after filtering")
|
||||
return ({"loras": [], "strengths": []}, "", 0)
|
||||
|
||||
# Apply sorting based on sort_order
|
||||
if sort_order != "natural":
|
||||
from .logic import sort_lora_files
|
||||
|
||||
lora_files = sort_lora_files(lora_files, sort_order)
|
||||
|
||||
# Only limit if NOT auto-batching
|
||||
if auto_batch == "disabled" and len(lora_files) > max_loras:
|
||||
self.log_info(
|
||||
f"⚠️ Limiting to {max_loras} LoRAs (found {len(lora_files)}). "
|
||||
f"Enable auto_batch or increase max_loras to process more."
|
||||
)
|
||||
lora_files = lora_files[:max_loras]
|
||||
|
||||
# Parse strength values
|
||||
strengths = parse_strength_string(strength)
|
||||
self.log_info(f"Using strength values: {strengths}")
|
||||
|
||||
# Create LORA_PARAMS
|
||||
lora_params = create_lora_params(lora_files, strengths, batch_mode)
|
||||
# Create LORA_PARAMS with auto-batching if enabled
|
||||
if auto_batch == "enabled" and len(lora_files) > batch_size:
|
||||
all_batches = create_lora_params_batched(
|
||||
lora_files, strengths, batch_mode, batch_size
|
||||
)
|
||||
|
||||
# Create info string
|
||||
# Check if batch_index is valid
|
||||
if batch_index >= len(all_batches):
|
||||
self.log_info(
|
||||
f"⚠️ Batch index {batch_index} out of range. "
|
||||
f"Only {len(all_batches)} batches available. Using batch 0."
|
||||
)
|
||||
batch_index = 0
|
||||
|
||||
lora_params = all_batches[batch_index]
|
||||
|
||||
# Update lora_files to only include current batch for list display
|
||||
batch_start = lora_params["batch_info"]["start_idx"]
|
||||
batch_end = lora_params["batch_info"]["end_idx"]
|
||||
lora_files_for_display = lora_files[batch_start:batch_end]
|
||||
else:
|
||||
# Regular single batch mode
|
||||
lora_params = create_lora_params(lora_files, strengths, batch_mode)
|
||||
lora_files_for_display = lora_files
|
||||
|
||||
# Create info string for current batch only
|
||||
lora_list = []
|
||||
for lora_file in lora_files:
|
||||
for lora_file in lora_files_for_display:
|
||||
info = get_lora_info(lora_file)
|
||||
if info["epoch"] is not None:
|
||||
lora_list.append(f"{info['name']} (epoch {info['epoch']})")
|
||||
else:
|
||||
lora_list.append(info["name"])
|
||||
|
||||
lora_list_str = "\n".join(lora_list)
|
||||
|
||||
# Calculate total combinations
|
||||
if batch_mode == "combinatorial":
|
||||
total_combos = len(lora_files) * len(strengths)
|
||||
# Add batch info to the list string if auto-batching
|
||||
if auto_batch == "enabled" and "batch_info" in lora_params:
|
||||
batch_header = (
|
||||
f"=== Batch {batch_index + 1}/{lora_params['batch_info']['total']} "
|
||||
f"(LoRAs {lora_params['batch_info']['start_idx'] + 1}-"
|
||||
f"{lora_params['batch_info']['end_idx']}) ===\n\n"
|
||||
)
|
||||
lora_list_str = batch_header + "\n".join(lora_list)
|
||||
else:
|
||||
total_combos = len(lora_files)
|
||||
lora_list_str = "\n".join(lora_list)
|
||||
|
||||
self.log_info(
|
||||
f"Created batch with {len(lora_files)} LoRAs, "
|
||||
f"{len(strengths)} strength values, "
|
||||
f"{total_combos} total combinations"
|
||||
)
|
||||
# Calculate total combinations for current batch
|
||||
current_batch_loras = len(lora_files_for_display)
|
||||
if batch_mode == "combinatorial":
|
||||
total_combos = current_batch_loras * len(strengths)
|
||||
else:
|
||||
total_combos = current_batch_loras
|
||||
|
||||
return (lora_params, lora_list_str, len(lora_files))
|
||||
# Warn if generating many combinations
|
||||
if total_combos > 100:
|
||||
self.log_info(
|
||||
f"⚠️ WARNING: Generating {total_combos} combinations! "
|
||||
f"This may cause UI disconnection. Consider reducing max_loras or strength values."
|
||||
)
|
||||
|
||||
if auto_batch == "enabled" and "batch_info" in lora_params:
|
||||
self.log_info(
|
||||
f"Output batch {batch_index + 1}/{lora_params['batch_info']['total']} "
|
||||
f"with {current_batch_loras} LoRAs, "
|
||||
f"{len(strengths)} strength values, "
|
||||
f"{total_combos} total combinations"
|
||||
)
|
||||
else:
|
||||
self.log_info(
|
||||
f"Created batch with {current_batch_loras} LoRAs, "
|
||||
f"{len(strengths)} strength values, "
|
||||
f"{total_combos} total combinations"
|
||||
)
|
||||
|
||||
return (lora_params, lora_list_str, current_batch_loras)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error creating LoRA batch: {str(e)}", e)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Logic module for Plot Parameters node."""
|
||||
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from typing import List, Dict, Tuple
|
||||
import math
|
||||
import textwrap
|
||||
import logging
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -202,8 +201,27 @@ def format_parameter_text(param: Dict, mode: str = "full") -> str:
|
||||
|
||||
# Optional LoRA line
|
||||
if "lora" in param and param["lora"]:
|
||||
lora_name = param["lora"][:32] if len(param["lora"]) > 32 else param["lora"]
|
||||
lines.append(f"LoRA: {lora_name}, str: {param.get('lora_strength', 'N/A')}")
|
||||
lora_path = param["lora"]
|
||||
# Extract just the filename and immediate parent directory for better readability
|
||||
path_parts = lora_path.replace("\\", "/").split("/")
|
||||
if len(path_parts) > 2:
|
||||
# Show parent directory and filename
|
||||
lora_display = f"{path_parts[-2]}/{path_parts[-1]}"
|
||||
else:
|
||||
# Use full path if it's short
|
||||
lora_display = lora_path
|
||||
|
||||
# Remove file extension for cleaner display
|
||||
if lora_display.endswith(".safetensors"):
|
||||
lora_display = lora_display[:-12]
|
||||
|
||||
lora_line = (
|
||||
f"LoRA: {lora_display}, str: {param.get('lora_strength', 'N/A')}"
|
||||
)
|
||||
# Add batch info if available
|
||||
if "lora_batch" in param:
|
||||
lora_line += f" [{param['lora_batch']}]"
|
||||
lines.append(lora_line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ from .logic import (
|
||||
filter_changing_params,
|
||||
format_parameter_text,
|
||||
wrap_prompt_text,
|
||||
calculate_text_dimensions,
|
||||
calculate_grid_dimensions,
|
||||
validate_plot_parameters,
|
||||
)
|
||||
@@ -113,7 +112,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,
|
||||
@@ -178,7 +177,7 @@ class PlotParametersNode(ComfyAssetsBaseNode):
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except:
|
||||
except (IOError, OSError):
|
||||
logger.warning(f"Could not load font from {font_path}, using default")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Logic module for Sampler Select Helper node."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Logic module for Scheduler Select Helper node."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Logic module for Text Encode Sampler Params node."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any
|
||||
import re
|
||||
import logging
|
||||
|
||||
@@ -69,9 +69,9 @@ def encode_prompts(prompts: List[str], clip_encoder) -> List[Any]:
|
||||
try:
|
||||
conditioning = encoder.encode(clip_encoder, prompt)[0]
|
||||
encoded.append(conditioning)
|
||||
logger.debug(f"Encoded prompt {i+1}/{len(prompts)}")
|
||||
logger.debug(f"Encoded prompt {i + 1}/{len(prompts)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode prompt {i+1}: {e}")
|
||||
logger.error(f"Failed to encode prompt {i + 1}: {e}")
|
||||
encoded.append(None)
|
||||
|
||||
encoded = [e for e in encoded if e is not None]
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.0.11"
|
||||
version = "1.0.20"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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!")
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
@@ -14,7 +14,7 @@ class TestComfyAssetsBaseNode:
|
||||
|
||||
def test_category_is_comfy_assets(self):
|
||||
"""Test that CATEGORY is set to ComfyAssets"""
|
||||
assert ComfyAssetsBaseNode.CATEGORY == "ComfyAssets"
|
||||
assert ComfyAssetsBaseNode.CATEGORY == "🫶 ComfyAssets"
|
||||
|
||||
def test_validate_inputs_default_implementation(self):
|
||||
"""Test default validate_inputs does nothing"""
|
||||
@@ -69,7 +69,7 @@ class TestComfyAssetsBaseNode:
|
||||
|
||||
assert isinstance(info, dict)
|
||||
assert info["class_name"] == "ComfyAssetsBaseNode"
|
||||
assert info["category"] == "ComfyAssets"
|
||||
assert info["category"] == "🫶 ComfyAssets"
|
||||
assert info["function"] == "Unknown" # Base class doesn't have FUNCTION
|
||||
assert info["return_types"] == ()
|
||||
assert info["return_names"] == ()
|
||||
@@ -91,14 +91,14 @@ class TestConcreteNodeInheritance:
|
||||
|
||||
def test_concrete_node_inherits_category(self):
|
||||
"""Test concrete node inherits ComfyAssets category"""
|
||||
assert MockConcreteNode.CATEGORY == "ComfyAssets"
|
||||
assert MockConcreteNode.CATEGORY == "🫶 ComfyAssets"
|
||||
|
||||
def test_concrete_node_get_info_includes_specific_attributes(self):
|
||||
"""Test concrete node info includes its specific attributes"""
|
||||
info = MockConcreteNode.get_node_info()
|
||||
|
||||
assert info["class_name"] == "MockConcreteNode"
|
||||
assert info["category"] == "ComfyAssets"
|
||||
assert info["category"] == "🫶 ComfyAssets"
|
||||
assert info["function"] == "mock_function"
|
||||
assert info["return_types"] == ("STRING", "INT")
|
||||
assert info["return_names"] == ("text", "number")
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Unit tests for Batch Prompts node."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from kikotools.tools.batch_prompts.logic import (
|
||||
load_prompts_from_file,
|
||||
get_prompt_at_index,
|
||||
get_next_prompt,
|
||||
get_prompt_preview,
|
||||
get_batch_info,
|
||||
validate_prompt_file,
|
||||
format_prompt_for_display,
|
||||
split_prompt_into_positive_negative,
|
||||
create_batch_queue,
|
||||
)
|
||||
from kikotools.tools.batch_prompts.node import BatchPromptsNode
|
||||
|
||||
|
||||
class TestBatchPromptsLogic:
|
||||
"""Test batch prompts logic functions."""
|
||||
|
||||
def test_load_prompts_from_file(self, tmp_path):
|
||||
"""Test loading prompts from a file with --- separators."""
|
||||
# Create test file
|
||||
test_file = tmp_path / "test_prompts.txt"
|
||||
test_content = """First prompt here
|
||||
with multiple lines
|
||||
---
|
||||
Second prompt
|
||||
also multiline
|
||||
---
|
||||
Third prompt"""
|
||||
test_file.write_text(test_content)
|
||||
|
||||
# Load prompts
|
||||
prompts = load_prompts_from_file(str(test_file))
|
||||
|
||||
assert len(prompts) == 3
|
||||
assert "First prompt here\nwith multiple lines" in prompts[0]
|
||||
assert "Second prompt\nalso multiline" in prompts[1]
|
||||
assert "Third prompt" in prompts[2]
|
||||
|
||||
def test_load_prompts_empty_sections(self, tmp_path):
|
||||
"""Test loading prompts with empty sections."""
|
||||
test_file = tmp_path / "test_prompts.txt"
|
||||
test_content = """First prompt
|
||||
---
|
||||
|
||||
---
|
||||
Second prompt
|
||||
---
|
||||
"""
|
||||
test_file.write_text(test_content)
|
||||
|
||||
prompts = load_prompts_from_file(str(test_file))
|
||||
|
||||
# Should only get non-empty prompts
|
||||
assert len(prompts) == 2
|
||||
assert "First prompt" in prompts[0]
|
||||
assert "Second prompt" in prompts[1]
|
||||
|
||||
def test_get_prompt_at_index(self):
|
||||
"""Test getting prompt at specific index."""
|
||||
prompts = ["Prompt 1", "Prompt 2", "Prompt 3"]
|
||||
|
||||
# Normal access
|
||||
prompt, idx = get_prompt_at_index(prompts, 1, wrap=False)
|
||||
assert prompt == "Prompt 2"
|
||||
assert idx == 1
|
||||
|
||||
# With wrapping
|
||||
prompt, idx = get_prompt_at_index(prompts, 4, wrap=True)
|
||||
assert prompt == "Prompt 2" # 4 % 3 = 1
|
||||
assert idx == 1
|
||||
|
||||
# Without wrapping, clamp to last
|
||||
prompt, idx = get_prompt_at_index(prompts, 5, wrap=False)
|
||||
assert prompt == "Prompt 3"
|
||||
assert idx == 2
|
||||
|
||||
def test_get_next_prompt(self):
|
||||
"""Test getting next prompt in sequence."""
|
||||
prompts = ["Prompt 1", "Prompt 2", "Prompt 3"]
|
||||
|
||||
# Normal next
|
||||
prompt, idx = get_next_prompt(prompts, 0, wrap=True)
|
||||
assert prompt == "Prompt 2"
|
||||
assert idx == 1
|
||||
|
||||
# Wrap around
|
||||
prompt, idx = get_next_prompt(prompts, 2, wrap=True)
|
||||
assert prompt == "Prompt 1"
|
||||
assert idx == 0
|
||||
|
||||
# No wrap
|
||||
prompt, idx = get_next_prompt(prompts, 2, wrap=False)
|
||||
assert prompt == "Prompt 3"
|
||||
assert idx == 2
|
||||
|
||||
def test_get_prompt_preview(self):
|
||||
"""Test prompt preview truncation."""
|
||||
short_prompt = "Short prompt"
|
||||
long_prompt = "This is a very long prompt " * 10
|
||||
|
||||
# Short prompt unchanged
|
||||
preview = get_prompt_preview(short_prompt, 100)
|
||||
assert preview == short_prompt
|
||||
|
||||
# Long prompt truncated
|
||||
preview = get_prompt_preview(long_prompt, 50)
|
||||
assert len(preview) == 53 # 50 + "..."
|
||||
assert preview.endswith("...")
|
||||
|
||||
def test_split_prompt_positive_negative(self):
|
||||
"""Test splitting prompts into positive and negative."""
|
||||
# With negative
|
||||
prompt = "Beautiful landscape\nNegative: blurry, dark"
|
||||
pos, neg = split_prompt_into_positive_negative(prompt)
|
||||
assert pos == "Beautiful landscape"
|
||||
assert neg == "blurry, dark"
|
||||
|
||||
# Without negative
|
||||
prompt = "Just a positive prompt"
|
||||
pos, neg = split_prompt_into_positive_negative(prompt)
|
||||
assert pos == "Just a positive prompt"
|
||||
assert neg == ""
|
||||
|
||||
# Case insensitive
|
||||
prompt = "Positive part\nnegative: negative part"
|
||||
pos, neg = split_prompt_into_positive_negative(prompt)
|
||||
assert pos == "Positive part"
|
||||
assert neg == "negative part"
|
||||
|
||||
def test_get_batch_info(self):
|
||||
"""Test batch information generation."""
|
||||
prompts = ["P1", "P2", "P3", "P4", "P5"]
|
||||
|
||||
info = get_batch_info(prompts, 2)
|
||||
assert info["current_index"] == 2
|
||||
assert info["total_prompts"] == 5
|
||||
assert info["progress"] == "3/5"
|
||||
assert info["percentage"] == 40.0
|
||||
assert info["remaining"] == 2
|
||||
assert info["is_complete"] == False
|
||||
|
||||
# Last prompt
|
||||
info = get_batch_info(prompts, 4)
|
||||
assert info["is_complete"] == True
|
||||
assert info["remaining"] == 0
|
||||
|
||||
def test_validate_prompt_file(self, tmp_path):
|
||||
"""Test prompt file validation."""
|
||||
# Valid file
|
||||
valid_file = tmp_path / "valid.txt"
|
||||
valid_file.write_text("content")
|
||||
is_valid, error = validate_prompt_file(str(valid_file))
|
||||
assert is_valid
|
||||
assert error == ""
|
||||
|
||||
# Non-existent file
|
||||
is_valid, error = validate_prompt_file("/nonexistent/file.txt")
|
||||
assert not is_valid
|
||||
assert "not found" in error
|
||||
|
||||
# Empty path
|
||||
is_valid, error = validate_prompt_file("")
|
||||
assert not is_valid
|
||||
assert "No file path" in error
|
||||
|
||||
def test_format_prompt_for_display(self):
|
||||
"""Test prompt display formatting."""
|
||||
prompt = "Test prompt"
|
||||
formatted = format_prompt_for_display(prompt, 2, 5)
|
||||
|
||||
assert "[Prompt 3/5]" in formatted
|
||||
assert "Test prompt" in formatted
|
||||
assert "---" in formatted
|
||||
|
||||
def test_create_batch_queue(self):
|
||||
"""Test batch queue creation."""
|
||||
prompts = ["P1", "P2", "P3", "P4", "P5"]
|
||||
|
||||
# Batch size 2
|
||||
batches = create_batch_queue(prompts, batch_size=2, randomize=False)
|
||||
assert len(batches) == 3
|
||||
assert batches[0] == [0, 1]
|
||||
assert batches[1] == [2, 3]
|
||||
assert batches[2] == [4]
|
||||
|
||||
# Batch size 1
|
||||
batches = create_batch_queue(prompts, batch_size=1, randomize=False)
|
||||
assert len(batches) == 5
|
||||
assert all(len(b) == 1 for b in batches)
|
||||
|
||||
|
||||
class TestBatchPromptsNode:
|
||||
"""Test BatchPromptsNode class."""
|
||||
|
||||
def test_node_input_types(self):
|
||||
"""Test node input type definitions."""
|
||||
input_types = BatchPromptsNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
assert "prompt_file" in input_types["required"]
|
||||
assert "index" in input_types["required"]
|
||||
assert "auto_increment" in input_types["required"]
|
||||
assert "wrap_around" in input_types["required"]
|
||||
assert "split_negative" in input_types["required"]
|
||||
|
||||
assert "optional" in input_types
|
||||
assert "reload_file" in input_types["optional"]
|
||||
assert "show_preview" in input_types["optional"]
|
||||
|
||||
def test_node_return_types(self):
|
||||
"""Test node return type definitions."""
|
||||
assert BatchPromptsNode.RETURN_TYPES == (
|
||||
"STRING",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"INT",
|
||||
"INT",
|
||||
"STRING",
|
||||
)
|
||||
assert BatchPromptsNode.RETURN_NAMES == (
|
||||
"positive",
|
||||
"negative",
|
||||
"full_prompt",
|
||||
"next_prompt",
|
||||
"current_index",
|
||||
"total_prompts",
|
||||
"batch_info",
|
||||
)
|
||||
assert BatchPromptsNode.FUNCTION == "process_batch_prompts"
|
||||
assert "ComfyAssets" in BatchPromptsNode.CATEGORY
|
||||
|
||||
def test_process_batch_prompts(self, tmp_path):
|
||||
"""Test processing batch prompts."""
|
||||
# Create test file
|
||||
test_file = tmp_path / "test_prompts.txt"
|
||||
test_content = """Beautiful sunset
|
||||
Negative: dark, blurry
|
||||
---
|
||||
Mountain landscape
|
||||
Negative: fog, rain
|
||||
---
|
||||
Ocean view"""
|
||||
test_file.write_text(test_content)
|
||||
|
||||
node = BatchPromptsNode()
|
||||
|
||||
# Process first prompt
|
||||
result = node.process_batch_prompts(
|
||||
prompt_file=str(test_file),
|
||||
index=0,
|
||||
auto_increment=False,
|
||||
wrap_around=True,
|
||||
split_negative=True,
|
||||
reload_file=False,
|
||||
show_preview=False,
|
||||
)
|
||||
|
||||
positive, negative, full, next_prompt, idx, total, info = result
|
||||
|
||||
assert positive == "Beautiful sunset"
|
||||
assert negative == "dark, blurry"
|
||||
assert "Beautiful sunset" in full
|
||||
assert "Mountain landscape" in next_prompt
|
||||
assert idx == 0
|
||||
assert total == 3
|
||||
assert "1 of 3" in info
|
||||
|
||||
def test_process_without_negative_split(self, tmp_path):
|
||||
"""Test processing without splitting negative prompts."""
|
||||
test_file = tmp_path / "test_prompts.txt"
|
||||
test_content = """Full prompt with Negative: included"""
|
||||
test_file.write_text(test_content)
|
||||
|
||||
node = BatchPromptsNode()
|
||||
|
||||
result = node.process_batch_prompts(
|
||||
prompt_file=str(test_file),
|
||||
index=0,
|
||||
auto_increment=False,
|
||||
wrap_around=True,
|
||||
split_negative=False,
|
||||
reload_file=False,
|
||||
show_preview=False,
|
||||
)
|
||||
|
||||
positive, negative, full, _, _, _, _ = result
|
||||
|
||||
assert positive == "Full prompt with Negative: included"
|
||||
assert negative == ""
|
||||
|
||||
def test_wrap_around_behavior(self, tmp_path):
|
||||
"""Test wrap around behavior."""
|
||||
test_file = tmp_path / "test_prompts.txt"
|
||||
test_content = """Prompt 1
|
||||
---
|
||||
Prompt 2"""
|
||||
test_file.write_text(test_content)
|
||||
|
||||
node = BatchPromptsNode()
|
||||
|
||||
# Test with wrap
|
||||
result = node.process_batch_prompts(
|
||||
prompt_file=str(test_file),
|
||||
index=2, # Beyond end
|
||||
auto_increment=False,
|
||||
wrap_around=True,
|
||||
split_negative=False,
|
||||
reload_file=False,
|
||||
show_preview=False,
|
||||
)
|
||||
|
||||
positive, _, _, _, idx, _, _ = result
|
||||
assert positive == "Prompt 1" # Wrapped to index 0
|
||||
assert idx == 0
|
||||
|
||||
# Test without wrap
|
||||
result = node.process_batch_prompts(
|
||||
prompt_file=str(test_file),
|
||||
index=2, # Beyond end
|
||||
auto_increment=False,
|
||||
wrap_around=False,
|
||||
split_negative=False,
|
||||
reload_file=True, # Force reload
|
||||
show_preview=False,
|
||||
)
|
||||
|
||||
positive, _, _, _, idx, _, _ = result
|
||||
assert positive == "Prompt 2" # Clamped to last
|
||||
assert idx == 1
|
||||
@@ -40,7 +40,7 @@ class TestDisplayAnyNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert DisplayAnyNode.CATEGORY == "ComfyAssets/👁️ Display"
|
||||
assert DisplayAnyNode.CATEGORY == "🫶 ComfyAssets/👁️ Display"
|
||||
assert DisplayAnyNode.FUNCTION == "display"
|
||||
assert DisplayAnyNode.RETURN_TYPES == ("STRING",)
|
||||
assert DisplayAnyNode.RETURN_NAMES == ("display_text",)
|
||||
|
||||
@@ -134,7 +134,7 @@ class TestEmptyLatentBatchNode:
|
||||
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT")
|
||||
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent", "width", "height")
|
||||
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
|
||||
assert EmptyLatentBatchNode.CATEGORY == "ComfyAssets/📦 Latents"
|
||||
assert EmptyLatentBatchNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def test_create_empty_latent_basic(self):
|
||||
"""Test basic empty latent creation through node."""
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestGeminiPromptNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert GeminiPromptNode.CATEGORY == "ComfyAssets/🧠 Prompts"
|
||||
assert GeminiPromptNode.CATEGORY == "🫶 ComfyAssets/🧠 Prompts"
|
||||
assert GeminiPromptNode.FUNCTION == "generate_prompt"
|
||||
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
|
||||
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
|
||||
|
||||
@@ -145,7 +145,7 @@ class TestImageScaleDownByNode:
|
||||
|
||||
def test_category_is_comfyassets(self):
|
||||
"""Test that the node is in the ComfyAssets category."""
|
||||
assert ImageScaleDownByNode.CATEGORY == "ComfyAssets/🖼️ Resolution"
|
||||
assert ImageScaleDownByNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def test_scale_down_with_batch(self, node):
|
||||
"""Test scaling down with batch of images."""
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestImageToMultipleOfNode:
|
||||
assert ImageToMultipleOfNode.RETURN_TYPES == ("IMAGE",)
|
||||
assert ImageToMultipleOfNode.RETURN_NAMES == ("image",)
|
||||
assert ImageToMultipleOfNode.FUNCTION == "process"
|
||||
assert ImageToMultipleOfNode.CATEGORY == "ComfyAssets/🖼️ Resolution"
|
||||
assert ImageToMultipleOfNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def test_node_process_center_crop(self):
|
||||
"""Test node processing with center crop."""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,301 @@
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Mock comfy modules
|
||||
mock_mm = MagicMock()
|
||||
sys.modules["comfy"] = MagicMock()
|
||||
sys.modules["comfy.model_management"] = mock_mm
|
||||
|
||||
from kikotools.tools.kiko_purge_vram.logic import (
|
||||
purge_memory,
|
||||
get_memory_stats,
|
||||
format_memory_report,
|
||||
)
|
||||
|
||||
# Ensure mm is available in the logic module after import
|
||||
import kikotools.tools.kiko_purge_vram.logic as logic_module
|
||||
|
||||
logic_module.mm = mock_mm
|
||||
|
||||
|
||||
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("torch.cuda.is_available")
|
||||
@patch("gc.collect")
|
||||
def test_purge_memory_models_only(self, mock_gc, 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": 6000, "free_mb": 2000},
|
||||
{"used_mb": 1000, "free_mb": 7000},
|
||||
]
|
||||
|
||||
freed_mb = purge_memory(mode="models_only", unload_models=True)
|
||||
|
||||
mock_mm.unload_all_models.assert_called_once()
|
||||
mock_mm.soft_empty_cache.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")
|
||||
@@ -329,7 +329,7 @@ class TestKikoSaveImageNode:
|
||||
assert KikoSaveImageNode.RETURN_TYPES == ()
|
||||
assert KikoSaveImageNode.FUNCTION == "save_images"
|
||||
assert KikoSaveImageNode.OUTPUT_NODE is True
|
||||
assert KikoSaveImageNode.CATEGORY == "ComfyAssets/💾 Images"
|
||||
assert KikoSaveImageNode.CATEGORY == "🫶 ComfyAssets/💾 Images"
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.node.process_image_batch")
|
||||
def test_save_images_success(self, mock_process):
|
||||
@@ -436,7 +436,7 @@ class TestKikoSaveImageNode:
|
||||
info = self.node.get_node_info()
|
||||
|
||||
assert info["class_name"] == "KikoSaveImageNode"
|
||||
assert info["category"] == "ComfyAssets/💾 Images"
|
||||
assert info["category"] == "🫶 ComfyAssets/💾 Images"
|
||||
assert info["function"] == "save_images"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Unit tests for Local Image Loader tool."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from kikotools.tools.local_image_loader.logic import (
|
||||
create_empty_tensor,
|
||||
get_supported_extensions,
|
||||
load_image_from_path,
|
||||
scan_directory,
|
||||
)
|
||||
from kikotools.tools.local_image_loader.node import LocalImageLoaderNode
|
||||
|
||||
|
||||
class TestLocalImageLoaderLogic:
|
||||
"""Test the logic functions for local image loader."""
|
||||
|
||||
def test_get_supported_extensions(self):
|
||||
"""Test getting supported file extensions."""
|
||||
extensions = get_supported_extensions()
|
||||
|
||||
assert "image" in extensions
|
||||
assert "video" in extensions
|
||||
assert "audio" in extensions
|
||||
|
||||
assert ".jpg" in extensions["image"]
|
||||
assert ".png" in extensions["image"]
|
||||
assert ".mp4" in extensions["video"]
|
||||
assert ".mp3" in extensions["audio"]
|
||||
|
||||
def test_create_empty_tensor(self):
|
||||
"""Test creating an empty tensor."""
|
||||
tensor = create_empty_tensor()
|
||||
|
||||
assert isinstance(tensor, torch.Tensor)
|
||||
assert tensor.shape == (1, 1, 1, 4)
|
||||
assert torch.all(tensor == 0)
|
||||
|
||||
def test_load_image_from_path_rgb(self):
|
||||
"""Test loading an RGB image from file."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
# Create a test image
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(tmp.name)
|
||||
|
||||
try:
|
||||
tensor, metadata = load_image_from_path(tmp.name)
|
||||
|
||||
# Check tensor
|
||||
assert isinstance(tensor, torch.Tensor)
|
||||
assert tensor.shape == (1, 100, 100, 3)
|
||||
assert tensor.min() >= 0.0
|
||||
assert tensor.max() <= 1.0
|
||||
|
||||
# Check metadata
|
||||
assert metadata["width"] == 100
|
||||
assert metadata["height"] == 100
|
||||
assert metadata["filename"] == os.path.basename(tmp.name)
|
||||
assert "mode" in metadata
|
||||
assert "format" in metadata
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_load_image_from_path_rgba(self):
|
||||
"""Test loading an RGBA image from file."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
# Create a test image with alpha
|
||||
img = Image.new("RGBA", (50, 50), color=(255, 0, 0, 128))
|
||||
img.save(tmp.name)
|
||||
|
||||
try:
|
||||
tensor, metadata = load_image_from_path(tmp.name)
|
||||
|
||||
# Check tensor
|
||||
assert isinstance(tensor, torch.Tensor)
|
||||
assert tensor.shape == (1, 50, 50, 4) # RGBA has 4 channels
|
||||
assert tensor.min() >= 0.0
|
||||
assert tensor.max() <= 1.0
|
||||
|
||||
# Check metadata
|
||||
assert metadata["width"] == 50
|
||||
assert metadata["height"] == 50
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_load_image_from_path_with_metadata(self):
|
||||
"""Test loading an image with embedded metadata."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
# Create image with metadata
|
||||
img = Image.new("RGB", (100, 100), color="blue")
|
||||
|
||||
# Add some metadata
|
||||
metadata_to_save = {
|
||||
"parameters": "test parameters",
|
||||
"prompt": json.dumps({"text": "test prompt"}),
|
||||
"workflow": json.dumps({"nodes": []}),
|
||||
}
|
||||
|
||||
pnginfo = PngImagePlugin.PngInfo()
|
||||
for key, value in metadata_to_save.items():
|
||||
pnginfo.add_text(key, value)
|
||||
|
||||
img.save(tmp.name, pnginfo=pnginfo)
|
||||
|
||||
try:
|
||||
tensor, metadata = load_image_from_path(tmp.name)
|
||||
|
||||
# Check embedded metadata
|
||||
assert metadata.get("parameters") == "test parameters"
|
||||
assert metadata.get("prompt") == {"text": "test prompt"}
|
||||
assert metadata.get("workflow") == {"nodes": []}
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_load_image_from_nonexistent_path(self):
|
||||
"""Test loading image from nonexistent path raises error."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_image_from_path("/nonexistent/path/image.png")
|
||||
|
||||
def test_scan_directory_images_only(self):
|
||||
"""Test scanning directory for images only."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create test files
|
||||
Path(tmpdir, "image1.jpg").touch()
|
||||
Path(tmpdir, "image2.png").touch()
|
||||
Path(tmpdir, "video.mp4").touch()
|
||||
Path(tmpdir, "audio.mp3").touch()
|
||||
Path(tmpdir, "document.txt").touch()
|
||||
Path(tmpdir, "subdir").mkdir()
|
||||
|
||||
items = scan_directory(tmpdir, show_videos=False, show_audio=False)
|
||||
|
||||
# Should have 1 directory and 2 images
|
||||
assert len(items) == 3
|
||||
|
||||
# Check types
|
||||
types = [item["type"] for item in items]
|
||||
assert "dir" in types
|
||||
assert types.count("image") == 2
|
||||
|
||||
def test_scan_directory_with_videos_audio(self):
|
||||
"""Test scanning directory with videos and audio enabled."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create test files
|
||||
Path(tmpdir, "image.jpg").touch()
|
||||
Path(tmpdir, "video.mp4").touch()
|
||||
Path(tmpdir, "audio.mp3").touch()
|
||||
|
||||
items = scan_directory(tmpdir, show_videos=True, show_audio=True)
|
||||
|
||||
assert len(items) == 3
|
||||
types = [item["type"] for item in items]
|
||||
assert "image" in types
|
||||
assert "video" in types
|
||||
assert "audio" in types
|
||||
|
||||
def test_scan_directory_sorting(self):
|
||||
"""Test directory scanning with different sort options."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create files with different names
|
||||
Path(tmpdir, "zebra.jpg").touch()
|
||||
Path(tmpdir, "apple.jpg").touch()
|
||||
Path(tmpdir, "banana.jpg").touch()
|
||||
|
||||
# Sort by name ascending
|
||||
items = scan_directory(tmpdir, sort_by="name", sort_order="asc")
|
||||
names = [item["name"] for item in items if item["type"] == "image"]
|
||||
assert names == ["apple.jpg", "banana.jpg", "zebra.jpg"]
|
||||
|
||||
# Sort by name descending
|
||||
items = scan_directory(tmpdir, sort_by="name", sort_order="desc")
|
||||
names = [item["name"] for item in items if item["type"] == "image"]
|
||||
assert names == ["zebra.jpg", "banana.jpg", "apple.jpg"]
|
||||
|
||||
def test_scan_nonexistent_directory(self):
|
||||
"""Test scanning nonexistent directory raises error."""
|
||||
with pytest.raises(NotADirectoryError):
|
||||
scan_directory("/nonexistent/directory")
|
||||
|
||||
|
||||
class TestLocalImageLoaderNode:
|
||||
"""Test the Local Image Loader node."""
|
||||
|
||||
def test_input_types(self):
|
||||
"""Test node input types definition."""
|
||||
input_types = LocalImageLoaderNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
assert "hidden" in input_types
|
||||
assert "unique_id" in input_types["hidden"]
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert LocalImageLoaderNode.RETURN_TYPES == (
|
||||
"IMAGE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
assert LocalImageLoaderNode.RETURN_NAMES == (
|
||||
"image",
|
||||
"video_path",
|
||||
"audio_path",
|
||||
"info",
|
||||
)
|
||||
assert LocalImageLoaderNode.FUNCTION == "load_media"
|
||||
assert LocalImageLoaderNode.CATEGORY == "🫶 ComfyAssets/💾 Images"
|
||||
|
||||
@patch("kikotools.tools.local_image_loader.node.load_selections")
|
||||
def test_load_media_no_selection(self, mock_load_selections):
|
||||
"""Test loading media with no selection returns empty values."""
|
||||
mock_load_selections.return_value = {}
|
||||
|
||||
node = LocalImageLoaderNode()
|
||||
image, video_path, audio_path, info = node.load_media("test_id")
|
||||
|
||||
# Check empty returns
|
||||
assert isinstance(image, torch.Tensor)
|
||||
assert image.shape == (1, 1, 1, 4)
|
||||
assert torch.all(image == 0)
|
||||
assert video_path == ""
|
||||
assert audio_path == ""
|
||||
assert info == ""
|
||||
|
||||
@patch("kikotools.tools.local_image_loader.node.load_selections")
|
||||
@patch("kikotools.tools.local_image_loader.node.load_image_from_path")
|
||||
def test_load_media_with_image_selection(
|
||||
self, mock_load_image, mock_load_selections
|
||||
):
|
||||
"""Test loading media with image selection."""
|
||||
# Setup mocks
|
||||
mock_load_selections.return_value = {
|
||||
"test_id": {"image": {"path": "/path/to/image.jpg"}}
|
||||
}
|
||||
|
||||
test_tensor = torch.ones(1, 100, 100, 3)
|
||||
test_metadata = {"width": 100, "height": 100, "filename": "image.jpg"}
|
||||
mock_load_image.return_value = (test_tensor, test_metadata)
|
||||
|
||||
# Mock os.path.exists
|
||||
with patch("os.path.exists", return_value=True):
|
||||
node = LocalImageLoaderNode()
|
||||
image, video_path, audio_path, info = node.load_media("test_id")
|
||||
|
||||
# Check returns
|
||||
assert torch.equal(image, test_tensor)
|
||||
assert video_path == ""
|
||||
assert audio_path == ""
|
||||
assert json.loads(info) == test_metadata
|
||||
|
||||
@patch("kikotools.tools.local_image_loader.node.load_selections")
|
||||
def test_load_media_with_video_audio_selection(self, mock_load_selections):
|
||||
"""Test loading media with video and audio selection."""
|
||||
mock_load_selections.return_value = {
|
||||
"test_id": {
|
||||
"video": {"path": "/path/to/video.mp4"},
|
||||
"audio": {"path": "/path/to/audio.mp3"},
|
||||
}
|
||||
}
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
node = LocalImageLoaderNode()
|
||||
image, video_path, audio_path, info = node.load_media("test_id")
|
||||
|
||||
# Check returns
|
||||
assert isinstance(image, torch.Tensor)
|
||||
assert image.shape == (1, 1, 1, 4) # Empty tensor
|
||||
assert video_path == "/path/to/video.mp4"
|
||||
assert audio_path == "/path/to/audio.mp3"
|
||||
assert info == ""
|
||||
|
||||
def test_is_changed(self):
|
||||
"""Test IS_CHANGED method."""
|
||||
with patch("os.path.exists", return_value=False):
|
||||
result = LocalImageLoaderNode.IS_CHANGED()
|
||||
assert result == float("inf")
|
||||
|
||||
with (
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.path.getmtime", return_value=12345.0),
|
||||
):
|
||||
result = LocalImageLoaderNode.IS_CHANGED()
|
||||
assert result == 12345.0
|
||||
@@ -179,7 +179,7 @@ class TestResolutionCalculatorNode:
|
||||
assert hasattr(ResolutionCalculatorNode, "CATEGORY")
|
||||
|
||||
# Check category is correct
|
||||
assert ResolutionCalculatorNode.CATEGORY == "ComfyAssets/🖼️ Resolution"
|
||||
assert ResolutionCalculatorNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
# Check return types
|
||||
assert ResolutionCalculatorNode.RETURN_TYPES == ("INT", "INT")
|
||||
@@ -281,7 +281,7 @@ class TestResolutionCalculatorNode:
|
||||
node = ResolutionCalculatorNode()
|
||||
node_info = node.get_node_info()
|
||||
|
||||
assert node_info["category"] == "ComfyAssets/🖼️ Resolution"
|
||||
assert node_info["category"] == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
assert node_info["class_name"] == "ResolutionCalculatorNode"
|
||||
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ class TestSamplerComboNode:
|
||||
"cfg",
|
||||
)
|
||||
assert SamplerComboNode.FUNCTION == "get_sampler_combo"
|
||||
assert SamplerComboNode.CATEGORY == "ComfyAssets/🌀 Samplers"
|
||||
assert SamplerComboNode.CATEGORY == "🫶 ComfyAssets/🌀 Samplers"
|
||||
|
||||
def test_get_sampler_combo_valid_inputs(self):
|
||||
"""Test get_sampler_combo with valid inputs."""
|
||||
|
||||
@@ -49,7 +49,7 @@ class TestSeedHistoryNode:
|
||||
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
|
||||
assert SeedHistoryNode.RETURN_NAMES == ("seed",)
|
||||
assert SeedHistoryNode.FUNCTION == "output_seed"
|
||||
assert SeedHistoryNode.CATEGORY == "ComfyAssets/🌱 Seeds"
|
||||
assert SeedHistoryNode.CATEGORY == "🫶 ComfyAssets/🌱 Seeds"
|
||||
|
||||
def test_output_seed_valid_input(self):
|
||||
"""Test seed output with valid input."""
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestWidthHeightSelectorNode:
|
||||
assert self.node.RETURN_TYPES == ("INT", "INT")
|
||||
assert self.node.RETURN_NAMES == ("width", "height")
|
||||
assert self.node.FUNCTION == "get_dimensions"
|
||||
assert self.node.CATEGORY == "ComfyAssets/🖼️ Resolution"
|
||||
assert self.node.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def test_custom_dimensions(self):
|
||||
"""Test custom dimensions."""
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestFluxSamplerParamsNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert FluxSamplerParamsNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
|
||||
assert FluxSamplerParamsNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
assert FluxSamplerParamsNode.FUNCTION == "process_batch"
|
||||
assert FluxSamplerParamsNode.RETURN_TYPES == ("LATENT", "SAMPLER_PARAMS")
|
||||
assert FluxSamplerParamsNode.RETURN_NAMES == ("latent", "params")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for LoRA Folder Batch node."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock
|
||||
import os
|
||||
import tempfile
|
||||
from kikotools.tools.xyz_helpers.lora_folder_batch import LoRAFolderBatchNode
|
||||
from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
|
||||
scan_folder_for_loras,
|
||||
@@ -10,8 +11,8 @@ from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
|
||||
filter_loras_by_pattern,
|
||||
parse_strength_string,
|
||||
create_lora_params,
|
||||
create_lora_params_batched,
|
||||
get_lora_info,
|
||||
validate_folder_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +36,27 @@ class TestLoRAFolderBatchLogic:
|
||||
# Base file could be first or last depending on implementation
|
||||
assert "model-v1.safetensors" in sorted_files
|
||||
|
||||
def test_natural_sort_with_paths(self):
|
||||
"""Test natural sorting with subdirectory paths."""
|
||||
files = [
|
||||
"subdir2/model-10.safetensors",
|
||||
"model-2.safetensors",
|
||||
"subdir1/model-20.safetensors",
|
||||
"subdir1/model-3.safetensors",
|
||||
"model-100.safetensors",
|
||||
]
|
||||
sorted_files = natural_sort(files)
|
||||
|
||||
# Should handle mixed paths and numbers correctly
|
||||
assert len(sorted_files) == 5
|
||||
# Files with smaller numbers should come first within their directories
|
||||
assert sorted_files.index("model-2.safetensors") < sorted_files.index(
|
||||
"model-100.safetensors"
|
||||
)
|
||||
assert sorted_files.index("subdir1/model-3.safetensors") < sorted_files.index(
|
||||
"subdir1/model-20.safetensors"
|
||||
)
|
||||
|
||||
def test_filter_loras_by_pattern(self):
|
||||
"""Test filtering LoRAs by patterns."""
|
||||
files = [
|
||||
@@ -114,6 +136,59 @@ class TestLoRAFolderBatchLogic:
|
||||
assert info["epoch"] is None
|
||||
assert info["version"] is None
|
||||
|
||||
def test_scan_folder_recursive(self):
|
||||
"""Test recursive scanning of LoRA files in subdirectories."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create nested directory structure
|
||||
os.makedirs(os.path.join(temp_dir, "flux", "style"))
|
||||
os.makedirs(os.path.join(temp_dir, "flux", "character"))
|
||||
os.makedirs(os.path.join(temp_dir, "sdxl"))
|
||||
|
||||
# Create test files
|
||||
test_files = [
|
||||
os.path.join(temp_dir, "root-lora.safetensors"),
|
||||
os.path.join(temp_dir, "flux", "flux-lora.safetensors"),
|
||||
os.path.join(temp_dir, "flux", "style", "style-lora.safetensors"),
|
||||
os.path.join(temp_dir, "flux", "character", "char-lora.safetensors"),
|
||||
os.path.join(temp_dir, "sdxl", "sdxl-lora.safetensors"),
|
||||
os.path.join(temp_dir, "not-a-lora.txt"), # Should be ignored
|
||||
]
|
||||
|
||||
for file_path in test_files:
|
||||
with open(file_path, "w") as f:
|
||||
f.write("test")
|
||||
|
||||
# Create a mock folder_paths module
|
||||
mock_folder_paths = MagicMock()
|
||||
mock_folder_paths.folder_names_and_paths = {"loras": [[temp_dir]]}
|
||||
|
||||
# Mock the import
|
||||
import sys
|
||||
|
||||
sys.modules["folder_paths"] = mock_folder_paths
|
||||
|
||||
try:
|
||||
# Test scanning from root - should find all .safetensors files
|
||||
results = scan_folder_for_loras(".")
|
||||
assert len(results) == 5
|
||||
assert "root-lora.safetensors" in results
|
||||
assert "flux/flux-lora.safetensors" in results
|
||||
assert "flux/style/style-lora.safetensors" in results
|
||||
assert "flux/character/char-lora.safetensors" in results
|
||||
assert "sdxl/sdxl-lora.safetensors" in results
|
||||
assert "not-a-lora.txt" not in str(results)
|
||||
|
||||
# Test scanning from subdirectory
|
||||
results = scan_folder_for_loras("flux")
|
||||
assert len(results) == 3
|
||||
assert "flux/flux-lora.safetensors" in results
|
||||
assert "flux/style/style-lora.safetensors" in results
|
||||
assert "flux/character/char-lora.safetensors" in results
|
||||
finally:
|
||||
# Clean up the mock
|
||||
if "folder_paths" in sys.modules:
|
||||
del sys.modules["folder_paths"]
|
||||
|
||||
|
||||
class TestLoRAFolderBatchNode:
|
||||
"""Test the LoRA Folder Batch node."""
|
||||
@@ -137,6 +212,9 @@ class TestLoRAFolderBatchNode:
|
||||
optional = input_types["optional"]
|
||||
assert "include_pattern" in optional
|
||||
assert "exclude_pattern" in optional
|
||||
assert "auto_batch" in optional
|
||||
assert "batch_size" in optional
|
||||
assert "batch_index" in optional
|
||||
|
||||
def test_batch_loras_empty_folder(self, node):
|
||||
"""Test with empty folder."""
|
||||
@@ -183,9 +261,13 @@ class TestLoRAFolderBatchNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert LoRAFolderBatchNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
|
||||
assert LoRAFolderBatchNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
assert LoRAFolderBatchNode.FUNCTION == "batch_loras"
|
||||
assert LoRAFolderBatchNode.RETURN_TYPES == ("LORA_PARAMS", "STRING", "INT")
|
||||
assert LoRAFolderBatchNode.RETURN_TYPES == (
|
||||
"LORA_PARAMS",
|
||||
"STRING",
|
||||
"INT",
|
||||
)
|
||||
assert LoRAFolderBatchNode.RETURN_NAMES == (
|
||||
"lora_params",
|
||||
"lora_list",
|
||||
@@ -200,3 +282,90 @@ class TestLoRAFolderBatchNode:
|
||||
time.sleep(0.01)
|
||||
result2 = LoRAFolderBatchNode.IS_CHANGED()
|
||||
assert result1 != result2
|
||||
|
||||
def test_create_lora_params_batched(self):
|
||||
"""Test the batched LoRA params creation."""
|
||||
lora_files = [f"lora_{i:03d}.safetensors" for i in range(75)]
|
||||
strengths = [0.5, 1.0]
|
||||
|
||||
# Test with batch size of 25
|
||||
batches = create_lora_params_batched(lora_files, strengths, "sequential", 25)
|
||||
|
||||
assert len(batches) == 3 # 75 / 25 = 3 batches
|
||||
|
||||
# Check first batch
|
||||
assert len(batches[0]["loras"]) == 25
|
||||
assert batches[0]["batch_info"]["index"] == 0
|
||||
assert batches[0]["batch_info"]["total"] == 3
|
||||
assert batches[0]["batch_info"]["start_idx"] == 0
|
||||
assert batches[0]["batch_info"]["end_idx"] == 25
|
||||
assert batches[0]["batch_info"]["size"] == 25
|
||||
|
||||
# Check second batch
|
||||
assert len(batches[1]["loras"]) == 25
|
||||
assert batches[1]["batch_info"]["index"] == 1
|
||||
assert batches[1]["batch_info"]["start_idx"] == 25
|
||||
assert batches[1]["batch_info"]["end_idx"] == 50
|
||||
|
||||
# Check third batch
|
||||
assert len(batches[2]["loras"]) == 25
|
||||
assert batches[2]["batch_info"]["index"] == 2
|
||||
assert batches[2]["batch_info"]["start_idx"] == 50
|
||||
assert batches[2]["batch_info"]["end_idx"] == 75
|
||||
|
||||
def test_auto_batch_node_integration(self, node):
|
||||
"""Test auto-batching in the node."""
|
||||
# Create mock LoRA files
|
||||
lora_files = [f"lora_{i:03d}.safetensors" for i in range(75)]
|
||||
|
||||
with patch(
|
||||
"kikotools.tools.xyz_helpers.lora_folder_batch.node.validate_folder_path"
|
||||
) as mock_validate:
|
||||
with patch(
|
||||
"kikotools.tools.xyz_helpers.lora_folder_batch.node.scan_folder_for_loras"
|
||||
) as mock_scan:
|
||||
mock_validate.return_value = True
|
||||
mock_scan.return_value = lora_files
|
||||
|
||||
# Test batch 0
|
||||
params, lora_list, count = node.batch_loras(
|
||||
folder_path="test",
|
||||
strength="1.0",
|
||||
batch_mode="sequential",
|
||||
auto_batch="enabled",
|
||||
batch_size=25,
|
||||
batch_index=0,
|
||||
)
|
||||
|
||||
assert count == 25
|
||||
assert "Batch 1/3" in lora_list
|
||||
assert len(params["loras"]) == 25
|
||||
assert params["loras"][0] == "lora_000.safetensors"
|
||||
|
||||
# Test batch 1
|
||||
params, lora_list, count = node.batch_loras(
|
||||
folder_path="test",
|
||||
strength="1.0",
|
||||
batch_mode="sequential",
|
||||
auto_batch="enabled",
|
||||
batch_size=25,
|
||||
batch_index=1,
|
||||
)
|
||||
|
||||
assert count == 25
|
||||
assert "Batch 2/3" in lora_list
|
||||
assert params["loras"][0] == "lora_025.safetensors"
|
||||
|
||||
# Test batch 2
|
||||
params, lora_list, count = node.batch_loras(
|
||||
folder_path="test",
|
||||
strength="1.0",
|
||||
batch_mode="sequential",
|
||||
auto_batch="enabled",
|
||||
batch_size=25,
|
||||
batch_index=2,
|
||||
)
|
||||
|
||||
assert count == 25
|
||||
assert "Batch 3/3" in lora_list
|
||||
assert params["loras"][0] == "lora_050.safetensors"
|
||||
|
||||
@@ -227,7 +227,7 @@ class TestPlotParametersNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert PlotParametersNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
|
||||
assert PlotParametersNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
assert PlotParametersNode.FUNCTION == "plot_parameters"
|
||||
assert PlotParametersNode.RETURN_TYPES == ("IMAGE",)
|
||||
assert PlotParametersNode.RETURN_NAMES == ("image",)
|
||||
|
||||
@@ -85,7 +85,7 @@ class TestSamplerSelectHelperNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert SamplerSelectHelperNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
|
||||
assert SamplerSelectHelperNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
assert SamplerSelectHelperNode.FUNCTION == "select_samplers"
|
||||
assert SamplerSelectHelperNode.RETURN_TYPES == ("STRING",)
|
||||
assert SamplerSelectHelperNode.RETURN_NAMES == ("selected_samplers",)
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestSchedulerSelectHelperNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert SchedulerSelectHelperNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
|
||||
assert SchedulerSelectHelperNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
assert SchedulerSelectHelperNode.FUNCTION == "select_schedulers"
|
||||
assert SchedulerSelectHelperNode.RETURN_TYPES == ("STRING",)
|
||||
assert SchedulerSelectHelperNode.RETURN_NAMES == ("selected_schedulers",)
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestTextEncodeSamplerParamsNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert TextEncodeSamplerParamsNode.CATEGORY == "ComfyAssets/🧰 xyz-helpers"
|
||||
assert TextEncodeSamplerParamsNode.CATEGORY == "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
assert TextEncodeSamplerParamsNode.FUNCTION == "encode_prompts"
|
||||
assert TextEncodeSamplerParamsNode.RETURN_TYPES == ("CONDITIONING",)
|
||||
assert TextEncodeSamplerParamsNode.RETURN_NAMES == ("conditioning",)
|
||||
|
||||
@@ -58,13 +58,6 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
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();
|
||||
|
||||
+58
-184
@@ -9,9 +9,6 @@ app.registerExtension({
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) onNodeCreated.apply(this, []);
|
||||
|
||||
// Track button click state for visual feedback
|
||||
this.swapButtonPressed = false;
|
||||
|
||||
// Helper function to extract resolution from formatted preset string
|
||||
this.extractResolutionFromPreset = function (presetValue) {
|
||||
if (presetValue === "custom") return null;
|
||||
@@ -26,6 +23,9 @@ app.registerExtension({
|
||||
return presetValue;
|
||||
};
|
||||
|
||||
// Create swap button as DOM widget
|
||||
this.createSwapButton();
|
||||
|
||||
// Override preset callback to update width/height widgets when preset changes
|
||||
const presetWidget = this.widgets.find((w) => w.name === "preset");
|
||||
if (presetWidget) {
|
||||
@@ -194,200 +194,74 @@ app.registerExtension({
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Override onResize to refresh button position
|
||||
const originalOnResize = this.onResize;
|
||||
this.onResize = function (size) {
|
||||
if (originalOnResize) {
|
||||
originalOnResize.call(this, size);
|
||||
}
|
||||
// Force redraw to update button position
|
||||
this.setDirtyCanvas(true, true);
|
||||
// Also mark the graph as dirty
|
||||
if (this.graph) {
|
||||
this.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Override onBounding to ensure proper updates
|
||||
const originalOnBounding = this.onBounding;
|
||||
this.onBounding = function (out) {
|
||||
if (originalOnBounding) {
|
||||
originalOnBounding.call(this, out);
|
||||
}
|
||||
// Force redraw when bounds change
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
};
|
||||
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
if (onDrawForeground) {
|
||||
onDrawForeground.apply(this, arguments);
|
||||
}
|
||||
// Create swap button as DOM widget
|
||||
nodeType.prototype.createSwapButton = function () {
|
||||
// Create button container
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.style.cssText = `
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
if (this.flags.collapsed) return;
|
||||
// Create swap button
|
||||
const swapButton = document.createElement("button");
|
||||
swapButton.innerHTML = "↔️ Swap W×H";
|
||||
swapButton.style.cssText = `
|
||||
background: #4A90E2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
`;
|
||||
|
||||
// Draw swap button with consistent spacing from widgets
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX = this.size[0] - swapButtonSize - margin;
|
||||
// Button hover effects
|
||||
swapButton.addEventListener("mouseenter", () => {
|
||||
swapButton.style.background = "#5BA0F2";
|
||||
swapButton.style.transform = "translateY(-1px)";
|
||||
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
|
||||
});
|
||||
|
||||
// Calculate button position based on widget spacing rather than bottom margin
|
||||
// Estimate widget area height and add consistent spacing
|
||||
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
|
||||
const topMargin = 35; // Space from top to first widget
|
||||
const buttonSpacing = 40; // Space between last widget and button (moved down 5)
|
||||
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
swapButton.addEventListener("mouseleave", () => {
|
||||
swapButton.style.background = "#4A90E2";
|
||||
swapButton.style.transform = "translateY(0)";
|
||||
swapButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
|
||||
});
|
||||
|
||||
// Button background - change color based on pressed state
|
||||
if (this.swapButtonPressed) {
|
||||
// Darker when pressed
|
||||
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
|
||||
} else {
|
||||
// Normal state
|
||||
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
swapButtonX,
|
||||
swapButtonY,
|
||||
swapButtonSize,
|
||||
swapButtonSize,
|
||||
4,
|
||||
);
|
||||
ctx.fill();
|
||||
// Button click effect and functionality
|
||||
swapButton.addEventListener("mousedown", () => {
|
||||
swapButton.style.background = "#3A80D2";
|
||||
swapButton.style.transform = "translateY(1px)";
|
||||
swapButton.style.boxShadow = "0 1px 2px rgba(0,0,0,0.2)";
|
||||
});
|
||||
|
||||
// Button border with subtle highlight
|
||||
ctx.strokeStyle = this.swapButtonPressed
|
||||
? "rgba(20, 100, 180, 1.0)"
|
||||
: "rgba(33, 150, 243, 0.9)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
swapButton.addEventListener("mouseup", () => {
|
||||
swapButton.style.background = "#5BA0F2";
|
||||
swapButton.style.transform = "translateY(-1px)";
|
||||
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
|
||||
});
|
||||
|
||||
// Draw swap icon - modern double arrow design
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
|
||||
const centerX = swapButtonX + 12;
|
||||
const centerY = swapButtonY + 12;
|
||||
|
||||
// Top arrow (pointing right) - width to height
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 7, centerY - 3);
|
||||
ctx.lineTo(centerX + 5, centerY - 3);
|
||||
ctx.stroke();
|
||||
|
||||
// Top arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + 5, centerY - 3);
|
||||
ctx.lineTo(centerX + 2, centerY - 5);
|
||||
ctx.moveTo(centerX + 5, centerY - 3);
|
||||
ctx.lineTo(centerX + 2, centerY - 1);
|
||||
ctx.stroke();
|
||||
|
||||
// Bottom arrow (pointing left) - height to width
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + 5, centerY + 3);
|
||||
ctx.lineTo(centerX - 7, centerY + 3);
|
||||
ctx.stroke();
|
||||
|
||||
// Bottom arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 1);
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 5);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const onMouseDown = nodeType.prototype.onMouseDown;
|
||||
nodeType.prototype.onMouseDown = function (e) {
|
||||
// Check if click is on swap button
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX =
|
||||
this.pos[0] + this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Use same positioning logic as drawing
|
||||
const estimatedWidgetHeight = 90;
|
||||
const topMargin = 35;
|
||||
const buttonSpacing = 40;
|
||||
const swapButtonY =
|
||||
this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
if (
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
e.canvasY >= swapButtonY &&
|
||||
e.canvasY <= swapButtonY + swapButtonSize
|
||||
) {
|
||||
// Visual feedback - set button as pressed
|
||||
this.swapButtonPressed = true;
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
// Execute swap
|
||||
// Main click functionality
|
||||
swapButton.addEventListener("click", () => {
|
||||
this.swapDimensions();
|
||||
});
|
||||
|
||||
// Reset button state after a short delay for visual feedback
|
||||
setTimeout(() => {
|
||||
this.swapButtonPressed = false;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 150);
|
||||
buttonContainer.appendChild(swapButton);
|
||||
|
||||
return true; // Consume the event
|
||||
}
|
||||
|
||||
// Call original onMouseDown if not clicking swap button
|
||||
if (onMouseDown) {
|
||||
return onMouseDown.apply(this, arguments);
|
||||
}
|
||||
// Add as DOM widget
|
||||
this.swapButtonWidget = this.addDOMWidget(
|
||||
"swap_button",
|
||||
"div",
|
||||
buttonContainer
|
||||
);
|
||||
};
|
||||
|
||||
// Optional: Add hover effect for better user feedback
|
||||
const onMouseMove = nodeType.prototype.onMouseMove;
|
||||
nodeType.prototype.onMouseMove = function (e) {
|
||||
// Check if hovering over swap button
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX =
|
||||
this.pos[0] + this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Use same positioning logic as drawing
|
||||
const estimatedWidgetHeight = 90;
|
||||
const topMargin = 35;
|
||||
const buttonSpacing = 40;
|
||||
const swapButtonY =
|
||||
this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
const isHovering =
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
e.canvasY >= swapButtonY &&
|
||||
e.canvasY <= swapButtonY + swapButtonSize;
|
||||
|
||||
// Update cursor style for better UX (safely)
|
||||
if (
|
||||
isHovering &&
|
||||
this.graph &&
|
||||
this.graph.canvas &&
|
||||
this.graph.canvas.canvas
|
||||
) {
|
||||
this.graph.canvas.canvas.style.cursor = "pointer";
|
||||
} else if (
|
||||
this.graph &&
|
||||
this.graph.canvas &&
|
||||
this.graph.canvas.canvas
|
||||
) {
|
||||
this.graph.canvas.canvas.style.cursor = "default";
|
||||
}
|
||||
|
||||
// Call original onMouseMove
|
||||
if (onMouseMove) {
|
||||
return onMouseMove.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
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 && colorValue !== "" && colorValue.startsWith("#")) {
|
||||
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 = node;
|
||||
pickerFull.value = node.bgcolor || "#000000";
|
||||
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 = node;
|
||||
pickerTitle.value = node.color || "#000000";
|
||||
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 = node;
|
||||
pickerBG.value = node.bgcolor || "#000000";
|
||||
pickerBG.click();
|
||||
};
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("span", {
|
||||
style: {
|
||||
paddingLeft: "4px",
|
||||
display: "block",
|
||||
},
|
||||
textContent: "🫶 Custom BG",
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,755 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
// Setup global lightbox for image preview
|
||||
function setupGlobalLightbox() {
|
||||
if (document.getElementById('kiko-image-lightbox')) return;
|
||||
|
||||
const lightboxId = 'kiko-image-lightbox';
|
||||
const lightboxHTML = `
|
||||
<div id="${lightboxId}" class="lightbox-overlay">
|
||||
<button class="lightbox-close">×</button>
|
||||
<button class="lightbox-prev"><</button>
|
||||
<button class="lightbox-next">></button>
|
||||
<div class="lightbox-content">
|
||||
<img src="" alt="Preview" style="display: none;">
|
||||
<video src="" controls autoplay style="display: none;"></video>
|
||||
<audio src="" controls autoplay style="display: none;"></audio>
|
||||
</div>
|
||||
<div class="lightbox-dimensions"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const lightboxCSS = `
|
||||
#${lightboxId} {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
box-sizing: border-box;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
#${lightboxId} .lightbox-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
#${lightboxId} img, #${lightboxId} video {
|
||||
max-width: 95%;
|
||||
max-height: 95%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.1s ease-out;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
#${lightboxId} audio {
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
}
|
||||
#${lightboxId} img {
|
||||
cursor: grab;
|
||||
}
|
||||
#${lightboxId} img.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
#${lightboxId} .lightbox-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 20px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
z-index: 10002;
|
||||
}
|
||||
#${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 45px;
|
||||
height: 60px;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 30px;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
#${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover {
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
}
|
||||
#${lightboxId} .lightbox-prev {
|
||||
left: 15px;
|
||||
}
|
||||
#${lightboxId} .lightbox-next {
|
||||
right: 15px;
|
||||
}
|
||||
#${lightboxId} [disabled] {
|
||||
display: none;
|
||||
}
|
||||
#${lightboxId} .lightbox-dimensions {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
z-index: 10001;
|
||||
}
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', lightboxHTML);
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = lightboxCSS;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
setupGlobalLightbox();
|
||||
|
||||
app.registerExtension({
|
||||
name: "KikoTools.LocalImageLoader",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "KikoLocalImageLoader") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
const galleryContainer = document.createElement("div");
|
||||
const uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`;
|
||||
galleryContainer.id = uniqueId;
|
||||
|
||||
const folderSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M928 320H488L416 232c-15.1-18.9-38.3-29.9-63.1-29.9H128c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h800c35.3 0 64-28.7 64-64V384c0-35.3-28.7-64-64-64z" fill="#F4D03F"></path></svg>`;
|
||||
const videoSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M895.9 203.4H128.1c-35.3 0-64 28.7-64 64v489.2c0 35.3 28.7 64 64 64h767.8c35.3 0 64-28.7 64-64V267.4c0-35.3-28.7-64-64-64zM384 691.2V332.8L668.1 512 384 691.2z" fill="#AED6F1"></path></svg>`;
|
||||
const audioSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M768 256H256c-35.3 0-64 28.7-64 64v384c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64V320c0-35.3-28.7-64-64-64zM512 665.6c-84.8 0-153.6-68.8-153.6-153.6S427.2 358.4 512 358.4s153.6 68.8 153.6 153.6-68.8 153.6-153.6 153.6z" fill="#A9DFBF"></path><path d="M512 409.6c-56.5 0-102.4 45.9-102.4 102.4s45.9 102.4 102.4 102.4 102.4-45.9 102.4-102.4-45.9-102.4-102.4-102.4z" fill="#A9DFBF"></path></svg>`;
|
||||
|
||||
galleryContainer.innerHTML = `
|
||||
<style>
|
||||
#${uniqueId} {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#${uniqueId} .kiko-container-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: sans-serif;
|
||||
color: #ccc;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#${uniqueId} .kiko-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#${uniqueId} .kiko-controls label {
|
||||
margin-left: 0px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#${uniqueId} .kiko-controls input,
|
||||
#${uniqueId} .kiko-controls select,
|
||||
#${uniqueId} .kiko-controls button {
|
||||
background-color: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
#${uniqueId} .kiko-controls input[type=text] {
|
||||
flex-grow: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
#${uniqueId} .kiko-path-controls {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
#${uniqueId} .kiko-path-presets {
|
||||
flex-grow: 1;
|
||||
}
|
||||
#${uniqueId} .kiko-controls button {
|
||||
cursor: pointer;
|
||||
}
|
||||
#${uniqueId} .kiko-controls button:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
#${uniqueId} .kiko-controls button:disabled {
|
||||
background-color: #222;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#${uniqueId} .kiko-cardholder {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #222;
|
||||
padding: 3px;
|
||||
border-radius: 5px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
#${uniqueId} .kiko-gallery-card {
|
||||
position: absolute;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
#${uniqueId} .kiko-gallery-card.kiko-selected {
|
||||
border-color: #00FFC9;
|
||||
}
|
||||
#${uniqueId} .kiko-card-media-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
#${uniqueId} .kiko-gallery-card img,
|
||||
#${uniqueId} .kiko-gallery-card video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
display: block;
|
||||
}
|
||||
#${uniqueId} .kiko-folder-card,
|
||||
#${uniqueId} .kiko-audio-card {
|
||||
background-color: transparent;
|
||||
flex-grow: 1;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
#${uniqueId} .kiko-folder-card:hover,
|
||||
#${uniqueId} .kiko-audio-card:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
#${uniqueId} .kiko-folder-icon,
|
||||
#${uniqueId} .kiko-audio-icon {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#${uniqueId} .kiko-folder-name,
|
||||
#${uniqueId} .kiko-audio-name {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
user-select: none;
|
||||
}
|
||||
#${uniqueId} .kiko-video-card-overlay {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
#${uniqueId} .kiko-card-info-panel {
|
||||
flex-shrink: 0;
|
||||
background-color: #353535;
|
||||
padding: 4px;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
min-height: 24px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
}
|
||||
#${uniqueId} .kiko-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
#${uniqueId} .kiko-status-message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="kiko-container-wrapper">
|
||||
<!-- Path controls -->
|
||||
<div class="kiko-controls">
|
||||
<div class="kiko-path-controls">
|
||||
<select class="kiko-path-presets" title="Saved paths">
|
||||
<option value="">-- Saved Paths --</option>
|
||||
</select>
|
||||
<button class="kiko-save-path" title="Save current path to favorites">💾</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory input -->
|
||||
<div class="kiko-controls">
|
||||
<button class="kiko-up-folder" title="Navigate to parent folder">⬆️</button>
|
||||
<input type="text" class="kiko-path-input" placeholder="Enter directory path..." />
|
||||
<button class="kiko-browse" title="Refresh folder">🔄</button>
|
||||
</div>
|
||||
|
||||
<!-- View options -->
|
||||
<div class="kiko-controls">
|
||||
<label>
|
||||
<input type="checkbox" class="kiko-show-videos" /> Videos
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" class="kiko-show-audio" /> Audio
|
||||
</label>
|
||||
<select class="kiko-sort-by">
|
||||
<option value="name">Name</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="size">Size</option>
|
||||
</select>
|
||||
<select class="kiko-sort-order">
|
||||
<option value="asc">↑</option>
|
||||
<option value="desc">↓</option>
|
||||
</select>
|
||||
<button class="kiko-refresh">🔄</button>
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="kiko-cardholder">
|
||||
<div class="kiko-status-message">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="kiko-pagination"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add the widget to the node
|
||||
this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, {
|
||||
serialize: false,
|
||||
});
|
||||
|
||||
// Set initial size for the node
|
||||
this.size = [800, 600];
|
||||
this.setSize(this.size);
|
||||
|
||||
// Initialize gallery functionality
|
||||
const node = this;
|
||||
const container = galleryContainer.querySelector('.kiko-container-wrapper');
|
||||
const pathInput = container.querySelector('.kiko-path-input');
|
||||
const pathPresets = container.querySelector('.kiko-path-presets');
|
||||
const savePathBtn = container.querySelector('.kiko-save-path');
|
||||
const browseBtn = container.querySelector('.kiko-browse');
|
||||
const upFolderBtn = container.querySelector('.kiko-up-folder');
|
||||
const showVideos = container.querySelector('.kiko-show-videos');
|
||||
const showAudio = container.querySelector('.kiko-show-audio');
|
||||
const sortBy = container.querySelector('.kiko-sort-by');
|
||||
const sortOrder = container.querySelector('.kiko-sort-order');
|
||||
const refreshBtn = container.querySelector('.kiko-refresh');
|
||||
const cardHolder = container.querySelector('.kiko-cardholder');
|
||||
const pagination = container.querySelector('.kiko-pagination');
|
||||
const statusMessage = container.querySelector('.kiko-status-message');
|
||||
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentDirectory = '';
|
||||
let parentDirectory = null;
|
||||
let currentItems = [];
|
||||
let selectedPaths = {
|
||||
image: null,
|
||||
video: null,
|
||||
audio: null
|
||||
};
|
||||
|
||||
// Utility: Debounce function
|
||||
const debounce = (func, delay) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
// Apply responsive masonry layout
|
||||
const applyMasonryLayout = () => {
|
||||
const minCardWidth = 120;
|
||||
const gap = 5;
|
||||
const containerWidth = cardHolder.clientWidth - 10; // Account for padding
|
||||
if (containerWidth <= 0) return;
|
||||
|
||||
const columnCount = Math.max(1, Math.floor(containerWidth / (minCardWidth + gap)));
|
||||
const totalGapSpace = (columnCount - 1) * gap;
|
||||
const actualCardWidth = Math.floor((containerWidth - totalGapSpace) / columnCount);
|
||||
const columnHeights = new Array(columnCount).fill(0);
|
||||
|
||||
const cards = cardHolder.querySelectorAll('.kiko-gallery-card');
|
||||
cards.forEach(card => {
|
||||
card.style.width = `${actualCardWidth}px`;
|
||||
const minHeight = Math.min(...columnHeights);
|
||||
const columnIndex = columnHeights.indexOf(minHeight);
|
||||
card.style.left = `${columnIndex * (actualCardWidth + gap)}px`;
|
||||
card.style.top = `${minHeight}px`;
|
||||
columnHeights[columnIndex] += card.offsetHeight + gap;
|
||||
});
|
||||
|
||||
// Set container height - let scrollbar handle overflow
|
||||
const maxHeight = Math.max(...columnHeights);
|
||||
if (maxHeight > 0) {
|
||||
cardHolder.style.minHeight = `${Math.min(200, maxHeight)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedLayout = debounce(applyMasonryLayout, 20);
|
||||
|
||||
// Set up ResizeObserver for responsive layout
|
||||
new ResizeObserver(debouncedLayout).observe(cardHolder);
|
||||
|
||||
// Load saved paths
|
||||
async function loadSavedPaths() {
|
||||
try {
|
||||
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
|
||||
const data = await response.json();
|
||||
|
||||
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
|
||||
data.saved_paths?.forEach(path => {
|
||||
const option = document.createElement('option');
|
||||
option.value = path;
|
||||
option.textContent = path.split('/').pop() || path;
|
||||
option.title = path;
|
||||
pathPresets.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading saved paths:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save current path
|
||||
savePathBtn.onclick = async () => {
|
||||
if (!currentDirectory) return;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
|
||||
const data = await response.json();
|
||||
const savedPaths = data.saved_paths || [];
|
||||
|
||||
if (!savedPaths.includes(currentDirectory)) {
|
||||
savedPaths.push(currentDirectory);
|
||||
await api.fetchApi('/kiko_local_image_loader/save_paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: savedPaths })
|
||||
});
|
||||
await loadSavedPaths();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving path:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to parent directory
|
||||
upFolderBtn.onclick = () => {
|
||||
if (parentDirectory) {
|
||||
loadImages(parentDirectory);
|
||||
}
|
||||
};
|
||||
|
||||
// Load images from directory
|
||||
async function loadImages(directory, page = 1) {
|
||||
if (!directory) return;
|
||||
|
||||
statusMessage.style.display = 'block';
|
||||
statusMessage.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
directory: directory,
|
||||
page: page.toString(),
|
||||
per_page: '50',
|
||||
show_videos: showVideos.checked,
|
||||
show_audio: showAudio.checked,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
statusMessage.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
currentDirectory = data.current_directory;
|
||||
parentDirectory = data.parent_directory;
|
||||
currentPage = data.current_page;
|
||||
totalPages = data.total_pages;
|
||||
currentItems = data.items;
|
||||
|
||||
pathInput.value = currentDirectory;
|
||||
upFolderBtn.disabled = !parentDirectory;
|
||||
|
||||
renderGallery();
|
||||
renderPagination();
|
||||
|
||||
statusMessage.style.display = 'none';
|
||||
} catch (error) {
|
||||
console.error('Error loading images:', error);
|
||||
statusMessage.textContent = 'Error loading directory';
|
||||
}
|
||||
}
|
||||
|
||||
// Render gallery cards
|
||||
function renderGallery() {
|
||||
cardHolder.innerHTML = '';
|
||||
|
||||
if (currentItems.length === 0) {
|
||||
cardHolder.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">No items found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentItems.forEach((item, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'kiko-gallery-card';
|
||||
|
||||
if (item.type === 'dir') {
|
||||
card.innerHTML = `
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<div class="kiko-folder-card">
|
||||
<div class="kiko-folder-icon">${folderSVG}</div>
|
||||
<div class="kiko-folder-name">${item.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => {
|
||||
loadImages(item.path);
|
||||
};
|
||||
} else if (item.type === 'image') {
|
||||
const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`;
|
||||
card.innerHTML = `
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<img src="${thumbnailUrl}" alt="${item.name}" />
|
||||
</div>
|
||||
<div class="kiko-card-info-panel">${item.name}</div>
|
||||
`;
|
||||
|
||||
// Trigger layout when image loads
|
||||
const img = card.querySelector('img');
|
||||
if (img) {
|
||||
img.onload = debouncedLayout;
|
||||
}
|
||||
|
||||
// Single click to select
|
||||
card.onclick = () => selectMedia(item, 'image', card);
|
||||
|
||||
// Double click to open in new tab
|
||||
card.ondblclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fullImageUrl = `/kiko_local_image_loader/view?filepath=${encodeURIComponent(item.path)}`;
|
||||
window.open(fullImageUrl, '_blank');
|
||||
};
|
||||
} else if (item.type === 'video') {
|
||||
const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`;
|
||||
card.innerHTML = `
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<img src="${thumbnailUrl}" alt="${item.name}" />
|
||||
<div class="kiko-video-card-overlay">${videoSVG}</div>
|
||||
</div>
|
||||
<div class="kiko-card-info-panel">${item.name}</div>
|
||||
`;
|
||||
|
||||
const img = card.querySelector('img');
|
||||
if (img) {
|
||||
img.onload = debouncedLayout;
|
||||
}
|
||||
|
||||
// Single click to select
|
||||
card.onclick = () => selectMedia(item, 'video', card);
|
||||
|
||||
// Double click to open in new tab
|
||||
card.ondblclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fullVideoUrl = `/kiko_local_image_loader/view?filepath=${encodeURIComponent(item.path)}`;
|
||||
window.open(fullVideoUrl, '_blank');
|
||||
};
|
||||
} else if (item.type === 'audio') {
|
||||
card.innerHTML = `
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<div class="kiko-audio-card">
|
||||
<div class="kiko-audio-icon">${audioSVG}</div>
|
||||
<div class="kiko-audio-name">${item.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Single click to select
|
||||
card.onclick = () => selectMedia(item, 'audio', card);
|
||||
|
||||
// Double click to open in new tab
|
||||
card.ondblclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fullAudioUrl = `/kiko_local_image_loader/view?filepath=${encodeURIComponent(item.path)}`;
|
||||
window.open(fullAudioUrl, '_blank');
|
||||
};
|
||||
}
|
||||
|
||||
// Check if selected
|
||||
if (selectedPaths[item.type] === item.path) {
|
||||
card.classList.add('kiko-selected');
|
||||
}
|
||||
|
||||
cardHolder.appendChild(card);
|
||||
});
|
||||
|
||||
// Apply layout after all cards are added
|
||||
requestAnimationFrame(debouncedLayout);
|
||||
}
|
||||
|
||||
// Select media
|
||||
async function selectMedia(item, type, cardElement) {
|
||||
// Update selection
|
||||
selectedPaths[type] = item.path;
|
||||
|
||||
// Update UI
|
||||
cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => {
|
||||
card.classList.remove('kiko-selected');
|
||||
});
|
||||
cardElement.classList.add('kiko-selected');
|
||||
|
||||
// Send to backend
|
||||
try {
|
||||
await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
node_id: node.id,
|
||||
path: item.path,
|
||||
type: type
|
||||
})
|
||||
});
|
||||
|
||||
// Trigger node update
|
||||
node.setDirtyCanvas(true);
|
||||
} catch (error) {
|
||||
console.error('Error setting selection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Render pagination
|
||||
function renderPagination() {
|
||||
pagination.innerHTML = '';
|
||||
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const createButton = (text, page) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = text;
|
||||
btn.style.cssText = 'background: #333; color: #ccc; border: 1px solid #555; padding: 1px 6px; cursor: pointer; font-size: 11px;';
|
||||
if (page === currentPage) {
|
||||
btn.style.background = '#555';
|
||||
}
|
||||
btn.onclick = () => loadImages(currentDirectory, page);
|
||||
return btn;
|
||||
};
|
||||
|
||||
if (currentPage > 1) {
|
||||
pagination.appendChild(createButton('◀', currentPage - 1));
|
||||
}
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||||
pagination.appendChild(createButton(i.toString(), i));
|
||||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '...';
|
||||
span.style.padding = '0 5px';
|
||||
pagination.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage < totalPages) {
|
||||
pagination.appendChild(createButton('▶', currentPage + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
browseBtn.onclick = () => loadImages(pathInput.value || currentDirectory);
|
||||
refreshBtn.onclick = () => loadImages(currentDirectory, currentPage);
|
||||
|
||||
pathInput.onkeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
loadImages(pathInput.value);
|
||||
}
|
||||
};
|
||||
|
||||
pathPresets.onchange = () => {
|
||||
if (pathPresets.value) {
|
||||
loadImages(pathPresets.value);
|
||||
}
|
||||
};
|
||||
|
||||
showVideos.onchange = () => loadImages(currentDirectory, 1);
|
||||
showAudio.onchange = () => loadImages(currentDirectory, 1);
|
||||
sortBy.onchange = () => loadImages(currentDirectory, 1);
|
||||
sortOrder.onchange = () => loadImages(currentDirectory, 1);
|
||||
|
||||
// Load initial data
|
||||
loadSavedPaths();
|
||||
|
||||
// Try to load last path
|
||||
api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
|
||||
const data = await response.json();
|
||||
if (data.last_path) {
|
||||
loadImages(data.last_path);
|
||||
}
|
||||
});
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
/**
|
||||
* KikoTools Extensions - Adds utility features to all ComfyAssets nodes
|
||||
*/
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.Extensions",
|
||||
|
||||
async setup() {
|
||||
// Wait for the canvas to be ready
|
||||
setTimeout(() => {
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
|
||||
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
const options = getNodeMenuOptions.apply(this, arguments);
|
||||
|
||||
// Only add our menu items to ComfyAssets nodes
|
||||
if (node.constructor.category && node.constructor.category.includes("ComfyAssets")) {
|
||||
node.setDirtyCanvas(true, true);
|
||||
|
||||
// Find the position before the last separator (usually before "Remove")
|
||||
let insertIndex = options.length - 1;
|
||||
for (let i = options.length - 1; i >= 0; i--) {
|
||||
if (options[i] === null) {
|
||||
insertIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert our custom menu items
|
||||
const kikoOptions = [
|
||||
null, // separator
|
||||
{
|
||||
content: "🎨 Node Dimensions",
|
||||
callback: () => {
|
||||
KikoToolsExtensions.showNodeDimensionsDialog(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
options.splice(insertIndex, 0, ...kikoOptions);
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* KikoTools Extensions utilities
|
||||
*/
|
||||
class KikoToolsExtensions {
|
||||
/**
|
||||
* Create a dialog for settings
|
||||
*/
|
||||
static createDialog(htmlContent, onOK, onCancel) {
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "kikotools-dialog";
|
||||
dialog.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #202020;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
z-index: 10000;
|
||||
font-family: Arial, sans-serif;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
dialog.innerHTML = htmlContent;
|
||||
|
||||
// Create button container
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.style.cssText = `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
`;
|
||||
|
||||
// Create OK button
|
||||
const okButton = document.createElement("button");
|
||||
okButton.textContent = "OK";
|
||||
okButton.style.cssText = `
|
||||
padding: 8px 20px;
|
||||
background: #4A90E2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
`;
|
||||
okButton.onmouseover = () => okButton.style.background = "#5BA0F2";
|
||||
okButton.onmouseout = () => okButton.style.background = "#4A90E2";
|
||||
|
||||
// Create Cancel button
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.textContent = "Cancel";
|
||||
cancelButton.style.cssText = `
|
||||
padding: 8px 20px;
|
||||
background: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
`;
|
||||
cancelButton.onmouseover = () => cancelButton.style.background = "#777";
|
||||
cancelButton.onmouseout = () => cancelButton.style.background = "#666";
|
||||
|
||||
buttonContainer.appendChild(cancelButton);
|
||||
buttonContainer.appendChild(okButton);
|
||||
dialog.appendChild(buttonContainer);
|
||||
|
||||
// Dialog close function
|
||||
dialog.close = function() {
|
||||
if (dialog.parentNode) {
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
};
|
||||
|
||||
// Get all inputs
|
||||
const inputs = Array.from(dialog.querySelectorAll("input, select"));
|
||||
|
||||
// Handle keyboard events
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener("keydown", function(e) {
|
||||
if (e.keyCode === 27) { // ESC
|
||||
onCancel && onCancel();
|
||||
dialog.close();
|
||||
} else if (e.keyCode === 13) { // Enter
|
||||
onOK && onOK(dialog, inputs.map(input => input.value));
|
||||
dialog.close();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Button click handlers
|
||||
okButton.onclick = () => {
|
||||
onOK && onOK(dialog, inputs.map(input => input.value));
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
cancelButton.onclick = () => {
|
||||
onCancel && onCancel();
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
// Add to document
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// Focus first input
|
||||
if (inputs.length > 0) {
|
||||
inputs[0].focus();
|
||||
inputs[0].select();
|
||||
}
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show node dimensions dialog
|
||||
*/
|
||||
static showNodeDimensionsDialog(node) {
|
||||
const nodeWidth = Math.round(node.size[0]);
|
||||
const nodeHeight = Math.round(node.size[1]);
|
||||
|
||||
const htmlContent = `
|
||||
<div style="color: #ddd; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #4A90E2;">Node Dimensions</h3>
|
||||
<div style="display: flex; gap: 20px; align-items: center;">
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Width:</label>
|
||||
<input type="number" class="width" value="${nodeWidth}"
|
||||
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Height:</label>
|
||||
<input type="number" class="height" value="${nodeHeight}"
|
||||
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px; font-size: 11px; color: #888;">
|
||||
Tip: Minimum size will be enforced based on node content
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.createDialog(
|
||||
htmlContent,
|
||||
function(dialog, values) {
|
||||
const widthValue = Number(values[0]) || nodeWidth;
|
||||
const heightValue = Number(values[1]) || nodeHeight;
|
||||
|
||||
// Calculate minimum size based on node content
|
||||
const minSize = node.computeSize();
|
||||
|
||||
// Apply new size (respecting minimums)
|
||||
node.setSize([
|
||||
Math.max(minSize[0], widthValue),
|
||||
Math.max(minSize[1], heightValue)
|
||||
]);
|
||||
|
||||
// Mark canvas as dirty to trigger redraw
|
||||
node.setDirtyCanvas(true, true);
|
||||
},
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for global access
|
||||
window.KikoToolsExtensions = KikoToolsExtensions;
|
||||
Reference in New Issue
Block a user