Compare commits

...
Author SHA1 Message Date
Vito Sansevero e6c8dd583f fix: convert Empty Latent Batch swap button from canvas to DOM widget
- Switch from unreliable canvas-based drawing to DOM widget approach
- Eliminate coordinate calculation complexity and mouse position issues
- Use same proven pattern as Seed History node buttons
- Add proper hover/click animations and visual feedback
- Fix callback bug in heightWidget.callback assignment
- Remove all canvas drawing, mouse handling, and coordinate code
- Button now works consistently without coordinate system problems

Resolves swap button not working issue by using reliable DOM elements
instead of manual canvas coordinate calculations.
2025-08-19 11:48:53 -07:00
Vito Sansevero f9db6f8635 chore: bump version to 1.0.19 in pyproject.toml 2025-08-16 17:49:54 -07:00
Vito 1b18873e65 Merge pull request #36 from ComfyAssets/feature/lora-auto-batching
feat: Add auto-batching support for large LoRA collections
2025-08-16 17:49:16 -07:00
Vito Sansevero d2b30f0a78 feat: Add auto-batching support for large LoRA collections
- Add auto-batching functionality to split large LoRA collections into manageable chunks
- Implement batch_size parameter to control number of LoRAs per batch (default: 25)
- Add batch_index parameter to select which batch to process
- Include batch tracking metadata in LORA_PARAMS for visualization
- Display batch info in plot parameters when available
- Update lora_list output to show batch header when auto-batching is enabled
- Add comprehensive tests for batching functionality
- Update documentation with detailed auto-batching usage instructions

This feature prevents UI disconnection issues when processing large numbers of LoRAs
by allowing users to process them in smaller batches sequentially.
2025-08-16 17:27:59 -07:00
Vito Sansevero 917421529b chore(pyproject): bump version to 1.0.18 2025-08-14 19:12:49 -07:00
Vito d29dcb8564 Merge pull request #35 from ComfyAssets/feature/remove-cfg-custom-slider
refactor: Remove custom CFG slider display in Sampler Combo nodes, requested from user
2025-08-14 19:11:05 -07:00
Vito Sansevero b95bf8e64a refactor: Remove custom CFG slider display in Sampler Combo nodes
- Remove "display": "slider" parameter from CFG input in SamplerComboNode
- Remove "display": "slider" parameter from CFG input in SamplerComboCompactNode
- Both nodes now use ComfyUI's standard float input instead of custom slider
- All tests passing (354 unit tests)
2025-08-14 19:06:44 -07:00
Vito Sansevero b8622e21da chore: bump version to 1.0.17 in pyproject.toml 2025-08-13 07:08:43 -07:00
Vito 4c8f20ef88 Merge pull request #34 from ComfyAssets/bugfix/display-any
Bugfix/display any
2025-08-13 07:08:12 -07:00
Vito 26d135e106 Merge pull request #33 from ComfyAssets/dependabot/github_actions/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-08-13 07:05:21 -07:00
Vito Sansevero db7d0dc86e test: Add line break and spacing adjustments 2025-08-13 07:04:55 -07:00
Vito Sansevero b5e24dbe57 test: Update test categories with emoji prefix 2025-08-13 07:01:21 -07:00
Vito Sansevero eb1e646453 test: Update CATEGORY assertion emoji in test 2025-08-13 07:01:07 -07:00
Vito Sansevero 793579a1dd refactor(web): remove redundant title update code 2025-08-13 07:00:55 -07:00
dependabot[bot] eba899b30d build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-11 22:33:21 +00:00
Vito Sansevero 914ba8e003 chore: bump version to 1.0.16 in pyproject.toml 2025-08-10 11:38:38 -07:00
Vito 51b057982e Merge pull request #32 from ComfyAssets/feature/follow-execution-and-custom-colors
feat: add follow execution and custom colors UI features
2025-08-10 11:37:58 -07:00
33 changed files with 644 additions and 313 deletions
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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: |
@@ -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.
@@ -53,7 +53,6 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
"min": 1.0,
"max": 15.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG",
},
),
-1
View File
@@ -58,7 +58,6 @@ class SamplerComboNode(ComfyAssetsBaseNode):
"min": 0.0,
"max": 20.0,
"step": 0.5,
"display": "slider",
"tooltip": "CFG scale (0-20)",
},
),
@@ -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,6 +73,47 @@ 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",
},
),
},
}
@@ -82,13 +122,18 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
FUNCTION = "batch_loras"
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)
@@ -202,8 +202,29 @@ 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
import os
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)
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.15"
version = "1.0.19"
license = {text = "MIT"}
dependencies = []
+1 -1
View File
@@ -12,7 +12,7 @@ def test_import():
assert (
KikoEmbeddingAutocomplete.DISPLAY_NAME == "🫶 Embedding Autocomplete Settings"
)
assert KikoEmbeddingAutocomplete.CATEGORY == "ComfyAssets"
assert KikoEmbeddingAutocomplete.CATEGORY == "🫶 ComfyAssets"
def test_settings_defined():
+4 -4
View File
@@ -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")
+1 -1
View File
@@ -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",)
+1 -1
View File
@@ -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."""
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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."""
+10 -8
View File
@@ -4,8 +4,9 @@ from unittest.mock import patch, MagicMock
import pytest
# Mock comfy modules
mock_mm = MagicMock()
sys.modules["comfy"] = MagicMock()
sys.modules["comfy.model_management"] = MagicMock()
sys.modules["comfy.model_management"] = mock_mm
from kikotools.tools.kiko_purge_vram.logic import (
purge_memory,
@@ -13,6 +14,11 @@ from kikotools.tools.kiko_purge_vram.logic import (
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")
@@ -94,13 +100,9 @@ class TestMemoryPurge:
assert freed_mb == 2500
@patch("kikotools.tools.kiko_purge_vram.logic.COMFY_AVAILABLE", True)
@patch("kikotools.tools.kiko_purge_vram.logic.mm.unload_all_models")
@patch("kikotools.tools.kiko_purge_vram.logic.mm.soft_empty_cache")
@patch("torch.cuda.is_available")
@patch("gc.collect")
def test_purge_memory_models_only(
self, mock_gc, mock_cuda, mock_soft_empty, mock_unload
):
def test_purge_memory_models_only(self, mock_gc, mock_cuda):
mock_cuda.return_value = True
with patch(
@@ -113,8 +115,8 @@ class TestMemoryPurge:
freed_mb = purge_memory(mode="models_only", unload_models=True)
mock_unload.assert_called_once()
mock_soft_empty.assert_called_once()
mock_mm.unload_all_models.assert_called_once()
mock_mm.soft_empty_cache.assert_called_once()
mock_gc.assert_called()
assert freed_mb == 5000
+2 -2
View File
@@ -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"
@@ -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"
+1 -1
View File
@@ -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."""
+1 -1
View File
@@ -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",)
-7
View File
@@ -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
View File
@@ -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);
}
};
}
},
});