Compare commits

...
Author SHA1 Message Date
dependabot[bot] 22758b4595 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 16:02:32 +00:00
Vito c7f24a3262 Merge pull request #55 from ComfyAssets/feature/batch-list-converter
feat: add batch/list conversion nodes for IMAGE and LATENT
2026-02-09 17:46:52 -08:00
Vito Sansevero c69281e795 fix(batch_list_converter): preserve all latent dict keys during split/join
Previously split_latent_batch and join_latent_batch only handled the
"samples" key, silently dropping metadata like noise_mask or batch_index.
This broke inpaint and masked workflows after a round-trip conversion.
2026-02-09 17:11:01 -08:00
Vito Sansevero 3bf8391e88 feat(batch_list_converter): add batch/list conversion nodes for IMAGE and LATENT
Add 4 utility nodes for converting between batched tensors and lists,
eliminating the dependency on Impact Pack for this common operation:
- ImageBatchToImageList (OUTPUT_IS_LIST)
- ImageListToImageBatch (INPUT_IS_LIST)
- LatentBatchToLatentList (OUTPUT_IS_LIST)
- LatentListToLatentBatch (INPUT_IS_LIST)
2026-02-09 16:39:28 -08:00
Vito d13bfe8fb4 Merge pull request #53 from ComfyAssets/feature/seed-range-and-latent-output
fix: 32-bit seed range, latent batch_size output
2026-02-09 16:32:17 -08:00
Vito Sansevero 702d0c889c fix(seed_history): accept legacy kwargs for backward compatibility
Old workflows may still pass 'mode' to output_seed. Using **kwargs
prevents TypeError when ComfyUI invokes with the extra argument.
2026-02-09 16:29:11 -08:00
Vito Sansevero d1a5282ca0 style: fix Black formatting issues
Remove extra blank line in local_image_loader/node.py and wrap long
tuple assertion in test_empty_latent_batch.py.
2026-02-09 16:26:08 -08:00
Vito Sansevero 36212adf73 ci: reduce test matrix to Python 3.11, 3.12, 3.13
Drop 3.8, 3.9, and 3.10 which are no longer needed.
2026-02-09 16:25:30 -08:00
Vito Sansevero 2cb2262293 chore(local_image_loader): update saved paths and selections 2026-02-09 16:22:10 -08:00
Vito Sansevero 8773c8f249 feat(latent): add batch_size as 4th output from EmptyLatentBatchNode
Exposes batch_size as an output so downstream nodes can reference it
directly without needing a separate input.
2026-02-09 16:22:03 -08:00
Vito Sansevero 0a1cbe4990 fix(seeds): reduce seed range from 64-bit to 32-bit across all nodes
JS Math.random() only has 53 bits of integer precision, making the
64-bit range produce non-uniform values. 32-bit (0xFFFFFFFF) is the
standard ComfyUI seed range and works correctly in both Python and JS.

Also removes the redundant mode input from SeedHistoryNode, relying on
ComfyUI's built-in control_after_generate widget instead.
2026-02-09 16:21:56 -08:00
Vito c4a27137e7 Merge pull request #52 from ComfyAssets/feature/height-width-2-vec
Feature/height width 2 vec
2025-12-21 06:49:05 -08:00
Vito Sansevero 20270d9501 test: Add unit tests for KikoWorkflowTimerNode 2025-12-21 06:45:35 -08:00
Vito Sansevero 2467dff704 feat(timer): add real-time workflow execution timer 2025-12-21 06:45:24 -08:00
Vito Sansevero cecd1e01e9 feat(timer): add Sketch-style color picker widget 2025-12-21 06:45:10 -08:00
Vito Sansevero e504f314aa chore: bump version to 1.0.28 in pyproject.toml 2025-12-21 06:45:00 -08:00
Vito Sansevero f12debe729 chore: update image paths in selections.json 2025-12-21 06:44:49 -08:00
Vito Sansevero c64e835d2e chore(local_image_loader): update config paths 2025-12-21 06:44:38 -08:00
Vito Sansevero 36d37db8a9 feat(init): add KikoWorkflowTimerNode to mappings 2025-12-21 06:44:24 -08:00
Vito Sansevero 969fd9e25e docs: Add Workflow Timer section to README.md 2025-12-21 06:44:14 -08:00
Vito Sansevero 6916b47a89 chore: bump version to 1.0.27 in pyproject.toml 2025-12-17 07:15:01 -08:00
Vito 8457db0041 Merge pull request #50 from ComfyAssets/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-12-17 07:07:19 -08:00
Vito 8a84927adf Merge pull request #51 from ComfyAssets/dependabot/github_actions/actions/cache-5
build(deps): bump actions/cache from 4 to 5
2025-12-17 07:06:54 -08:00
Vito Sansevero 8c0b89ff70 style(node): Reformat code for readability 2025-12-17 07:06:16 -08:00
Vito Sansevero b527635254 test: update return types in test_assertion 2025-12-17 07:02:08 -08:00
Vito Sansevero 14738b80c5 feat(image_loader): add directory listing API endpoint 2025-12-17 07:01:55 -08:00
Vito Sansevero bff1276d06 style: Add newline at EOF in JSON file 2025-12-17 07:01:46 -08:00
Vito Sansevero a7805ad587 feat: add WidthHeightToVec2Node to mappings 2025-12-17 07:01:34 -08:00
Vito Sansevero f2093d6567 style(docs): Ensure newline at EOF in markdown files 2025-12-17 07:01:22 -08:00
Vito Sansevero 0e26f0ad36 style: Remove trailing spaces in config files 2025-12-17 07:00:53 -08:00
Vito Sansevero 9a05f20790 style: Remove unnecessary whitespace for consistency 2025-12-17 07:00:31 -08:00
Vito Sansevero 6368582e1b feat: Add WidthHeightToVec2Node with tests 2025-12-17 06:59:11 -08:00
dependabot[bot] 97ea5b9a7d build(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 16:28:14 +00:00
dependabot[bot] f5f956ab4e build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [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/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 16:55:51 +00:00
Vito Sansevero 498fae1211 feat(image_loader): add search_files function 2025-11-14 13:05:52 -08:00
Vito Sansevero d24912036c chore(local_image_loader): update last_path in config.json 2025-11-14 13:05:52 -08:00
Vito 523b0509f1 Update pyproject.toml 2025-11-12 08:23:11 -08:00
Vito 5f8117aa56 Merge pull request #49 from wzgrx/patch-1
Update requirements.txt
2025-11-12 08:22:31 -08:00
wzgrx 22dc975353 Update requirements.txt 2025-10-25 22:05:14 +08:00
Vito Sansevero 66afb2b204 chore: bump version to 1.0.25 in pyproject.toml 2025-10-18 13:09:46 -07:00
Vito 1dd1dcf895 Merge pull request #48 from ComfyAssets/feature/downloader
Feature/downloader
2025-10-18 13:08:00 -07:00
70 changed files with 2542 additions and 616 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
[flake8]
max-line-length = 127
max-complexity = 10
exclude =
exclude =
.git,
__pycache__,
.mypy_cache,
@@ -12,7 +12,7 @@ exclude =
dist,
*.egg-info,
.tox
ignore =
ignore =
# W503: line break before binary operator (conflicts with Black)
W503,
# E203: whitespace before ':' (conflicts with Black)
@@ -32,4 +32,4 @@ per-file-ignores =
# Statistics
count = True
statistics = True
statistics = True
+1 -1
View File
@@ -38,4 +38,4 @@
*.safetensors binary
*.ckpt binary
*.pt binary
*.pth binary
*.pth binary
+1 -1
View File
@@ -7,4 +7,4 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "weekly"
+4 -4
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -21,7 +21,7 @@ jobs:
python-version: '3.10'
- name: Cache pip dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
@@ -141,7 +141,7 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -172,7 +172,7 @@ jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository_owner == 'ComfyAssets' }}
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
submodules: true
- name: Publish Custom Node
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -114,7 +114,7 @@ jobs:
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
+5 -5
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
@@ -25,7 +25,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
@@ -401,7 +401,7 @@ jobs:
test-package-structure:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -465,7 +465,7 @@ jobs:
test-documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Test documentation completeness
run: |
+1 -1
View File
@@ -81,4 +81,4 @@ exclude: |
.*\.egg-info/|
venv/|
env/
)
)
+24 -2
View File
@@ -39,6 +39,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
| [🧹 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 |
| [🌐 Model Downloader](#-model-downloader) | Download models from CivitAI, HuggingFace, and custom URLs | 🛠️ Utils |
| [⏱️ Workflow Timer](#️-workflow-timer) | Real-time execution timer with customizable display | 🛠️ Utils |
### 🧰 xyz-helpers Tools
@@ -405,6 +406,26 @@ Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and
![Model Downloader Example](examples/workflows/model_downloader_example.png)
#### ⏱️ Workflow Timer
Real-time execution timer that displays workflow duration with millisecond precision.
- **Live Timing**: Updates in real-time during workflow execution (MM:SS:mmm format)
- **Customizable Color**: Choose your preferred display color via KikoTools settings
- **Glow Effect**: Optional pulsing glow animation (can be enabled/disabled in settings)
- **Global Settings**: Color and glow preferences apply to all timer nodes
- **Persistent Display**: Shows final execution time after workflow completes
- **Multi-Node Sync**: All timer nodes stay synchronized during execution
**Use Cases:**
- Monitor workflow execution performance
- Compare generation times across different settings
- Identify slow nodes by adding timers at different workflow stages
- Track optimization improvements over time
**Settings (KikoTools Settings Panel):**
- **Workflow Timer: Color** - Custom color picker for timer display
- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off
### 🔤 Embedding Autocomplete
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
@@ -448,7 +469,7 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
**Intelligent GPU memory management with threshold-based triggering and detailed reporting.**
**Key Features:**
- **4 Purge Modes**:
- **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
@@ -728,6 +749,7 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) |
| **Local Image Loader** | Visual gallery browser for local media files | ✅ Complete | [Docs](examples/documentation/local_image_loader.md) |
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](examples/documentation/model_downloader.md) |
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](examples/documentation/workflow_timer.md) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -1021,7 +1043,7 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 20 (14 core tools + 6 xyz-helpers)
- **Nodes**: 21 (15 core tools + 6 xyz-helpers)
- **Features**: Embedding Autocomplete (settings-based, not a node)
- **Categories**: 9 emoji-based categories for better organization
- **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs)
+1 -1
View File
@@ -103,4 +103,4 @@ The node provides several ways to track progress:
### 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
- Use `reload_file` to reset counter for a specific file
+1 -1
View File
@@ -117,4 +117,4 @@ Config Node → Display Any (raw value) → Processing Node
[Text Multiline] ← [Concatenate] ← "Image dimensions: "
```
This creates a text output showing the current image dimensions that can be used elsewhere in your workflow.
This creates a text output showing the current image dimensions that can be used elsewhere in your workflow.
+3 -3
View File
@@ -85,7 +85,7 @@ Display long text content with scrolling and word wrapping.
The node intelligently detects prompt formats:
1. **SDXL Format**:
1. **SDXL Format**:
- Looks for "Positive prompt:" and "Negative prompt:" markers
- Case-insensitive detection
- Handles various formatting styles
@@ -98,7 +98,7 @@ The node intelligently detects prompt formats:
## Styling
- **Font**: Monospace for consistent alignment
- **Colors**:
- **Colors**:
- Text: Light gray (#ddd) on dark background
- Background: Semi-transparent dark (#1a1a1a)
- Borders: Subtle gray (#333)
@@ -144,4 +144,4 @@ The node intelligently detects prompt formats:
SDXL Format Split View Display Clean Prompts
```
This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction.
This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction.
@@ -149,4 +149,4 @@ base_shift: 0.4
- **1.0.3**: Improved UI elements and parameter validation
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
+1 -1
View File
@@ -206,4 +206,4 @@ Errors are displayed in the prompt output for easy debugging.
**Import error for google-generativeai**:
- Run `pip install google-generativeai` in your ComfyUI environment
- Restart ComfyUI after installation
- Restart ComfyUI after installation
@@ -79,4 +79,4 @@ Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batc
The node will raise an error if:
- The image dimensions are smaller than the specified multiple_of value
- Invalid input types are provided
- The resulting dimensions would be 0 or negative
- The resulting dimensions would be 0 or negative
+1 -1
View File
@@ -122,4 +122,4 @@ Creates monochrome grain perfect for black and white photography.
- 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
- Efficient batch processing support
+2 -2
View File
@@ -50,7 +50,7 @@ Enhanced image saving node with multiple format support, quality controls, and a
### Image Grid
- **Thumbnails**: Click any image to open full-size in new tab
- **File Info**: Shows filename and size for each image
- **Quality Indicators**:
- **Quality Indicators**:
- PNG: Compression level (0-9)
- JPEG/WebP: Quality percentage
- **Batch Selection**: Checkboxes for multi-select operations
@@ -210,4 +210,4 @@ Batch Generate → Kiko Save Image → Popup Viewer
**Can't see all images**:
- Scroll within the popup grid
- Maximize the popup window
- Images are shown newest first
- Images are shown newest first
+1 -1
View File
@@ -155,4 +155,4 @@ The node creates a visual widget that runs in the ComfyUI interface and communic
- Save user preferences
- Handle file selection
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
+1 -1
View File
@@ -261,4 +261,4 @@ batch_mode: sequential
- **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.
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
+3 -3
View File
@@ -109,12 +109,12 @@ Parameter Grid → PlotParameters → Analysis Display
x_axis: "guidance"
y_axis: "perceived_quality"
# Step efficiency analysis
# Step efficiency analysis
x_axis: "steps"
y_axis: "generation_time"
# LoRA impact assessment
x_axis: "lora_strength"
x_axis: "lora_strength"
y_axis: "style_adherence"
```
@@ -231,4 +231,4 @@ Compare multiple generation runs to identify optimal parameters.
- **1.0.3**: Improved export capabilities
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
@@ -257,4 +257,4 @@ Compare all compatible samplers for specific model/prompt combination.
- **1.0.3**: Improved auto-detection
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
@@ -297,4 +297,4 @@ Progress through schedulers from fast to quality for different use cases.
- **1.0.3**: Improved compatibility matrix
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
@@ -142,7 +142,7 @@ steps: 30-40
cfg: 7-8
sampler: dpmpp_3m_sde
# Speed over quality
# Speed over quality
steps: 10-15
cfg: 5-6
sampler: euler
@@ -307,4 +307,4 @@ Very High (50+): Diminishing returns
- **1.0.3**: Improved batch processing
## Credits
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
+1 -1
View File
@@ -11,4 +11,4 @@ Enchanted forest with magical glowing mushrooms, fairy lights, mystical atmosphe
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
Negative: fantasy, medieval, underwater, cartoon style
+1 -1
View File
@@ -376,4 +376,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
+1 -1
View File
@@ -141,4 +141,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -298,4 +298,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -204,4 +204,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -549,4 +549,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -162,4 +162,4 @@
}
},
"version": 0.4
}
}
@@ -144,4 +144,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -352,4 +352,4 @@
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
@@ -166,4 +166,4 @@
],
"attribution": "xyz_helpers nodes adapted from comfyui-essentials-nodes"
}
}
}
+20
View File
@@ -3,6 +3,12 @@ KikoTools package initialization and node registry
Handles automatic discovery and registration of all ComfyAssets tools
"""
from .tools.batch_list_converter import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
from .tools.batch_prompts import BatchPromptsNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
@@ -13,6 +19,7 @@ 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_workflow_timer import KikoWorkflowTimerNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.local_image_loader import LocalImageLoaderNode
from .tools.model_downloader import ModelDownloaderNode
@@ -21,6 +28,7 @@ from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
from .tools.seed_history import SeedHistoryNode
from .tools.text_input import TextInputNode
from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.width_height_to_vec2 import WidthHeightToVec2Node
from .tools.xyz_helpers import (
FluxSamplerParamsNode,
LoRAFolderBatchNode,
@@ -32,6 +40,10 @@ from .tools.xyz_helpers import (
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
"ImageBatchToImageList": ImageBatchToImageListNode,
"ImageListToImageBatch": ImageListToImageBatchNode,
"LatentBatchToLatentList": LatentBatchToLatentListNode,
"LatentListToLatentBatch": LatentListToLatentBatchNode,
"BatchPrompts": BatchPromptsNode,
"ResolutionCalculator": ResolutionCalculatorNode,
"WidthHeightSelector": WidthHeightSelectorNode,
@@ -56,11 +68,17 @@ NODE_CLASS_MAPPINGS = {
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"WidthHeightToVec2": WidthHeightToVec2Node,
"KikoWorkflowTimer": KikoWorkflowTimerNode,
# Note: KikoEmbeddingAutocomplete is not registered as a node
# It's a settings-only feature accessed through ComfyUI settings menu
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
"BatchPrompts": "Batch Prompts",
"ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector",
@@ -85,6 +103,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FluxSamplerParams": "Flux Sampler Parameters",
"PlotParameters+": "Plot Parameters",
"LoRAFolderBatch": "LoRA Folder Batch",
"WidthHeightToVec2": "Width Height to VEC2",
"KikoWorkflowTimer": "Workflow Timer",
# KikoEmbeddingAutocomplete removed - settings only, not a node
}
@@ -0,0 +1,15 @@
"""Batch/List conversion tool for ComfyUI."""
from .node import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
__all__ = [
"ImageBatchToImageListNode",
"ImageListToImageBatchNode",
"LatentBatchToLatentListNode",
"LatentListToLatentBatchNode",
]
@@ -0,0 +1,55 @@
"""Pure tensor split/join functions for batch-list conversions."""
import torch
from typing import Dict, List
def split_image_batch(images: torch.Tensor) -> List[torch.Tensor]:
"""Split [B,H,W,C] image batch into list of [1,H,W,C] tensors."""
return [images[i : i + 1] for i in range(images.shape[0])]
def join_image_batch(image_list: List[torch.Tensor]) -> torch.Tensor:
"""Join list of image tensors into single [B,H,W,C] batch."""
return torch.cat(image_list, dim=0)
def split_latent_batch(
latent: Dict[str, torch.Tensor],
) -> List[Dict[str, torch.Tensor]]:
"""Split latent dict into list of single-item latent dicts.
Preserves all keys (e.g. noise_mask, batch_index). Tensor values whose
first dimension matches the batch size of ``samples`` are sliced along
dim-0; all other values are copied as-is to every item.
"""
samples = latent["samples"]
batch_size = samples.shape[0]
result: List[Dict[str, torch.Tensor]] = []
for i in range(batch_size):
item: Dict[str, torch.Tensor] = {}
for key, value in latent.items():
if isinstance(value, torch.Tensor) and value.shape[0] == batch_size:
item[key] = value[i : i + 1]
else:
item[key] = value
result.append(item)
return result
def join_latent_batch(
latent_list: List[Dict[str, torch.Tensor]],
) -> Dict[str, torch.Tensor]:
"""Join list of latent dicts into single batched latent dict.
Tensor values that were sliced during split are concatenated along dim-0.
Non-tensor values are taken from the first item.
"""
result: Dict[str, torch.Tensor] = {}
first = latent_list[0]
for key in first:
if isinstance(first[key], torch.Tensor):
result[key] = torch.cat([lat[key] for lat in latent_list], dim=0)
else:
result[key] = first[key]
return result
@@ -0,0 +1,130 @@
"""Batch/List conversion nodes for ComfyUI."""
from typing import Dict, List, Tuple
import torch
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
split_image_batch,
join_image_batch,
split_latent_batch,
join_latent_batch,
)
class ImageBatchToImageListNode(ComfyAssetsBaseNode):
"""Split an IMAGE batch [B,H,W,C] into a list of individual images."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
OUTPUT_IS_LIST = (True, False)
FUNCTION = "split_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def split_batch(self, images: torch.Tensor) -> Tuple[List[torch.Tensor], int]:
image_list = split_image_batch(images)
count = len(image_list)
self.log_info(f"Split image batch of {count} into list")
return (image_list, count)
class ImageListToImageBatchNode(ComfyAssetsBaseNode):
"""Join a list of IMAGE tensors into a single batched IMAGE [B,H,W,C]."""
INPUT_IS_LIST = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
FUNCTION = "join_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def join_batch(self, images: List[torch.Tensor]) -> Tuple[torch.Tensor, int]:
batch = join_image_batch(images)
count = batch.shape[0]
self.log_info(f"Joined {count} images into batch")
return (batch, count)
class LatentBatchToLatentListNode(ComfyAssetsBaseNode):
"""Split a LATENT batch into a list of individual latent dicts."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latents", "count")
OUTPUT_IS_LIST = (True, False)
FUNCTION = "split_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def split_batch(
self, latent: Dict[str, torch.Tensor]
) -> Tuple[List[Dict[str, torch.Tensor]], int]:
latent_list = split_latent_batch(latent)
count = len(latent_list)
self.log_info(f"Split latent batch of {count} into list")
return (latent_list, count)
class LatentListToLatentBatchNode(ComfyAssetsBaseNode):
"""Join a list of LATENT dicts into a single batched LATENT."""
INPUT_IS_LIST = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latents": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latent", "count")
FUNCTION = "join_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def join_batch(
self, latents: List[Dict[str, torch.Tensor]]
) -> Tuple[Dict[str, torch.Tensor], int]:
batch = join_latent_batch(latents)
count = batch["samples"].shape[0]
self.log_info(f"Joined {count} latents into batch")
return (batch, count)
NODE_CLASS_MAPPINGS = {
"ImageBatchToImageList": ImageBatchToImageListNode,
"ImageListToImageBatch": ImageListToImageBatchNode,
"LatentBatchToLatentList": LatentBatchToLatentListNode,
"LatentListToLatentBatch": LatentListToLatentBatchNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
}
+5 -5
View File
@@ -93,14 +93,14 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
}
}
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
RETURN_TYPES = ("LATENT", "INT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height", "batch_size")
FUNCTION = "create_empty_latent"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def create_empty_latent(
self, preset: str, width: int, height: int, batch_size: int
) -> Tuple[Dict[str, torch.Tensor], int, int]:
) -> Tuple[Dict[str, torch.Tensor], int, int, int]:
"""
Create empty latent tensor with specified dimensions and batch size.
@@ -111,7 +111,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
batch_size: Number of latents in the batch
Returns:
Tuple containing (latent dictionary with 'samples' tensor, width, height)
Tuple containing (latent dict, width, height, batch_size)
"""
try:
# Extract original preset name from formatted string if needed
@@ -160,7 +160,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
f"(pixel dims: {final_width}×{final_height})"
)
return (latent_dict, final_width, final_height)
return (latent_dict, final_width, final_height, batch_size)
except Exception as e:
# Handle any unexpected errors gracefully
@@ -86,4 +86,4 @@
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
},
"timestamp": 1754568195.1098156
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ class KikoFilmGrainNode(ComfyAssetsBaseNode):
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"max": 0xFFFFFFFF, # 2**32 - 1
"description": "Random seed for grain pattern generation",
},
),
@@ -0,0 +1,10 @@
"""
KikoWorkflow Timer - Display real-time execution timer for ComfyUI workflows.
Provides a visual timer that tracks workflow execution duration with
millisecond precision.
"""
from .node import KikoWorkflowTimerNode
__all__ = ["KikoWorkflowTimerNode"]
@@ -0,0 +1,52 @@
"""
KikoWorkflow Timer Node
A display-only node that shows real-time execution timing for ComfyUI workflows.
The timer is managed entirely on the frontend via WebSocket events.
"""
from ...base import ComfyAssetsBaseNode
class KikoWorkflowTimerNode(ComfyAssetsBaseNode):
"""
A UI node that displays a real-time timer for workflow execution.
The timer starts when execution begins and stops when the workflow
completes, showing the total elapsed time in MM:SS:mmm format.
This is a display-only node with no inputs or outputs - all timing
logic is handled by the JavaScript frontend via WebSocket events.
"""
DISPLAY_NAME = "Workflow Timer"
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ()
FUNCTION = "execute"
OUTPUT_NODE = True
def execute(self, **kwargs):
"""
Execute method - returns empty since this is a display-only node.
The actual timer functionality is handled entirely by the JavaScript
frontend which hooks into ComfyUI's WebSocket events.
Args:
**kwargs: Hidden parameters (prompt, unique_id)
Returns:
Empty dict - no outputs
"""
return {}
@@ -1,7 +1,12 @@
{
"last_path": "/home/vito/ai-apps/ComfyUI/output",
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids",
"saved_paths": [
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
"/home/vito/ai-apps/ComfyUI-3.12/output/"
"/home/vito/ai-apps/ComfyUI-3.12/output/",
"/home/vito/Downloads/vito",
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
"/home/vito/ai-apps/ComfyUI/output",
"/home/vito/Downloads",
"/home/vito/Downloads/images"
]
}
@@ -73,6 +73,7 @@ def scan_directory(
show_audio: bool = False,
sort_by: str = "name",
sort_order: str = "asc",
hide_dot_folders: bool = True,
) -> List[Dict[str, Any]]:
"""
Scan a directory for supported media files.
@@ -83,6 +84,7 @@ def scan_directory(
show_audio: Include audio files
sort_by: Sort criteria ('name', 'date', 'size')
sort_order: Sort order ('asc', 'desc')
hide_dot_folders: Hide folders starting with a dot
Returns:
List of file information dictionaries
@@ -94,6 +96,10 @@ def scan_directory(
items = []
for item in os.listdir(directory):
# Skip dot folders/files if hide_dot_folders is enabled
if hide_dot_folders and item.startswith("."):
continue
full_path = os.path.join(directory, item)
try:
@@ -139,6 +145,98 @@ def scan_directory(
return items
def search_files(
root_directory: str,
query: str,
show_videos: bool = False,
show_audio: bool = False,
max_results: int = 100,
) -> List[Dict[str, Any]]:
"""
Recursively search for files matching the query.
Args:
root_directory: Root directory to start search
query: Search query (case-insensitive filename match)
show_videos: Include video files
show_audio: Include audio files
max_results: Maximum number of results to return
Returns:
List of file information dictionaries
"""
if not os.path.isdir(root_directory):
raise NotADirectoryError(f"Not a directory: {root_directory}")
if not query or len(query.strip()) == 0:
return []
extensions = get_supported_extensions()
results = []
query_lower = query.lower().strip()
def search_recursive(directory: str) -> None:
"""Recursively search directory."""
if len(results) >= max_results:
return
try:
items = os.listdir(directory)
except (PermissionError, FileNotFoundError):
return
for item in items:
if len(results) >= max_results:
break
full_path = os.path.join(directory, item)
try:
# Check if item name matches query
if query_lower not in item.lower():
# If directory, search inside
if os.path.isdir(full_path):
search_recursive(full_path)
continue
stats = os.stat(full_path)
item_data = {
"path": full_path,
"name": item,
"directory": directory,
"mtime": stats.st_mtime,
"size": stats.st_size,
}
if os.path.isdir(full_path):
results.append({**item_data, "type": "dir"})
# Continue searching inside matching directories
search_recursive(full_path)
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:
results.append({**item_data, "type": item_type})
except (PermissionError, FileNotFoundError):
continue
search_recursive(root_directory)
# Sort by name
results.sort(key=lambda x: x["name"].lower())
return results
def create_empty_tensor() -> torch.Tensor:
"""Create an empty tensor for when no image is selected."""
return torch.zeros(1, 1, 1, 4)
+95 -23
View File
@@ -8,7 +8,6 @@ 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")
@@ -68,13 +67,9 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
RETURN_TYPES = (
"IMAGE",
"STRING",
"STRING",
"STRING",
)
RETURN_NAMES = (
"image",
"video_path",
"audio_path",
"info",
)
FUNCTION = "load_media"
@@ -87,7 +82,7 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
return os.path.getmtime(SELECTIONS_FILE)
return float("inf")
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str, str, str]:
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str]:
"""
Load selected media based on node's unique ID.
@@ -95,11 +90,9 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
unique_id: Unique identifier for this node instance
Returns:
Tuple of (image tensor, video path, audio path, info string)
Tuple of (image tensor, info string)
"""
image_tensor = create_empty_tensor()
video_path = ""
audio_path = ""
info_string = ""
selections = load_selections()
@@ -116,19 +109,7 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
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)
return (image_tensor, info_string)
# Setup API routes
@@ -194,6 +175,9 @@ try:
if not directory or not os.path.isdir(directory):
return web.json_response({"error": "Directory not found."}, status=404)
# Normalize path to remove trailing slashes and resolve relative paths
directory = os.path.normpath(directory)
# Save last path
config = load_config()
config["last_path"] = directory
@@ -201,6 +185,9 @@ try:
show_videos = request.query.get("show_videos", "false").lower() == "true"
show_audio = request.query.get("show_audio", "false").lower() == "true"
hide_dot_folders = (
request.query.get("hide_dot_folders", "true").lower() == "true"
)
page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", 50))
@@ -209,7 +196,12 @@ try:
try:
items = scan_directory(
directory, show_videos, show_audio, sort_by, sort_order
directory,
show_videos,
show_audio,
sort_by,
sort_order,
hide_dot_folders,
)
# Get parent directory
@@ -239,6 +231,86 @@ try:
"""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/list_directories")
async def list_directories(request):
"""API endpoint to list directories for autocomplete."""
path = request.query.get("path", "")
try:
# Handle empty path - show root or common starting points
if not path:
# Return filesystem root
if os.name == "nt": # Windows
import string
drives = [
f"{d}:\\"
for d in string.ascii_uppercase
if os.path.exists(f"{d}:\\")
]
return web.json_response({"directories": drives})
else: # Unix/Linux/Mac
return web.json_response({"directories": ["/"]})
# Normalize the path
path = os.path.expanduser(path) # Handle ~ for home directory
# If path ends with separator, list contents of that directory
if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")):
if os.path.isdir(path):
try:
entries = os.listdir(path)
dirs = []
for entry in entries:
full_path = os.path.join(path, entry)
if os.path.isdir(full_path):
dirs.append(full_path)
dirs.sort(key=lambda x: x.lower())
return web.json_response(
{"directories": dirs[:50]}
) # Limit results
except PermissionError:
return web.json_response(
{"directories": [], "error": "Permission denied"}
)
else:
return web.json_response({"directories": []})
# Otherwise, find matching directories in parent
parent_dir = os.path.dirname(path)
basename = os.path.basename(path).lower()
if not parent_dir:
# Handle root level on Unix
if path.startswith("/"):
parent_dir = "/"
basename = path[1:].lower()
else:
return web.json_response({"directories": []})
if os.path.isdir(parent_dir):
try:
entries = os.listdir(parent_dir)
dirs = []
for entry in entries:
full_path = os.path.join(parent_dir, entry)
if os.path.isdir(full_path) and entry.lower().startswith(
basename
):
dirs.append(full_path)
dirs.sort(key=lambda x: x.lower())
return web.json_response(
{"directories": dirs[:50]}
) # Limit results
except PermissionError:
return web.json_response(
{"directories": [], "error": "Permission denied"}
)
return web.json_response({"directories": []})
except Exception as e:
return web.json_response({"directories": [], "error": str(e)})
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
async def get_thumbnail(request):
"""API endpoint to get image thumbnail."""
@@ -11,12 +11,77 @@
},
"18": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
},
"445": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
}
},
"23": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
}
},
"69": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"52": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
}
},
"38": {
"image": {
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
}
},
"170": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
}
},
"214": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
}
},
"299": {
"image": {
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
}
},
"527": {
"image": {
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
}
},
"522": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"517": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
},
"144": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
}
},
"569": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00008.png"
}
},
"136": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
}
}
+7 -7
View File
@@ -10,14 +10,14 @@ def generate_random_seed() -> int:
Generate a cryptographically strong random seed value.
Returns:
Random integer in the valid ComfyUI seed range
Random integer in the valid ComfyUI seed range (0 to 2**32 - 1)
"""
return random.randint(0, 0xFFFFFFFFFFFFFFFF)
return random.randint(0, 0xFFFFFFFF) # 2**32 - 1
def validate_seed_value(seed: Any) -> bool:
"""
Validate that a seed value is within acceptable range.
Validate that a seed value is within acceptable range (0 to 2**32 - 1).
Args:
seed: Seed value to validate
@@ -30,7 +30,7 @@ def validate_seed_value(seed: Any) -> bool:
try:
seed_int = int(seed)
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF
return 0 <= seed_int <= 0xFFFFFFFF # 2**32 - 1
except (ValueError, TypeError):
return False
@@ -54,11 +54,11 @@ def sanitize_seed_value(seed: Any) -> int:
try:
seed_int = int(seed)
# Clamp to valid range
# Clamp to valid range (0 to 2**32 - 1)
if seed_int < 0:
seed_int = 0
elif seed_int > 0xFFFFFFFFFFFFFFFF:
seed_int = 0xFFFFFFFFFFFFFFFF
elif seed_int > 0xFFFFFFFF:
seed_int = 0xFFFFFFFF
return seed_int
+7 -60
View File
@@ -27,26 +27,10 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
{
"default": 12345,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"max": 0xFFFFFFFF, # 2**32 - 1
"control_after_generate": True,
"tooltip": "Seed value for generation processes. "
"Auto-increments/decrements after each run based on mode.",
},
),
},
"optional": {
"mode": (
[
"",
"fixed",
"increment",
"decrement",
"randomize",
], # Added empty string for legacy workflows
{
"default": "fixed",
"tooltip": "Seed behavior after generation: "
"fixed (no change), increment (+1), decrement (-1), or randomize (new random)",
"Use 'control after generate' to set behavior after each run.",
},
),
},
@@ -57,51 +41,20 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
FUNCTION = "output_seed"
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
@classmethod
def VALIDATE_INPUTS(cls, seed, mode="fixed"):
"""Validate inputs and handle legacy workflows."""
# Handle empty or missing mode from old workflows (legacy support)
if mode is None or mode == "" or mode == "undefined":
return True # Will use default "fixed" in output_seed
# Validate mode is in allowed list
valid_modes = ["fixed", "increment", "decrement", "randomize"]
if mode not in valid_modes:
return f"Invalid mode: {mode}. Must be one of {valid_modes}"
return True
def output_seed(self, seed: int, mode: str = "fixed") -> Tuple[int]:
def output_seed(self, seed: int, **kwargs) -> Tuple[int]:
"""
Output the seed value for use in other nodes.
Args:
seed: Input seed value
mode: Seed mode (fixed, increment, decrement, randomize) - not used in output,
but controls the widget behavior via control_after_generate
**kwargs: Accepts legacy parameters (e.g. mode) for backward compatibility
Returns:
Tuple containing the seed value
"""
try:
# Handle empty mode from old workflows
if not mode or mode == "":
mode = "fixed"
# Validate mode is in allowed list
valid_modes = ["fixed", "increment", "decrement", "randomize"]
if mode not in valid_modes:
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"{self.__class__.__name__}: Invalid mode '{mode}'. Using 'fixed'."
)
mode = "fixed"
# Validate and sanitize the seed
if not validate_seed_value(seed):
# Log the validation error but don't raise
import logging
logger = logging.getLogger(__name__)
@@ -112,15 +65,9 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
return (12345,)
clean_seed = sanitize_seed_value(seed)
# Note: The mode parameter controls the widget's control_after_generate behavior
# The actual increment/decrement/randomize happens automatically in the UI
# based on the control_after_generate setting and the mode dropdown value
return (clean_seed,)
except Exception as e:
# Handle any unexpected errors gracefully
import logging
logger = logging.getLogger(__name__)
@@ -194,7 +141,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
Returns:
Range information string
"""
max_seed = 0xFFFFFFFFFFFFFFFF
max_seed = 0xFFFFFFFF # 2**32 - 1
return f"Valid range: 0 to {max_seed:,} ({hex(max_seed)})"
@classmethod
@@ -218,7 +165,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
Returns:
True if seed is in valid range
"""
return 0 <= seed <= 0xFFFFFFFFFFFFFFFF
return 0 <= seed <= 0xFFFFFFFF # 2**32 - 1
def __str__(self) -> str:
"""String representation of the node."""
@@ -230,6 +177,6 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
f"SeedHistoryNode("
f"category='{self.CATEGORY}', "
f"function='{self.FUNCTION}', "
f"max_seed={hex(0xFFFFFFFFFFFFFFFF)}"
f"max_seed={hex(0xFFFFFFFF)}" # 2**32 - 1
f")"
)
@@ -98,6 +98,16 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
"Portrait",
"SDXL near-square portrait - subtle portrait",
),
"720×1280": PresetMetadata(
720,
1280,
"9:16",
0.5625,
0.92,
"SDXL",
"Portrait",
"SDXL portrait 9:16 - vertical video/mobile",
),
# SDXL Presets - Landscape
"1024×960": PresetMetadata(
1024,
@@ -159,6 +169,16 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
"Landscape",
"SDXL landscape 3:1 - extreme wide panoramic",
),
"1280×720": PresetMetadata(
1280,
720,
"16:9",
1.778,
0.92,
"SDXL",
"Landscape",
"SDXL landscape 16:9 - HD widescreen video",
),
# FLUX Presets - High Quality
"1920×1080": PresetMetadata(
1920,
@@ -0,0 +1,8 @@
"""
Width Height to VEC2 converter node
Converts width and height inputs to VEC2 tuple for jovi_glsl and similar nodes
"""
from .node import WidthHeightToVec2Node
__all__ = ["WidthHeightToVec2Node"]
@@ -0,0 +1,128 @@
"""
Width Height to VEC2 Node
Converts width and height inputs to a VEC2 tuple for use with
nodes like jovi_glsl that expect vector inputs.
"""
from typing import Any, Dict, Tuple, Union
from ...base import ComfyAssetsBaseNode
class WidthHeightToVec2Node(ComfyAssetsBaseNode):
"""
Convert width and height values to VEC2 format.
Accepts INT, FLOAT, STRING, or ANY types and outputs a VEC2 tuple
suitable for nodes expecting vector inputs.
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""Define ComfyUI input interface."""
return {
"required": {
"width": (
"INT",
{
"default": 1024,
"min": 1,
"max": 8192,
"step": 1,
"tooltip": "Width value (x component of VEC2)",
},
),
"height": (
"INT",
{
"default": 1024,
"min": 1,
"max": 8192,
"step": 1,
"tooltip": "Height value (y component of VEC2)",
},
),
},
}
RETURN_TYPES = ("VEC2",)
RETURN_NAMES = ("vec2",)
FUNCTION = "convert_to_vec2"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
def convert_to_vec2(
self,
width: Union[int, float, str, Any],
height: Union[int, float, str, Any],
) -> Tuple[Tuple[int, int]]:
"""
Convert width and height to VEC2 tuple.
Args:
width: Width value (will be converted to int)
height: Height value (will be converted to int)
Returns:
Tuple containing the VEC2 tuple (width, height)
"""
try:
# Convert to integers, handling various input types
w = self._to_int(width, "width")
h = self._to_int(height, "height")
# Clamp values to valid range
w = max(1, min(8192, w))
h = max(1, min(8192, h))
self.log_info(f"Converted to VEC2: ({w}, {h})")
# Return as tuple wrapped in tuple (ComfyUI return format)
return ((w, h),)
except Exception as e:
error_msg = f"Failed to convert to VEC2: {str(e)}"
self.handle_error(error_msg, e)
def _to_int(self, value: Any, name: str) -> int:
"""
Convert a value to integer.
Args:
value: Value to convert (int, float, str, or any)
name: Parameter name for error messages
Returns:
Integer value
Raises:
ValueError: If conversion fails
"""
if isinstance(value, int):
return value
elif isinstance(value, float):
return int(value)
elif isinstance(value, str):
try:
# Try parsing as float first (handles "1024.0")
return int(float(value.strip()))
except ValueError:
raise ValueError(f"Cannot convert {name} string '{value}' to integer")
else:
# Try generic conversion for ANY type
try:
return int(value)
except (ValueError, TypeError):
raise ValueError(
f"Cannot convert {name} of type {type(value).__name__} to integer"
)
# Node registration
NODE_CLASS_MAPPINGS = {
"WidthHeightToVec2": WidthHeightToVec2Node,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WidthHeightToVec2": "Width Height to VEC2",
}
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.24"
version = "1.0.28"
license = {text = "MIT"}
dependencies = []
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for ComfyUI-KikoTools
# Gemini API integration (optional - only needed for Gemini Prompt node)
google-generativeai>=0.3.0
google-generativeai
@@ -0,0 +1,262 @@
"""Tests for Batch/List conversion nodes and logic."""
import pytest
import torch
from kikotools.tools.batch_list_converter.logic import (
split_image_batch,
join_image_batch,
split_latent_batch,
join_latent_batch,
)
from kikotools.tools.batch_list_converter.node import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
class TestBatchListConverterLogic:
"""Test pure split/join functions."""
# -- Image split --
def test_split_image_batch_single(self):
"""Single image batch returns list of one."""
images = torch.rand(1, 64, 64, 3)
result = split_image_batch(images)
assert len(result) == 1
assert result[0].shape == (1, 64, 64, 3)
assert torch.equal(result[0], images)
def test_split_image_batch_multiple(self):
"""Multi-image batch splits correctly."""
images = torch.rand(4, 64, 64, 3)
result = split_image_batch(images)
assert len(result) == 4
for i, img in enumerate(result):
assert img.shape == (1, 64, 64, 3)
assert torch.equal(img, images[i : i + 1])
def test_split_image_preserves_batch_dim(self):
"""Each split image keeps 4D shape [1,H,W,C]."""
images = torch.rand(3, 128, 256, 3)
result = split_image_batch(images)
for img in result:
assert img.ndim == 4
assert img.shape[0] == 1
# -- Image join --
def test_join_image_batch_single(self):
"""Join single image produces batch of 1."""
image_list = [torch.rand(1, 64, 64, 3)]
result = join_image_batch(image_list)
assert result.shape == (1, 64, 64, 3)
def test_join_image_batch_multiple(self):
"""Join multiple images into batch."""
image_list = [torch.rand(1, 64, 64, 3) for _ in range(5)]
result = join_image_batch(image_list)
assert result.shape == (5, 64, 64, 3)
def test_image_roundtrip(self):
"""split -> join produces identical tensor."""
original = torch.rand(4, 64, 64, 3)
reconstructed = join_image_batch(split_image_batch(original))
assert torch.equal(original, reconstructed)
# -- Latent split --
def test_split_latent_batch_single(self):
"""Single latent returns list of one dict."""
latent = {"samples": torch.rand(1, 4, 32, 32)}
result = split_latent_batch(latent)
assert len(result) == 1
assert "samples" in result[0]
assert result[0]["samples"].shape == (1, 4, 32, 32)
def test_split_latent_batch_multiple(self):
"""Multi-item latent splits correctly."""
latent = {"samples": torch.rand(3, 4, 32, 32)}
result = split_latent_batch(latent)
assert len(result) == 3
for i, lat in enumerate(result):
assert lat["samples"].shape == (1, 4, 32, 32)
assert torch.equal(lat["samples"], latent["samples"][i : i + 1])
# -- Latent join --
def test_join_latent_batch_single(self):
"""Join single latent dict."""
latent_list = [{"samples": torch.rand(1, 4, 32, 32)}]
result = join_latent_batch(latent_list)
assert "samples" in result
assert result["samples"].shape == (1, 4, 32, 32)
def test_join_latent_batch_multiple(self):
"""Join multiple latent dicts into batch."""
latent_list = [{"samples": torch.rand(1, 4, 32, 32)} for _ in range(4)]
result = join_latent_batch(latent_list)
assert result["samples"].shape == (4, 4, 32, 32)
def test_latent_roundtrip(self):
"""split -> join produces identical tensor."""
original = {"samples": torch.rand(5, 4, 64, 64)}
reconstructed = join_latent_batch(split_latent_batch(original))
assert torch.equal(original["samples"], reconstructed["samples"])
def test_split_latent_preserves_noise_mask(self):
"""noise_mask is sliced alongside samples."""
latent = {
"samples": torch.rand(3, 4, 32, 32),
"noise_mask": torch.rand(3, 1, 32, 32),
}
result = split_latent_batch(latent)
assert len(result) == 3
for i, item in enumerate(result):
assert "noise_mask" in item
assert item["noise_mask"].shape == (1, 1, 32, 32)
assert torch.equal(item["noise_mask"], latent["noise_mask"][i : i + 1])
def test_join_latent_preserves_noise_mask(self):
"""noise_mask is concatenated alongside samples."""
latent_list = [
{
"samples": torch.rand(1, 4, 32, 32),
"noise_mask": torch.rand(1, 1, 32, 32),
}
for _ in range(3)
]
result = join_latent_batch(latent_list)
assert "noise_mask" in result
assert result["noise_mask"].shape == (3, 1, 32, 32)
def test_latent_roundtrip_with_extra_keys(self):
"""Round-trip preserves all tensor keys."""
original = {
"samples": torch.rand(4, 4, 64, 64),
"noise_mask": torch.rand(4, 1, 64, 64),
}
reconstructed = join_latent_batch(split_latent_batch(original))
assert torch.equal(original["samples"], reconstructed["samples"])
assert torch.equal(original["noise_mask"], reconstructed["noise_mask"])
def test_split_latent_copies_non_tensor_values(self):
"""Non-tensor metadata is copied to each item."""
latent = {
"samples": torch.rand(2, 4, 32, 32),
"some_flag": "preserve_me",
}
result = split_latent_batch(latent)
for item in result:
assert item["some_flag"] == "preserve_me"
class TestBatchListConverterNodes:
"""Test ComfyUI node classes."""
# -- ImageBatchToImageList --
def test_image_b2l_attributes(self):
assert ImageBatchToImageListNode.RETURN_TYPES == ("IMAGE", "INT")
assert ImageBatchToImageListNode.RETURN_NAMES == ("images", "count")
assert ImageBatchToImageListNode.OUTPUT_IS_LIST == (True, False)
assert ImageBatchToImageListNode.FUNCTION == "split_batch"
assert ImageBatchToImageListNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
def test_image_b2l_input_types(self):
inputs = ImageBatchToImageListNode.INPUT_TYPES()
assert "required" in inputs
assert "images" in inputs["required"]
assert inputs["required"]["images"] == ("IMAGE",)
def test_image_b2l_execute(self):
node = ImageBatchToImageListNode()
images = torch.rand(3, 64, 64, 3)
result = node.split_batch(images)
image_list, count = result
assert isinstance(image_list, list)
assert len(image_list) == 3
assert count == 3
# -- ImageListToImageBatch --
def test_image_l2b_attributes(self):
assert ImageListToImageBatchNode.INPUT_IS_LIST is True
assert ImageListToImageBatchNode.RETURN_TYPES == ("IMAGE", "INT")
assert ImageListToImageBatchNode.RETURN_NAMES == ("images", "count")
assert ImageListToImageBatchNode.FUNCTION == "join_batch"
def test_image_l2b_execute(self):
node = ImageListToImageBatchNode()
image_list = [torch.rand(1, 64, 64, 3) for _ in range(4)]
batch, count = node.join_batch(image_list)
assert batch.shape == (4, 64, 64, 3)
assert count == 4
# -- LatentBatchToLatentList --
def test_latent_b2l_attributes(self):
assert LatentBatchToLatentListNode.RETURN_TYPES == ("LATENT", "INT")
assert LatentBatchToLatentListNode.RETURN_NAMES == ("latents", "count")
assert LatentBatchToLatentListNode.OUTPUT_IS_LIST == (True, False)
assert LatentBatchToLatentListNode.FUNCTION == "split_batch"
def test_latent_b2l_execute(self):
node = LatentBatchToLatentListNode()
latent = {"samples": torch.rand(2, 4, 32, 32)}
latent_list, count = node.split_batch(latent)
assert isinstance(latent_list, list)
assert len(latent_list) == 2
assert count == 2
# -- LatentListToLatentBatch --
def test_latent_l2b_attributes(self):
assert LatentListToLatentBatchNode.INPUT_IS_LIST is True
assert LatentListToLatentBatchNode.RETURN_TYPES == ("LATENT", "INT")
assert LatentListToLatentBatchNode.RETURN_NAMES == ("latent", "count")
assert LatentListToLatentBatchNode.FUNCTION == "join_batch"
def test_latent_l2b_execute(self):
node = LatentListToLatentBatchNode()
latent_list = [{"samples": torch.rand(1, 4, 32, 32)} for _ in range(3)]
batch, count = node.join_batch(latent_list)
assert "samples" in batch
assert batch["samples"].shape == (3, 4, 32, 32)
assert count == 3
# -- Inheritance --
def test_all_nodes_inherit_base(self):
from kikotools.base.base_node import ComfyAssetsBaseNode
for cls in (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
):
assert issubclass(cls, ComfyAssetsBaseNode)
# -- Registration mappings --
def test_node_class_mappings(self):
from kikotools.tools.batch_list_converter.node import NODE_CLASS_MAPPINGS
assert len(NODE_CLASS_MAPPINGS) == 4
assert "ImageBatchToImageList" in NODE_CLASS_MAPPINGS
assert "ImageListToImageBatch" in NODE_CLASS_MAPPINGS
assert "LatentBatchToLatentList" in NODE_CLASS_MAPPINGS
assert "LatentListToLatentBatch" in NODE_CLASS_MAPPINGS
def test_node_display_name_mappings(self):
from kikotools.tools.batch_list_converter.node import NODE_DISPLAY_NAME_MAPPINGS
assert len(NODE_DISPLAY_NAME_MAPPINGS) == 4
assert (
NODE_DISPLAY_NAME_MAPPINGS["ImageBatchToImageList"]
== "Image Batch to Image List"
)
+16 -8
View File
@@ -131,8 +131,13 @@ class TestEmptyLatentBatchNode:
def test_node_attributes(self):
"""Test node class attributes."""
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT")
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent", "width", "height")
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT", "INT")
assert EmptyLatentBatchNode.RETURN_NAMES == (
"latent",
"width",
"height",
"batch_size",
)
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
assert EmptyLatentBatchNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
@@ -141,13 +146,14 @@ class TestEmptyLatentBatchNode:
result = self.node.create_empty_latent("custom", 512, 512, 1)
assert isinstance(result, tuple)
assert len(result) == 3 # Now returns (latent, width, height)
assert len(result) == 4 # Returns (latent, width, height, batch_size)
latent_dict, width, height = result
latent_dict, width, height, batch_size = result
assert isinstance(latent_dict, dict)
assert "samples" in latent_dict
assert width == 512
assert height == 512
assert batch_size == 1
samples = latent_dict["samples"]
assert isinstance(samples, torch.Tensor)
@@ -155,12 +161,13 @@ class TestEmptyLatentBatchNode:
def test_create_empty_latent_with_batch(self):
"""Test empty latent creation with batch size."""
batch_size = 3
result = self.node.create_empty_latent("custom", 1024, 768, batch_size)
input_batch_size = 3
result = self.node.create_empty_latent("custom", 1024, 768, input_batch_size)
latent_dict, width, height = result
latent_dict, width, height, batch_size = result
assert width == 1024
assert height == 768
assert batch_size == 3
samples = latent_dict["samples"]
assert samples.shape == (3, 4, 96, 128) # batch=3, 768/8=96, 1024/8=128
@@ -169,10 +176,11 @@ class TestEmptyLatentBatchNode:
# Input dimensions not divisible by 8
result = self.node.create_empty_latent("custom", 513, 515, 1)
latent_dict, width, height = result
latent_dict, width, height, batch_size = result
# Dimensions should be rounded UP to nearest multiple of 8
assert width == 520 # 513 -> 520
assert height == 520 # 515 -> 520
assert batch_size == 1
samples = latent_dict["samples"]
# Should be adjusted to 520x520 -> 65x65 latent
assert samples.shape == (1, 4, 65, 65)
@@ -0,0 +1,110 @@
"""
Unit tests for KikoWorkflowTimerNode.
Since this is a display-only node with all logic handled by JavaScript,
these tests focus on validating the node's structure and ComfyUI integration.
"""
class TestKikoWorkflowTimerNode:
"""Test suite for KikoWorkflowTimerNode."""
def test_node_import(self):
"""Test that the node can be imported successfully."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
assert KikoWorkflowTimerNode is not None
def test_node_has_required_attributes(self):
"""Test that the node has all required ComfyUI attributes."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
# Check required ComfyUI attributes
assert hasattr(KikoWorkflowTimerNode, "INPUT_TYPES")
assert hasattr(KikoWorkflowTimerNode, "RETURN_TYPES")
assert hasattr(KikoWorkflowTimerNode, "FUNCTION")
assert hasattr(KikoWorkflowTimerNode, "CATEGORY")
def test_node_input_types(self):
"""Test that INPUT_TYPES is correctly defined."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
input_types = KikoWorkflowTimerNode.INPUT_TYPES()
# Should have required dict (empty)
assert "required" in input_types
assert input_types["required"] == {}
# Should have hidden inputs for prompt and unique_id
assert "hidden" in input_types
assert "prompt" in input_types["hidden"]
assert "unique_id" in input_types["hidden"]
assert input_types["hidden"]["prompt"] == "PROMPT"
assert input_types["hidden"]["unique_id"] == "UNIQUE_ID"
def test_node_return_types(self):
"""Test that the node has empty return types (display-only)."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
assert KikoWorkflowTimerNode.RETURN_TYPES == ()
def test_node_is_output_node(self):
"""Test that OUTPUT_NODE is set to True."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
assert KikoWorkflowTimerNode.OUTPUT_NODE is True
def test_node_category(self):
"""Test that the node has correct category."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
assert "ComfyAssets" in KikoWorkflowTimerNode.CATEGORY
def test_node_display_name(self):
"""Test that the node has a display name."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
assert hasattr(KikoWorkflowTimerNode, "DISPLAY_NAME")
assert KikoWorkflowTimerNode.DISPLAY_NAME == "Workflow Timer"
def test_node_execute_returns_empty(self):
"""Test that execute() returns empty dict."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
node = KikoWorkflowTimerNode()
result = node.execute()
assert result == {}
def test_node_execute_with_kwargs(self):
"""Test that execute() handles kwargs correctly."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
node = KikoWorkflowTimerNode()
result = node.execute(prompt={}, unique_id="test-123")
assert result == {}
def test_node_inherits_from_base(self):
"""Test that node inherits from ComfyAssetsBaseNode."""
from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode
from kikotools.base import ComfyAssetsBaseNode
assert issubclass(KikoWorkflowTimerNode, ComfyAssetsBaseNode)
class TestKikoWorkflowTimerModuleInit:
"""Test the module's __init__.py exports."""
def test_module_exports_node(self):
"""Test that the module exports the node class."""
from kikotools.tools.kiko_workflow_timer import KikoWorkflowTimerNode
assert KikoWorkflowTimerNode is not None
def test_module_all_exports(self):
"""Test that __all__ is correctly defined."""
from kikotools.tools import kiko_workflow_timer
assert hasattr(kiko_workflow_timer, "__all__")
assert "KikoWorkflowTimerNode" in kiko_workflow_timer.__all__
+1 -1
View File
@@ -178,7 +178,7 @@ class TestSamplerComboNode:
def test_return_types_structure(self):
"""Test that return types are correctly defined."""
assert SamplerComboNode.RETURN_TYPES == ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_TYPES == (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
assert SamplerComboNode.RETURN_NAMES == (
"sampler_name",
"scheduler",
+12 -12
View File
@@ -43,7 +43,7 @@ class TestSeedHistoryNode:
assert "min" in seed_config[1]
assert "max" in seed_config[1]
assert seed_config[1]["min"] == 0
assert seed_config[1]["max"] == 0xFFFFFFFFFFFFFFFF
assert seed_config[1]["max"] == 0xFFFFFFFF # 2**32 - 1
# Test return types
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
@@ -56,7 +56,7 @@ class TestSeedHistoryNode:
node = SeedHistoryNode()
# Test various valid seeds
test_seeds = [0, 12345, 999999, 0xFFFFFFFFFFFFFFFF]
test_seeds = [0, 12345, 999999, 0xFFFFFFFF] # 2**32 - 1
for seed in test_seeds:
result = node.output_seed(seed)
@@ -73,7 +73,7 @@ class TestSeedHistoryNode:
assert result == (12345,) # Fallback
# Test seeds too large
result = node.output_seed(0xFFFFFFFFFFFFFFFF + 1)
result = node.output_seed(0xFFFFFFFF + 1) # 2**32
assert result == (12345,) # Fallback
def test_generate_new_seed(self):
@@ -100,11 +100,11 @@ class TestSeedHistoryNode:
# Valid seeds
assert node.validate_seed_input(0)
assert node.validate_seed_input(12345)
assert node.validate_seed_input(0xFFFFFFFFFFFFFFFF)
assert node.validate_seed_input(0xFFFFFFFF) # 2**32 - 1
# Invalid seeds
assert not node.validate_seed_input(-1)
assert not node.validate_seed_input(0xFFFFFFFFFFFFFFFF + 1)
assert not node.validate_seed_input(0xFFFFFFFF + 1) # 2**32
assert not node.validate_seed_input(None)
def test_get_seed_info(self):
@@ -132,7 +132,7 @@ class TestSeedHistoryNode:
range_info = node.get_seed_range_info()
assert "Valid range" in range_info
# Check for the hex representation which should be in the string
assert "0xffffffffffffffff" in range_info.lower()
assert "0xffffffff" in range_info.lower() # 2**32 - 1
def test_class_methods(self):
"""Test class methods."""
@@ -143,9 +143,9 @@ class TestSeedHistoryNode:
# Test range checking
assert SeedHistoryNode.is_seed_in_range(0)
assert SeedHistoryNode.is_seed_in_range(12345)
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF)
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFF) # 2**32 - 1
assert not SeedHistoryNode.is_seed_in_range(-1)
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF + 1)
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFF + 1) # 2**32
class TestSeedHistoryLogic:
@@ -168,11 +168,11 @@ class TestSeedHistoryLogic:
# Valid seeds
assert validate_seed_value(0)
assert validate_seed_value(12345)
assert validate_seed_value(0xFFFFFFFFFFFFFFFF)
assert validate_seed_value(0xFFFFFFFF) # 2**32 - 1
# Invalid seeds
assert not validate_seed_value(-1)
assert not validate_seed_value(0xFFFFFFFFFFFFFFFF + 1)
assert not validate_seed_value(0xFFFFFFFF + 1) # 2**32
assert not validate_seed_value(None)
assert not validate_seed_value("invalid")
assert not validate_seed_value([])
@@ -182,7 +182,7 @@ class TestSeedHistoryLogic:
# Valid seeds should pass through
assert sanitize_seed_value(12345) == 12345
assert sanitize_seed_value(0) == 0
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF) == 0xFFFFFFFFFFFFFFFF
assert sanitize_seed_value(0xFFFFFFFF) == 0xFFFFFFFF # 2**32 - 1
# String numbers should convert
assert sanitize_seed_value("12345") == 12345
@@ -190,7 +190,7 @@ class TestSeedHistoryLogic:
# Out of range should clamp
assert sanitize_seed_value(-100) == 0
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF + 100) == 0xFFFFFFFFFFFFFFFF
assert sanitize_seed_value(0xFFFFFFFF + 100) == 0xFFFFFFFF # clamp to 2**32 - 1
# Invalid should raise
try:
@@ -0,0 +1,81 @@
"""Unit tests for Width Height to VEC2 node."""
import pytest
from kikotools.tools.width_height_to_vec2 import WidthHeightToVec2Node
class TestWidthHeightToVec2Node:
"""Test cases for WidthHeightToVec2Node."""
def setup_method(self):
"""Set up test fixtures."""
self.node = WidthHeightToVec2Node()
def test_basic_int_conversion(self):
"""Test basic integer inputs."""
result = self.node.convert_to_vec2(512, 768)
assert result == ((512, 768),)
def test_float_conversion(self):
"""Test float inputs are converted to int."""
result = self.node.convert_to_vec2(512.7, 768.3)
assert result == ((512, 768),)
def test_string_conversion(self):
"""Test string inputs are parsed correctly."""
result = self.node.convert_to_vec2("1024", "768")
assert result == ((1024, 768),)
def test_string_with_decimal(self):
"""Test string with decimal point."""
result = self.node.convert_to_vec2("1024.5", "768.0")
assert result == ((1024, 768),)
def test_clamp_max_values(self):
"""Test values are clamped to maximum."""
result = self.node.convert_to_vec2(10000, 9999)
assert result == ((8192, 8192),)
def test_clamp_min_values(self):
"""Test values are clamped to minimum."""
result = self.node.convert_to_vec2(0, -5)
assert result == ((1, 1),)
def test_return_type_is_tuple_of_tuple(self):
"""Test return type is correct for ComfyUI."""
result = self.node.convert_to_vec2(512, 512)
assert isinstance(result, tuple)
assert len(result) == 1
assert isinstance(result[0], tuple)
assert len(result[0]) == 2
def test_input_types_defined(self):
"""Test INPUT_TYPES is properly defined."""
input_types = WidthHeightToVec2Node.INPUT_TYPES()
assert "required" in input_types
assert "width" in input_types["required"]
assert "height" in input_types["required"]
def test_return_types_defined(self):
"""Test RETURN_TYPES is properly defined."""
assert WidthHeightToVec2Node.RETURN_TYPES == ("VEC2",)
assert WidthHeightToVec2Node.RETURN_NAMES == ("vec2",)
def test_category_set(self):
"""Test node category is set."""
assert "ComfyAssets" in WidthHeightToVec2Node.CATEGORY
class TestWidthHeightToVec2Errors:
"""Test error handling for WidthHeightToVec2Node."""
def setup_method(self):
"""Set up test fixtures."""
self.node = WidthHeightToVec2Node()
def test_invalid_string_raises_error(self):
"""Test invalid string input raises error."""
with pytest.raises(ValueError) as excinfo:
self.node.convert_to_vec2("not_a_number", 512)
assert "Cannot convert width" in str(excinfo.value)
+10 -10
View File
@@ -1,5 +1,5 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.DisplayAny",
@@ -25,11 +25,11 @@ app.registerExtension({
const lines = text ? text.split('\n') : [''];
const maxLinesPerWidget = 20;
const chunks = [];
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
}
// Create a widget for each chunk
chunks.forEach((chunk, index) => {
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
@@ -38,7 +38,7 @@ app.registerExtension({
w.inputEl.style.fontFamily = "monospace";
w.value = chunk;
});
// Add copy button widget
const copyWidget = {
type: "button",
@@ -76,9 +76,9 @@ app.registerExtension({
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
console.log("DisplayAny onExecuted message:", message);
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
console.log("DisplayAny displayText:", displayText);
@@ -113,14 +113,14 @@ app.registerExtension({
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size - make it wider for better JSON display
this.size[0] = Math.max(this.size[0], 450);
this.size[1] = Math.max(this.size[1], 250);
// Add placeholder text
populate.call(this, "Value will appear here...");
};
}
}
});
});
+16 -16
View File
@@ -1,5 +1,5 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.DisplayText",
@@ -24,23 +24,23 @@ app.registerExtension({
// Parse the text to detect positive/negative prompt format
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract prompt content
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
// Create header widget for positive prompt
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
posHeader.inputEl.readOnly = true;
@@ -49,13 +49,13 @@ app.registerExtension({
posHeader.inputEl.style.color = "#8f8";
posHeader.inputEl.style.fontWeight = "bold";
posHeader.value = "✓ Positive Prompt";
// Create positive prompt widget
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
posWidget.inputEl.readOnly = true;
posWidget.inputEl.style.opacity = 0.9;
posWidget.value = positiveText;
// Create copy button widget for positive prompt
const posCopyWidget = {
type: "button",
@@ -75,7 +75,7 @@ app.registerExtension({
}
};
this.addCustomWidget(posCopyWidget);
// Create header widget for negative prompt
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
negHeader.inputEl.readOnly = true;
@@ -84,13 +84,13 @@ app.registerExtension({
negHeader.inputEl.style.color = "#f88";
negHeader.inputEl.style.fontWeight = "bold";
negHeader.value = "✗ Negative Prompt";
// Create negative prompt widget
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
negWidget.inputEl.readOnly = true;
negWidget.inputEl.style.opacity = 0.9;
negWidget.value = negativeText;
// Create copy button widget for negative prompt
const negCopyWidget = {
type: "button",
@@ -110,14 +110,14 @@ app.registerExtension({
}
};
this.addCustomWidget(negCopyWidget);
} else {
// Single text display with ComfyUI's standard STRING widget
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.9;
w.value = text;
// Create copy button widget
const copyWidget = {
type: "button",
@@ -186,14 +186,14 @@ app.registerExtension({
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
populate.call(this, "Text will appear here after execution...");
};
}
}
});
});
+1 -1
View File
@@ -269,7 +269,7 @@ app.registerExtension({
// Add as DOM widget
this.swapButtonWidget = this.addDOMWidget(
"swap_button",
"div",
"div",
buttonContainer
);
};
+36 -36
View File
@@ -1,43 +1,43 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "ComfyAssets.GeminiPrompt",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeminiPrompt") {
// Add visual enhancements to the node
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
// Store reference to widgets
this.promptTypeWidget = this.widgets.find(w => w.name === "prompt_type");
this.modelWidget = this.widgets.find(w => w.name === "model");
this.apiKeyWidget = this.widgets.find(w => w.name === "api_key");
this.customPromptWidget = this.widgets.find(w => w.name === "custom_prompt");
// Add helper text button
const helpButton = this.addWidget("button", "Help / API Setup", null, () => {
this.showHelpDialog();
});
// Style the button
helpButton.serialize = false;
// Add refresh models button
const refreshButton = this.addWidget("button", "Refresh Model List", null, () => {
this.refreshModelList();
});
refreshButton.serialize = false;
// Add status indicator
this.status = this.addWidget("text", "status", "Ready", () => {}, {
serialize: false
});
this.status.disabled = true;
// Update custom prompt visibility based on selection
if (this.promptTypeWidget && this.customPromptWidget) {
const originalCallback = this.promptTypeWidget.callback;
@@ -46,19 +46,19 @@ app.registerExtension({
this.updateCustomPromptVisibility();
};
}
return result;
};
// Add method to show help dialog
nodeType.prototype.showHelpDialog = function() {
const helpContent = `
<div style="padding: 20px; max-width: 600px;">
<h2>Gemini Prompt Engineer Setup</h2>
<h3>1. Get API Key</h3>
<p>Get your free API key from: <a href="https://makersuite.google.com/app/apikey" target="_blank">Google AI Studio</a></p>
<h3>2. Set API Key</h3>
<p>Choose one of these methods:</p>
<ul>
@@ -66,10 +66,10 @@ app.registerExtension({
<li><strong>Config File:</strong> Create gemini_config.json in ComfyUI root with {"api_key": "your-key"}</li>
<li><strong>Node Input:</strong> Enter directly in the api_key field</li>
</ul>
<h3>3. Install Dependencies</h3>
<code>pip install google-generativeai</code>
<h3>Prompt Types</h3>
<ul>
<li><strong>FLUX:</strong> Detailed artistic prompts with quality markers</li>
@@ -77,7 +77,7 @@ app.registerExtension({
<li><strong>Danbooru:</strong> Anime-style booru tags</li>
<li><strong>Video:</strong> Motion and temporal descriptions</li>
</ul>
<h3>Gemini Models</h3>
<ul>
<li><strong>gemini-1.5-flash:</strong> Fast and efficient (recommended for most uses)</li>
@@ -85,43 +85,43 @@ app.registerExtension({
<li><strong>gemini-1.5-pro:</strong> Most capable, best quality results</li>
<li><strong>gemini-1.0-pro:</strong> Previous generation, stable option</li>
</ul>
<h3>Custom Prompts</h3>
<p>You can override any template by entering your own system prompt in the custom_prompt field.</p>
</div>
`;
app.ui.dialog.show(helpContent);
};
// Add method to update custom prompt visibility
nodeType.prototype.updateCustomPromptVisibility = function() {
// You could implement logic here to show/hide custom prompt based on selection
// For now, it's always visible but this method provides extensibility
};
// Add method to refresh model list
nodeType.prototype.refreshModelList = function() {
if (this.status) {
this.status.value = "Refreshing models...";
}
// Set the refresh_models flag
const refreshWidget = this.widgets.find(w => w.name === "refresh_models");
if (refreshWidget) {
refreshWidget.value = true;
}
// Show message
alert("Model list will refresh on next execution. Make sure API key is set and run the node.");
if (this.status) {
setTimeout(() => {
this.status.value = "Ready - Run node to refresh";
}, 2000);
}
};
// Override execute to show status
const onExecute = nodeType.prototype.onExecute;
nodeType.prototype.onExecute = function() {
@@ -131,12 +131,12 @@ app.registerExtension({
const result = onExecute?.apply(this, arguments);
return result;
};
// Handle execution feedback
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
const result = onExecuted?.apply(this, arguments);
if (this.status) {
// Check if there was an error in the output
const outputs = message.output;
@@ -147,7 +147,7 @@ app.registerExtension({
this.status.value = "Success!";
this.bgcolor = "#225522";
}
// Reset color after delay
setTimeout(() => {
this.bgcolor = "";
@@ -156,12 +156,12 @@ app.registerExtension({
}
}, 3000);
}
return result;
};
}
},
// Add custom styling
async setup() {
const style = document.createElement("style");
@@ -172,33 +172,33 @@ app.registerExtension({
border-radius: 8px;
color: #fff;
}
.gemini-prompt-help h2 {
color: #4285f4;
margin-top: 0;
}
.gemini-prompt-help h3 {
color: #8ab4f8;
margin-top: 20px;
}
.gemini-prompt-help code {
background: #333;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
.gemini-prompt-help a {
color: #8ab4f8;
text-decoration: none;
}
.gemini-prompt-help a:hover {
text-decoration: underline;
}
`;
document.head.appendChild(style);
}
});
});
+1 -1
View File
@@ -1221,4 +1221,4 @@ app.registerExtension({
autocompleteInstance.cleanup();
}
}
});
});
+63 -39
View File
@@ -30,8 +30,8 @@ app.registerExtension({
id: "kikotools.custom_colors.enabled",
name: "🫶 Custom Colors: Enable",
type: "boolean",
defaultValue: false,
tooltip: "Enable custom color picker options in node context menu",
defaultValue: true,
tooltip: "Enable custom color picker options in node context menu (Full/Title/BG)",
});
app.ui.settings.addSetting({
@@ -70,11 +70,11 @@ app.registerExtension({
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;
return setting !== undefined ? setting : true;
};
const getSettings = () => ({
@@ -85,37 +85,49 @@ app.registerExtension({
});
// Helper function to apply color to node(s)
// Uses setColorOption like the built-in colors when possible
const applyColorToNodes = (colorValue, colorType, node) => {
const settings = getSettings();
if (!colorValue) return;
const graphcanvas = LGraphCanvas.active_canvas;
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
? [node]
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') {
for (const n of nodes) {
if (n.constructor === LiteGraph.LGraphGroup) {
// For groups, use setColorOption if available
if (colorType === 'full' || colorType === 'bg') {
if (n.setColorOption) {
n.setColorOption({ groupcolor: colorValue });
} else {
n.color = colorValue;
}
} else {
// For regular nodes
switch(colorType) {
case 'full':
n.color = settings.autoShade ? colorShade(colorValue, 20) : colorValue;
}
} else {
// For regular nodes
switch(colorType) {
case 'full':
// Use setColorOption if available (like built-in colors)
if (n.setColorOption) {
n.setColorOption({
color: colorShade(colorValue, 20),
bgcolor: colorValue
});
} else {
n.color = colorShade(colorValue, 20);
n.bgcolor = colorValue;
break;
case 'title':
n.color = colorValue;
break;
case 'bg':
n.bgcolor = colorValue;
break;
}
}
break;
case 'title':
n.color = colorValue;
break;
case 'bg':
n.bgcolor = colorValue;
break;
}
}
});
}
node.setDirtyCanvas(true, true);
};
@@ -129,13 +141,13 @@ app.registerExtension({
display: "none",
},
});
picker.onchange = () => {
if (activeNode) {
applyColorToNodes(picker.value, type, activeNode);
}
};
return picker;
};
@@ -143,24 +155,36 @@ app.registerExtension({
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")) {
const menu = menus[i];
if (!menu || !menu.firstElementChild) continue;
// Check for "No color" in various ways to be compatible with different frontend versions
const firstChild = menu.firstElementChild;
const textContent = firstChild.textContent || '';
const innerHTML = firstChild.innerHTML || '';
const valueContent = firstChild.value?.content || '';
const isColorMenu = textContent.includes("No color") ||
innerHTML.includes("No color") ||
valueContent.includes("No color");
if (isColorMenu) {
// Add Custom Full option
if (settings.showFull) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
parent: menu,
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
@@ -190,7 +214,7 @@ app.registerExtension({
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
parent: menu,
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
@@ -220,7 +244,7 @@ app.registerExtension({
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
parent: menu,
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
@@ -244,7 +268,7 @@ app.registerExtension({
]
);
}
break;
}
}
@@ -252,4 +276,4 @@ app.registerExtension({
return r;
};
},
});
});
+5 -5
View File
@@ -32,7 +32,7 @@ app.registerExtension({
tooltip: "Automatically start following execution when workflow starts",
});
},
async setup() {
let followExecution = false;
let isEnabled = false;
@@ -41,7 +41,7 @@ app.registerExtension({
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;
@@ -81,14 +81,14 @@ app.registerExtension({
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",
@@ -154,4 +154,4 @@ app.registerExtension({
return options;
};
},
});
});
File diff suppressed because it is too large Load Diff
+539
View File
@@ -0,0 +1,539 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.js";
/**
* Sketch-style Color Picker for Settings
*/
class SketchColorPicker {
constructor(initialColor = '#ff9966', onChange = () => {}) {
this.color = this.hexToHsv(initialColor);
this.hex = initialColor;
this.onChange = onChange;
this.isOpen = false;
this.presetColors = [
'#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2',
'#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF',
];
this.createElements();
}
createElements() {
// Swatch button
this.swatch = document.createElement('div');
this.swatch.style.cssText = `
width: 50px; height: 28px; border-radius: 4px;
background: ${this.hex}; cursor: pointer;
box-shadow: 0 0 0 1px rgba(0,0,0,.2), inset 0 0 0 1px rgba(0,0,0,.1);
display: inline-block;
`;
this.swatch.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Popup container
this.popup = document.createElement('div');
this.popup.style.cssText = `
position: absolute; z-index: 10000;
background: #2a2a2a; border-radius: 6px;
box-shadow: 0 0 0 1px rgba(255,255,255,.1), 0 8px 24px rgba(0,0,0,.4);
padding: 12px; display: none; width: 220px;
bottom: 100%; margin-bottom: 8px; right: 0;
`;
// Saturation/Brightness picker
this.satBright = document.createElement('div');
this.satBright.style.cssText = `
width: 196px; height: 140px; position: relative;
border-radius: 4px; cursor: crosshair; margin-bottom: 12px;
`;
this.updateSatBrightBackground();
this.satBrightPointer = document.createElement('div');
this.satBrightPointer.style.cssText = `
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.3);
position: absolute; transform: translate(-50%, -50%);
pointer-events: none;
`;
this.satBright.appendChild(this.satBrightPointer);
// Hue slider
this.hueSlider = document.createElement('div');
this.hueSlider.style.cssText = `
width: 196px; height: 14px; border-radius: 4px;
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
position: relative; cursor: pointer; margin-bottom: 12px;
`;
this.huePointer = document.createElement('div');
this.huePointer.style.cssText = `
width: 8px; height: 18px; border-radius: 3px;
background: #fff; border: 1px solid rgba(0,0,0,.3);
position: absolute; top: -2px; transform: translateX(-50%);
pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,.3);
`;
this.hueSlider.appendChild(this.huePointer);
// Hex input row
this.hexRow = document.createElement('div');
this.hexRow.style.cssText = 'display: flex; align-items: center; margin-bottom: 12px; gap: 8px;';
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.value = this.hex;
this.hexInput.style.cssText = `
flex: 1; padding: 6px 8px; border: 1px solid #444;
border-radius: 4px; font-size: 13px; font-family: monospace;
text-transform: uppercase; background: #1a1a1a; color: #eee;
`;
this.hexInput.addEventListener('change', () => this.setHex(this.hexInput.value));
const hexLabel = document.createElement('span');
hexLabel.textContent = 'Hex';
hexLabel.style.cssText = 'font-size: 12px; color: #888; min-width: 28px;';
this.hexRow.appendChild(this.hexInput);
this.hexRow.appendChild(hexLabel);
// Preset swatches
this.presetsContainer = document.createElement('div');
this.presetsContainer.style.cssText = `
display: flex; flex-wrap: wrap; gap: 6px;
border-top: 1px solid #444; padding-top: 12px;
`;
this.presetColors.forEach(color => {
const preset = document.createElement('div');
preset.style.cssText = `
width: 18px; height: 18px; border-radius: 3px;
background: ${color}; cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(0,0,0,.2);
transition: transform 0.1s;
`;
preset.addEventListener('mouseenter', () => preset.style.transform = 'scale(1.15)');
preset.addEventListener('mouseleave', () => preset.style.transform = 'scale(1)');
preset.addEventListener('click', () => this.setHex(color));
this.presetsContainer.appendChild(preset);
});
// Assemble popup
this.popup.appendChild(this.satBright);
this.popup.appendChild(this.hueSlider);
this.popup.appendChild(this.hexRow);
this.popup.appendChild(this.presetsContainer);
// Event handlers
this.setupDrag(this.satBright, this.handleSatBrightChange.bind(this));
this.setupDrag(this.hueSlider, this.handleHueChange.bind(this));
// Close on outside click
this.closeHandler = (e) => {
if (!this.popup.contains(e.target) && e.target !== this.swatch) {
this.close();
}
};
}
setupDrag(element, handler) {
const onMove = (e) => {
e.preventDefault();
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
handler(x, y);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
// Click and drag behavior
element.addEventListener('mousedown', (e) => {
e.preventDefault();
onMove(e);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// Hover preview - color follows mouse pointer
element.addEventListener('mousemove', (e) => {
if (e.buttons === 0) { // Only on hover, not during drag
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
handler(x, y);
}
});
}
handleSatBrightChange(x, y) {
this.color.s = x * 100;
this.color.v = (1 - y) * 100;
this.updateFromHsv();
}
handleHueChange(x) {
this.color.h = x * 360;
this.updateFromHsv();
}
updateFromHsv() {
this.hex = this.hsvToHex(this.color.h, this.color.s, this.color.v);
this.updateUI();
this.onChange(this.hex);
}
updateUI() {
this.swatch.style.background = this.hex;
this.updateSatBrightBackground();
this.satBrightPointer.style.left = `${this.color.s}%`;
this.satBrightPointer.style.top = `${100 - this.color.v}%`;
this.huePointer.style.left = `${(this.color.h / 360) * 100}%`;
this.hexInput.value = this.hex.toUpperCase();
}
updateSatBrightBackground() {
const hueColor = this.hsvToHex(this.color.h, 100, 100);
this.satBright.style.background = `
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, ${hueColor})
`;
}
setHex(hex) {
if (!/^#?[0-9A-Fa-f]{6}$/.test(hex)) return;
if (!hex.startsWith('#')) hex = '#' + hex;
this.hex = hex;
this.color = this.hexToHsv(hex);
this.updateUI();
this.onChange(this.hex);
}
toggle() { this.isOpen ? this.close() : this.open(); }
open() {
this.isOpen = true;
this.popup.style.display = 'block';
this.updateUI();
setTimeout(() => document.addEventListener('click', this.closeHandler), 0);
}
close() {
this.isOpen = false;
this.popup.style.display = 'none';
document.removeEventListener('click', this.closeHandler);
}
getElement() {
const container = document.createElement('div');
container.style.cssText = 'position: relative; display: inline-block;';
container.appendChild(this.swatch);
container.appendChild(this.popup);
return container;
}
hexToHsv(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const v = max * 100, d = max - min;
const s = max === 0 ? 0 : (d / max) * 100;
let h = 0;
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) * 60; break;
case g: h = ((b - r) / d + 2) * 60; break;
case b: h = ((r - g) / d + 4) * 60; break;
}
}
return { h, s, v };
}
hsvToHex(h, s, v) {
s /= 100; v /= 100;
const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
}
/**
* Global Timer Manager
*/
const KikoGlobalTimer = {
startTime: 0,
intervalId: null,
isRunning: false,
activeNodes: new Set(),
formatTime(ms) {
if (ms < 0) ms = 0;
const minutes = String(Math.floor(ms / 60000)).padStart(2, '0');
const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
const milliseconds = String(ms % 1000).padStart(3, '0');
return `${minutes}:${seconds}:${milliseconds}`;
},
getTimerColor() {
return app.ui.settings.getSettingValue("kikotools.workflow_timer.color") || '#ff9966';
},
isGlowEnabled() {
const val = app.ui.settings.getSettingValue("kikotools.workflow_timer.glow");
return val !== undefined ? val : true;
},
start() {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = Date.now();
const timerColor = this.getTimerColor();
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.style.color = timerColor;
node.timerDisplay.classList.add('running');
}
});
this.intervalId = setInterval(() => {
const elapsed = Date.now() - this.startTime;
const timeString = this.formatTime(elapsed);
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.textContent = timeString;
}
});
}, 33);
},
stop() {
if (!this.isRunning) return;
this.isRunning = false;
clearInterval(this.intervalId);
const finalTime = Date.now() - this.startTime;
const finalTimeString = this.formatTime(finalTime);
const timerColor = this.getTimerColor();
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.textContent = finalTimeString;
node.timerDisplay.style.color = timerColor;
node.timerDisplay.classList.remove('running');
}
node.properties.elapsed_time_str = finalTimeString;
});
},
updateColor(color) {
if (this.isRunning) return;
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.style.color = color;
}
});
},
updateGlow(enabled) {
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.classList.toggle('no-glow', !enabled);
}
});
},
registerNode(node) { this.activeNodes.add(node); },
unregisterNode(node) { this.activeNodes.delete(node); },
};
/**
* ComfyUI Extension for KikoWorkflow Timer
*/
const KikoWorkflowTimerExtension = {
name: "ComfyAssets.KikoWorkflowTimer",
async init() {
// Color picker setting with custom render
app.ui.settings.addSetting({
id: "kikotools.workflow_timer.color",
name: "🫶 Workflow Timer: Color",
type: (name, setter, value) => {
const container = $el("div", {
style: { display: "flex", alignItems: "center", gap: "10px" }
});
const picker = new SketchColorPicker(value || '#ff9966', (newColor) => {
setter(newColor);
KikoGlobalTimer.updateColor(newColor);
});
container.appendChild(picker.getElement());
// Also show hex value as text
const hexDisplay = $el("span", {
textContent: value || '#ff9966',
style: { color: "#888", fontFamily: "monospace", fontSize: "12px" }
});
container.appendChild(hexDisplay);
// Update hex display when color changes
const origOnChange = picker.onChange;
picker.onChange = (newColor) => {
origOnChange(newColor);
hexDisplay.textContent = newColor.toUpperCase();
};
return container;
},
defaultValue: "#ff9966",
tooltip: "Color of the timer display when not running",
});
// Glow toggle
app.ui.settings.addSetting({
id: "kikotools.workflow_timer.glow",
name: "🫶 Workflow Timer: Enable Glow",
type: "boolean",
defaultValue: true,
tooltip: "Enable pulsing glow effect on the timer",
onChange: (value) => {
KikoGlobalTimer.updateGlow(value);
},
});
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "KikoWorkflowTimer") {
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
origOnNodeCreated?.apply(this, arguments);
this.bgcolor = "#1a1a2e";
this.color = "#16213e";
this.title = "Workflow Timer";
this.properties = this.properties || {};
this.size = [300, 100];
const container = document.createElement("div");
container.className = "kiko-timer-container";
this.timerDisplay = document.createElement("div");
this.timerDisplay.className = "kiko-timer-display";
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
const timerColor = KikoGlobalTimer.getTimerColor();
this.timerDisplay.style.color = timerColor;
if (!KikoGlobalTimer.isGlowEnabled()) {
this.timerDisplay.classList.add('no-glow');
}
container.appendChild(this.timerDisplay);
this.addDOMWidget("kikoTimer", "Kiko Timer", container, { serialize: false });
KikoGlobalTimer.registerNode(this);
};
nodeType.prototype.onRemoved = function() {
KikoGlobalTimer.unregisterNode(this);
};
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
origOnSerialize?.apply(this, arguments);
o.properties = this.properties;
};
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
origOnConfigure?.apply(this, arguments);
this.properties = info.properties || {};
if (this.timerDisplay) {
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
const timerColor = KikoGlobalTimer.getTimerColor();
this.timerDisplay.style.color = timerColor;
if (!KikoGlobalTimer.isGlowEnabled()) {
this.timerDisplay.classList.add('no-glow');
}
}
};
}
},
setup() {
const style = document.createElement("style");
style.textContent = `
@keyframes kiko-timer-pulse {
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
}
@keyframes kiko-timer-running-pulse {
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
}
.kiko-timer-container {
width: 100%; height: 100%;
position: relative; display: flex;
align-items: center; justify-content: center;
}
.kiko-timer-display {
text-align: center; width: 100%; height: 100%;
position: absolute; top: 0; left: 0;
background: transparent; border: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
box-sizing: border-box; outline: none; margin: 0;
overflow: hidden; display: flex;
justify-content: center; align-items: center;
font-size: 42px;
animation: kiko-timer-pulse 4s infinite ease-in-out;
transition: color 0.3s ease-in-out;
font-variant-numeric: tabular-nums;
letter-spacing: 0.08em;
white-space: nowrap; font-weight: 600;
}
.kiko-timer-display.no-glow {
animation: none;
text-shadow: none;
}
.kiko-timer-display.running {
animation: kiko-timer-running-pulse 2s infinite ease-in-out;
}
.kiko-timer-display.running.no-glow {
animation: none;
text-shadow: none;
}
`;
document.head.appendChild(style);
api.addEventListener("execution_start", () => KikoGlobalTimer.start());
api.addEventListener("executing", ({ detail }) => {
if (detail === null) KikoGlobalTimer.stop();
});
api.addEventListener("execution_error", () => KikoGlobalTimer.stop());
api.addEventListener("execution_interrupted", () => KikoGlobalTimer.stop());
}
};
app.registerExtension(KikoWorkflowTimerExtension);
+30 -30
View File
@@ -1,23 +1,23 @@
import { app } from "../../../scripts/app.js";
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--) {
@@ -26,7 +26,7 @@ app.registerExtension({
break;
}
}
// Insert our custom menu items
const kikoOptions = [
null, // separator
@@ -37,10 +37,10 @@ app.registerExtension({
}
}
];
options.splice(insertIndex, 0, ...kikoOptions);
}
return options;
};
}, 500);
@@ -70,9 +70,9 @@ class KikoToolsExtensions {
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 = `
@@ -81,7 +81,7 @@ class KikoToolsExtensions {
gap: 10px;
margin-top: 15px;
`;
// Create OK button
const okButton = document.createElement("button");
okButton.textContent = "OK";
@@ -96,7 +96,7 @@ class KikoToolsExtensions {
`;
okButton.onmouseover = () => okButton.style.background = "#5BA0F2";
okButton.onmouseout = () => okButton.style.background = "#4A90E2";
// Create Cancel button
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
@@ -111,21 +111,21 @@ class KikoToolsExtensions {
`;
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) {
@@ -139,49 +139,49 @@ class KikoToolsExtensions {
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}"
<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}"
<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>
@@ -190,22 +190,22 @@ class KikoToolsExtensions {
</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);
},
@@ -215,4 +215,4 @@ class KikoToolsExtensions {
}
// Export for global access
window.KikoToolsExtensions = KikoToolsExtensions;
window.KikoToolsExtensions = KikoToolsExtensions;
+1 -1
View File
@@ -296,7 +296,7 @@ app.registerExtension({
// Generate new random seed
nodeType.prototype.generateRandomSeed = function () {
const newSeed = Math.floor(Math.random() * 0xFFFFFFFFFFFFFFFF);
const newSeed = Math.floor(Math.random() * 0xFFFFFFFF); // 2**32 - 1
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {