Compare commits

...
Author SHA1 Message Date
Vito Sansevero 11931a2e51 docs: add comprehensive plan.md for session recovery
- Document all completed XYZ Grid node work
- List current issues and pending fixes
- Include technical patterns and code examples
- Add testing instructions and debug points
- Provide git commands for session recovery
2025-08-05 16:15:03 -07:00
Vito Sansevero 9da65b7e38 chore: remove CLAUDE.md from repository and add to .gitignore
- Remove CLAUDE.md from version control
- Add CLAUDE.md to .gitignore to keep it local-only
- Project instructions should remain private to each developer
2025-08-05 16:05:59 -07:00
Vito Sansevero 0868b32318 fix: resolve grid_data structure mismatch between controller and combiner
- Add dimensions object with cols, rows, and grids_count for ImageGridCombiner
- Add axes object with human-readable labels for each axis
- Create _create_labels method to format axis values appropriately
- Keep backward compatibility with root-level total_images
- Fix ImageGridCombiner crash when processing grid data
2025-08-05 16:04:49 -07:00
Vito Sansevero de49b8abec removed 2025-08-05 14:52:34 -07:00
Vito Sansevero 64f2b0530d fix: XYZ Prompt widget values now properly pass to Python backend
- Add FlexibleOptionalInputType to accept dynamic widget values from JavaScript
- Create protected button container to prevent text overflow onto remove buttons
- Add visual separator and background for button area
- Add debug logging for troubleshooting widget value serialization
- Fix widget value collection to match XYZ Plot Controller pattern
2025-08-05 14:50:35 -07:00
Vito Sansevero 1308d0055e feat: add XYZ Prompt node with dynamic prompt management
- Create separate XYZ Prompt node for cleaner architecture
- Add include_negative toggle to show/hide negative prompts
- Add repeat_negative option to use first negative for all variations
- Implement dynamic prompt widget management with add/remove functionality
- Style positive prompts with green background, negative with red
- Fix widget spacing issues with proper margins and spacers
- Track non-empty prompts in node title counter
2025-08-05 14:33:36 -07:00
Vito Sansevero f484482e2c feat: complete XYZ Plot Controller widget functionality
- Remove unwanted input connection from node
- Fix FlexibleOptionalInputType to not create input slot
- Add callbacks to update image count when any input changes
- Support text widget changes for ranges (e.g., 10:50:5)
- Update count when dropdown selections change
- Update count when widgets are toggled on/off
- Properly calculate total images from all axis combinations
- Verified outputs with Display Any nodes showing correct grid data
2025-08-05 13:59:52 -07:00
Vito Sansevero 8c44b99d37 feat: improve text input widgets with placeholders and auto-resize
- Add helpful placeholder hints for all text input fields
- Make all text inputs multiline for better hint display
- Use monospace font for value entry
- Set minimum height for non-prompt fields
- Auto-resize node when adding widgets to prevent overflow
- Add proper padding to prevent last widget from clipping
2025-08-05 13:04:22 -07:00
Vito Sansevero c035313e99 feat: implement right-click context menu for widgets
- Override getSlotInPosition to detect widget clicks
- Return fake slot with widget attached for menu handling
- Add context menu with Toggle, Move Up/Down, and Remove options
- Follow RGThree's pattern for widget context menus
2025-08-05 12:19:06 -07:00
Vito Sansevero 0d9d6d1872 fix: properly remove text input DOM elements when switching axis types
- Add DOM element cleanup when removing widgets
- Call onRemoved callbacks for proper widget cleanup
- Handle both 'text' and 'customtext' widget types
- Fixes issue where text inputs remained visible after switching from prompt to none
2025-08-05 11:09:32 -07:00
Vito Sansevero 40bfccc952 feat: implement XYZ Plot Controller with RGThree-style widget framework
- Created XYZ Plot Controller node with dynamic widget management
- Implemented full widget persistence across page refreshes
- Added RGThree-style UI with toggles and strength controls
- Fixed text widget serialization issues
- Implemented hide/show pattern for widget management
- Added comprehensive right-click context menus
- Created detailed documentation of the widget framework
- Removed all debug console.log statements for production
2025-08-04 19:17:54 -07:00
Vito Sansevero 7b87b01535 feat: implement XYZ Plot Controller with native dropdown selections
- Use individual dropdown widgets for each model/vae/lora selection
- Similar UI to checkpoint loader - select from dropdown, disable to remove
- Support up to 5 models, 3 VAEs, 3 LoRAs, 3 samplers, 2 schedulers
- Keep text fields for numeric values and prompts
- Update JavaScript to count selections and show total images
- Use native ComfyUI file selection dropdowns
2025-08-04 14:54:12 -07:00
Vito Sansevero cb61b60c27 fix: update imports to use new simplified XYZPlotController
- Replace XYZPlotControllerAdvanced with XYZPlotController
- Update all import statements to match new class name
- Fix __all__ exports in xyz_grid module
2025-08-04 14:40:02 -07:00
Vito Sansevero c45c9c0b91 feat: redesign XYZ Plot Controller using native ComfyUI widgets
- Remove complex HTML/JS custom interface
- Create simplified node using standard ComfyUI inputs
- Add helpful tooltips and placeholder text via minimal JS
- Support range notation (start:stop:step) for numeric values
- Show total image count in node title
- Use multiline text inputs for value entry
- Work with ComfyUI's native widget system
2025-08-04 14:26:52 -07:00
Vito Sansevero b3c510b90d fix: keep original widgets in array to prevent execution errors
- Keep widgets in the array but hide them visually
- Add custom widget to the array instead of replacing it
- Ensure backend can still access widget values
- Remove duplicate size setting
2025-08-04 14:20:05 -07:00
Vito Sansevero 417e99b172 fix: use fixed dimensions to prevent massive overflow
- Set fixed width (360px) and height (620px) for container
- Remove percentage-based sizing that was causing overflow
- Use explicit pixel dimensions for widget element
- Ensure consistent sizing throughout
2025-08-04 14:14:32 -07:00
Vito Sansevero 59fe64d04f fix: constrain widget height to prevent overflow
- Use max-height instead of fixed height for container
- Set overflow hidden on widget element
- Update resize handler to use maxHeight instead of height
- Ensure widget respects node boundaries
2025-08-04 14:11:49 -07:00
Vito Sansevero 19666de804 fix: simplify widget creation and remove old conflicting file
- Remove xyz_plot_controller_old.js that was interfering
- Create widget immediately without delay
- Use fixed height container instead of absolute positioning
- Add computeSize function to widget for proper sizing
- Schedule resize with setTimeout(0) for next tick
2025-08-04 14:09:17 -07:00
Vito Sansevero 60a3104a38 fix: improve initial widget rendering with delayed creation
- Delay widget creation by 50ms to ensure node is fully initialized
- Set node size before creating widget
- Use absolute positioning for container to fill available space
- Force multiple canvas redraws to ensure proper display
- Explicitly set widget dimensions in pixels
2025-08-04 14:05:38 -07:00
Vito Sansevero 01c14ea363 fix: resolve initial rendering issue in XYZ Plot Controller
- Add computeSize callback to DOM widget for proper initial sizing
- Force widget size update after creation
- Add onResize handler to properly adjust widget when node is resized
- Set container minimum height and overflow properties
- Force canvas redraw after widget creation
2025-08-04 14:02:37 -07:00
Vito Sansevero 63c9d81ebd fix: adjust XYZ Plot Controller sizing to show all elements properly
- Increase initial node height to 680px to show all 3 axis groups
- Reduce padding and margins in axis groups for more compact layout
- Adjust textarea min/max heights for better space utilization
- Ensure Total Images counter is properly positioned at bottom
- Fix element overlap issues by providing adequate vertical space
2025-08-04 13:58:12 -07:00
Vito Sansevero 94200003ee fix: set proper initial size for XYZ Plot Controller node
- Set initial size to 350x550 immediately in onNodeCreated
- Remove computeSize override for simpler implementation
- Ensure node displays correctly when first added to canvas
- Match behavior of Display Text node for consistent UX
2025-08-04 13:50:24 -07:00
Vito Sansevero f1e73d1e94 fix: optimize XYZ Plot Controller layout and sizing
- Reduce padding and margins throughout for more compact display
- Decrease font sizes appropriately (11px for inputs, 10px for info)
- Set fixed node dimensions (350x520) for consistent appearance
- Limit textarea heights to prevent excessive vertical space
- Adjust button and info box styling for tighter layout
- Override computeSize to maintain proper dimensions
2025-08-04 13:38:09 -07:00
Vito Sansevero 429f3f067a fix: properly hide original widgets in XYZ Plot Controller
- Store original widgets in separate array for access
- Remove all widgets from display array to prevent them showing
- Hide widget parent elements to remove spacing
- Update all widget references to use originalWidgets array
- Ensure custom UI is the only visible widget
2025-08-04 13:29:20 -07:00
Vito Sansevero 84138cd46e feat: complete rewrite of XYZ Plot Controller UI using custom HTML interface
- Replace problematic widget-based UI with full HTML interface
- Fix overlapping buttons and spacing issues
- Add proper multi-select dialogs with search functionality
- Implement Select All/Clear All buttons in dialogs
- Hide original widgets to prevent conflicts
- Add visual grouping for X/Y/Z axes
- Improve responsive layout and styling
- Maintain sync with underlying widget values
2025-08-04 13:21:49 -07:00
Vito Sansevero 978580924a fix: improve XYZ Plot Controller UI with generic parameter detection and proper widget updates
- Add generic findOptionsForParameter function that searches all nodes
- Fix button not updating when axis type changes
- Improve spacing to prevent widget overlap
- Add proper pluralization for button labels (VAEs, LoRAs, etc.)
- Add hover effects and better visual styling
- Ensure widget callbacks properly trigger updates
- Add node resizing when content changes
2025-08-04 13:09:58 -07:00
Vito Sansevero be560ad2f5 fix: rewrite XYZ Plot Controller JS using proper ComfyUI patterns
- Use correct import path (../../scripts/app.js)
- Implement using addDOMWidget for custom UI elements
- Use onNodeCreated and onWidgetChange hooks properly
- Add info display and total image count in DOM widget
- Fix widget element access patterns
- Simplify implementation for better compatibility
2025-08-04 12:44:24 -07:00
Vito Sansevero 0e7747d248 fix: consolidate and fix XYZ Plot Controller JavaScript
- Move JS files to correct web directory location
- Fix import paths for ComfyUI compatibility
- Consolidate all UI functionality into single xyz_plot_controller.js
- Add proper widget enhancement with select buttons
- Fix API calls to use ComfyUI's api object
- Add working multi-select dialogs and validation
2025-08-04 11:56:35 -07:00
Vito Sansevero 012c0e698c feat: add intelligent UI for XYZ Plot Controller
- Dynamic value selection widgets for models, VAEs, LoRAs, samplers
- Multi-select dialogs with search functionality
- Context-sensitive examples and usage hints for each parameter type
- Real-time validation for numeric inputs with range syntax support
- Visual feedback with proper styling and animations
- Connection hints showing where to connect outputs
- Parameter-specific placeholders and tooltips
2025-08-04 11:50:12 -07:00
Vito Sansevero b18f7cad38 feat: add complete set of example workflows for XYZ grid
- sampler_comparison.json: 12 samplers x 4 step counts grid
- prompt_variations.json: 3 models x 5 diverse prompts
- advanced_3d_grid.json: LoRA x Seed x Denoise strength (3D)
- flux_guidance_test.json: Flux guidance x CFG scale comparison
- All workflows include proper node connections and documentation
2025-08-04 11:43:45 -07:00
Vito Sansevero 4bab6eb73e feat: complete XYZ grid implementation with all features
- Add execution flow with batch management and queue system
- Implement Z-axis support for multiple grid pages with labels
- Add model caching manager with intelligent memory management
- Create progress tracking system with WebSocket support
- Write comprehensive test suite (59 tests, 100% passing)
- Add example workflows and detailed documentation
- Optimize grid assembly with better label positioning
- Support for all parameter types including Flux guidance
2025-08-04 11:36:37 -07:00
Vito Sansevero b14b86fc85 feat: implement XYZ Plot Controller and Image Grid Combiner nodes
- Add comprehensive XYZ grid generation system for parameter comparisons
- Support for X, Y, and Z axes with any parameter type (models, samplers, CFG, etc.)
- Automatic grid assembly with professional labeling and annotations
- Dynamic UI with real-time image count preview
- Execution flow management for automated batch processing
- Full test coverage for helpers and converters
- Extensible architecture for future parameter types
2025-08-04 11:24:51 -07:00
Vito Sansevero a8ee5930ff chore: bump version to 1.0.10 in pyproject.toml 2025-08-04 10:36:11 -07:00
Vito Sansevero 9fd80793db feat(init): add ImageScaleDownByNode support 2025-08-04 10:35:54 -07:00
Vito Sansevero 972c487dd4 Add image_scale_down_by tool and display_any.js
- Add new image_scale_down_by tool for downscaling images/latents
- Add display_any.js web component for node display
- Include comprehensive unit tests for the new tool
2025-08-04 07:17:55 -07:00
Vito 1a3efd3802 Merge pull request #26 from ComfyAssets/alert-autofix-8
Potential fix for code scanning alert no. 8: Workflow does not contain permissions
2025-08-02 08:48:43 -07:00
VitoandCopilot Autofix powered by AI 34f54e515d Potential fix for code scanning alert no. 8: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-02 08:38:37 -07:00
Vito c844abea51 Merge pull request #25 from ComfyAssets/alert-autofix-1
Potential fix for code scanning alert no. 1: Workflow does not contain permissions
2025-08-02 08:22:06 -07:00
VitoandCopilot Autofix powered by AI 404a1efd61 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-02 08:14:03 -07:00
Vito 4ae514dacf Merge pull request #24 from ComfyAssets/alert-autofix-10
Potential fix for code scanning alert no. 10: Workflow does not contain permissions
2025-08-02 08:12:04 -07:00
VitoandCopilot Autofix powered by AI 5efae8eeb8 Potential fix for code scanning alert no. 10: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-02 08:00:10 -07:00
Vito 85288c8fd8 Create SECURITY.md 2025-08-02 07:54:21 -07:00
Vito 7a97f7c2bc Create CODE_OF_CONDUCT.md 2025-08-02 07:50:25 -07:00
Vito a4692a286c Merge pull request #22 from ComfyAssets/dependabot/github_actions/softprops/action-gh-release-2
build(deps): bump softprops/action-gh-release from 1 to 2
2025-08-02 07:48:07 -07:00
Vito 72a3fea3cb Merge pull request #23 from ComfyAssets/dependabot/github_actions/actions/cache-4
build(deps): bump actions/cache from 3 to 4
2025-08-02 07:47:44 -07:00
Vito d5d4145a04 Merge pull request #21 from ComfyAssets/dependabot/github_actions/actions/setup-python-5
build(deps): bump actions/setup-python from 4 to 5
2025-08-02 07:46:49 -07:00
dependabot[bot] 0e288dd109 build(deps): bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [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/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 14:43:46 +00:00
dependabot[bot] c4882f894e build(deps): bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [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/v1...v2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 14:43:43 +00:00
dependabot[bot] 6cbe6e5ae6 build(deps): bump actions/setup-python from 4 to 5
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 14:43:40 +00:00
Vito Sansevero df20afb83e style(dependabot): fix indentation in config file 2025-08-02 07:42:56 -07:00
Vito 7d63e11e18 Create dependabot.yml 2025-08-02 07:40:43 -07:00
Vito a8364b5c57 Merge pull request #20 from ComfyAssets/feature/add-tools-toc
docs: add tools table of contents to README
2025-08-02 07:35:13 -07:00
Vito Sansevero 332a74225d docs: add tools table of contents to README
- Add comprehensive TOC table under Current Tools section
- Include tool names with emojis as clickable links
- Add brief descriptions for each tool
- Categorize tools by functionality (Image Processing, Debugging, etc.)
- Improve navigation and tool discovery for users
2025-08-02 07:31:06 -07:00
Vito d757b623d6 Merge pull request #19 from ComfyAssets/feature/add-readme-screenshots
docs: add screenshots and complete documentation for all nodes
2025-08-02 07:24:25 -07:00
Vito Sansevero 64e844ec42 style: fix code formatting with black
- Add missing newlines at end of files
- Fix whitespace and indentation issues
- Format long function calls properly
2025-08-02 07:20:40 -07:00
Vito Sansevero 3ed188d63f docs: add screenshots and complete documentation for all nodes
- Add PNG screenshots for 7 nodes in README.md
- Create missing documentation files (display_text.md, kiko_save_image.md)
- Update gemini_prompt.md with new features (model refresh, enhanced SDXL)
- Add missing example workflow JSON files for 5 nodes
- Include Display Any and Image to Multiple Of nodes in README
- Update node count from 8 to 10 in stats section
2025-08-02 07:03:46 -07:00
Vito b9cc9f295d Merge pull request #18 from ComfyAssets/feature/display-text-and-gemini-improvements
feat: add Display Text node with smart formatting and enhance Gemini …
2025-08-01 21:26:54 -07:00
Vito Sansevero 271cd020c1 merge: resolve conflicts with main branch model management improvements 2025-08-01 16:35:39 -07:00
Vito Sansevero e34807855a feat: add Display Text node with smart formatting and enhance Gemini with model refresh
Display Text improvements:
- Add new DisplayText node with intelligent prompt detection and split view
- Implement text wrapping that reflows when node is resized
- Add scrollable content with mouse wheel support and visual indicators
- Include always-visible copy button with visual feedback for easy prompt copying
- Auto-detect SDXL-style prompts and display in side-by-side format
- Strip prompt labels when copying for direct use in workflows

Gemini model refresh functionality:
- Add refresh button to fetch latest available Gemini models dynamically
- Implement model caching system with persistent storage
- Support for Gemini 2.0 and 2.5 models with automatic detection
- Enhanced SDXL prompt template with improved layered structure
- Better error handling and status feedback for model operations

Documentation and version updates:
- Update README with comprehensive Display Text and Gemini feature descriptions
- Add detailed usage examples and workflow patterns
- Bump version to 1.0.9 in pyproject.toml
- Update stats to reflect 8 total nodes and new AI integration features
2025-08-01 14:57:56 -07:00
Vito d32e18f844 Merge pull request #17 from ComfyAssets/feature/gemini-dynamic-models
Feature/gemini dynamic models
2025-08-01 13:36:04 -07:00
Vito Sansevero 228b74ae5e chore: add flake8 complexity exceptions for Gemini module 2025-08-01 13:30:38 -07:00
Vito Sansevero 6197b482df feat: implement dynamic model fetching for Gemini node
- Add dynamic model fetching with 24-hour caching
- Update prompt templates based on 2025 best practices:
  - FLUX: Natural language descriptions
  - SDXL: Simplified with natural language support
  - Danbooru: Strict tagging conventions
  - Video: Optimized for WAN 2.2
- Add cache file to .gitignore
- Handle missing API key gracefully on initial load
2025-08-01 13:30:31 -07:00
Vito Sansevero f62129afda fix: update pre-commit config to use line-length 88
- Update black line-length from 127 to 88 to match pyproject.toml
- Update flake8 max-line-length from 127 to 88 for consistency
- Remove broken pre-commit hook that was referencing non-existent pyenv
2025-08-01 11:02:44 -07:00
Vito Sansevero 8d5065c975 chore: bump version to 1.0.8 in pyproject.toml 2025-08-01 10:58:57 -07:00
Vito 2d27c32bfd Merge pull request #16 from ComfyAssets/feature/display-any
Feature/display any
2025-08-01 10:51:23 -07:00
93 changed files with 15727 additions and 666 deletions
+4
View File
@@ -25,6 +25,10 @@ per-file-ignores =
__init__.py:F401,F403
# Allow assertions in tests
tests/*:S101
# Allow higher complexity for Gemini prompt module
kikotools/tools/gemini_prompt/logic.py:C901
kikotools/tools/gemini_prompt/models.py:C901
kikotools/tools/gemini_prompt/node.py:C901
# Statistics
count = True
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+6 -4
View File
@@ -1,4 +1,6 @@
name: Code Quality
permissions:
contents: read
on:
push:
@@ -14,12 +16,12 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Cache pip dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
@@ -134,7 +136,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
@@ -165,7 +167,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
+7 -2
View File
@@ -1,5 +1,8 @@
name: Release
permissions:
contents: read
on:
push:
tags:
@@ -8,12 +11,14 @@ on:
jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
@@ -109,7 +114,7 @@ jobs:
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
+6 -3
View File
@@ -1,5 +1,8 @@
name: Tests
permissions:
contents: read
on:
push:
branches: [main, develop]
@@ -17,12 +20,12 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
@@ -398,7 +401,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.10"
+4
View File
@@ -159,3 +159,7 @@ test_images/
test_outputs/
experiments/
.claude/
# Gemini model cache
.gemini_models_cache.json
CLAUDE.md
+2 -2
View File
@@ -8,14 +8,14 @@ repos:
hooks:
- id: black
language_version: python3.10
args: ['--line-length=127'] # Match CI configuration
args: ['--line-length=88'] # Match CI configuration
# Python linting with flake8
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ['--max-line-length=127', '--max-complexity=10']
args: ['--max-line-length=88', '--max-complexity=10']
exclude: '^tests/'
# Python type checking with mypy
-288
View File
@@ -1,288 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ComfyUI-KikoTools is a planned modular collection of custom ComfyUI nodes that will provide essential tools missing from the standard ComfyUI release. All nodes will be grouped under "ComfyAssets" in the ComfyUI interface. The project is designed for extensibility, allowing new tools to be added easily while maintaining clean separation of concerns.
**Current Status**: Project is in initial planning phase. Only documentation and licensing files exist.
## Architecture
### Design Principles
- **Modular Design**: Each tool is a separate, self-contained module
- **ComfyAssets Grouping**: All nodes appear under the "ComfyAssets" category
- **Test-Driven Development**: Every tool includes comprehensive tests
- **Clean Interfaces**: Standardized input/output patterns across tools
### Core Components
- **Tool Registry**: Central registration system for all KikoTools nodes
- **Base Classes**: Shared functionality for consistent tool behavior
- **Individual Tools**: Self-contained modules for specific functionality
### Current Tools
#### 1. Resolution Calculator (First Tool)
- **Purpose**: Calculate upscale resolution from image or latent inputs
- **Inputs**:
- Image or Latent tensor
- Scale factor (1, 2, 3, 1.2, 1.5, 2.0)
- **Outputs**:
- Width (INT)
- Height (INT)
- **Target Models**: Flux and SDXL optimized
- **Use Case**: Connect calculated dimensions to upscaler nodes
## Technology Stack
- **Backend**: Python with ComfyUI node patterns
- **Node Framework**: ComfyUI INPUT_TYPES, RETURN_TYPES, execute() patterns
- **Testing**: pytest with ComfyUI test fixtures
- **Code Quality**: black, flake8, mypy
- **Integration**: ComfyUI execution queue and tensor systems
## Development Commands
**Note**: These commands are planned for when the project structure is implemented.
### Initial Setup
```bash
# Create basic project structure
mkdir -p kikotools/{base,tools} tests/{unit,integration,fixtures} scripts examples
# Create entry point files
touch __init__.py kikotools/__init__.py
```
### Code Quality (Future)
```bash
# Format Python code
black .
# Python linting
flake8 .
# Type checking
mypy .
```
### Testing (Future TDD Workflow)
```bash
# Run all tests
pytest tests/
# Run tests for specific tool
pytest tests/unit/tools/test_{tool_name}.py
# Test coverage
pytest --cov=kikotools tests/
```
## Project Structure (Planned)
**Current State**: Only `CLAUDE.md` and `LICENSE` files exist.
**Planned Structure**:
```
├── __init__.py # ComfyUI node registration entry point
├── kikotools/ # Main package
│ ├── __init__.py # Package initialization and tool registry
│ ├── base/ # Base classes and shared utilities
│ │ ├── __init__.py
│ │ ├── base_node.py # Base node class with ComfyAssets grouping
│ │ └── utils.py # Shared utility functions
│ ├── tools/ # Individual tool implementations
│ │ ├── __init__.py
│ │ ├── resolution_calculator/ # First planned tool
│ │ │ ├── __init__.py
│ │ │ ├── node.py # ResolutionCalculatorNode implementation
│ │ │ └── logic.py # Core calculation logic
│ │ └── template/ # Template for new tools
│ │ ├── __init__.py
│ │ ├── node.py
│ │ └── logic.py
├── tests/ # Comprehensive test suite (TDD approach)
│ ├── __init__.py
│ ├── conftest.py # pytest fixtures and ComfyUI test setup
│ ├── unit/ # Unit tests for individual components
│ │ ├── test_base_node.py
│ │ └── tools/
│ │ └── test_resolution_calculator.py
│ ├── integration/ # ComfyUI integration tests
│ │ ├── test_node_registration.py
│ │ └── test_workflow_execution.py
│ └── fixtures/ # Test data and workflow files
│ ├── workflows/ # .json workflow files for testing
│ ├── images/ # Test images
│ └── latents/ # Test latent tensors
├── scripts/ # Development automation
│ ├── create_tool.py # Tool template generator
│ ├── register_tool.py # Tool registration helper
│ └── validate_nodes.py # Node validation script
├── examples/ # Usage examples and demonstrations
│ ├── workflows/ # Example workflow .json files
│ └── documentation/ # Usage documentation per tool
└── requirements-dev.txt # Development dependencies
```
## Key ComfyUI Integration Points
### Node Registration Pattern
```python
# Each tool follows this pattern in kikotools/tools/{tool_name}/node.py
class ResolutionCalculatorNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"scale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1}),
},
"optional": {
"image": ("IMAGE",),
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "calculate_resolution"
CATEGORY = "ComfyAssets" # All tools use this category
def calculate_resolution(self, scale_factor, image=None, latent=None):
# Implementation here
pass
```
### Base Node Class
- Provides consistent "ComfyAssets" categorization
- Standardizes error handling and logging
- Implements common validation patterns
- Ensures consistent return type handling
### Tool Registry System
- Automatic discovery of tools in `kikotools/tools/`
- Dynamic node registration during ComfyUI startup
- Version compatibility checking
- Dependency validation
## Test-Driven Development (TDD) Workflow
### 1. Write Tests First
```python
# tests/unit/tools/test_resolution_calculator.py
def test_resolution_calculator_with_image():
"""Test resolution calculation with image input."""
# Arrange
node = ResolutionCalculatorNode()
test_image = create_test_image(512, 512) # fixture
scale_factor = 2.0
# Act
width, height = node.calculate_resolution(scale_factor, image=test_image)
# Assert
assert width == 1024
assert height == 1024
def test_resolution_calculator_with_latent():
"""Test resolution calculation with latent input."""
# Similar pattern for latent inputs
pass
```
### 2. Run Tests (Should Fail)
```bash
pytest tests/unit/tools/test_resolution_calculator.py -v
```
### 3. Implement Minimal Code
```python
# kikotools/tools/resolution_calculator/logic.py
def calculate_upscale_resolution(input_tensor, scale_factor):
"""Calculate new resolution based on input and scale factor."""
# Minimal implementation to pass tests
pass
```
### 4. Refactor and Expand
- Add error handling
- Optimize for Flux/SDXL specific requirements
- Add comprehensive validation
- Implement edge case handling
### 5. Integration Testing
```python
# tests/integration/test_workflow_execution.py
def test_resolution_calculator_in_workflow():
"""Test resolution calculator in full ComfyUI workflow."""
workflow = load_test_workflow("resolution_calculator_example.json")
result = execute_comfyui_workflow(workflow)
assert result.success
```
## Tool-Specific Implementation Notes
### Resolution Calculator
- **Input Validation**: Handle both image and latent tensors
- **Scale Factors**: Support integer (1, 2, 3) and float (1.2, 1.5, 2.0) multipliers
- **Model Optimization**: Consider Flux and SDXL specific resolution requirements
- **Output Format**: Integer width/height suitable for upscaler node connections
- **Error Handling**: Graceful handling of invalid inputs or edge cases
### Future Tools (Planned)
- Batch Image Processor
- Advanced Prompt Utilities
- Model Management Tools
- Custom Sampling Methods
## Development Workflow
### Adding a New Tool
1. **Plan**: Define tool purpose, inputs, outputs, and test cases
2. **Generate**: Use `python scripts/create_tool.py --name "NewTool"`
3. **Test**: Write comprehensive tests following TDD principles
4. **Implement**: Build tool logic with proper ComfyUI integration
5. **Register**: Add tool to registry and validate registration
6. **Document**: Update examples and documentation
7. **Validate**: Test in real ComfyUI environment with actual workflows
### Code Quality Standards
- **Type Hints**: Full type annotation for all functions
- **Documentation**: Docstrings for all public methods and classes
- **Testing**: Minimum 90% test coverage for all tools
- **Linting**: Pass all flake8 and mypy checks
- **Formatting**: Auto-formatted with black
### Release Process
1. Run full test suite: `pytest tests/`
2. Validate in ComfyUI: `python scripts/validate_nodes.py`
3. Update version numbers and changelog
4. Create example workflows demonstrating new features
5. Update ComfyUI-Manager compatibility metadata
## Critical Implementation Notes
### ComfyUI Compatibility
- Follow ComfyUI tensor format conventions
- Implement proper memory management for large tensors
- Handle ComfyUI execution context correctly
- Ensure compatibility with ComfyUI's automatic typing system
### Performance Considerations
- Optimize for real-time workflow execution
- Minimize memory allocation during processing
- Cache expensive computations when appropriate
- Profile performance with typical Flux/SDXL workflows
### User Experience
- Clear, descriptive node names and parameter labels
- Helpful tooltips and parameter descriptions
- Consistent visual styling within ComfyAssets group
- Robust error messages with actionable guidance
### Extensibility
- Plugin architecture for easy tool addition
- Shared utilities for common operations
- Consistent API patterns across all tools
- Future-proof design for ComfyUI updates
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+134 -9
View File
@@ -14,6 +14,19 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
### ✨ Current Tools
| Tool | Description | Category |
|------|-------------|----------|
| [📐 Resolution Calculator](#-resolution-calculator) | Calculate upscaled dimensions with model optimization | Image Processing |
| [📏 Width Height Selector](#-width-height-selector) | Preset-based dimension selection with visual swap | Dimension Control |
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | Generation Control |
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | Sampling |
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | Latent Generation |
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | File Management |
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | Text Display |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | AI Integration |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | Debugging |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | Image Processing |
#### 📐 Resolution Calculator
Calculate upscaled dimensions from image or latent inputs with precision.
@@ -29,6 +42,8 @@ Calculate upscaled dimensions from image or latent inputs with precision.
- Ensure ComfyUI tensor compatibility
- Optimize batch processing workflows
![Resolution Calculator Example](examples/workflows/resolution_calculator_example.png)
#### 📏 Width Height Selector
Advanced preset-based dimension selection with visual swap button.
@@ -60,6 +75,8 @@ Advanced seed tracking with interactive history management and UI.
- Maintain reproducibility across sessions
- Compare results from different seeds efficiently
![Seed History functionality is shown in various workflow examples]
#### ⚙️ Sampler Combo
Unified sampling configuration interface combining sampler, scheduler, steps, and CFG.
@@ -92,6 +109,8 @@ Advanced empty latent creation with preset support and batch processing capabili
- Optimize memory usage with batch size planning
- Quick preset-based latent generation for different aspect ratios
![Empty Latent Batch Example](examples/workflows/empty_latent_batch_example.png)
#### 💾 Kiko Save Image
Enhanced image saving with format selection, quality control, and floating popup viewer.
@@ -104,6 +123,28 @@ Enhanced image saving with format selection, quality control, and floating popup
- **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality
- **Popup Toggle**: Enable/disable popup viewer per save operation
![Kiko Save Image Example](examples/workflows/kiko_save_image_example.png)
#### 📋 Display Text
Advanced text display node with intelligent formatting and enhanced user interaction.
- **Smart Prompt Detection**: Automatically detects positive/negative prompt pairs and displays in split view
- **Text Wrapping**: Proper word wrapping that reflows when node is resized
- **Scrollable Content**: Mouse wheel scrolling for long texts with visual scroll indicators
- **Copy Functionality**: Always-visible copy button with visual feedback
- **Split View Mode**: Automatic detection and formatting of SDXL-style prompts
- **Responsive Design**: Content adapts to node resizing with proper text reflow
- **Clean Formatting**: Strips prompt labels when copying for direct use
**Use Cases:**
- Display generated prompts with proper formatting
- Compare positive and negative prompts side-by-side
- Copy prompts without manual label removal
- View long text content with proper wrapping
- Debug prompt generation workflows
![Display Text Example](examples/workflows/display_text_example.png)
#### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
@@ -114,6 +155,9 @@ AI-powered image analysis using Google's Gemini to generate optimized prompts fo
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
- **Model Caching**: Persistent model list storage for offline access
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
**Use Cases:**
- Reverse-engineer prompts from reference images
@@ -121,6 +165,45 @@ AI-powered image analysis using Google's Gemini to generate optimized prompts fo
- Generate consistent style descriptions across workflows
- Create detailed scene breakdowns for complex compositions
- Analyze and replicate lighting/mood from existing artwork
- Access latest Gemini models including 2.0 and 2.5 versions
![Gemini Prompt Example](examples/workflows/gemini_prompt_example.png)
#### 🔍 Display Any
Universal debugging node that displays any type of input value or tensor information.
- **Universal Input Acceptance**: Works with any data type (tensors, strings, numbers, lists, dicts)
- **Two Display Modes**: Raw value showing string representation, or tensor shape extraction
- **Nested Structure Support**: Finds tensors within complex nested data structures
- **Debugging Focus**: Essential tool for understanding data flow and tensor dimensions
- **Clean Output**: Formatted display directly in ComfyUI interface
**Use Cases:**
- Debug tensor dimensions at any point in workflow
- Inspect latent space data structures
- View metadata and configuration objects
- Track shape changes through processing nodes
- Understand complex data types in ComfyUI
![Display Any Example](examples/workflows/display_any_example.png)
#### 🖼️ Image to Multiple Of
Adjusts image dimensions to be multiples of a specified value for model compatibility.
- **Dimension Adjustment**: Ensures image dimensions are multiples of specified value (e.g., 64, 128)
- **Two Processing Methods**: Center crop for minimal loss, or rescale to fit
- **Model Compatibility**: Essential for models requiring specific dimension constraints
- **Flexible Multiple Values**: Support from 1 to 256 with 16-step increments
- **Preserves Quality**: Smart processing maintains image quality
**Use Cases:**
- Prepare images for VAE encoding (multiple of 8 requirement)
- Ensure compatibility with specific model architectures
- Standardize dimensions across image batches
- Fix dimension errors in complex workflows
- Optimize for tiled processing requirements
![Image to Multiple Of Example](examples/workflows/image_to_multiple_of_example.png)
### 💾 Kiko Save Image Features
@@ -245,18 +328,55 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
**Features:** Click images to open in new tabs, download individual files, batch selection
**Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls
### Display Text Example
```
Gemini Prompt → Display Text → Copy to Clipboard
📋 SDXL prompt ↘ auto-split ↘ [📋 Positive] [📋 Negative]
view → formatted display
```
**Input:** Text with "Positive prompt:" and "Negative prompt:" sections
**Output:** Split view with individual copy buttons
**Features:** Text wrapping, scrolling, responsive resizing
**Smart Detection:** Automatically formats SDXL-style prompts
### Gemini Prompt Engineer Example
```
Load Image → Gemini Prompt → Text Generation Model
🖼️ reference ↘ type: FLUX ↘ "majestic landscape..."
[API key] → FLUX model
Load Image → Gemini Prompt → Display Text → Text Generation Model
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
[Refresh Models] → SDXL model
```
**Input:** Reference image for style analysis
**Prompt Type:** FLUX (detailed artistic prompts)
**Output:** Optimized prompt with style, lighting, composition details
**Prompt Type:** SDXL (positive/negative pairs with layered structure)
**Model Selection:** Dynamic list with latest Gemini models (2.0, 2.5)
**Output:** Optimized prompts following community best practices
**API:** Requires Gemini API key (free tier available)
**Use Case:** Recreate similar style/mood from reference images
**Refresh:** Click button to fetch latest available models
### Display Any Example
```
Any Node → Display Any → Debug Output
🔍 tensor ↘ mode: shape ↘ "[[1, 3, 512, 512]]"
```
**Input:** Any data type (image, latent, config, etc.)
**Mode:** "raw value" or "tensor shape"
**Output:** Formatted display of value or tensor dimensions
**Use Case:** Debug workflows, inspect data structures
### Image to Multiple Of Example
```
Load Image → Image to Multiple Of → VAE Encode → KSampler
🖼️ 513×769 ↘ multiple: 64 ↘ 512×768 → latent
method: crop
```
**Input:** Image with arbitrary dimensions
**Multiple Of:** 64 (common for VAE compatibility)
**Method:** "center crop" or "rescale"
**Output:** Adjusted image with compatible dimensions
### Common Workflows
@@ -300,6 +420,10 @@ Load Image → Gemini Prompt → Text Generation Model
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) |
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -593,14 +717,15 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 6 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image)
- **Nodes**: 10 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image, Display Text, Gemini Prompt Engineer, Display Any, Image to Multiple Of)
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 4 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer)
- **Interactive Features**: 6 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer, Display Text Split View, Gemini Model Refresh)
- **AI Integration**: Gemini API with 40+ model support
- **Test Coverage**: 100% (200+ comprehensive tests)
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
---
+66
View File
@@ -0,0 +1,66 @@
# Security Policy
## Supported Versions
ComfyUI-KikoTools is actively maintained. We provide security updates for the following versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
## Reporting a Vulnerability
We take the security of ComfyUI-KikoTools seriously. If you believe you have found a security vulnerability, please report it to us as described below.
### How to Report
Please report security vulnerabilities by [opening a new issue](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/new) with the following:
- Use the title prefix `[SECURITY]`
- Provide a clear description of the vulnerability
- Include steps to reproduce the issue
- Specify the version(s) affected
- If possible, suggest a fix or mitigation
### What to Expect
- **Response Time**: We aim to acknowledge receipt within 48 hours
- **Investigation**: We will investigate and validate the reported vulnerability
- **Updates**: We will keep you informed about the progress
- **Resolution**: Once verified, we will work on a fix and release it as soon as possible
- **Credit**: We will acknowledge your contribution in the release notes (unless you prefer to remain anonymous)
### Scope
Security vulnerabilities in scope include:
- Code execution vulnerabilities in node implementations
- Path traversal or file system access issues
- API key or credential exposure
- Dependency vulnerabilities that affect the project
- Any issue that could compromise user data or system security
### Out of Scope
The following are generally not considered security vulnerabilities:
- Issues in ComfyUI core (report these to the ComfyUI project)
- Performance issues
- Bugs that don't have security implications
- Feature requests
## Security Best Practices
When using ComfyUI-KikoTools:
- Keep your installation up to date
- Store API keys (like Gemini API keys) securely using environment variables
- Review generated files before sharing them
- Be cautious with custom prompts that might expose sensitive information
## Contact
For urgent security matters, you can also reach out to the maintainers directly through GitHub.
Thank you for helping keep ComfyUI-KikoTools secure!
+148
View File
@@ -0,0 +1,148 @@
# ComfyUI XYZ Grid Comparison Nodes
## Project Objective
Create a modular suite of ComfyUI nodes for visual grid-based comparisons across parameters such as:
- Models
- LoRAs
- Schedulers
- Samplers
- CFG Scale
- Steps
- Clip Skip
- VAEs
- Flux Guidance (custom model settings)
The tool will support X, Y, and optional Z axis configuration using a polished, intuitive UI with no scripting or coding required.
---
## Design Goals
- **Modular Architecture:** Built as multiple nodes (not monolithic)
- **Standard Node Compatibility:** Work with *any* KSampler, Model Loader, etc.
- **User Friendly UI:** Dropdowns, toggles, and visual input—no syntax or scripting
- **Flexible Axis Mapping:** Any parameter can go on X, Y, or Z
- **Dynamic Grid Generation:** One-click execution queues all combinations
- **Labeling:** Automatic overlay and metadata support with clean presentation
- **High Performance:** Smart resource caching and sequential queuing
---
## Key Nodes
### 1. `XYZ Plot Controller`
- Main config node
- Allows axis selection (X, Y, optional Z)
- Outputs: axis values, labels, grid ID
- Automatically queues image generation
### 2. `Image Grid Combiner`
- Accepts image + axis metadata
- Assembles a labeled grid (or multiple grids)
- Outputs: grid image(s), optional metadata (label list, value list)
---
## Parameter Types
Supported as axis values:
- Model (checkpoint)
- LoRA (file)
- VAE
- Sampler (Euler, DPM++, etc.)
- Scheduler
- CFG Scale (float list)
- Steps (int list)
- Clip Skip
- Prompt (swap full prompt or use template)
- Seed
- Custom (e.g., Flux guidance strength)
---
## UI Design
### Axis Config (for X, Y, Z)
- Dropdown: Select parameter type
- Input: List of values (dynamic UI)
- File pickers (models, LoRAs)
- Number range or CSV (steps, CFG)
- Text input (prompts)
- Label customization
- Prefix: optional (e.g., CFG=, Sampler:)
- Label format: full, short, value only
### Execution
- One-click generate
- Internally queues all combinations (X * Y * Z)
- Reuses sampler, model loader, etc.
- Supports caching to avoid repeated loads
---
## Output Behavior
- Combiner tracks image count
- Assembles grid when complete
- Draws axis labels using PIL
- Handles Z axis by outputting multiple grids
- Preview as images come in
- Metadata export (optional JSON/text)
---
## Example Use Cases
### Model vs CFG
- X: Models A/B
- Y: CFG [5,10,15]
- Output: 2x3 grid with axis labels
### Prompt vs Sampler
- X: Prompt variations
- Y: Samplers
- Output: labeled comparison grid
### LoRA vs Seed, Z=Strength
- X: LoRA name
- Y: Seeds
- Z: LoRA strength
- Output: Multiple 2D grids, one per Z value
---
## Development Phases
### Phase 1: MVP
- X/Y support
- Core image generation loop
- Grid image stitching
### Phase 2: Z Axis + More Parameters
- Prompt, LoRA, Flux guidance, etc.
### Phase 3: UI Polish
- Dynamic widgets
- Label controls, error handling
### Phase 4: Performance & Optimization
- Model caching
- Memory handling
- Abort/resume logic
### Phase 5: Docs & Examples
- Example workflows
- Visual documentation
---
## References & Inspirations
- [TinyTerra ComfyUI_tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes)
- [kenjiqq/qq-nodes-comfyui](https://github.com/kenjiqq/qq-nodes-comfyui)
- [jags111/efficiency-nodes-comfyui](https://github.com/jags111/efficiency-nodes-comfyui)
- [shockz-comfy/comfy-easy-grids](https://github.com/shockz-comfy/comfy-easy-grids)
---
## Final Outcome
A polished, no-code, modular XYZ plotting system in ComfyUI for exploring image generation across any combination of models, settings, or parameters with professional-grade visual output.
+394
View File
@@ -0,0 +1,394 @@
# RGThree-Style Dynamic Widget Framework for ComfyUI
This document explains how to implement RGThree's Power Lora Loader-style dynamic widget system in your own ComfyUI nodes. This framework provides a clean UI with toggles, dynamic widget management, and proper persistence across page refreshes.
## Key Features
- **Dynamic widget addition/removal** - Users can add/remove items at runtime
- **Toggle switches** - Clean circular toggles instead of checkboxes
- **Strength controls** - Arrow buttons with editable values for fine control
- **Right-click context menus** - Only on the item name area
- **Full persistence** - All values persist across page refreshes
- **Hide/show widgets** - Proper cleanup when switching between types
## Core Implementation Pattern
### 1. Node Setup in JavaScript
```javascript
app.registerExtension({
name: "YourExtension.YourNode",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "YourNodeName") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Enable widget serialization
this.serialize_widgets = true;
// Track widget visibility
this.hiddenWidgets = new Set();
// Initialize storage for dynamic widgets
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
category1: [],
category2: []
};
}
// Store references to buttons and text widgets
if (!node.addButtons) {
node.addButtons = {};
}
if (!node.textWidgets) {
node.textWidgets = {};
}
};
}
}
});
```
### 2. Custom Widget Class
```javascript
class DynamicWidget {
constructor(name, value) {
this.name = name;
this._value = value;
this.type = "custom_dynamic_widget";
this.y = 0;
this.options = {};
// Mouse tracking for drag operations
this.mouseState = {
dragging: false,
startX: 0,
startValue: 0,
lastClickTime: 0
};
}
get value() {
return this._value;
}
set value(v) {
this._value = v;
}
serializeValue(node, index) {
// Return a deep copy to prevent modification
return this._value ? { ...this._value } : null;
}
draw(ctx, node, width, y) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = y + height / 2;
let posX = margin;
ctx.save();
// Draw background
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
// Draw toggle (Power Lora style)
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
// Toggle background
ctx.beginPath();
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
// Toggle circle
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
ctx.fillStyle = this.value.on ? "#89B" : "#888";
ctx.beginPath();
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
ctx.fill();
this.toggleBounds = [posX, toggleBgWidth];
posX += toggleBgWidth + innerMargin;
// Apply opacity if disabled
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Draw strength controls (if applicable)
if (this.value.strength !== undefined) {
let strengthX = width - margin - innerMargin;
// Draw arrows and value
// ... (implement arrow drawing as shown in xyz_plot_controller.js)
}
// Draw item name
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(this.value.name || "None", posX, midY);
ctx.restore();
}
mouse(event, pos, node) {
// Handle mouse events for toggle and controls
if (event.type === "mousedown") {
// Check toggle bounds
if (pos[0] >= this.toggleBounds[0] &&
pos[0] <= this.toggleBounds[0] + this.toggleBounds[1]) {
this.value.on = !this.value.on;
node.setDirtyCanvas(true, true);
return true;
}
// Handle other controls...
}
return false;
}
}
```
### 3. Configuration and Restoration
```javascript
// Override onConfigure for proper restoration
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
// Mark as configured to prevent duplicate initialization
this._configured = true;
// Store widget values before ComfyUI modifies them
const savedWidgetValues = [...(info.widgets_values || [])];
// Clear tracking for fresh restoration
if (!this.hiddenWidgets) {
this.hiddenWidgets = new Set();
}
this.dynamicWidgets = { /* categories */ };
this.addButtons = {};
this.textWidgets = {};
// Let ComfyUI restore base widgets
if (onConfigure) {
onConfigure.call(this, info);
}
// Restore dynamic widgets from saved values
// ... (implement restoration logic)
// Manually restore text widget values
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
if (widget && typeof savedValue === 'string' && savedValue !== '') {
widget.value = savedValue;
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
}
}
};
```
### 4. Serialization Override
```javascript
// Override onSerialize to fix widget value persistence
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
// Let ComfyUI serialize first
if (origOnSerialize) {
origOnSerialize.call(this, info);
}
// Fix empty text widget values
if (info.widgets_values && this.widgets) {
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
const widget = this.widgets[i];
const serializedValue = info.widgets_values[i];
// If serialized value is empty but widget has value, fix it
if ((serializedValue === '' || serializedValue === null) &&
widget && widget.value !== '' && widget.value !== null) {
info.widgets_values[i] = widget.value;
}
// Also check inputEl for text widgets
if (widget && widget.inputEl && widget.inputEl.value &&
(serializedValue === '' || serializedValue === null)) {
info.widgets_values[i] = widget.inputEl.value;
}
}
}
};
```
### 5. Right-Click Context Menu
```javascript
// Override getSlotInPosition to detect clicks on widget areas
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(x, y) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
if (!slot) {
// Check if we clicked on a dynamic widget's name area
const localX = x - this.pos[0];
const localY = y - this.pos[1];
for (const w of this.widgets || []) {
if (w.type === "custom_dynamic_widget" && w.y &&
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
// Check if click is within name bounds
if (w.nameBounds && localX >= w.nameBounds[0] &&
localX <= w.nameBounds[0] + w.nameBounds[1]) {
return { widget: w, output: { type: "DYNAMIC_WIDGET" } };
}
}
}
}
return slot;
};
// Override getSlotMenuOptions for context menu
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot?.output?.type === "DYNAMIC_WIDGET") {
const widget = slot.widget;
const menuItems = [
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
}
},
{
content: `⬆️ Move Up`,
disabled: !canMoveUp,
callback: () => { /* implement move */ }
},
{
content: `⬇️ Move Down`,
disabled: !canMoveDown,
callback: () => { /* implement move */ }
},
{
content: `🗑️ Remove`,
callback: () => { /* implement remove */ }
}
];
new LiteGraph.ContextMenu(menuItems, {
title: "WIDGET OPTIONS",
event: app.canvas.last_mouse_event || window.event
});
return null; // Prevent default menu
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
```
### 6. Widget Visibility Management
```javascript
function updateWidgets(node, category, type, skipClear = false) {
// Hide/show widgets instead of removing them
if (!skipClear) {
// Hide all widgets for this category
node.widgets?.forEach(widget => {
if (widget.name?.includes(category)) {
widget.hidden = true;
widget.computeSize = () => [0, 0];
node.hiddenWidgets?.add(widget.name);
}
});
// Clear dynamic widgets
if (node.dynamicWidgets[category]) {
while (node.dynamicWidgets[category].length > 0) {
const widget = node.dynamicWidgets[category].pop();
const index = node.widgets.indexOf(widget);
if (index > -1) {
node.widgets.splice(index, 1);
}
}
}
}
// Add or unhide widgets based on type
if (needsTextWidget(type)) {
const widgetName = `${category}_text`;
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
// Create new widget
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true
}]);
node.textWidgets[category] = textWidget.widget;
} else {
// Unhide existing widget
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
node.textWidgets[category] = existingWidget;
}
}
}
```
## Best Practices
1. **Always use hide/show instead of remove/add** for text widgets to preserve values
2. **Track widget state** in dedicated objects (dynamicWidgets, textWidgets, etc.)
3. **Override serialization** to ensure ComfyUI properly saves widget values
4. **Use skipClear flags** during restoration to prevent widget clearing
5. **Implement proper mouse bounds checking** for custom controls
6. **Store metadata** (_axis, _type) with widget values for easier restoration
7. **Don't auto-resize nodes** - respect user's manual sizing
## Common Pitfalls to Avoid
1. **Don't remove widgets during configure** - this loses their values
2. **Don't rely on widget indices** - they can change
3. **Don't forget to handle inputEl** for text widgets
4. **Don't create widgets without checking if they exist** first
5. **Always deep copy values** when serializing to prevent modification
## Testing Checklist
- [ ] Widgets persist across page refresh
- [ ] Toggle states are maintained
- [ ] Strength/value controls work with click and drag
- [ ] Right-click menu only appears on name area
- [ ] Moving widgets up/down works correctly
- [ ] Removing widgets works without errors
- [ ] Switching between types doesn't leave artifacts
- [ ] All text input types persist (numbers, ranges, prompts)
- [ ] Hidden widgets don't take up visual space
- [ ] Widget values serialize correctly in workflow JSON
This framework provides a robust foundation for creating professional, user-friendly ComfyUI nodes with dynamic widget management that matches the quality of RGThree's implementations.
+684
View File
@@ -0,0 +1,684 @@
# RGThree Widget Framework - Complete Example Implementation
This file provides a complete, working example of implementing the RGThree-style widget framework for a hypothetical "Advanced Sampler Controller" node.
## Complete Implementation Example
```javascript
// File: web/advanced_sampler_controller.js
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
// Widget counter for unique names
let widgetCounter = 0;
// Custom dynamic widget class
class SamplerDynamicWidget {
constructor(name, value) {
this.name = name;
this._value = value;
this.type = "sampler_dynamic_widget";
this.y = 0;
this.options = {};
// Mouse state for drag operations
this.mouseState = {
dragging: false,
startX: 0,
startValue: 0,
lastClickTime: 0
};
}
get value() {
return this._value;
}
set value(v) {
this._value = v;
}
serializeValue(node, index) {
return this._value ? { ...this._value } : null;
}
draw(ctx, node, width, y) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = y + height / 2;
let posX = margin;
ctx.save();
// Background
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
// Toggle
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
// Toggle background
ctx.beginPath();
ctx.roundRect(posX + 4, y + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
// Toggle circle
const toggleX = this.value.on ? posX + height : posX + height * 0.5;
ctx.fillStyle = this.value.on ? "#89B" : "#888";
ctx.beginPath();
ctx.arc(toggleX, midY, toggleRadius, 0, Math.PI * 2);
ctx.fill();
// Store bounds for mouse interaction
this.toggleBounds = [posX, toggleBgWidth];
posX += toggleBgWidth + innerMargin;
// Apply opacity if disabled
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Strength controls and value
let strengthX = width - margin - innerMargin;
// Down arrow
const arrowSize = 10;
const arrowX = strengthX - arrowSize;
ctx.fillStyle = "#666";
ctx.beginPath();
ctx.moveTo(arrowX + arrowSize/2, midY + 3);
ctx.lineTo(arrowX + 2, midY - 3);
ctx.lineTo(arrowX + arrowSize - 2, midY - 3);
ctx.closePath();
ctx.fill();
this.downArrowBounds = [arrowX, arrowSize];
strengthX = arrowX - innerMargin;
// Up arrow
const upArrowX = strengthX - arrowSize;
ctx.beginPath();
ctx.moveTo(upArrowX + arrowSize/2, midY - 3);
ctx.lineTo(upArrowX + 2, midY + 3);
ctx.lineTo(upArrowX + arrowSize - 2, midY + 3);
ctx.closePath();
ctx.fill();
this.upArrowBounds = [upArrowX, arrowSize];
strengthX = upArrowX - innerMargin;
// Strength value
const strengthText = this.value.strength.toFixed(2);
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.font = `${ctx.font}`;
const textMetrics = ctx.measureText(strengthText);
const strengthTextX = strengthX - textMetrics.width/2 - 4;
// Draggable background
ctx.fillStyle = "rgba(255,255,255,0.1)";
ctx.beginPath();
ctx.roundRect(strengthTextX - textMetrics.width/2 - 2, y + 4,
textMetrics.width + 4, height - 8, [3]);
ctx.fill();
// Value text
ctx.fillStyle = this.value.on ? "#FFF" : "#AAA";
ctx.fillText(strengthText, strengthTextX, midY);
this.strengthBounds = [strengthTextX - textMetrics.width/2 - 2, textMetrics.width + 4];
// Name
const nameX = posX;
const maxNameWidth = strengthTextX - textMetrics.width/2 - nameX - 10;
ctx.textAlign = "left";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
// Clip long names
const displayName = this.value.name || "None";
let truncatedName = displayName;
if (ctx.measureText(displayName).width > maxNameWidth) {
while (truncatedName.length > 0 &&
ctx.measureText(truncatedName + "...").width > maxNameWidth) {
truncatedName = truncatedName.slice(0, -1);
}
truncatedName += "...";
}
ctx.fillText(truncatedName, nameX, midY);
// Store name bounds for right-click detection
this.nameBounds = [nameX, ctx.measureText(truncatedName).width];
ctx.restore();
}
mouse(event, pos, node) {
const margin = 10;
const localX = pos[0] - margin;
if (event.type === "mousedown") {
// Toggle click
if (localX >= this.toggleBounds[0] &&
localX <= this.toggleBounds[0] + this.toggleBounds[1]) {
this.value.on = !this.value.on;
node.setDirtyCanvas(true, true);
return true;
}
// Up arrow
if (localX >= this.upArrowBounds[0] &&
localX <= this.upArrowBounds[0] + this.upArrowBounds[1]) {
this.value.strength = Math.min(this.value.strength + 0.1, 10);
node.setDirtyCanvas(true, true);
return true;
}
// Down arrow
if (localX >= this.downArrowBounds[0] &&
localX <= this.downArrowBounds[0] + this.downArrowBounds[1]) {
this.value.strength = Math.max(this.value.strength - 0.1, -10);
node.setDirtyCanvas(true, true);
return true;
}
// Strength drag start
if (localX >= this.strengthBounds[0] &&
localX <= this.strengthBounds[0] + this.strengthBounds[1]) {
this.mouseState.dragging = true;
this.mouseState.startX = pos[0];
this.mouseState.startValue = this.value.strength;
// Double-click detection
const now = Date.now();
if (now - this.mouseState.lastClickTime < 300) {
// Double-click - show input dialog
const newValue = prompt("Enter strength value:", this.value.strength);
if (newValue !== null && !isNaN(parseFloat(newValue))) {
this.value.strength = Math.max(-10, Math.min(10, parseFloat(newValue)));
node.setDirtyCanvas(true, true);
}
this.mouseState.dragging = false;
}
this.mouseState.lastClickTime = now;
return true;
}
}
else if (event.type === "mousemove" && this.mouseState.dragging) {
const deltaX = pos[0] - this.mouseState.startX;
const sensitivity = 0.01;
this.value.strength = Math.max(-10, Math.min(10,
this.mouseState.startValue + deltaX * sensitivity));
node.setDirtyCanvas(true, true);
return true;
}
else if (event.type === "mouseup") {
this.mouseState.dragging = false;
}
return false;
}
computeSize() {
return [node.size[0], LiteGraph.NODE_WIDGET_HEIGHT];
}
}
// Main extension registration
app.registerExtension({
name: "Example.AdvancedSamplerController",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "AdvancedSamplerController") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Enable widget serialization
this.serialize_widgets = true;
// Initialize tracking
this.hiddenWidgets = new Set();
// Initialize storage
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
samplers: [],
schedulers: []
};
}
if (!node.addButtons) {
node.addButtons = {};
}
if (!node.textWidgets) {
node.textWidgets = {};
}
// Override configuration
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
this._configured = true;
// Save widget values before ComfyUI modifies them
const savedWidgetValues = [...(info.widgets_values || [])];
// Clear for fresh restoration
if (!this.hiddenWidgets) {
this.hiddenWidgets = new Set();
}
this.dynamicWidgets = {
samplers: [],
schedulers: []
};
this.addButtons = {};
this.textWidgets = {};
// Let ComfyUI restore base widgets
if (onConfigure) {
onConfigure.call(this, info);
}
// Restore dynamic widgets
let widgetIndex = this.widgets.length;
for (let i = widgetIndex; i < savedWidgetValues.length; i++) {
const value = savedWidgetValues[i];
if (value && typeof value === 'object' && value._type) {
const widget = new SamplerDynamicWidget(
`dynamic_${widgetCounter++}`,
value
);
this.addCustomWidget(widget);
if (this.dynamicWidgets[value._type]) {
this.dynamicWidgets[value._type].push(widget);
}
}
}
// Restore text widget values
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
if (widget && typeof savedValue === 'string' && savedValue !== '') {
widget.value = savedValue;
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
}
}
// Update UI based on restored state
if (this.widgets?.length > 0) {
const typeWidget = this.widgets.find(w => w.name === "sampler_type");
if (typeWidget) {
updateTypeWidgets(this, typeWidget.value, true);
}
}
};
// Override serialization
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
if (origOnSerialize) {
origOnSerialize.call(this, info);
}
// Fix empty text widget values
if (info.widgets_values && this.widgets) {
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
const widget = this.widgets[i];
const serializedValue = info.widgets_values[i];
if ((serializedValue === '' || serializedValue === null) &&
widget && widget.value !== '' && widget.value !== null) {
info.widgets_values[i] = widget.value;
}
if (widget && widget.inputEl && widget.inputEl.value &&
(serializedValue === '' || serializedValue === null)) {
info.widgets_values[i] = widget.inputEl.value;
}
}
}
};
// Implement right-click context menu
implementContextMenu(node);
// Widget change handlers
const samplerWidget = this.widgets.find(w => w.name === "sampler_type");
if (samplerWidget) {
const origCallback = samplerWidget.callback;
samplerWidget.callback = function() {
if (origCallback) {
origCallback.apply(this, arguments);
}
updateTypeWidgets(node, samplerWidget.value);
};
}
};
}
}
});
// Helper function to update widgets based on type
function updateTypeWidgets(node, type, skipClear = false) {
if (!skipClear) {
// Hide text widgets
node.widgets?.forEach(widget => {
if (widget.name?.includes("custom_values")) {
widget.hidden = true;
widget.computeSize = () => [0, 0];
node.hiddenWidgets?.add(widget.name);
}
});
// Clear dynamic widgets
if (node.dynamicWidgets.samplers) {
while (node.dynamicWidgets.samplers.length > 0) {
const widget = node.dynamicWidgets.samplers.pop();
const index = node.widgets.indexOf(widget);
if (index > -1) {
node.widgets.splice(index, 1);
}
}
}
}
// Add or unhide widgets based on type
if (type === "custom") {
const widgetName = "custom_values";
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: true
}]);
node.textWidgets.custom = textWidget.widget;
} else {
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20, LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
node.textWidgets.custom = existingWidget;
}
} else if (type === "samplers") {
// Add button for samplers
if (!node.addButtons.samplers) {
const button = node.addWidget("button", "+ Add Sampler", null, () => {
addDynamicWidget(node, "samplers");
});
node.addButtons.samplers = button;
}
}
}
// Helper function to add dynamic widgets
function addDynamicWidget(node, type) {
const widget = new SamplerDynamicWidget(
`dynamic_${widgetCounter++}`,
{
on: true,
name: type === "samplers" ? "euler" : "normal",
strength: 1.0,
_type: type
}
);
node.addCustomWidget(widget);
node.dynamicWidgets[type].push(widget);
}
// Helper function to implement context menu
function implementContextMenu(node) {
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(x, y) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
if (!slot) {
const localX = x - this.pos[0];
const localY = y - this.pos[1];
for (const w of this.widgets || []) {
if (w.type === "sampler_dynamic_widget" && w.y &&
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
if (w.nameBounds && localX >= w.nameBounds[0] &&
localX <= w.nameBounds[0] + w.nameBounds[1]) {
return { widget: w, output: { type: "SAMPLER_WIDGET" } };
}
}
}
}
return slot;
};
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot?.output?.type === "SAMPLER_WIDGET") {
const widget = slot.widget;
const arrayName = widget.value._type;
const array = this.dynamicWidgets[arrayName];
const currentIndex = array.indexOf(widget);
const menuItems = [
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
}
},
{
content: `⬆️ Move Up`,
disabled: currentIndex === 0,
callback: () => {
if (currentIndex > 0) {
// Swap in array
[array[currentIndex - 1], array[currentIndex]] =
[array[currentIndex], array[currentIndex - 1]];
// Swap in widgets
const widgetIndex = this.widgets.indexOf(widget);
const prevWidget = array[currentIndex];
const prevIndex = this.widgets.indexOf(prevWidget);
if (widgetIndex > -1 && prevIndex > -1) {
[this.widgets[prevIndex], this.widgets[widgetIndex]] =
[this.widgets[widgetIndex], this.widgets[prevIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
{
content: `⬇️ Move Down`,
disabled: currentIndex === array.length - 1,
callback: () => {
if (currentIndex < array.length - 1) {
// Swap in array
[array[currentIndex], array[currentIndex + 1]] =
[array[currentIndex + 1], array[currentIndex]];
// Swap in widgets
const widgetIndex = this.widgets.indexOf(widget);
const nextWidget = array[currentIndex];
const nextIndex = this.widgets.indexOf(nextWidget);
if (widgetIndex > -1 && nextIndex > -1) {
[this.widgets[widgetIndex], this.widgets[nextIndex]] =
[this.widgets[nextIndex], this.widgets[widgetIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
null, // Separator
{
content: `🗑️ Remove`,
callback: () => {
const index = array.indexOf(widget);
if (index > -1) {
array.splice(index, 1);
}
const wIndex = this.widgets.indexOf(widget);
if (wIndex > -1) {
this.widgets.splice(wIndex, 1);
}
this.setDirtyCanvas(true, true);
}
}
];
new LiteGraph.ContextMenu(menuItems, {
title: "SAMPLER OPTIONS",
event: app.canvas.last_mouse_event || window.event
});
return null;
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
}
```
## Python Node Definition
```python
# File: kikotools/tools/advanced_sampler_controller/node.py
class AdvancedSamplerController:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sampler_type": (["samplers", "custom", "schedulers"], {
"default": "samplers"
}),
"enabled": ("BOOLEAN", {"default": True}),
},
"optional": {
"custom_values": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = ("SAMPLER_CONFIG",)
RETURN_NAMES = ("config",)
FUNCTION = "process"
CATEGORY = "ComfyAssets"
def process(self, sampler_type, enabled, custom_values="", **kwargs):
config = {
"type": sampler_type,
"enabled": enabled,
"samplers": [],
"custom": custom_values
}
# Process dynamic widgets
for key, value in kwargs.items():
if isinstance(value, dict) and value.get("_type") == "samplers":
if value.get("on", False):
config["samplers"].append({
"name": value.get("name"),
"strength": value.get("strength", 1.0)
})
return (config,)
```
## Key Implementation Points
1. **Widget Class Design**
- Custom widget class with proper value getter/setter
- `serializeValue` method for persistence
- Complete `draw` and `mouse` methods
- Proper bounds tracking for all interactive elements
2. **Node Setup**
- `serialize_widgets = true` in onNodeCreated
- Tracking objects for dynamic widgets, buttons, and text widgets
- Hidden widgets set for visibility management
3. **Configuration Override**
- Save widget values before ComfyUI modifies them
- Clear tracking objects for fresh restoration
- Restore dynamic widgets from saved values
- Manually restore text widget values
4. **Serialization Override**
- Fix empty text widget values
- Check both widget.value and widget.inputEl.value
- Ensure all widget types persist correctly
5. **Context Menu Implementation**
- Override getSlotInPosition to detect widget clicks
- Check name bounds for right-click detection
- Return custom slot type for menu trigger
- Override getSlotMenuOptions for menu items
6. **Widget Management**
- Hide/show pattern instead of remove/add
- Proper cleanup when switching types
- Dynamic widget arrays for organization
- Button widgets for adding new items
## Testing Your Implementation
1. **Create Test Workflow**
```json
{
"nodes": [{
"type": "AdvancedSamplerController",
"widgets_values": [
"samplers",
true,
"",
{
"on": true,
"name": "euler",
"strength": 0.8,
"_type": "samplers"
}
]
}]
}
```
2. **Test Checklist**
- [ ] Add dynamic widgets with button
- [ ] Toggle on/off states persist
- [ ] Strength values persist after refresh
- [ ] Right-click menu only on name area
- [ ] Move up/down works correctly
- [ ] Remove widget works
- [ ] Switch types doesn't leave artifacts
- [ ] Text values persist
- [ ] Double-click to edit strength works
3. **Debug Tips**
- Add console.log in key methods
- Check browser console for errors
- Verify widget array contents
- Test with workflow JSON export/import
This complete example demonstrates all aspects of the RGThree widget framework and can be adapted for any custom node that needs dynamic widget management with professional UI/UX.
+147
View File
@@ -0,0 +1,147 @@
# Display Text
The Display Text node provides advanced text display capabilities with smart formatting, interactive features, and responsive design for ComfyUI workflows.
## Features
- **Smart Prompt Detection**: Automatically detects and formats SDXL-style positive/negative prompt pairs
- **Text Wrapping**: Proper word wrapping that reflows when node is resized
- **Scrollable Content**: Mouse wheel scrolling for long texts with visual indicators
- **Copy Functionality**: Always-visible copy button with visual feedback
- **Split View Mode**: Side-by-side display for prompt pairs
- **Responsive Design**: Content adapts to node resizing
## Inputs
- **text** (STRING): The text to display
- Can be a single text block
- Can contain "Positive prompt:" and "Negative prompt:" sections for automatic split view
## Outputs
- **text** (STRING): Pass-through of the input text
## Display Modes
### Single Text Mode
When the input is regular text without prompt markers, it displays as a single scrollable text area with:
- Word wrapping at word boundaries
- Vertical scrolling for long content
- Single copy button for the entire text
### Split View Mode
Automatically activated when text contains both "Positive prompt:" and "Negative prompt:" sections:
- Side-by-side display with 50/50 split
- Independent scrolling for each section
- Separate copy buttons for each prompt
- Labels are stripped when copying (clean prompts)
## Usage Examples
### 1. Display Generated Prompts
```
Gemini Prompt → Display Text → Copy to workflow
```
The node automatically detects SDXL format and shows positive/negative prompts side-by-side.
### 2. Debug Text Processing
```
Text Processing → Display Text → Further Processing
```
View intermediate text processing results with proper formatting.
### 3. Show Long Descriptions
```
Load Text → Display Text → Review
```
Display long text content with scrolling and word wrapping.
## Interactive Features
### Copy Button
- Always visible in the top-right corner
- Shows "✓ Copied!" feedback on click
- In split view: separate buttons for each section
- Strips prompt labels for clean copying
### Scrolling
- Mouse wheel scrolling when hovering over text
- Visual indicators appear when content is scrollable
- Smooth scrolling with proper boundaries
- Independent scrolling in split view mode
### Resizing
- Text reflows when node width changes
- Maintains readability at different sizes
- Split view maintains 50/50 proportions
- Minimum height ensures usability
## Smart Prompt Detection
The node intelligently detects prompt formats:
1. **SDXL Format**:
- Looks for "Positive prompt:" and "Negative prompt:" markers
- Case-insensitive detection
- Handles various formatting styles
2. **Label Stripping**:
- When copying from split view, labels are removed
- "Positive prompt: beautiful sunset" → "beautiful sunset"
- Clean prompts ready for direct use
## Styling
- **Font**: Monospace for consistent alignment
- **Colors**:
- Text: Light gray (#ddd) on dark background
- Background: Semi-transparent dark (#1a1a1a)
- Borders: Subtle gray (#333)
- **Spacing**: Comfortable padding and line height
- **Visual Feedback**: Hover effects on interactive elements
## Use Cases
### Prompt Engineering Workflows
- Display AI-generated prompts with proper formatting
- Compare positive and negative prompts side-by-side
- Copy refined prompts without manual cleanup
### Text Processing Pipelines
- Debug text transformations at each step
- View formatted outputs from text nodes
- Monitor prompt construction workflows
### Documentation and Notes
- Display workflow instructions
- Show generation parameters
- Present formatted metadata
## Technical Details
- **Text Processing**: Preserves original text while adding display formatting
- **Responsive Design**: CSS-based layout adapts to node dimensions
- **Event Handling**: Proper event propagation for ComfyUI compatibility
- **Memory Efficient**: Only renders visible text portions
## Tips
1. **For Long Prompts**: The scrolling feature handles texts of any length efficiently
2. **Quick Copy**: Use the copy buttons to quickly grab prompts for other nodes
3. **Resizing**: Drag node edges to find optimal display width for your content
4. **Split View**: Works best with SDXL-format prompts but handles any dual-section text
## Integration Example
```
[Gemini Prompt Engineer] → [Display Text] → [Copy Button Click]
↓ ↓ ↓
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.
+51 -4
View File
@@ -8,6 +8,9 @@ The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and ge
- **Custom Prompts**: Override templates with your own system prompts
- **Visual Feedback**: UI shows processing status and error states
- **Flexible API Key Management**: Multiple ways to provide API credentials
- **Dynamic Model Selection**: Fetch and use latest Gemini models with refresh button
- **Model Caching**: Persistent storage of available models for offline access
- **Help Integration**: Built-in setup guide accessible via help button
## Setup
@@ -49,6 +52,10 @@ Choose one of these methods:
- `sdxl`: Positive/negative prompt pairs with weight emphasis
- `danbooru`: Anime-style booru tags with underscores
- `video`: Motion and temporal descriptions for video generation
- **model** (DROPDOWN): Gemini model selection
- Dynamically populated list of available models
- Includes latest models like gemini-2.0-flash-exp
- Click refresh button to update model list
- **api_key** (STRING, optional): Gemini API key if not set elsewhere
- **custom_prompt** (STRING, optional): Override template with custom system prompt
@@ -72,15 +79,27 @@ majestic mountain landscape at golden hour, oil painting style, dramatic lightin
```
### SDXL Format
Generates positive and negative prompt pairs:
- Detailed positive prompts with weight emphasis
Generates positive and negative prompt pairs with enhanced structure:
- Layered positive prompts: main subject → style → composition → technical
- Comprehensive negative prompts to avoid common issues
- Uses parentheses for emphasis: `(detailed eyes:1.2)`
- Includes quality boosters and technical specifications
Example output:
```
Positive: beautiful woman, (detailed eyes:1.2), flowing red dress, golden hour lighting, professional photography, 85mm lens, shallow depth of field, bokeh, high resolution, masterpiece
Negative: low quality, blurry, distorted features, bad anatomy, poorly drawn, amateur, oversaturated, jpeg artifacts
Positive prompt:
beautiful woman with flowing red hair, elegant pose, (detailed eyes:1.2), serene expression
oil painting style, renaissance art influence, classical portraiture
golden hour lighting, warm color palette, soft shadows, dramatic chiaroscuro
centered composition, rule of thirds, shallow depth of field, bokeh background
masterpiece, best quality, highly detailed, 8k uhd, professional artwork
Negative prompt:
low quality, worst quality, blurry, out of focus, pixelated, low resolution
bad anatomy, deformed features, extra limbs, missing limbs, disconnected limbs
poorly drawn face, poorly drawn hands, amateur drawing, bad proportions
oversaturated, overexposed, underexposed, bad lighting, harsh shadows
jpeg artifacts, watermark, signature, text, cropped, duplicate
```
### Danbooru Format
@@ -130,12 +149,40 @@ The node provides clear error messages for common issues:
Errors are displayed in the prompt output for easy debugging.
## Model Selection
### Dynamic Model List
- Click the refresh button (🔄) next to the model dropdown to fetch latest models
- Models are fetched from Google's API and include all available versions
- Common models include:
- `gemini-2.0-flash-exp`: Latest experimental flash model
- `gemini-1.5-pro`: Advanced model with larger context
- `gemini-1.5-flash`: Fast and efficient for most tasks
### Model Caching
- Available models are cached locally for offline access
- Cache persists across ComfyUI sessions
- Refresh button updates the cache with latest models
## UI Features
### Help Button
- Click the help button (?) for quick setup instructions
- Shows API key setup methods
- Links to Google AI Studio for key generation
### Status Indicators
- Processing spinner during API calls
- Error messages displayed in red
- Success feedback when prompt is generated
## Tips
1. **API Usage**: Gemini has generous free tier limits, but be mindful of rate limits
2. **Image Quality**: Higher resolution images provide better analysis results
3. **Prompt Refinement**: You can chain multiple Gemini nodes with different custom prompts
4. **Caching**: Results are not cached, so identical images will make new API calls
5. **Model Selection**: Use flash models for faster responses, pro models for complex analysis
## Example Workflow
+213
View File
@@ -0,0 +1,213 @@
# Kiko Save Image
Enhanced image saving node with multiple format support, quality controls, and an interactive floating popup viewer for ComfyUI.
## Features
- **Multiple Format Support**: Save as PNG, JPEG, or WebP with format-specific optimizations
- **Advanced Quality Controls**: Fine-tune compression settings per format
- **Floating Popup Viewer**: Interactive window showing saved images immediately
- **Batch Operations**: Multi-select images for bulk actions
- **File Size Display**: Real-time feedback on compression effectiveness
- **Smart UI**: Auto-hide, draggable, resizable popup window
## Inputs
- **images** (IMAGE): Batch of images to save
- **filename_prefix** (STRING): Prefix for saved filenames
- Default: "KikoSave"
- Supports subfolder paths (e.g., "outputs/renders/final")
- **format** (DROPDOWN): Output format selection
- `PNG`: Lossless compression, best quality
- `JPEG`: Lossy compression, smaller files
- `WEBP`: Modern format, best compression ratio
- **quality** (INT): JPEG/WebP quality level
- Range: 1-100 (default: 90)
- Higher values = better quality, larger files
- **png_compress_level** (INT): PNG compression level
- Range: 0-9 (default: 4)
- Higher values = smaller files, slower saving
- **webp_lossless** (BOOLEAN): Use lossless WebP compression
- Default: False (lossy)
- True: Lossless compression like PNG
- **popup** (BOOLEAN): Enable popup viewer window
- Default: True
- Toggle per save operation
## Outputs
- **UI**: Enhanced preview data with interactive popup viewer
## Popup Viewer Features
### Window Controls
- **Drag Handle**: Click and drag the header to move window
- **Minimize Button**: Collapse to title bar only
- **Maximize Button**: Expand to larger viewing size
- **Roll-up Button**: Show/hide content area
- **Close Button**: Hide the popup (can reopen with toggle)
### Image Grid
- **Thumbnails**: Click any image to open full-size in new tab
- **File Info**: Shows filename and size for each image
- **Quality Indicators**:
- PNG: Compression level (0-9)
- JPEG/WebP: Quality percentage
- **Batch Selection**: Checkboxes for multi-select operations
### Bulk Actions
- **Open All Selected**: Opens selected images in new tabs
- **Download All Selected**: Downloads selected images as a batch
- **Individual Downloads**: Download button per image
### Smart Behavior
- **Auto-positioning**: Appears in convenient screen location
- **Persistence**: Stays open across multiple saves
- **Auto-hide**: Can be minimized when not needed
- **Responsive**: Adapts to different image counts
## Format Details
### PNG Format
- **Pros**: Lossless quality, transparency support, wide compatibility
- **Cons**: Larger file sizes
- **Best for**: Final outputs, images with transparency, archival
- **Compression**: 0 (none) to 9 (maximum)
- Level 4 (default) balances size and speed
- Level 9 for maximum compression (slow)
### JPEG Format
- **Pros**: Smaller files, fast loading, universal support
- **Cons**: Lossy compression, no transparency
- **Best for**: Web images, previews, photos
- **Quality**: 1-100%
- 90% (default) excellent quality with good compression
- 95%+ for near-lossless quality
- 70-85% for web optimization
### WebP Format
- **Pros**: Best compression ratios, supports transparency, modern
- **Cons**: Limited software support
- **Best for**: Web deployment, storage optimization
- **Modes**:
- Lossy (default): Excellent compression with quality control
- Lossless: PNG-like quality with better compression
## Usage Examples
### High-Quality Archive
```
Format: PNG
Compression: 0-2
Use Case: Final renders for portfolio or client delivery
```
### Web Optimization
```
Format: JPEG or WebP
Quality: 80-85
Use Case: Website images, social media posts
```
### Balanced Storage
```
Format: WebP
Quality: 90
Lossless: False
Use Case: Large batches with storage constraints
```
### Transparency Preservation
```
Format: PNG or WebP (lossless)
Use Case: Logos, UI elements, cutout images
```
## Workflow Integration
### Basic Save
```
Generate → Kiko Save Image
format: PNG
popup: enabled
```
### Format Comparison
```
Generate → Kiko Save Image (PNG) → Compare file sizes
↘ Kiko Save Image (JPEG) ↗
↘ Kiko Save Image (WebP) ↗
```
### Batch Processing
```
Batch Generate → Kiko Save Image → Popup Viewer
↓ ↓
4 images Select best results
```
## Tips and Best Practices
1. **Format Selection**:
- Use PNG for maximum quality and transparency
- Use JPEG for photographs without transparency
- Use WebP for modern web deployment
2. **Quality Settings**:
- Start with defaults (90 for JPEG/WebP, 4 for PNG)
- Adjust based on file size requirements
- Preview results in popup before finalizing
3. **Popup Management**:
- Drag to second monitor for larger workspace
- Use roll-up to save screen space
- Disable popup for automated workflows
4. **Batch Operations**:
- Use checkboxes to select multiple images
- Open all in tabs for side-by-side comparison
- Download all for quick collection
5. **File Organization**:
- Use subfolders in filename_prefix
- Include descriptive prefixes
- Let ComfyUI handle timestamp suffixes
## Advantages Over Standard Save Image
- **Immediate Preview**: No need to navigate file system
- **Format Flexibility**: Choose optimal format per use case
- **Quality Control**: Fine-tune compression settings
- **Batch Management**: Handle multiple images efficiently
- **Modern UI**: Floating interface doesn't interrupt workflow
- **File Size Awareness**: See compression effectiveness immediately
- **Quick Access**: One-click opening and downloading
## Technical Details
- **Image Processing**: Uses Pillow for format conversion
- **Metadata**: Preserves ComfyUI metadata in saved files
- **File Naming**: Automatic timestamp and counter suffixes
- **Memory Efficiency**: Processes images individually
- **Thread Safety**: Proper handling of concurrent saves
## Troubleshooting
**Popup not appearing**:
- Check that popup input is enabled
- Look for minimized window
- Try toggling the popup button in node
**WebP not working**:
- Ensure Pillow has WebP support
- Update Pillow: `pip install --upgrade pillow`
**Large file sizes**:
- Increase compression (PNG) or reduce quality (JPEG/WebP)
- Consider switching formats
- Check image dimensions
**Can't see all images**:
- Scroll within the popup grid
- Maximize the popup window
- Images are shown newest first
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+379
View File
@@ -0,0 +1,379 @@
{
"id": "display-any-example",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "DisplayAny",
"pos": [
400,
270
],
"size": [
210,
60
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 1
}
],
"outputs": [
{
"name": "display_text",
"type": "STRING",
"links": [
8
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayAny"
},
"widgets_values": [
"tensor shape"
]
},
{
"id": 2,
"type": "LoadImage",
"pos": [
50,
270
],
"size": [
315,
314
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1,
5
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png",
"image"
]
},
{
"id": 4,
"type": "DisplayAny",
"pos": [
400,
520
],
"size": [
210,
70
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 7
}
],
"outputs": [
{
"name": "display_text",
"type": "STRING",
"links": [
9
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayAny"
},
"widgets_values": [
"raw value"
]
},
{
"id": 7,
"type": "VAELoader",
"pos": [
-250,
410
],
"size": [
270,
58
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [
6
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "VAELoader"
},
"widgets_values": [
"ae.safetensors"
]
},
{
"id": 8,
"type": "VAEEncode",
"pos": [
430,
390
],
"size": [
140,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 5
},
{
"name": "vae",
"type": "VAE",
"link": 6
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "VAEEncode"
},
"widgets_values": []
},
{
"id": 9,
"type": "DisplayText",
"pos": [
640,
270
],
"size": [
450,
278
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 8
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
},
{
"id": 10,
"type": "DisplayText",
"pos": [
640,
610
],
"size": [
590,
278
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 9
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
},
{
"id": 11,
"type": "MarkdownNote",
"pos": [
-320,
530
],
"size": [
350,
270
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Display Any Example\n\nUniversal debugging tool:\n- Accepts ANY input type\n- Two modes: raw value or tensor shape\n- Finds tensors in nested structures\n\nUse cases:\n- Debug tensor dimensions\n- Inspect latent data\n- View config objects\n- Track data flow\n\nConnect anything to see its contents!"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
2,
0,
1,
0,
"IMAGE"
],
[
5,
2,
0,
8,
0,
"IMAGE"
],
[
6,
7,
0,
8,
1,
"VAE"
],
[
7,
8,
0,
4,
0,
"*"
],
[
8,
1,
0,
9,
0,
"STRING"
],
[
9,
4,
0,
10,
0,
"STRING"
]
],
"groups": [
{
"id": 1,
"title": "Display Any",
"bounding": [
-400,
130,
1740,
850
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.6115909044841477,
"offset": [
689.1392030323894,
-27.435530779258137
]
},
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

@@ -0,0 +1,144 @@
{
"id": "display-text-example",
"revision": 0,
"last_node_id": 6,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "DisplayText",
"pos": [
-160,
70
],
"size": [
500,
400
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 2
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
},
{
"id": 4,
"type": "easy positive",
"pos": [
-620,
70
],
"size": [
400,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "positive",
"type": "STRING",
"links": [
2
]
}
],
"properties": {
"cnr_id": "comfyui-easy-use",
"ver": "1.3.1",
"Node name for S&R": "easy positive"
},
"widgets_values": [
"Positive prompt:\nbeautiful landscape, mountains in background, sunset lighting, golden hour, professional photography, high resolution, detailed textures, vibrant colors, masterpiece\n\nNegative prompt:\nlow quality, blurry, pixelated, bad composition, oversaturated, underexposed, amateur"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 6,
"type": "MarkdownNote",
"pos": [
-620,
330
],
"size": [
360,
250
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Display Text Example\n\nSmart text display with:\n- Auto-detection of SDXL prompt format\n- Split view for positive/negative prompts\n- Text wrapping and scrolling\n- Copy buttons (strips labels)\n- Responsive resizing\n\nTry editing the text to see:\n1. Single text mode (no prompt markers)\n2. Split view mode (with Positive/Negative prompts)\n\nPerfect for displaying Gemini-generated prompts!"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
2,
4,
0,
1,
0,
"STRING"
]
],
"groups": [
{
"id": 1,
"title": "Display Text",
"bounding": [
-750,
-60,
1240,
700
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.90909090909091,
"offset": [
720,
30
]
},
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

@@ -0,0 +1,301 @@
{
"id": "empty-latent-batch-example",
"revision": 0,
"last_node_id": 9,
"last_link_id": 8,
"nodes": [
{
"id": 1,
"type": "EmptyLatentBatch",
"pos": [
350,
350
],
"size": [
370,
210
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "latent",
"type": "LATENT",
"slot_index": 0,
"links": []
},
{
"name": "width",
"type": "INT",
"slot_index": 1,
"links": [
4
]
},
{
"name": "height",
"type": "INT",
"slot_index": 2,
"links": [
5
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "EmptyLatentBatch"
},
"widgets_values": [
"896×1152 - 7:9 (1.0MP) - SDXL",
896,
1152,
4
]
},
{
"id": 2,
"type": "Note",
"pos": [
-50,
320
],
"size": [
350,
250
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"text": "Empty Latent Batch Example\n\nThis node creates empty latent tensors with:\n- 26 preset resolutions for SDXL/FLUX/Ultra-wide\n- Batch size support (1-64)\n- Interactive swap button\n- Memory usage estimation\n\nConnect to:\n- KSampler for image generation\n- Other latent processing nodes\n\nOutputs sanitized dimensions divisible by 8."
},
"widgets_values": [
"Empty Latent Batch Example\n\nThis node creates empty latent tensors with:\n- 26 preset resolutions for SDXL/FLUX/Ultra-wide\n- Batch size support (1-64)\n- Interactive swap button\n- Memory usage estimation\n\nConnect to:\n- KSampler for image generation\n- Other latent processing nodes\n\nOutputs sanitized dimensions divisible by 8."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 5,
"type": "DisplayAny",
"pos": [
760,
280
],
"size": [
210,
60
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 4
}
],
"outputs": [
{
"name": "display_text",
"type": "STRING",
"links": [
7
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayAny"
},
"widgets_values": [
"raw value"
]
},
{
"id": 6,
"type": "DisplayAny",
"pos": [
770,
500
],
"size": [
210,
60
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 5
}
],
"outputs": [
{
"name": "display_text",
"type": "STRING",
"links": [
8
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayAny"
},
"widgets_values": [
"raw value"
]
},
{
"id": 8,
"type": "DisplayText",
"pos": [
1000,
280
],
"size": [
210,
138
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 7
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
},
{
"id": 9,
"type": "DisplayText",
"pos": [
1010,
500
],
"size": [
210,
138
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 8
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
}
],
"links": [
[
4,
1,
1,
5,
0,
"*"
],
[
5,
1,
2,
6,
0,
"*"
],
[
7,
5,
0,
8,
0,
"STRING"
],
[
8,
6,
0,
9,
0,
"STRING"
]
],
"groups": [
{
"id": 1,
"title": "Empty Latent",
"bounding": [
-110,
120,
1440,
620
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917358,
"offset": [
156.41790912499965,
-2.272547312500285
]
},
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

@@ -0,0 +1,207 @@
{
"id": "gemini-prompt-example",
"revision": 0,
"last_node_id": 6,
"last_link_id": 4,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [
300,
30
],
"size": [
315,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png",
"image"
]
},
{
"id": 3,
"type": "DisplayText",
"pos": [
1070,
30
],
"size": [
500,
400
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 4
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
},
{
"id": 5,
"type": "GeminiPrompt",
"pos": [
640,
30
],
"size": [
400,
276
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 3
}
],
"outputs": [
{
"name": "prompt",
"type": "STRING",
"links": [
4
]
},
{
"name": "negative_prompt",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "GeminiPrompt"
},
"widgets_values": [
"sdxl",
"gemini-2.0-flash",
"1234-1234-1234-1234",
"",
true,
null,
null,
null
]
},
{
"id": 6,
"type": "MarkdownNote",
"pos": [
300,
410
],
"size": [
400,
240
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Gemini Prompt Engineer Example\n\nAI-powered image analysis:\n- Analyzes image content and style\n- Generates optimized prompts for FLUX/SDXL/Danbooru/Video\n- Dynamic model selection with refresh button\n- Custom system prompt override\n\nSetup:\n1. Get free API key from Google AI Studio\n2. Set GEMINI_API_KEY env var or use config file\n3. Click refresh button to get latest models\n\nDisplayText automatically shows split view for SDXL prompts!"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
3,
2,
0,
5,
0,
"IMAGE"
],
[
4,
5,
0,
3,
0,
"STRING"
]
],
"groups": [
{
"id": 1,
"title": "Gemini Prompt Engineer",
"bounding": [
210,
-110,
1460,
850
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7513148009015777,
"offset": [
97.5582232217803,
133.12148846503663
]
},
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

@@ -1,123 +1,552 @@
{
"last_node_id": 4,
"last_link_id": 3,
"id": "3cd0ac86-8d11-41e5-9d8b-6621bc810ee6",
"revision": 0,
"last_node_id": 21,
"last_link_id": 21,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [100, 200],
"size": [315, 314],
"pos": [
-80,
850
],
"size": [
315,
314.0000305175781
],
"flags": {},
"order": 0,
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"shape": 3,
"label": "IMAGE"
"links": [
13
]
},
{
"name": "MASK",
"type": "MASK",
"links": null,
"shape": 3
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
"widgets_values": [
"example.png",
"image"
]
},
{
"id": 2,
"type": "ImageToMultipleOf",
"pos": [500, 200],
"size": [315, 106],
"id": 8,
"type": "GetNode",
"pos": [
540,
840
],
"size": [
210,
34
],
"flags": {
"collapsed": true
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "UPSCALE_MODEL",
"type": "UPSCALE_MODEL",
"links": []
}
],
"title": "Get_upscale_skin_model",
"properties": {
"widget_ue_connectable": {}
},
"widgets_values": [
"upscale_skin_model"
]
},
{
"id": 9,
"type": "ImageUpscaleWithModel",
"pos": [
530,
810
],
"size": [
221.98202514648438,
60
],
"flags": {},
"order": 1,
"order": 5,
"mode": 0,
"inputs": [
{
"name": "upscale_model",
"type": "UPSCALE_MODEL",
"link": 16
},
{
"name": "image",
"type": "IMAGE",
"link": 5
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
10,
21
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.28",
"Node name for S&R": "ImageUpscaleWithModel",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 10,
"type": "ImageToMultipleOf",
"pos": [
1000,
810
],
"size": [
230,
82
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
"link": 6
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [2, 3],
"shape": 3,
"label": "image"
"links": [
18
]
}
],
"properties": {
"Node name for S&R": "ImageToMultipleOf"
"cnr_id": "comfyui-glifnodes",
"ver": "1.0.0",
"Node name for S&R": "ImageToMultipleOf",
"widget_ue_connectable": {}
},
"widgets_values": [64, "center crop"]
"widgets_values": [
65,
"center crop"
]
},
{
"id": 3,
"type": "PreviewImage",
"pos": [900, 100],
"size": [210, 246],
"id": 13,
"type": "ImageScaleBy",
"pos": [
760,
810
],
"size": [
220,
82
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 10
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
6,
19
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.28",
"Node name for S&R": "ImageScaleBy",
"widget_ue_connectable": {}
},
"widgets_values": [
"lanczos",
0.5000000000000001
]
},
{
"id": 14,
"type": "ImageUpscaleWithModel",
"pos": [
280,
830
],
"size": [
221.98202514648438,
60
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "upscale_model",
"type": "UPSCALE_MODEL",
"link": 15
},
{
"name": "image",
"type": "IMAGE",
"link": 13
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
5,
20
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.28",
"Node name for S&R": "ImageUpscaleWithModel",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 15,
"type": "UpscaleModelLoader",
"pos": [
-90,
720
],
"size": [
320,
60
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "UPSCALE_MODEL",
"type": "UPSCALE_MODEL",
"links": [
15
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.28",
"Node name for S&R": "UpscaleModelLoader",
"widget_ue_connectable": {}
},
"widgets_values": [
"4x-ClearRealityV1.pth"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 16,
"type": "UpscaleModelLoader",
"pos": [
140,
610
],
"size": [
320,
60
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "UPSCALE_MODEL",
"type": "UPSCALE_MODEL",
"links": [
16
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.28",
"Node name for S&R": "UpscaleModelLoader",
"widget_ue_connectable": {}
},
"widgets_values": [
"1xSkinContrast-High-SuperUltraCompact.pth"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 18,
"type": "PreviewImage",
"pos": [
1260,
810
],
"size": [
270,
280
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
"link": 18
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "PreviewImage"
}
},
"widgets_values": []
},
{
"id": 4,
"type": "VAEEncode",
"pos": [900, 400],
"size": [210, 46],
"id": 19,
"type": "PreviewImage",
"pos": [
1010,
940
],
"size": [
210,
258
],
"flags": {},
"order": 3,
"order": 10,
"mode": 0,
"inputs": [
{
"name": "pixels",
"name": "images",
"type": "IMAGE",
"link": 3
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
"link": 19
}
],
"outputs": [],
"properties": {
"Node name for S&R": "VAEEncode"
}
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 20,
"type": "PreviewImage",
"pos": [
520,
940
],
"size": [
210,
258
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 20
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 21,
"type": "PreviewImage",
"pos": [
750,
940
],
"size": [
210,
258
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 21
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"],
[3, 2, 0, 4, 0, "IMAGE"]
[
5,
14,
0,
9,
1,
"IMAGE"
],
[
6,
13,
0,
10,
0,
"IMAGE"
],
[
10,
9,
0,
13,
0,
"IMAGE"
],
[
13,
1,
0,
14,
1,
"IMAGE"
],
[
15,
15,
0,
14,
0,
"UPSCALE_MODEL"
],
[
16,
16,
0,
9,
0,
"UPSCALE_MODEL"
],
[
18,
10,
0,
18,
0,
"IMAGE"
],
[
19,
13,
0,
19,
0,
"IMAGE"
],
[
20,
14,
0,
20,
0,
"IMAGE"
],
[
21,
9,
0,
21,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Image to Multiple of",
"bounding": [
-180,
420,
1800,
830
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"groups": [],
"config": {},
"extra": {},
"extra": {
"ds": {
"scale": 0.6655312045216585,
"offset": [
292.51725658775155,
-375.0766486499791
]
},
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

@@ -0,0 +1,147 @@
{
"id": "kiko-save-image-example",
"revision": 0,
"last_node_id": 4,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "KikoSaveImage",
"pos": [
390,
80
],
"size": [
400,
390
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "KikoSaveImage"
},
"widgets_values": [
"KikoSave",
"PNG",
90,
4,
false,
true
]
},
{
"id": 2,
"type": "LoadImage",
"pos": [
-10,
80
],
"size": [
315,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.47",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png",
"image"
]
},
{
"id": 4,
"type": "MarkdownNote",
"pos": [
-10,
460
],
"size": [
370,
270
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Kiko Save Image Example\n\nEnhanced image saving with:\n- Format selection: PNG, JPEG, WebP\n- Quality controls per format\n- Floating popup viewer (draggable)\n- Batch operations support\n- File size display\n\nPopup Features:\n- Click images to open in new tab\n- Download individual or selected images\n- Minimize/maximize/roll-up controls\n- Persistent across saves\n\nTry different formats to compare file sizes!"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
2,
0,
1,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Kiko save Image",
"bounding": [
-110,
-60,
1020,
870
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7513148009015777,
"offset": [
648.1018911400128,
110.02838653698085
]
},
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

@@ -1,15 +1,15 @@
{
"id": "41469b2d-d616-479d-879a-95cdc6074a37",
"revision": 0,
"last_node_id": 6,
"last_link_id": 5,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "ResolutionCalculator",
"pos": [
60,
430
-30,
210
],
"size": [
315,
@@ -38,8 +38,7 @@
"type": "INT",
"slot_index": 0,
"links": [
1,
3
6
]
},
{
@@ -47,15 +46,15 @@
"type": "INT",
"slot_index": 1,
"links": [
2,
4
7
]
}
],
"properties": {
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
"cnr_id": "kikotools",
"ver": "965ad60c74d7f25b1acce890d9c06518e46e6d0b",
"Node name for S&R": "ResolutionCalculator",
"aux_id": "ComfyAssets/ComfyUI-KikoTools",
"widget_ue_connectable": {}
},
"widgets_values": [
@@ -66,8 +65,8 @@
"id": 6,
"type": "LoadImage",
"pos": [
-250,
430
-340,
210
],
"size": [
274.080078125,
@@ -94,8 +93,8 @@
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.40",
"widget_ue_connectable": {},
"Node name for S&R": "LoadImage"
"Node name for S&R": "LoadImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"image-2025-06-13-105737.jpg",
@@ -103,80 +102,14 @@
]
},
{
"id": 4,
"type": "Display Int (rgthree)",
"id": 7,
"type": "MarkdownNote",
"pos": [
420,
360
-350,
610
],
"size": [
210,
88
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"dir": 3,
"name": "input",
"type": "INT",
"link": 3
}
],
"outputs": [],
"properties": {
"cnr_id": "rgthree-comfy",
"ver": "1.0.2506081210",
"Node name for S&R": "Display Int (rgthree)",
"widget_ue_connectable": {}
},
"widgets_values": [
""
]
},
{
"id": 5,
"type": "Display Int (rgthree)",
"pos": [
430,
530
],
"size": [
210,
88
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"dir": 3,
"name": "input",
"type": "INT",
"link": 4
}
],
"outputs": [],
"properties": {
"cnr_id": "rgthree-comfy",
"ver": "1.0.2506081210",
"widget_ue_connectable": {},
"Node name for S&R": "Display Int (rgthree)"
},
"widgets_values": [
""
]
},
{
"id": 2,
"type": "Note",
"pos": [
-150,
170
],
"size": [
400,
410,
200
],
"flags": {},
@@ -184,50 +117,167 @@
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"text": "Resolution Calculator Example\n\nThis node calculates upscaled dimensions from:\n- IMAGE tensors (connect from image loaders)\n- LATENT tensors (connect from VAE encode/generation)\n\nUse cases:\n- Calculate target dimensions for upscalers\n- Plan memory usage for large generations\n- Ensure dimensions are divisible by 8\n\nScale factors optimized for SDXL and FLUX models.",
"widget_ue_connectable": {}
},
"properties": {},
"widgets_values": [
"Resolution Calculator Example\n\nThis node calculates upscaled dimensions from:\n- IMAGE tensors (connect from image loaders)\n- LATENT tensors (connect from VAE encode/generation)\n\nUse cases:\n- Calculate target dimensions for upscalers\n- Plan memory usage for large generations\n- Ensure dimensions are divisible by 8\n\nScale factors optimized for SDXL and FLUX models."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 8,
"type": "DisplayAny",
"pos": [
330,
160
],
"size": [
270,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 6
}
],
"outputs": [
{
"name": "display_text",
"type": "STRING",
"links": [
8
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayAny"
},
"widgets_values": [
"raw value"
]
},
{
"id": 9,
"type": "DisplayAny",
"pos": [
330,
310
],
"size": [
270,
58
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 7
}
],
"outputs": [
{
"name": "display_text",
"type": "STRING",
"links": [
9
]
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayAny"
},
"widgets_values": [
"raw value"
]
},
{
"id": 10,
"type": "DisplayText",
"pos": [
670,
160
],
"size": [
210,
138
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 8
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
},
{
"id": 11,
"type": "DisplayText",
"pos": [
660,
400
],
"size": [
210,
138
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 9
}
],
"outputs": [
{
"name": "text",
"type": "STRING",
"links": null
}
],
"properties": {
"cnr_id": "kikotools",
"ver": "271cd020c1b2f78e7ee40c1f08e70191fed00012",
"Node name for S&R": "DisplayText"
},
"widgets_values": [
null
]
}
],
"links": [
[
1,
1,
0,
3,
0,
"INT"
],
[
2,
1,
1,
3,
1,
"INT"
],
[
3,
1,
0,
4,
0,
"INT"
],
[
4,
1,
1,
5,
0,
"INT"
],
[
5,
6,
@@ -235,25 +285,71 @@
1,
0,
"IMAGE"
],
[
6,
1,
0,
8,
0,
"*"
],
[
7,
1,
1,
9,
0,
"*"
],
[
8,
8,
0,
10,
0,
"STRING"
],
[
9,
9,
0,
11,
0,
"STRING"
]
],
"groups": [],
"groups": [
{
"id": 1,
"title": "Resolution Calculator",
"bounding": [
-480,
20,
1480,
880
],
"color": "#ffffff",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ue_links": [],
"ds": {
"scale": 0.7972024500000015,
"scale": 0.45000000000000145,
"offset": [
709.6391289161842,
-90.0127389290854
1248.1498802376432,
256.0155843113725
]
},
"links_added_by_ue": [],
"frontendVersion": "1.21.7",
"frontendVersion": "1.23.4",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

+129
View File
@@ -0,0 +1,129 @@
# XYZ Grid Examples
This directory contains example workflows demonstrating the XYZ Grid nodes for ComfyUI.
## Overview
The XYZ Grid system allows you to create parameter comparison grids with any combination of:
- Models/Checkpoints
- Samplers
- Schedulers
- CFG Scale
- Steps
- Clip Skip
- VAEs
- LoRAs
- Prompts
- Seeds
- Flux Guidance
- Denoise strength
## Basic Usage
1. Add an **XYZ Plot Controller** node to your workflow
2. Configure X and Y axes (and optionally Z for multiple grids)
3. Connect the appropriate outputs to your generation nodes
4. Add an **Image Grid Combiner** node
5. Connect your generated images to the combiner
6. Run once - the system handles all iterations automatically!
## Node Descriptions
### XYZ Plot Controller
The main configuration node that drives the grid generation.
**Inputs:**
- `x_axis_type`: Parameter type for X axis (horizontal)
- `x_values`: Values to iterate over (comma-separated or range syntax)
- `y_axis_type`: Parameter type for Y axis (vertical)
- `y_values`: Values to iterate over
- `z_axis_type`: (Optional) Parameter type for Z axis (multiple grids)
- `z_values`: Values for Z axis
- `auto_queue`: Enable automatic execution queuing
**Outputs:**
- `grid_data`: Configuration data for the combiner
- `x_string`, `x_int`, `x_float`: Current X value in different types
- `y_string`, `y_int`, `y_float`: Current Y value in different types
- `z_string`, `z_int`, `z_float`: Current Z value in different types
- `batch_id`: Unique identifier for this grid batch
### Image Grid Combiner
Collects generated images and assembles them into labeled grids.
**Inputs:**
- `images`: Generated images from your workflow
- `grid_data`: Configuration from XYZ Plot Controller
- `font_size`: Size of label text (default: 20)
- `grid_gap`: Pixel gap between images (default: 10)
- `label_height`: Height of label area (default: 30)
- `include_labels`: Whether to add labels (default: true)
**Outputs:**
- `grid_image`: The assembled grid image(s)
- `grid_info`: Information about the grid
## Value Syntax
### Lists
Use comma-separated values:
```
euler, euler_ancestral, dpm_2, dpm_2_ancestral
```
### Ranges
Use colon syntax for numeric ranges:
```
5:10:1 # From 5 to 10, step 1 → [5, 6, 7, 8, 9, 10]
0.5:2:0.5 # From 0.5 to 2, step 0.5 → [0.5, 1.0, 1.5, 2.0]
10:50:10 # From 10 to 50, step 10 → [10, 20, 30, 40, 50]
```
### Model/File Selection
Use the quick-select dropdowns or type filenames:
```
model1.safetensors, model2.ckpt, checkpoint_v3.pt
```
## Connection Examples
### Varying Sampler
1. Set X axis to "sampler"
2. Connect `x_string` output to KSampler's `sampler_name` input
### Varying CFG Scale
1. Set Y axis to "cfg_scale"
2. Connect `y_float` output to KSampler's `cfg` input
### Varying Model
1. Set X axis to "model"
2. Connect `x_string` output to CheckpointLoader's `ckpt_name` input
### Varying Prompt
1. Set Y axis to "prompt"
2. Enter different prompts on separate lines in `y_values`
3. Connect `y_string` output to CLIPTextEncode's `text` input
## Tips and Tricks
1. **Memory Management**: The system includes intelligent model caching. For large grids with multiple models, it will optimize loading order.
2. **Progress Tracking**: Watch the node title for progress updates (e.g., "XYZ Plot Controller [3/12]")
3. **Large Grids**: Be mindful of total image count. The node shows a warning for grids over 100 images.
4. **Z-Axis**: When using Z-axis, you'll get multiple grid images - one for each Z value.
5. **Label Customization**: Use prefixes to clarify labels (e.g., "CFG=" for CFG values)
## Workflow Files
- `basic_model_cfg_grid.json`: Compare 2 models across 3 CFG values
- `sampler_comparison.json`: Compare all samplers at different step counts
- `prompt_variations.json`: Test prompt variations across different models
- `advanced_3d_grid.json`: Use Z-axis for LoRA strength variations
- `flux_guidance_test.json`: Test Flux-specific parameters
Load these workflows in ComfyUI to see practical examples of the XYZ Grid system in action!
+244
View File
@@ -0,0 +1,244 @@
{
"last_node_id": 12,
"last_link_id": 18,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [50, 100],
"size": [500, 450],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
{"name": "x_string", "type": "STRING", "links": [11]},
{"name": "y_int", "type": "INT", "links": [12]},
{"name": "z_float", "type": "FLOAT", "links": [13]}
],
"properties": {},
"widgets_values": [
"lora",
"None, style_lora_v1.safetensors, detail_lora_v2.safetensors, anime_lora_v3.safetensors",
"LoRA: ",
"seed",
"100, 200, 300, 400, 500",
"Seed: ",
true,
"denoise",
"0.4, 0.7, 1.0",
"Strength: ",
true,
false
]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [600, 100],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [1, 14]},
{"name": "CLIP", "type": "CLIP", "links": [2, 3, 15]},
{"name": "VAE", "type": "VAE", "links": [4]}
],
"properties": {},
"widgets_values": ["sd_xl_base_1.0.safetensors"]
},
{
"id": 3,
"type": "LoraLoader",
"pos": [950, 100],
"size": [315, 126],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 14},
{"name": "clip", "type": "CLIP", "link": 15},
{"name": "lora_name", "type": "STRING", "link": 11}
],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [16]},
{"name": "CLIP", "type": "CLIP", "links": [17, 18]}
],
"properties": {},
"widgets_values": ["None", 1.0, 1.0]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [600, 250],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 17}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
],
"properties": {},
"widgets_values": ["a magical forest with glowing mushrooms and fairy lights, ethereal atmosphere, fantasy art"]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [600, 500],
"size": [400, 200],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 18}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
],
"properties": {},
"widgets_values": ["blurry, low quality, distorted"]
},
{
"id": 6,
"type": "EmptyLatentImage",
"pos": [1300, 100],
"size": [315, 106],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [7]}
],
"properties": {},
"widgets_values": [512, 512, 1]
},
{
"id": 7,
"type": "KSampler",
"pos": [1050, 350],
"size": [315, 262],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 16},
{"name": "positive", "type": "CONDITIONING", "link": 5},
{"name": "negative", "type": "CONDITIONING", "link": 6},
{"name": "latent_image", "type": "LATENT", "link": 7},
{"name": "seed", "type": "INT", "link": 12},
{"name": "denoise", "type": "FLOAT", "link": 13}
],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [8]}
],
"properties": {},
"widgets_values": [0, "fixed", 20, 7.5, "dpmpp_2m", "karras", 1.0]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1400, 350],
"size": [210, 46],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 8},
{"name": "vae", "type": "VAE", "link": 4}
],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
],
"properties": {}
},
{
"id": 9,
"type": "ImageGridCombiner",
"pos": [1650, 350],
"size": [315, 200],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": [19]},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [18, 8, 30, 30, true]
},
{
"id": 10,
"type": "SaveImage",
"pos": [2000, 350],
"size": [315, 270],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 19}
],
"outputs": [],
"properties": {},
"widgets_values": ["lora_seed_strength_3d_grid"]
}
],
"links": [
[1, 2, 0, 7, 0, "MODEL"],
[2, 2, 1, 4, 0, "CLIP"],
[3, 2, 1, 5, 0, "CLIP"],
[4, 2, 2, 8, 1, "VAE"],
[5, 4, 0, 7, 1, "CONDITIONING"],
[6, 5, 0, 7, 2, "CONDITIONING"],
[7, 6, 0, 7, 3, "LATENT"],
[8, 7, 0, 8, 0, "LATENT"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 1, 0, 9, 1, "XYZ_GRID"],
[11, 1, 1, 3, 2, "STRING"],
[12, 1, 4, 7, 4, "INT"],
[13, 1, 7, 7, 5, "FLOAT"],
[14, 2, 0, 3, 0, "MODEL"],
[15, 2, 1, 3, 1, "CLIP"],
[16, 3, 0, 7, 0, "MODEL"],
[17, 3, 1, 4, 0, "CLIP"],
[18, 3, 1, 5, 0, "CLIP"],
[19, 9, 0, 10, 0, "IMAGE"]
],
"groups": [
{
"title": "3D XYZ Grid Configuration",
"bounding": [30, 20, 540, 530],
"color": "#3f789e"
},
{
"title": "LoRA Loading Pipeline",
"bounding": [580, 20, 700, 220],
"color": "#8b4c7a"
},
{
"title": "Generation Pipeline",
"bounding": [580, 240, 1060, 480],
"color": "#4c7a3f"
},
{
"title": "Grid Assembly & Output",
"bounding": [1630, 270, 700, 400],
"color": "#7a4c3f"
}
],
"config": {},
"extra": {
"info": "This advanced workflow demonstrates 3D grid functionality with X=LoRA (4 options including None), Y=Seed (5 values), and Z=Denoise strength (3 values). This generates 3 separate 4x5 grids, one for each denoise strength, totaling 60 images. Perfect for finding the optimal LoRA and strength combination across different seeds."
},
"version": 0.4
}
@@ -0,0 +1,64 @@
{
"last_node_id": 5,
"last_link_id": 6,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [400, 300],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [1]},
{"name": "x_string", "type": "STRING", "links": null},
{"name": "x_int", "type": "INT", "links": null},
{"name": "x_float", "type": "FLOAT", "links": null},
{"name": "y_string", "type": "STRING", "links": null},
{"name": "y_int", "type": "INT", "links": null},
{"name": "y_float", "type": "FLOAT", "links": null},
{"name": "z_string", "type": "STRING", "links": null},
{"name": "z_int", "type": "INT", "links": null},
{"name": "z_float", "type": "FLOAT", "links": null},
{"name": "batch_id", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [
"models",
"cfg_scale",
"none",
true
]
},
{
"id": 2,
"type": "ImageGridCombiner",
"pos": [600, 100],
"size": [315, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": null},
{"name": "grid_data", "type": "XYZ_GRID", "link": 1}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": null},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [20, 10, 30, 30, true]
}
],
"links": [
[1, 1, 0, 2, 1, "XYZ_GRID"]
],
"groups": [],
"config": {},
"extra": {
"info": "Example workflow showing the new advanced XYZ Plot Controller with dynamic widget addition."
},
"version": 0.4
}
+213
View File
@@ -0,0 +1,213 @@
{
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [400, 300],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
{"name": "x_string", "type": "STRING", "links": [11]},
{"name": "y_float", "type": "FLOAT", "links": [12]}
],
"properties": {},
"widgets_values": [
"model",
"sd_xl_base_1.0.safetensors, dreamshaperXL_v2.safetensors",
"Model: ",
"cfg_scale",
"5, 7.5, 10",
"CFG: ",
true,
"none",
"",
"",
true,
false
]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [550, 100],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{"name": "ckpt_name", "type": "STRING", "link": 11}
],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [1]},
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
{"name": "VAE", "type": "VAE", "links": [4]}
],
"properties": {},
"widgets_values": ["sd_xl_base_1.0.safetensors"]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [550, 250],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
],
"properties": {},
"widgets_values": ["a beautiful landscape with mountains and a lake, highly detailed, professional photography"]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [550, 500],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 3}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
],
"properties": {},
"widgets_values": ["blurry, low quality, distorted"]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [1000, 100],
"size": [315, 106],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [7]}
],
"properties": {},
"widgets_values": [1024, 1024, 1]
},
{
"id": 6,
"type": "KSampler",
"pos": [1000, 250],
"size": [315, 262],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 1},
{"name": "positive", "type": "CONDITIONING", "link": 5},
{"name": "negative", "type": "CONDITIONING", "link": 6},
{"name": "latent_image", "type": "LATENT", "link": 7},
{"name": "cfg", "type": "FLOAT", "link": 12}
],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [8]}
],
"properties": {},
"widgets_values": [42, "fixed", 20, 7.5, "euler", "normal", 1]
},
{
"id": 7,
"type": "VAEDecode",
"pos": [1350, 250],
"size": [210, 46],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 8},
{"name": "vae", "type": "VAE", "link": 4}
],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
],
"properties": {}
},
{
"id": 8,
"type": "ImageGridCombiner",
"pos": [1600, 250],
"size": [315, 200],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": [13]},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [20, 10, 30, 30, true]
},
{
"id": 9,
"type": "SaveImage",
"pos": [1950, 250],
"size": [315, 270],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 13}
],
"outputs": [],
"properties": {},
"widgets_values": ["model_cfg_comparison"]
}
],
"links": [
[1, 2, 0, 6, 0, "MODEL"],
[2, 2, 1, 3, 0, "CLIP"],
[3, 2, 1, 4, 0, "CLIP"],
[4, 2, 2, 7, 1, "VAE"],
[5, 3, 0, 6, 1, "CONDITIONING"],
[6, 4, 0, 6, 2, "CONDITIONING"],
[7, 5, 0, 6, 3, "LATENT"],
[8, 6, 0, 7, 0, "LATENT"],
[9, 7, 0, 8, 0, "IMAGE"],
[10, 1, 0, 8, 1, "XYZ_GRID"],
[11, 1, 1, 2, 0, "STRING"],
[12, 1, 5, 6, 4, "FLOAT"],
[13, 8, 0, 9, 0, "IMAGE"]
],
"groups": [
{
"title": "XYZ Grid Setup",
"bounding": [80, 20, 440, 380],
"color": "#3f789e"
},
{
"title": "Image Generation",
"bounding": [530, 20, 1050, 720],
"color": "#4c7a3f"
},
{
"title": "Grid Output",
"bounding": [1580, 170, 700, 400],
"color": "#7a4c3f"
}
],
"config": {},
"extra": {
"info": "This workflow demonstrates a basic 2x3 grid comparing two models at three different CFG scale values. The XYZ Plot Controller automatically handles all 6 iterations."
},
"version": 0.4
}
+235
View File
@@ -0,0 +1,235 @@
{
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [450, 350],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
{"name": "x_float", "type": "FLOAT", "links": [11]},
{"name": "y_float", "type": "FLOAT", "links": [12]}
],
"properties": {},
"widgets_values": [
"flux_guidance",
"1.0:5.0:0.5",
"Guidance: ",
"cfg_scale",
"1.0, 3.0, 5.0, 7.0",
"CFG: ",
true,
"none",
"",
"",
true,
false
]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [600, 100],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [1]},
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
{"name": "VAE", "type": "VAE", "links": [4]}
],
"properties": {},
"widgets_values": ["flux1-dev.safetensors"]
},
{
"id": 3,
"type": "FluxGuidance",
"pos": [950, 100],
"size": [315, 58],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "conditioning", "type": "CONDITIONING", "link": 14},
{"name": "guidance", "type": "FLOAT", "link": 11}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
],
"properties": {},
"widgets_values": [3.5]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [600, 250],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [14]}
],
"properties": {},
"widgets_values": ["a stunning digital artwork of a phoenix rising from ashes, vibrant colors, dramatic lighting, highly detailed feathers with fire effects"]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [600, 500],
"size": [400, 200],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 3}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
],
"properties": {},
"widgets_values": [""]
},
{
"id": 6,
"type": "EmptyLatentImage",
"pos": [1050, 250],
"size": [315, 106],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [7]}
],
"properties": {},
"widgets_values": [1024, 1024, 1]
},
{
"id": 7,
"type": "KSamplerAdvanced",
"pos": [1050, 400],
"size": [315, 334],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 1},
{"name": "positive", "type": "CONDITIONING", "link": 5},
{"name": "negative", "type": "CONDITIONING", "link": 6},
{"name": "latent_image", "type": "LATENT", "link": 7},
{"name": "cfg", "type": "FLOAT", "link": 12}
],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [8]}
],
"properties": {},
"widgets_values": ["enable", 42, "fixed", 20, 1.0, "euler", "simple", 0, 20, "disable"]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1400, 400],
"size": [210, 46],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 8},
{"name": "vae", "type": "VAE", "link": 4}
],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
],
"properties": {}
},
{
"id": 9,
"type": "ImageGridCombiner",
"pos": [1650, 400],
"size": [315, 200],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": [15]},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [20, 10, 30, 30, true]
},
{
"id": 10,
"type": "SaveImage",
"pos": [2000, 400],
"size": [315, 270],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 15}
],
"outputs": [],
"properties": {},
"widgets_values": ["flux_guidance_cfg_grid"]
}
],
"links": [
[1, 2, 0, 7, 0, "MODEL"],
[2, 2, 1, 4, 0, "CLIP"],
[3, 2, 1, 5, 0, "CLIP"],
[4, 2, 2, 8, 1, "VAE"],
[5, 3, 0, 7, 1, "CONDITIONING"],
[6, 5, 0, 7, 2, "CONDITIONING"],
[7, 6, 0, 7, 3, "LATENT"],
[8, 7, 0, 8, 0, "LATENT"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 1, 0, 9, 1, "XYZ_GRID"],
[11, 1, 2, 3, 1, "FLOAT"],
[12, 1, 5, 7, 4, "FLOAT"],
[14, 4, 0, 3, 0, "CONDITIONING"],
[15, 9, 0, 10, 0, "IMAGE"]
],
"groups": [
{
"title": "Flux Parameter Grid Setup",
"bounding": [80, 20, 490, 430],
"color": "#3f789e"
},
{
"title": "Flux Model Pipeline",
"bounding": [580, 20, 450, 200],
"color": "#8b4c7a"
},
{
"title": "Generation Pipeline",
"bounding": [580, 240, 810, 520],
"color": "#4c7a3f"
},
{
"title": "Grid Output",
"bounding": [1630, 320, 700, 400],
"color": "#7a4c3f"
}
],
"config": {},
"extra": {
"info": "This workflow demonstrates testing Flux-specific parameters. It creates a 9x4 grid comparing Flux guidance values (1.0 to 5.0 in 0.5 steps) against different CFG scales. This is useful for finding the optimal balance between Flux guidance and traditional CFG for your specific use case. Note: Requires Flux model and FluxGuidance node."
},
"version": 0.4
}
+213
View File
@@ -0,0 +1,213 @@
{
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [450, 400],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
{"name": "x_string", "type": "STRING", "links": [11]},
{"name": "y_string", "type": "STRING", "links": [12]}
],
"properties": {},
"widgets_values": [
"model",
"sd_xl_base_1.0.safetensors, dreamshaperXL_v2.safetensors, juggernautXL_v8.safetensors",
"",
"prompt",
"a serene japanese garden with cherry blossoms\na futuristic cyberpunk city at night\na medieval castle on a misty mountain\nan underwater coral reef teeming with life\na cozy cabin in a snowy forest",
"",
true,
"none",
"",
"",
true,
false
]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [600, 100],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{"name": "ckpt_name", "type": "STRING", "link": 11}
],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [1]},
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
{"name": "VAE", "type": "VAE", "links": [4]}
],
"properties": {},
"widgets_values": ["sd_xl_base_1.0.safetensors"]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [600, 250],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2},
{"name": "text", "type": "STRING", "link": 12, "widget": {"name": "text"}}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
],
"properties": {},
"widgets_values": ["beautiful scenery"]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [600, 500],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 3}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
],
"properties": {},
"widgets_values": ["blurry, low quality, distorted, ugly, poorly drawn"]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [1050, 100],
"size": [315, 106],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [7]}
],
"properties": {},
"widgets_values": [768, 768, 1]
},
{
"id": 6,
"type": "KSampler",
"pos": [1050, 250],
"size": [315, 262],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 1},
{"name": "positive", "type": "CONDITIONING", "link": 5},
{"name": "negative", "type": "CONDITIONING", "link": 6},
{"name": "latent_image", "type": "LATENT", "link": 7}
],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [8]}
],
"properties": {},
"widgets_values": [789012, "fixed", 25, 7.5, "dpmpp_2m", "karras", 1]
},
{
"id": 7,
"type": "VAEDecode",
"pos": [1400, 250],
"size": [210, 46],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 8},
{"name": "vae", "type": "VAE", "link": 4}
],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
],
"properties": {}
},
{
"id": 8,
"type": "ImageGridCombiner",
"pos": [1650, 250],
"size": [315, 200],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": [13]},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [20, 10, 35, 35, true]
},
{
"id": 9,
"type": "SaveImage",
"pos": [2000, 250],
"size": [315, 270],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 13}
],
"outputs": [],
"properties": {},
"widgets_values": ["prompt_model_variations"]
}
],
"links": [
[1, 2, 0, 6, 0, "MODEL"],
[2, 2, 1, 3, 0, "CLIP"],
[3, 2, 1, 4, 0, "CLIP"],
[4, 2, 2, 7, 1, "VAE"],
[5, 3, 0, 6, 1, "CONDITIONING"],
[6, 4, 0, 6, 2, "CONDITIONING"],
[7, 5, 0, 6, 3, "LATENT"],
[8, 6, 0, 7, 0, "LATENT"],
[9, 7, 0, 8, 0, "IMAGE"],
[10, 1, 0, 8, 1, "XYZ_GRID"],
[11, 1, 1, 2, 0, "STRING"],
[12, 1, 4, 3, 1, "STRING"],
[13, 8, 0, 9, 0, "IMAGE"]
],
"groups": [
{
"title": "Prompt x Model Grid Setup",
"bounding": [80, 20, 490, 480],
"color": "#3f789e"
},
{
"title": "Image Generation Pipeline",
"bounding": [580, 20, 1050, 720],
"color": "#4c7a3f"
},
{
"title": "Grid Assembly & Output",
"bounding": [1630, 170, 700, 400],
"color": "#7a4c3f"
}
],
"config": {},
"extra": {
"info": "This workflow creates a 3x5 grid comparing 3 different models with 5 diverse prompt scenarios. Great for seeing how different models interpret various styles and subjects. The Y axis connects directly to the positive prompt input, automatically switching prompts for each row."
},
"version": 0.4
}
+212
View File
@@ -0,0 +1,212 @@
{
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 1,
"type": "XYZPlotController",
"pos": [100, 100],
"size": [400, 350],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [10]},
{"name": "x_string", "type": "STRING", "links": [11]},
{"name": "y_int", "type": "INT", "links": [12]}
],
"properties": {},
"widgets_values": [
"sampler",
"euler, euler_ancestral, heun, dpm_2, dpm_2_ancestral, lms, dpm_fast, dpm_adaptive, dpmpp_2s_ancestral, dpmpp_sde, dpmpp_2m, ddim",
"",
"steps",
"10, 20, 30, 50",
"Steps: ",
true,
"none",
"",
"",
true,
false
]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [550, 100],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [1]},
{"name": "CLIP", "type": "CLIP", "links": [2, 3]},
{"name": "VAE", "type": "VAE", "links": [4]}
],
"properties": {},
"widgets_values": ["sd_xl_base_1.0.safetensors"]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [550, 250],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5]}
],
"properties": {},
"widgets_values": ["a majestic dragon soaring through clouds, fantasy art, highly detailed, epic lighting"]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [550, 500],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 3}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]}
],
"properties": {},
"widgets_values": ["blurry, low quality, distorted, ugly"]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [1000, 100],
"size": [315, 106],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [7]}
],
"properties": {},
"widgets_values": [512, 512, 1]
},
{
"id": 6,
"type": "KSampler",
"pos": [1000, 250],
"size": [315, 262],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 1},
{"name": "positive", "type": "CONDITIONING", "link": 5},
{"name": "negative", "type": "CONDITIONING", "link": 6},
{"name": "latent_image", "type": "LATENT", "link": 7},
{"name": "sampler_name", "type": "combo", "link": 11},
{"name": "steps", "type": "INT", "link": 12}
],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [8]}
],
"properties": {},
"widgets_values": [123456, "fixed", 20, 8.0, "euler", "normal", 1]
},
{
"id": 7,
"type": "VAEDecode",
"pos": [1350, 250],
"size": [210, 46],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 8},
{"name": "vae", "type": "VAE", "link": 4}
],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [9]}
],
"properties": {}
},
{
"id": 8,
"type": "ImageGridCombiner",
"pos": [1600, 250],
"size": [315, 200],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "grid_data", "type": "XYZ_GRID", "link": 10}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": [13]},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {},
"widgets_values": [16, 8, 25, 25, true]
},
{
"id": 9,
"type": "SaveImage",
"pos": [1950, 250],
"size": [315, 270],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 13}
],
"outputs": [],
"properties": {},
"widgets_values": ["sampler_steps_comparison"]
}
],
"links": [
[1, 2, 0, 6, 0, "MODEL"],
[2, 2, 1, 3, 0, "CLIP"],
[3, 2, 1, 4, 0, "CLIP"],
[4, 2, 2, 7, 1, "VAE"],
[5, 3, 0, 6, 1, "CONDITIONING"],
[6, 4, 0, 6, 2, "CONDITIONING"],
[7, 5, 0, 6, 3, "LATENT"],
[8, 6, 0, 7, 0, "LATENT"],
[9, 7, 0, 8, 0, "IMAGE"],
[10, 1, 0, 8, 1, "XYZ_GRID"],
[11, 1, 1, 6, 4, "combo"],
[12, 1, 4, 6, 5, "INT"],
[13, 8, 0, 9, 0, "IMAGE"]
],
"groups": [
{
"title": "Sampler vs Steps Grid",
"bounding": [80, 20, 440, 430],
"color": "#3f789e"
},
{
"title": "Image Generation",
"bounding": [530, 20, 1050, 720],
"color": "#4c7a3f"
},
{
"title": "Grid Output",
"bounding": [1580, 170, 700, 400],
"color": "#7a4c3f"
}
],
"config": {},
"extra": {
"info": "This workflow creates a 12x4 grid comparing 12 different samplers at 4 step counts (10, 20, 30, 50). Perfect for finding the optimal sampler and step count for your use case. Note: Using smaller image size (512x512) due to the large number of generations (48 total)."
},
"version": 0.4
}
+238
View File
@@ -0,0 +1,238 @@
{
"last_node_id": 20,
"last_link_id": 30,
"nodes": [
{
"id": 1,
"type": "XYZPrompt",
"pos": [100, 100],
"size": {"0": 350, "1": 400},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{"name": "prompts", "type": "XYZ_PROMPTS", "links": [1]},
{"name": "positive", "type": "STRING", "links": [2]},
{"name": "negative", "type": "STRING", "links": [3]},
{"name": "count", "type": "INT", "links": null}
],
"properties": {"Node name for S&R": "XYZPrompt"},
"widgets_values": [
true,
true,
"a beautiful landscape",
"ugly, blurry, watermark",
"a serene mountain scene",
"a vibrant cityscape at night",
"a peaceful forest path"
]
},
{
"id": 2,
"type": "XYZPlotController",
"pos": [500, 100],
"size": {"0": 400, "1": 500},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{"name": "prompts", "type": "XYZ_PROMPTS", "link": 1}
],
"outputs": [
{"name": "grid_data", "type": "XYZ_GRID", "links": [4]},
{"name": "x_string", "type": "STRING", "links": [5]},
{"name": "x_int", "type": "INT", "links": null},
{"name": "x_float", "type": "FLOAT", "links": null},
{"name": "y_string", "type": "STRING", "links": null},
{"name": "y_int", "type": "INT", "links": [6]},
{"name": "y_float", "type": "FLOAT", "links": null},
{"name": "z_string", "type": "STRING", "links": null},
{"name": "z_int", "type": "INT", "links": null},
{"name": "z_float", "type": "FLOAT", "links": null},
{"name": "batch_id", "type": "STRING", "links": null}
],
"properties": {"Node name for S&R": "XYZPlotController"},
"widgets_values": [
"prompt",
"steps",
"none",
true,
"20\n30\n40",
""
]
},
{
"id": 3,
"type": "CheckpointLoaderSimple",
"pos": [100, 550],
"size": {"0": 315, "1": 98},
"flags": {},
"order": 2,
"mode": 0,
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [7]},
{"name": "CLIP", "type": "CLIP", "links": [8, 9]},
{"name": "VAE", "type": "VAE", "links": [10]}
],
"properties": {"Node name for S&R": "CheckpointLoaderSimple"},
"widgets_values": ["sd_xl_base_1.0.safetensors"]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [500, 650],
"size": {"0": 400, "1": 200},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 8},
{"name": "text", "type": "STRING", "link": 2, "widget": {"name": "text"}}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [11]}
],
"properties": {"Node name for S&R": "CLIPTextEncode"},
"widgets_values": [""]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [500, 900],
"size": {"0": 400, "1": 200},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 9},
{"name": "text", "type": "STRING", "link": 3, "widget": {"name": "text"}}
],
"outputs": [
{"name": "CONDITIONING", "type": "CONDITIONING", "links": [12]}
],
"properties": {"Node name for S&R": "CLIPTextEncode"},
"widgets_values": [""]
},
{
"id": 6,
"type": "EmptyLatentImage",
"pos": [950, 550],
"size": {"0": 315, "1": 106},
"flags": {},
"order": 5,
"mode": 0,
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [13]}
],
"properties": {"Node name for S&R": "EmptyLatentImage"},
"widgets_values": [1024, 1024, 1]
},
{
"id": 7,
"type": "KSampler",
"pos": [950, 700],
"size": {"0": 315, "1": 262},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 7},
{"name": "positive", "type": "CONDITIONING", "link": 11},
{"name": "negative", "type": "CONDITIONING", "link": 12},
{"name": "latent_image", "type": "LATENT", "link": 13},
{"name": "steps", "type": "INT", "link": 6, "widget": {"name": "steps"}}
],
"outputs": [
{"name": "LATENT", "type": "LATENT", "links": [14]}
],
"properties": {"Node name for S&R": "KSampler"},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1300, 700],
"size": {"0": 210, "1": 46},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 14},
{"name": "vae", "type": "VAE", "link": 10}
],
"outputs": [
{"name": "IMAGE", "type": "IMAGE", "links": [15]}
],
"properties": {"Node name for S&R": "VAEDecode"}
},
{
"id": 9,
"type": "ImageGridCombiner",
"pos": [1550, 700],
"size": {"0": 315, "1": 202},
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 15},
{"name": "grid_data", "type": "XYZ_GRID", "link": 4}
],
"outputs": [
{"name": "grid_image", "type": "IMAGE", "links": [16]},
{"name": "grid_info", "type": "STRING", "links": null}
],
"properties": {"Node name for S&R": "ImageGridCombiner"},
"widgets_values": [20, 10, 30, 30, true]
},
{
"id": 10,
"type": "SaveImage",
"pos": [1900, 700],
"size": {"0": 315, "1": 270},
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 16}
],
"properties": {},
"widgets_values": ["xyz_grid"]
}
],
"links": [
[1, 1, 0, 2, 0, "XYZ_PROMPTS"],
[2, 1, 1, 4, 1, "STRING"],
[3, 1, 2, 5, 1, "STRING"],
[4, 2, 0, 9, 1, "XYZ_GRID"],
[5, 2, 1, 4, 1, "STRING"],
[6, 2, 5, 7, 4, "INT"],
[7, 3, 0, 7, 0, "MODEL"],
[8, 3, 1, 4, 0, "CLIP"],
[9, 3, 1, 5, 0, "CLIP"],
[10, 3, 2, 8, 1, "VAE"],
[11, 4, 0, 7, 1, "CONDITIONING"],
[12, 5, 0, 7, 2, "CONDITIONING"],
[13, 6, 0, 7, 3, "LATENT"],
[14, 7, 0, 8, 0, "LATENT"],
[15, 8, 0, 9, 0, "IMAGE"],
[16, 9, 0, 10, 0, "IMAGE"]
],
"groups": [
{
"title": "XYZ Grid Test Workflow",
"bounding": [80, 20, 2160, 1100],
"color": "#3f789e"
}
],
"config": {},
"extra": {},
"version": 0.4
}
+13
View File
@@ -10,8 +10,11 @@ from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
from .tools.empty_latent_batch import EmptyLatentBatchNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.image_scale_down_by import ImageScaleDownByNode
from .tools.gemini_prompt import GeminiPromptNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
from .tools.xyz_grid import XYZPlotController, ImageGridCombiner, XYZPrompt
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
@@ -23,8 +26,13 @@ NODE_CLASS_MAPPINGS = {
"EmptyLatentBatch": EmptyLatentBatchNode,
"KikoSaveImage": KikoSaveImageNode,
"ImageToMultipleOf": ImageToMultipleOfNode,
"ImageScaleDownBy": ImageScaleDownByNode,
"GeminiPrompt": GeminiPromptNode,
"DisplayAny": DisplayAnyNode,
"DisplayText": DisplayTextNode,
"XYZPlotController": XYZPlotController,
"ImageGridCombiner": ImageGridCombiner,
"XYZPrompt": XYZPrompt,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -36,8 +44,13 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"EmptyLatentBatch": "Empty Latent Batch",
"KikoSaveImage": "Kiko Save Image",
"ImageToMultipleOf": "Image to Multiple of",
"ImageScaleDownBy": "Image Scale Down By",
"GeminiPrompt": "Gemini Prompt Engineer",
"DisplayAny": "Display Any",
"DisplayText": "Display Text",
"XYZPlotController": "XYZ Plot Controller",
"ImageGridCombiner": "Image Grid Combiner",
"XYZPrompt": "XYZ Prompt",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+5
View File
@@ -0,0 +1,5 @@
"""Display Text tool for ComfyUI."""
from .node import DisplayTextNode, NODE_DISPLAY_NAME
__all__ = ["DisplayTextNode", "NODE_DISPLAY_NAME"]
+48
View File
@@ -0,0 +1,48 @@
"""Display Text node implementation."""
from ...base import ComfyAssetsBaseNode
class DisplayTextNode(ComfyAssetsBaseNode):
"""Displays text in the ComfyUI interface with copy-to-clipboard functionality."""
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
OUTPUT_NODE = True
FUNCTION = "display_text"
CATEGORY = "ComfyAssets"
DESCRIPTION = """
Displays text in the UI with a copy-to-clipboard feature.
Features:
- Shows text content in a readable format
- Copy button appears on hover
- Passes text through for chaining
"""
def display_text(self, text):
"""Display the text and pass it through.
Args:
text: Input text to display
Returns:
Tuple containing the text
"""
# The actual display happens in the frontend
# We just pass the text through
return {"ui": {"text": [text]}, "result": (text,)}
# Node display name
NODE_DISPLAY_NAME = "Display Text"
@@ -0,0 +1,89 @@
{
"models": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro-preview-03-25",
"gemini-2.5-flash-preview-05-20",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-06-05",
"gemini-2.5-flash-lite-preview-06-17",
"gemini-2.0-flash",
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-001",
"gemini-2.0-flash-lite",
"gemini-2.5-flash-preview-tts",
"gemini-2.5-pro-preview-tts",
"gemini-2.0-flash-preview-image-generation",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-2.0-flash-lite-preview",
"gemini-2.0-pro-exp",
"gemini-2.0-pro-exp-02-05",
"learnlm-2.0-flash-experimental",
"gemini-1.5-pro-latest",
"gemini-1.5-pro-002",
"gemini-1.5-pro",
"gemini-1.5-flash-latest",
"gemini-1.5-flash",
"gemini-1.5-flash-002",
"gemini-1.5-flash-8b",
"gemini-1.5-flash-8b-001",
"gemini-1.5-flash-8b-latest",
"gemini-2.0-flash-thinking-exp-01-21",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-flash-thinking-exp-1219",
"gemma-3-1b-it",
"gemma-3-4b-it",
"gemma-3-12b-it",
"gemma-3-27b-it",
"gemma-3n-e4b-it",
"gemma-3n-e2b-it",
"gemini-exp-1206"
],
"descriptions": {
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
"gemini-1.5-pro": "Gemini 1.5 Pro",
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
"gemini-1.5-flash": "Gemini 1.5 Flash",
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash": "Gemini 2.5 Flash",
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
"gemini-2.5-pro": "Gemini 2.5 Pro",
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
"gemini-exp-1206": "Gemini Experimental 1206",
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
"gemma-3-1b-it": "Gemma 3 1B",
"gemma-3-4b-it": "Gemma 3 4B",
"gemma-3-12b-it": "Gemma 3 12B",
"gemma-3-27b-it": "Gemma 3 27B",
"gemma-3n-e4b-it": "Gemma 3n E4B",
"gemma-3n-e2b-it": "Gemma 3n E2B",
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
},
"timestamp": 1754142231.0568295
}
+191
View File
@@ -0,0 +1,191 @@
"""Dynamic model fetching and caching for Gemini API."""
import json
import os
import time
from typing import List, Dict, Optional, Tuple
import logging
logger = logging.getLogger(__name__)
# Cache settings
CACHE_DURATION = 3600 * 24 # 24 hours in seconds
CACHE_FILE = os.path.join(os.path.dirname(__file__), ".gemini_models_cache.json")
def get_available_models(
api_key: Optional[str] = None, silent: bool = False
) -> Tuple[List[str], Dict[str, str]]:
"""Fetch available Gemini models that support generateContent.
Args:
api_key: Optional API key. If not provided, will try to get from environment.
silent: If True, suppress error logging (useful for initial load).
Returns:
Tuple of (model_names_list, model_descriptions_dict)
"""
# Check cache first
cached_data = _load_cache()
if cached_data:
return cached_data["models"], cached_data["descriptions"]
# Try to fetch from API
try:
models, descriptions = _fetch_models_from_api(api_key, silent=silent)
if models:
_save_cache(models, descriptions)
return models, descriptions
except Exception as e:
if not silent:
logger.warning(f"Failed to fetch models from API: {e}")
# Fall back to defaults
from .prompts import DEFAULT_GEMINI_MODELS
return DEFAULT_GEMINI_MODELS, {}
def _fetch_models_from_api(
api_key: Optional[str] = None, silent: bool = False
) -> Tuple[List[str], Dict[str, str]]:
"""Fetch models from Gemini API.
Args:
api_key: Optional API key.
silent: If True, suppress error logging.
Returns:
Tuple of (model_names_list, model_descriptions_dict)
"""
try:
import google.generativeai as genai
except ImportError:
if not silent:
logger.error("google-generativeai not installed")
return [], {}
# Get API key
if not api_key:
from .logic import get_api_key
api_key = get_api_key()
if not api_key:
if not silent:
logger.debug("No API key available for fetching models")
return [], {}
try:
genai.configure(api_key=api_key)
models = []
descriptions = {}
# Fetch all models
for model in genai.list_models():
# Only include models that support generateContent
if "generateContent" in model.supported_generation_methods:
# Remove "models/" prefix from name
model_name = model.name.replace("models/", "")
models.append(model_name)
descriptions[model_name] = model.display_name
# Sort models by priority (newer versions first)
models = _sort_models(models)
return models, descriptions
except Exception as e:
if not silent:
logger.error(f"Error fetching models from API: {e}")
return [], {}
def _sort_models(models: List[str]) -> List[str]:
"""Sort models by version and capability.
Prioritizes:
1. Newer versions (2.5 > 2.0 > 1.5)
2. Non-experimental models
3. Flash models for general use
"""
def sort_key(model: str):
# Priority scoring
score = 0
# Version priority
if "2.5" in model:
score += 1000
elif "2.0" in model:
score += 800
elif "1.5" in model:
score += 600
# Model type priority
if "pro" in model and "preview" not in model and "exp" not in model:
score += 100
elif "flash" in model and "preview" not in model and "exp" not in model:
score += 90
# Penalize experimental/preview models
if "exp" in model or "experimental" in model:
score -= 50
if "preview" in model:
score -= 30
# Penalize specific variants
if "thinking" in model:
score -= 100
if "tts" in model:
score -= 100
if "lite" in model:
score -= 20
return -score # Negative for descending sort
return sorted(models, key=sort_key)
def _load_cache() -> Optional[Dict]:
"""Load cached model data if available and not expired."""
if not os.path.exists(CACHE_FILE):
return None
try:
with open(CACHE_FILE, "r") as f:
data = json.load(f)
# Check if cache is expired
if time.time() - data.get("timestamp", 0) > CACHE_DURATION:
return None
return data
except Exception as e:
logger.warning(f"Failed to load cache: {e}")
return None
def _save_cache(models: List[str], descriptions: Dict[str, str]) -> None:
"""Save model data to cache."""
try:
data = {
"models": models,
"descriptions": descriptions,
"timestamp": time.time(),
}
with open(CACHE_FILE, "w") as f:
json.dump(data, f, indent=2)
except Exception as e:
logger.warning(f"Failed to save cache: {e}")
def clear_cache() -> None:
"""Clear the model cache."""
if os.path.exists(CACHE_FILE):
try:
os.remove(CACHE_FILE)
except Exception as e:
logger.warning(f"Failed to clear cache: {e}")
+61 -7
View File
@@ -5,7 +5,8 @@ import torch
from ...base import ComfyAssetsBaseNode
from .logic import analyze_image_with_gemini, validate_prompt_type
from .prompts import PROMPT_OPTIONS, GEMINI_MODELS
from .prompts import PROMPT_OPTIONS, DEFAULT_GEMINI_MODELS
from .models import get_available_models
class GeminiPromptNode(ComfyAssetsBaseNode):
@@ -14,11 +15,21 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
# Get available models dynamically (silent mode for initial load)
models, _ = get_available_models(silent=True)
# Use default if no models available
if not models:
models = DEFAULT_GEMINI_MODELS
# Find best default model
default_model = models[0] if models else "gemini-2.5-flash"
return {
"required": {
"image": ("IMAGE",),
"prompt_type": (PROMPT_OPTIONS, {"default": "flux"}),
"model": (GEMINI_MODELS, {"default": "gemini-1.5-flash"}),
"model": (models, {"default": default_model}),
},
"optional": {
"api_key": ("STRING", {"default": "", "multiline": False}),
@@ -30,6 +41,10 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
"placeholder": "Optional: Enter custom system prompt instead of using templates",
},
),
"refresh_models": (
"BOOLEAN",
{"default": False, "label_on": "Refresh", "label_off": "Skip"},
),
},
}
@@ -51,7 +66,15 @@ Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
Install: pip install google-generativeai
"""
def generate_prompt(self, image, prompt_type, model, api_key="", custom_prompt=""):
def generate_prompt(
self,
image,
prompt_type,
model,
api_key="",
custom_prompt="",
refresh_models=False,
):
"""Generate prompt from image using Gemini.
Args:
@@ -60,10 +83,24 @@ Install: pip install google-generativeai
model: Gemini model to use
api_key: Optional API key
custom_prompt: Optional custom system prompt
refresh_models: Whether to refresh the model list
Returns:
Tuple of (prompt, negative_prompt)
"""
# Refresh models if requested
if refresh_models and api_key:
try:
from .models import clear_cache
# Clear cache to force refresh on next node creation
clear_cache()
print(
"Model cache cleared. Please recreate the node to see updated models."
)
except Exception as e:
print(f"Failed to clear model cache: {e}")
# Validate prompt type
if not validate_prompt_type(prompt_type):
raise ValueError(f"Invalid prompt type: {prompt_type}")
@@ -74,6 +111,19 @@ Install: pip install google-generativeai
else:
image_np = image
# If API key is provided, try to refresh model list in background
if api_key:
try:
from .models import get_available_models
# Try to get fresh models with the provided API key
fresh_models, _ = get_available_models(api_key=api_key, silent=True)
if fresh_models and fresh_models != DEFAULT_GEMINI_MODELS:
# Models were successfully fetched with this API key
pass
except Exception:
pass
# Analyze image with Gemini
prompt, error = analyze_image_with_gemini(
image_np,
@@ -95,10 +145,14 @@ Install: pip install google-generativeai
negative_prompt = ""
for line in lines:
if line.startswith("Positive:"):
positive_prompt = line.replace("Positive:", "").strip()
elif line.startswith("Negative:"):
negative_prompt = line.replace("Negative:", "").strip()
if line.lower().startswith("positive:"):
positive_prompt = (
line.replace("Positive:", "").replace("positive:", "").strip()
)
elif line.lower().startswith("negative:"):
negative_prompt = (
line.replace("Negative:", "").replace("negative:", "").strip()
)
# If format not found, assume entire response is positive prompt
if not positive_prompt:
+93 -165
View File
@@ -1,176 +1,113 @@
"""System prompts for different AI model types."""
FLUX_PROMPT = """You are an expert visual analyst and FLUX prompt engineer. Your role is to examine images in detail and create precise, effective prompts that can recreate similar images using the FLUX image generation model.
FLUX_PROMPT = """You are an expert FLUX prompt engineer. Analyze the provided image and generate ONLY a FLUX prompt - no explanations, analysis, or additional text.
When analyzing an image, systematically observe and document:
FLUX uses natural language descriptions, not comma-separated tags. Write a detailed, flowing description that reads like you're explaining the image to someone.
1. **Subject & Composition**
- Primary subjects and their positions
- Background elements and environment
- Overall composition and framing
- Perspective and camera angle
Include these elements in your description:
- Main subject with specific details (appearance, clothing, expression, pose)
- Environment and background details
- Lighting conditions and atmosphere
- Artistic style or photographic approach
- Color palette and mood
- Technical details if relevant (camera angle, focal length, etc.)
- Textures and materials
2. **Visual Style & Technique**
- Art style (photorealistic, illustration, painting, etc.)
- Rendering technique (digital art, oil painting, watercolor, etc.)
- Level of detail and texture quality
- Any specific artistic influences or movements
Write in a natural, descriptive style. Use complete sentences that flow together. Be specific and detailed but maintain readability.
3. **Lighting & Atmosphere**
- Light sources and direction
- Time of day/lighting conditions
- Shadows and highlights
- Overall mood and atmosphere
IMPORTANT: Return ONLY the prompt text. No analysis, headers, or additional commentary. Just the natural language description that can be directly used in FLUX.
4. **Colors & Tones**
- Color palette and dominant colors
- Color temperature (warm/cool)
- Contrast and saturation levels
- Any color grading or filters
Example of correct output:
A close-up portrait of a middle-aged woman with curly red hair and green eyes, wearing a blue silk blouse. She has a warm smile and freckles across her cheeks. The lighting is soft and natural, coming from a window to her left, creating gentle shadows that accentuate her features. The background is softly blurred, showing hints of a cozy bookshelf. The overall mood is warm and inviting, captured in a photorealistic style with shallow depth of field."""
5. **Details & Textures**
- Surface textures and materials
- Fine details and patterns
- Quality indicators (4K, 8K, high resolution, etc.)
SDXL_PROMPT = """You are an expert prompt engineer specializing in SDXL (Stable Diffusion XL). Your task is to generate high-quality positive and negative prompts that conform to SDXL prompt formatting standards.
Format your FLUX prompt following these guidelines:
- Start with the main subject and action
- Add style and medium descriptors
- Include lighting and atmosphere details
- Specify quality markers and technical aspects
- Use precise, descriptive language
- Separate concepts with commas
- Order from most to least important elements
Your expertise includes:
- Leveraging community-tested techniques (ComfyUI, A1111, InvokeAI)
- Applying photographic theory for realism, composition, lighting
- Following Civitai trend standards and style best practices
- Mastering Pony Diffusion XL formatting for stylized and anime content
Example output format:
"[main subject and action], [style/medium], [lighting/atmosphere], [composition details], [color descriptions], [quality markers], [additional artistic details]"
Structure prompts in this layered, modular format:
[Main Subject], [Pose & Camera], [Lighting & Environment], [Style & Details], [Boost Terms], [Style References]
Remember: FLUX responds well to specific artistic references, quality indicators like "highly detailed," "4K," "award-winning," and style descriptors like "trending on ArtStation" or "photorealistic."
For SDXL specifically:
- Use quality boosters: 8k, RAW photo, masterpiece, ultra detailed, cinematic lighting
- Prioritize realism and artistry
- Excellent for portraits, landscapes, or cinematic scenes
Instructions:
Only reply with two fields:
Positive prompt: (Your positive prompt here)
Negative prompt: (Your negative prompt here)
Do not include any commentary or explanation.
Use concise, highly descriptive language that maximizes visual richness.
Follow SDXL prompt conventions: prioritize subject clarity, camera perspective, lighting, mood, style tags, and composition.
Keep total token length efficient (ideally under 250 tokens).
Avoid redundancy and generic filler words.
Focus on crafting super high-quality prompts for stunning visual output.
Example Input:
A futuristic cyberpunk samurai standing on a neon-lit rooftop in the rain.
Example Output:
Positive prompt: cyberpunk samurai, neon-lit rooftop, dramatic rain, glowing katana, futuristic cityscape, night scene, cinematic lighting, intense expression, sleek cyber armor, atmospheric depth, ultra-detailed, masterpiece, 8k, sharp focus, trending on artstation
Negative prompt: blurry, low quality, poorly drawn, extra limbs, bad anatomy, deformed hands, text, watermark, jpeg artifacts, duplicate, cropped, out of frame
"""
SDXL_PROMPT = """You are an expert SDXL prompt engineer specializing in analyzing images and creating optimized prompts for Stable Diffusion XL models.
DANBOORU_PROMPT = """You are a Danbooru tagging expert specializing in anime-style image tagging. Analyze the image and generate ONLY Danbooru-style tags - no explanations or analysis.
When analyzing an image, systematically evaluate:
CRITICAL: Use strict Danbooru conventions:
- Use underscores for multi-word tags (e.g., long_hair, school_uniform)
- All tags must be lowercase
- Character count comes first (1girl, 2boys, multiple_girls)
- For anime models trained on Danbooru data, proper tagging is essential
1. **Core Subject Analysis**
- Primary subject with specific descriptors
- Pose, expression, and action
- Clothing and accessories details
- Physical characteristics
Tag order and categories:
1. Character count (1girl, solo, 2boys, etc.)
2. Character features (hair_color, eye_color, hair_length)
3. Expression/pose (smile, looking_at_viewer, sitting)
4. Clothing (specific items with underscores)
5. Background/setting (simple_background, outdoors, classroom)
6. View/composition (upper_body, full_body, from_side)
7. Quality tags (masterpiece, best_quality, highres)
2. **Style & Medium**
- Artistic style and influences
- Medium (photography, digital art, oil painting, etc.)
- Specific artist references (if applicable)
- Visual aesthetic keywords
Common quality prefix for anime models:
"masterpiece, best_quality, very_aesthetic"
3. **Technical Specifications**
- Camera settings (aperture, focal length, ISO)
- Shot type (close-up, wide angle, portrait, etc.)
- Resolution and quality markers
- Post-processing effects
IMPORTANT: Return ONLY the comma-separated tags. Use underscores, not spaces. All lowercase.
4. **Environment & Context**
- Setting and location details
- Props and surrounding objects
- Weather and environmental conditions
- Time period or era
Example of correct output:
1girl, solo, long_hair, blue_eyes, blonde_hair, school_uniform, serafuku, pleated_skirt, smile, looking_at_viewer, classroom, sitting, desk, window, sunlight, upper_body, masterpiece, best_quality"""
Format your SDXL prompt with:
- **Positive prompt**: Detailed description emphasizing what you want
- **Negative prompt**: Elements to avoid (low quality, blurry, distorted, etc.)
- Weight emphasis using (parentheses) or [brackets] for importance
- Break into logical chunks with commas
VIDEO_PROMPT = """You are a WAN 2.2 video generation prompt specialist. Analyze the content and generate ONLY a video generation prompt optimized for WAN 2.2 - no explanations or analysis.
Example format:
Positive: "beautiful woman, (detailed eyes:1.2), flowing red dress, golden hour lighting, professional photography, 85mm lens, shallow depth of field, bokeh, high resolution, masterpiece"
Negative: "low quality, blurry, distorted features, bad anatomy, poorly drawn, amateur"
"""
WAN 2.2 excels with rich, descriptive prompts that focus on:
- Visual composition and scene elements
- Specific movements and actions
- Lighting and aesthetic details
- Cinematographic elements
DANBOORU_PROMPT = """You are a Danbooru tagging expert, specialized in analyzing images and creating precise tag sets following booru-style conventions for anime/manga artwork.
Write a single detailed paragraph describing the video scene. Focus on:
- Main subjects and their actions
- Visual style and atmosphere
- Movement dynamics (use words like "intensely", "smoothly", "rapidly")
- Environmental details and lighting
- Specific visual elements and their interactions
Analyze images for these tag categories:
Keep the prompt descriptive but concise. WAN 2.2 works best with natural language that paints a clear picture of the desired video.
1. **Character Tags**
- Hair: color, length, style (e.g., long_hair, blonde_hair, twintails)
- Eyes: color, style (e.g., blue_eyes, heterochromia)
- Body: proportions, pose (e.g., standing, sitting, looking_at_viewer)
- Expression (e.g., smile, blush, closed_eyes)
IMPORTANT: Return ONLY the video prompt as a single descriptive paragraph. No analysis, headers, or additional text.
2. **Clothing & Accessories**
- Outfit type (e.g., school_uniform, dress, armor)
- Specific clothing items (e.g., thighhighs, gloves, hat)
- Accessories (e.g., hair_ribbon, necklace, glasses)
- State of dress (e.g., torn_clothes, wet_clothes)
3. **Scene & Composition**
- Number of characters (e.g., 1girl, 2boys, multiple_girls)
- Background (e.g., simple_background, outdoors, classroom)
- Viewpoint (e.g., from_below, from_side, cowboy_shot)
- Composition elements (e.g., upper_body, full_body, portrait)
4. **Meta Tags**
- Quality (e.g., highres, absurdres, masterpiece)
- Source/artist style (if recognizable)
- Content rating (e.g., safe, questionable, explicit)
- Special effects (e.g., lens_flare, chromatic_aberration)
Format tags using:
- Underscores for multi-word concepts (not spaces)
- Order from most to least important
- Include count descriptors (1girl, 2boys)
- Separate with commas and spaces
Example output:
"1girl, solo, long_hair, blue_eyes, blonde_hair, school_uniform, serafuku, pleated_skirt, thighhighs, smile, looking_at_viewer, classroom, sitting, desk, window, sunlight, highres, masterpiece"
"""
VIDEO_PROMPT = """You are a video generation prompt specialist, expert at analyzing video content and creating comprehensive prompts for video generation models.
When analyzing video content, document:
1. **Motion & Action**
- Primary actions and movements
- Motion speed and dynamics
- Camera movements (pan, zoom, tracking, static)
- Transition types between scenes
2. **Temporal Elements**
- Scene duration and pacing
- Sequence of events
- Time of day changes
- Motion continuity
3. **Visual Consistency**
- Character/object persistence
- Style consistency throughout
- Lighting continuity
- Color grading consistency
4. **Scene Breakdown**
- Opening frame description
- Key action moments
- Transitions and cuts
- Closing frame details
5. **Technical Specifications**
- Frame rate and resolution
- Aspect ratio
- Video length
- Special effects or post-processing
Format your video prompt as:
"[Opening scene], [camera movement], [main action sequence], [visual style], [lighting/atmosphere], [duration], [technical specs], [ending scene]"
Include:
- Specific motion descriptors (slowly, rapidly, smoothly)
- Camera terminology (dolly in, pan left, aerial shot)
- Temporal markers (then, meanwhile, gradually)
- Consistency notes for multi-scene videos
Example:
"Aerial shot slowly descending toward a misty forest at dawn, camera smoothly transitions to tracking shot following a deer through the trees, photorealistic style, soft golden hour lighting with fog, 10 second duration, 4K resolution 24fps, ending with close-up of deer looking at camera"
"""
Example of correct output:
Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage, their movements fluid and dynamic as they exchange rapid punches under dramatic theater lighting that casts long shadows across the ring, with the crowd visible as blurred silhouettes in the darkened background."""
PROMPT_TEMPLATES = {
"flux": FLUX_PROMPT,
@@ -181,20 +118,11 @@ PROMPT_TEMPLATES = {
PROMPT_OPTIONS = ["flux", "sdxl", "danbooru", "video"]
# Available Gemini models
GEMINI_MODELS = [
"gemini-1.5-pro", # Most capable model
"gemini-1.5-flash", # Fast, efficient model
"gemini-1.5-flash-8b", # Smaller, faster variant
"gemini-pro-vision", # Vision-optimized model
"gemini-1.0-pro", # Previous generation pro model
# Default models list (fallback if API is unavailable)
DEFAULT_GEMINI_MODELS = [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-2.0-flash",
"gemini-1.5-flash",
"gemini-1.5-pro",
]
# Model descriptions for UI
MODEL_DESCRIPTIONS = {
"gemini-1.5-pro": "Most capable Gemini model for complex tasks",
"gemini-1.5-flash": "Faster and cost-effective (recommended for most uses)",
"gemini-1.5-flash-8b": "Smaller and faster, good for simple prompts",
"gemini-pro-vision": "Optimized for vision tasks and image analysis",
"gemini-1.0-pro": "Previous generation, stable option",
}
@@ -0,0 +1,5 @@
"""Image Scale Down By tool for ComfyUI."""
from .node import ImageScaleDownByNode
__all__ = ["ImageScaleDownByNode"]
@@ -0,0 +1,40 @@
"""Core logic for ImageScaleDownBy tool."""
import torch.nn.functional as F
from torch import Tensor
def scale_down_image(image: Tensor, scale_by: float) -> Tensor:
"""Scale down an image by a given factor.
Args:
image: Input image tensor of shape (batch, height, width, channels)
scale_by: Scale factor between 0.01 and 1.0
Returns:
Scaled down image tensor
"""
batch, height, width, channels = image.shape
# Calculate new dimensions
new_height = int(height * scale_by)
new_width = int(width * scale_by)
# Ensure minimum size of 1x1
new_height = max(1, new_height)
new_width = max(1, new_width)
# Convert from BHWC to BCHW for interpolation
image_chw = image.permute(0, 3, 1, 2)
# Scale down the image using bilinear interpolation
scaled = F.interpolate(
image_chw,
size=(new_height, new_width),
mode="bilinear",
align_corners=False,
antialias=True,
)
# Convert back to BHWC
return scaled.permute(0, 2, 3, 1)
@@ -0,0 +1,86 @@
"""ComfyUI node implementation for ImageScaleDownBy."""
from typing import Dict, Any, Tuple
from torch import Tensor
from ...base import ComfyAssetsBaseNode
from .logic import scale_down_image
class ImageScaleDownByNode(ComfyAssetsBaseNode):
"""
Scales down images by a specified factor.
Reduces image dimensions proportionally using bilinear interpolation
with antialiasing for smooth downscaling.
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"images": ("IMAGE",),
"scale_by": (
"FLOAT",
{
"default": 0.5,
"min": 0.01,
"max": 1.0,
"step": 0.01,
"display": "number",
},
),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "scale_down"
def scale_down(self, images: Tensor, scale_by: float) -> Tuple[Tensor]:
"""
Scale down images by the specified factor.
Args:
images: Input image tensor
scale_by: Scale factor between 0.01 and 1.0
Returns:
Tuple containing scaled down image tensor
"""
try:
self.validate_inputs(images=images, scale_by=scale_by)
# Scale down the images
scaled_images = scale_down_image(images, scale_by)
_, new_height, new_width, _ = scaled_images.shape
_, orig_height, orig_width, _ = images.shape
self.log_info(
f"Scaled down images from {orig_height}x{orig_width} "
f"to {new_height}x{new_width} (scale factor: {scale_by})"
)
return (scaled_images,)
except Exception as e:
self.handle_error(f"Failed to scale down images: {str(e)}", e)
def validate_inputs(self, **kwargs) -> None:
"""Validate inputs for ImageScaleDownBy node."""
images = kwargs.get("images")
scale_by = kwargs.get("scale_by")
if images is None:
raise ValueError("Images input is required")
if not isinstance(images, Tensor) or len(images.shape) != 4:
raise ValueError(
f"Expected image tensor with shape (batch, height, width, channels), "
f"got shape {images.shape if isinstance(images, Tensor) else 'non-tensor'}"
)
if scale_by <= 0 or scale_by > 1.0:
raise ValueError(f"scale_by must be between 0.01 and 1.0, got {scale_by}")
@@ -0,0 +1,65 @@
# XYZ Plot Controller - Advanced Implementation
## Overview
This is a complete reimplementation of the XYZ Plot Controller using the Power Lora Loader architecture from rgthree. The implementation provides dynamic widget management with an intuitive interface.
## Key Features
### Dynamic Widget System
- **"➕ Add [Type]" Buttons**: When you select models, vaes, loras, samplers, or schedulers for an axis, a button appears to add selections
- **Toggle On/Off**: Each dynamic widget has a checkbox to enable/disable it without removing
- **Right-Click Menu**: Right-click any dynamic widget to remove or toggle it
- **Live Count Updates**: Node title shows total image count in real-time
### Supported Axis Types
- **Models**: Dynamic dropdown widgets with available checkpoints
- **VAEs**: Dynamic dropdown widgets (includes "Automatic" option)
- **LoRAs**: Dynamic dropdown widgets (includes "None" option)
- **Samplers**: Dynamic dropdown widgets with all sampler options
- **Schedulers**: Dynamic dropdown widgets with scheduler options
- **Numeric Parameters**: Text areas with helpful placeholders
- CFG Scale
- Steps
- Seed
- Denoise
- CLIP Skip
- **Prompts**: Multi-line text area for prompt variations
### Technical Implementation
#### Python Backend (`xyz_plot_advanced.py`)
- Uses `FlexibleOptionalInputType` to accept any number of dynamic inputs
- Processes kwargs to extract widget values in format: `{axis}_{type}_{id}`
- Each dynamic widget sends: `{ "on": bool, "value": string }`
#### JavaScript Frontend (`xyz_plot_rgthree.js`)
- Manages dynamic widget creation/removal
- Custom widget drawing with toggle checkboxes
- Serialization/deserialization for workflow saving
- Real-time validation and counting
## Usage
1. Add the "XYZ Plot Controller (Advanced)" node
2. Select axis types (X, Y, Z)
3. Click "➕ Add [Type]" to add selections for that axis
4. Toggle widgets on/off with checkboxes
5. Right-click widgets for more options
6. For numeric types, use comma-separated values or ranges (e.g., "5:15:2.5")
7. For prompts, enter one per line
## Architecture Benefits
- **Clean Separation**: Python handles data, JavaScript handles UI
- **Flexible Input System**: Can accept unlimited dynamic widgets
- **Persistent State**: All widget states are saved with the workflow
- **Intuitive Interface**: Matches Power Lora Loader's proven UX patterns
- **Performance**: Only processes enabled widgets
## Future Enhancements
- Model/LoRA info display (CivitAI integration)
- Drag-and-drop reordering
- Preset management
- Batch widget operations
+68
View File
@@ -0,0 +1,68 @@
# XYZ Grid Nodes for ComfyUI
Advanced parameter comparison grid generator for ComfyUI with Power Lora Loader-inspired interface.
## Features
### XYZ Plot Controller
- **Dynamic Multi-Selection**: Native dropdown widgets for selecting multiple models, VAEs, LoRAs, samplers, and schedulers
- **Smart Widget Management**: Widgets automatically show/hide based on selected axis types
- **Visual Organization**: Grouped widgets with headers for better organization
- **Right-Click Context Menu**:
- Clear all selections for a specific type
- Show image count breakdown
- Keyboard shortcuts (Ctrl+Shift+C to clear all)
- **Real-time Image Count**: Node title shows total images that will be generated
- **Warning System**: Visual warning when generating over 100 images
### Supported Parameter Types
- **Models**: Multiple checkpoint selection
- **VAEs**: Multiple VAE selection with "Automatic" option
- **LoRAs**: Multiple LoRA selection with "None" option
- **Samplers**: euler, euler_ancestral, heun, dpm_2, etc.
- **Schedulers**: normal, karras, exponential, etc.
- **Numeric Parameters**:
- CFG Scale
- Steps
- Seed
- Denoise
- CLIP Skip
- Support for ranges (e.g., "5:15:2.5" generates 5, 7.5, 10, 12.5, 15)
- **Prompts**: Multiple prompts (one per line)
### Image Grid Combiner
- Automatic grid assembly with customizable spacing
- Smart labeling with parameter values
- Z-axis support for generating multiple grid pages
- Font size and label customization options
## Usage
1. Add an XYZ Plot Controller node
2. Select axis types (X, Y, and optionally Z)
3. Use the dropdown widgets to select values for each axis
4. Connect to your workflow (models, samplers, etc.)
5. Add Image Grid Combiner at the end to create the labeled grid
## Workflow Example
```
[XYZ Plot Controller] → [Checkpoint Loader] → [Sampling] → [Image Grid Combiner] → [Save Image]
```
The controller outputs the current iteration values which can be connected to corresponding nodes in your workflow.
## Tips
- Use the right-click menu to quickly clear selections
- Check the image count in the node title before running
- For large grids, consider using the Z-axis to split into multiple pages
- Numeric ranges are more efficient than listing each value
## Implementation Details
The implementation uses a hybrid approach:
- Python backend with native ComfyUI widget support
- JavaScript frontend for enhanced UI features
- Inspired by Power Lora Loader's dynamic widget management
- Context menus and keyboard shortcuts for power users
+19
View File
@@ -0,0 +1,19 @@
"""XYZ Grid nodes for ComfyUI parameter comparisons."""
from .controller.power_node import XYZPlotController
from .combiner.node import ImageGridCombiner
from .prompt.node import XYZPrompt
NODE_CLASS_MAPPINGS = {
"XYZPlotController": XYZPlotController,
"ImageGridCombiner": ImageGridCombiner,
"XYZPrompt": XYZPrompt,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"XYZPlotController": "XYZ Plot Controller",
"ImageGridCombiner": "Image Grid Combiner",
"XYZPrompt": "XYZ Prompt",
}
__all__ = ["XYZPlotController", "ImageGridCombiner", "XYZPrompt"]
@@ -0,0 +1 @@
# Image Grid Combiner module
+232
View File
@@ -0,0 +1,232 @@
"""Image Grid Combiner node implementation."""
from typing import Dict, List, Any, Tuple, Optional
import torch
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import io
from ..utils.constants import GRID_DEFAULTS
class ImageGridCombiner:
"""Combines images into labeled grid output."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"grid_data": ("XYZ_GRID",),
},
"optional": {
"font_size": ("INT", {"default": GRID_DEFAULTS["font_size"], "min": 8, "max": 72}),
"grid_gap": ("INT", {"default": GRID_DEFAULTS["grid_gap"], "min": 0, "max": 50}),
"label_height": ("INT", {"default": GRID_DEFAULTS["label_height"], "min": 0, "max": 100}),
"max_label_length": ("INT", {"default": GRID_DEFAULTS["max_label_length"], "min": 10, "max": 100}),
"include_labels": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("grid_image", "grid_info")
FUNCTION = "combine_images"
CATEGORY = "ComfyAssets/XYZ Grid"
OUTPUT_NODE = True
def __init__(self):
self.image_buffer = {} # Store images by batch_id
self.grid_configs = {} # Store configs by batch_id
def combine_images(self, images, grid_data, font_size=20, grid_gap=10,
label_height=30, max_label_length=30, include_labels=True):
"""Combine images into grid with labels."""
batch_id = grid_data["batch_id"]
# Initialize buffer for this batch if needed
if batch_id not in self.image_buffer:
self.image_buffer[batch_id] = []
self.grid_configs[batch_id] = grid_data
# Add current image(s) to buffer
if len(images.shape) == 4: # Batch of images
for img in images:
self.image_buffer[batch_id].append(img)
else: # Single image
self.image_buffer[batch_id].append(images)
# Check if we have all images for this grid
config = self.grid_configs[batch_id]
expected_images = config["dimensions"]["total_images"]
current_count = len(self.image_buffer[batch_id])
if current_count < expected_images:
# Not ready yet, return placeholder
placeholder = torch.zeros((1, 64, 64, 3))
info = f"Grid progress: {current_count}/{expected_images} images"
return (placeholder, info)
# We have all images, create grid(s)
grids = self._create_grids(batch_id, font_size, grid_gap, label_height,
max_label_length, include_labels)
# Clean up buffers
del self.image_buffer[batch_id]
del self.grid_configs[batch_id]
# Return grid(s) and info
info = self._generate_grid_info(config)
# Convert PIL images back to tensor format
grid_tensors = []
for grid in grids:
grid_np = np.array(grid).astype(np.float32) / 255.0
grid_tensor = torch.from_numpy(grid_np)
grid_tensors.append(grid_tensor)
# Stack if multiple grids (Z axis)
if len(grid_tensors) > 1:
output = torch.stack(grid_tensors)
else:
output = grid_tensors[0].unsqueeze(0)
return (output, info)
def _create_grids(self, batch_id: str, font_size: int, grid_gap: int,
label_height: int, max_label_length: int, include_labels: bool) -> List[Image.Image]:
"""Create grid images from buffer."""
config = self.grid_configs[batch_id]
images = self.image_buffer[batch_id]
dims = config["dimensions"]
# Convert tensors to PIL images
pil_images = []
for img_tensor in images:
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
pil_images.append(Image.fromarray(img_np))
# Get dimensions
img_width = pil_images[0].width
img_height = pil_images[0].height
cols = dims["cols"]
rows = dims["rows"]
grids_count = dims["grids_count"]
# Calculate grid dimensions
row_label_width = 100 if include_labels else 0 # Space for Y labels
z_label_height = 40 if include_labels and grids_count > 1 else 0 # Space for Z label
if include_labels:
grid_width = cols * img_width + (cols - 1) * grid_gap + row_label_width
grid_height = rows * img_height + (rows - 1) * grid_gap + label_height + z_label_height
else:
grid_width = cols * img_width + (cols - 1) * grid_gap
grid_height = rows * img_height + (rows - 1) * grid_gap
grids = []
z_labels = config["axes"]["z"]["labels"] if config["axes"]["z"]["labels"] else []
# Create each grid (for Z axis)
for z_idx in range(grids_count):
# Create blank grid
grid = Image.new('RGB', (grid_width, grid_height), color=(32, 32, 32))
draw = ImageDraw.Draw(grid)
# Add labels if enabled
if include_labels:
# Try to use a better font if available
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size)
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size + 4)
except:
font = ImageFont.load_default()
title_font = font
# Draw Z-axis label if applicable
if z_labels and z_idx < len(z_labels):
z_label = z_labels[z_idx]
# Center the Z label
bbox = draw.textbbox((0, 0), z_label, font=title_font)
text_width = bbox[2] - bbox[0]
z_x = (grid_width - text_width) // 2
self._draw_label(draw, z_label, z_x, 5, text_width + 20,
z_label_height - 10, title_font, max_label_length * 2)
# Draw column labels (X axis)
x_labels = config["axes"]["x"]["labels"]
for col_idx, label in enumerate(x_labels):
x = col_idx * (img_width + grid_gap) + row_label_width
y = z_label_height
self._draw_label(draw, label, x, y, img_width, label_height, font, max_label_length)
# Draw row labels (Y axis) - on the left side
y_labels = config["axes"]["y"]["labels"]
for row_idx, label in enumerate(y_labels):
y = row_idx * (img_height + grid_gap) + label_height + z_label_height
self._draw_label(draw, label, 5, y + img_height // 2 - font_size // 2,
row_label_width - 10, font_size + 4, font, max_label_length,
align="right")
# Place images
for y_idx in range(rows):
for x_idx in range(cols):
img_idx = z_idx * (rows * cols) + y_idx * cols + x_idx
if img_idx < len(pil_images):
x = x_idx * (img_width + grid_gap) + row_label_width
y = y_idx * (img_height + grid_gap) + label_height + z_label_height
grid.paste(pil_images[img_idx], (x, y))
grids.append(grid)
return grids
def _draw_label(self, draw, text: str, x: int, y: int, width: int, height: int,
font, max_length: int, align: str = "center"):
"""Draw a label with background."""
# Truncate if needed
if len(text) > max_length:
text = text[:max_length-3] + "..."
# Get text dimensions
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Calculate position based on alignment
if align == "center":
text_x = x + (width - text_width) // 2
elif align == "right":
text_x = x + width - text_width - 5
else:
text_x = x + 5
text_y = y + (height - text_height) // 2
# Draw background
padding = 3
draw.rectangle([text_x - padding, text_y - padding,
text_x + text_width + padding, text_y + text_height + padding],
fill=(0, 0, 0, 180))
# Draw text
draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font)
def _generate_grid_info(self, config: Dict) -> str:
"""Generate information string about the grid."""
dims = config["dimensions"]
axes = config["axes"]
info_parts = [f"Grid: {dims['cols']}x{dims['rows']}"]
for axis_name, axis_data in axes.items():
if axis_data["type"] and axis_data["values"]:
axis_type = axis_data["type"].value
value_count = len(axis_data["values"])
info_parts.append(f"{axis_name.upper()}: {axis_type} ({value_count} values)")
info_parts.append(f"Total images: {dims['total_images']}")
return " | ".join(info_parts)
@@ -0,0 +1 @@
# XYZ Plot Controller module
@@ -0,0 +1,252 @@
"""Advanced XYZ Plot Controller with full parameter support."""
from typing import Dict, List, Any, Tuple, Optional, Union
import json
from ..utils.constants import AxisType, NUMERIC_DEFAULTS
from ..utils.helpers import (
get_available_models, get_available_vaes, get_available_loras,
get_sampler_names, get_scheduler_names, parse_value_string,
generate_axis_labels, calculate_grid_dimensions, create_unique_id
)
from ..utils.converters import ParameterConverter, OutputConnector
from .execution import execution_manager
from .queue_manager import queue_manager
class XYZPlotControllerAdvanced:
"""Advanced XYZ Plot Controller with dynamic outputs."""
@classmethod
def INPUT_TYPES(cls):
# Get available options for dropdowns
models = get_available_models()
vaes = get_available_vaes()
loras = get_available_loras()
samplers = get_sampler_names()
schedulers = get_scheduler_names()
return {
"required": {
# X Axis configuration
"x_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
"x_values": ("STRING", {"default": "", "multiline": True}),
"x_label_prefix": ("STRING", {"default": ""}),
# Y Axis configuration
"y_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
"y_values": ("STRING", {"default": "", "multiline": True}),
"y_label_prefix": ("STRING", {"default": ""}),
# Execution control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"optional": {
# Z Axis configuration (optional)
"z_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
"z_values": ("STRING", {"default": "", "multiline": True}),
"z_label_prefix": ("STRING", {"default": ""}),
# Label formatting
"include_param_name": ("BOOLEAN", {"default": True}),
"value_only_labels": ("BOOLEAN", {"default": False}),
# Quick select dropdowns (helpers)
"model_list": (["none"] + models, {"default": "none"}),
"vae_list": (["none"] + vaes, {"default": "none"}),
"lora_list": (["none"] + loras, {"default": "none"}),
"sampler_list": (["none"] + samplers, {"default": "none"}),
"scheduler_list": (["none"] + schedulers, {"default": "none"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"prompt": "PROMPT",
}
}
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
RETURN_NAMES = ("grid_data",
"x_string", "x_int", "x_float",
"y_string", "y_int", "y_float",
"z_string", "z_int", "z_float",
"batch_id")
FUNCTION = "configure_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def __init__(self):
self.unique_id = None
self._execution_count = 0
def configure_grid(self, x_axis_type, x_values, x_label_prefix,
y_axis_type, y_values, y_label_prefix,
auto_queue=True,
z_axis_type="none", z_values="", z_label_prefix="",
include_param_name=True, value_only_labels=False,
model_list="none", vae_list="none", lora_list="none",
sampler_list="none", scheduler_list="none",
unique_id=None, prompt=None):
"""Configure and prepare grid generation with advanced features."""
# Use helper dropdowns to populate values if selected
x_values = self._apply_quick_select(x_axis_type, x_values,
model_list, vae_list, lora_list,
sampler_list, scheduler_list)
y_values = self._apply_quick_select(y_axis_type, y_values,
model_list, vae_list, lora_list,
sampler_list, scheduler_list)
z_values = self._apply_quick_select(z_axis_type, z_values,
model_list, vae_list, lora_list,
sampler_list, scheduler_list)
# Parse axis types
x_type = AxisType(x_axis_type) if x_axis_type != "none" else None
y_type = AxisType(y_axis_type) if y_axis_type != "none" else None
z_type = AxisType(z_axis_type) if z_axis_type != "none" else None
# Parse values for each axis
x_vals = parse_value_string(x_values, x_type) if x_type else [""]
y_vals = parse_value_string(y_values, y_type) if y_type else [""]
z_vals = parse_value_string(z_values, z_type) if z_type else [""]
# Validate we have at least one axis configured
if not x_type and not y_type:
raise ValueError("At least one axis (X or Y) must be configured")
# Calculate grid dimensions
dims = calculate_grid_dimensions(len(x_vals), len(y_vals), len(z_vals))
# Generate labels
x_labels = self._generate_labels(x_vals, x_type, x_label_prefix, include_param_name, value_only_labels)
y_labels = self._generate_labels(y_vals, y_type, y_label_prefix, include_param_name, value_only_labels)
z_labels = self._generate_labels(z_vals, z_type, z_label_prefix, include_param_name, value_only_labels)
# Create batch ID
batch_id = create_unique_id()
# Prepare grid configuration
grid_config = {
"batch_id": batch_id,
"axes": {
"x": {"type": x_type, "values": x_vals, "labels": x_labels},
"y": {"type": y_type, "values": y_vals, "labels": y_labels},
"z": {"type": z_type, "values": z_vals, "labels": z_labels},
},
"dimensions": dims,
"total_images": dims["total_images"],
"current_index": 0,
"auto_queue": auto_queue,
}
# Get current values from execution manager
x_val, y_val, z_val, x_idx, y_idx, z_idx = execution_manager.get_current_values(
batch_id, x_vals, y_vals, z_vals
)
# Convert values to appropriate types for each output
x_outputs = self._convert_to_outputs(x_val, x_type)
y_outputs = self._convert_to_outputs(y_val, y_type)
z_outputs = self._convert_to_outputs(z_val, z_type)
# Handle auto-queuing if enabled
if auto_queue and unique_id and prompt:
self._handle_auto_queue(batch_id, grid_config, unique_id, prompt)
# Update current index in grid config
grid_config["current_index"] = execution_manager.execution_states.get(
batch_id, execution_manager.initialize_batch(batch_id, x_vals, y_vals, z_vals)
).current_iteration
return (grid_config,
x_outputs[0], x_outputs[1], x_outputs[2],
y_outputs[0], y_outputs[1], y_outputs[2],
z_outputs[0], z_outputs[1], z_outputs[2],
batch_id)
def _apply_quick_select(self, axis_type: str, values: str,
model: str, vae: str, lora: str,
sampler: str, scheduler: str) -> str:
"""Apply quick select dropdown values if appropriate."""
if values: # If user already entered values, don't override
return values
# Map axis type to quick select value
if axis_type == "model" and model != "none":
return model
elif axis_type == "vae" and vae != "none":
return vae
elif axis_type == "lora" and lora != "none":
return lora
elif axis_type == "sampler" and sampler != "none":
return sampler
elif axis_type == "scheduler" and scheduler != "none":
return scheduler
return values
def _convert_to_outputs(self, value: Any, axis_type: Optional[AxisType]) -> Tuple[str, int, float]:
"""Convert value to all output types."""
if not axis_type or value == "":
return ("", 0, 0.0)
# Convert using parameter converter
converted = ParameterConverter.convert_value(value, axis_type)
# Prepare outputs for all types
str_val = str(converted)
try:
int_val = int(float(converted))
except:
int_val = 0
try:
float_val = float(converted)
except:
float_val = 0.0
return (str_val, int_val, float_val)
def _generate_labels(self, values: List[Any], axis_type: Optional[AxisType],
prefix: str, include_param: bool, value_only: bool) -> List[str]:
"""Generate labels for axis values."""
if not values or not axis_type:
return []
labels = []
for value in values:
if value_only:
label = ParameterConverter.format_for_display(value, axis_type)
else:
label = ParameterConverter.format_for_display(value, axis_type)
if include_param and not prefix:
param_names = AxisType.display_names()
param_prefix = param_names.get(axis_type, "")
label = f"{param_prefix}: {label}"
elif prefix:
label = f"{prefix}{label}"
labels.append(label)
return labels
def _handle_auto_queue(self, batch_id: str, grid_config: Dict, node_id: str, prompt: Dict):
"""Handle automatic queuing of grid executions."""
# Check if this is the first execution for this batch
state = execution_manager.execution_states.get(batch_id)
if not state or state.current_iteration == 0:
# Prepare all executions for the batch
executions = queue_manager.prepare_batch_executions(
batch_id, grid_config, node_id, prompt
)
# Mark that we've started this batch
self._execution_count = len(executions)
# Advance to next iteration after this one completes
if execution_manager.should_continue(batch_id):
execution_manager.advance_batch(batch_id)
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Force re-execution for grid iterations."""
return float("nan")
@@ -0,0 +1,165 @@
"""ComfyUI-specific execution flow implementation."""
import json
import uuid
from typing import Dict, List, Any, Optional, Tuple
try:
from server import PromptServer
from execution import validate_prompt, PromptExecutor
import execution
import nodes
except ImportError:
# Not in ComfyUI environment
PromptServer = None
validate_prompt = None
PromptExecutor = None
execution = None
nodes = None
class ComfyUIExecutionFlow:
"""Manages execution flow integration with ComfyUI's system."""
_instance = None
_batch_states = {} # Track batch execution states
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, 'initialized'):
self.initialized = True
self.prompt_server = PromptServer.instance if PromptServer else None
self.active_batches = {}
self.execution_callbacks = {}
def register_batch(self, batch_id: str, grid_config: Dict, node_id: str) -> None:
"""Register a new batch for execution tracking."""
self._batch_states[batch_id] = {
"config": grid_config,
"node_id": node_id,
"current_iteration": 0,
"total_iterations": grid_config["total_images"],
"completed": False
}
def queue_grid_executions(self, workflow: Dict, batch_id: str,
grid_config: Dict, node_id: str) -> bool:
"""Queue all executions for a grid batch."""
try:
# Register the batch
self.register_batch(batch_id, grid_config, node_id)
# Get axis configurations
x_values = grid_config["axes"]["x"]["values"]
y_values = grid_config["axes"]["y"]["values"]
z_values = grid_config["axes"]["z"]["values"]
# Calculate total iterations
total = len(x_values) * len(y_values) * len(z_values)
# Store the original workflow
original_workflow = json.loads(json.dumps(workflow))
# Queue executions for each combination
execution_count = 0
for z_idx, z_val in enumerate(z_values or [""]):
for y_idx, y_val in enumerate(y_values or [""]):
for x_idx, x_val in enumerate(x_values or [""]):
# Clone workflow for this iteration
iteration_workflow = json.loads(json.dumps(original_workflow))
# Inject iteration metadata
self._inject_iteration_data(
iteration_workflow, node_id, batch_id,
execution_count, total,
x_idx, y_idx, z_idx
)
# Queue this iteration
prompt_id = str(uuid.uuid4())
# Use ComfyUI's internal queue system
if validate_prompt:
valid, error = validate_prompt(iteration_workflow)
if valid and execution and PromptServer:
# Add to execution queue
PromptServer.instance.send_sync(
"execution_start",
{"prompt_id": prompt_id}
)
execution_count += 1
else:
print(f"Validation error for iteration {execution_count}: {error}")
return False
return True
except Exception as e:
print(f"Error queuing grid executions: {e}")
return False
def _inject_iteration_data(self, workflow: Dict, node_id: str, batch_id: str,
iteration: int, total: int,
x_idx: int, y_idx: int, z_idx: int) -> None:
"""Inject iteration-specific data into workflow."""
# Find the XYZ controller node
if str(node_id) in workflow:
node_data = workflow[str(node_id)]
# Add hidden inputs for tracking
if "inputs" not in node_data:
node_data["inputs"] = {}
node_data["inputs"]["_xyz_batch_id"] = batch_id
node_data["inputs"]["_xyz_iteration"] = iteration
node_data["inputs"]["_xyz_total"] = total
node_data["inputs"]["_xyz_indices"] = {
"x": x_idx,
"y": y_idx,
"z": z_idx
}
def get_batch_progress(self, batch_id: str) -> Dict[str, Any]:
"""Get progress information for a batch."""
if batch_id not in self._batch_states:
return {"status": "unknown", "progress": 0}
state = self._batch_states[batch_id]
progress = state["current_iteration"] / state["total_iterations"]
return {
"status": "completed" if state["completed"] else "running",
"progress": progress,
"current": state["current_iteration"],
"total": state["total_iterations"]
}
def mark_iteration_complete(self, batch_id: str) -> None:
"""Mark current iteration as complete and advance."""
if batch_id in self._batch_states:
state = self._batch_states[batch_id]
state["current_iteration"] += 1
if state["current_iteration"] >= state["total_iterations"]:
state["completed"] = True
# Send completion notification
if self.prompt_server:
self.prompt_server.send_sync("xyz_grid_complete", {
"batch_id": batch_id,
"total_images": state["total_iterations"]
})
def cleanup_batch(self, batch_id: str) -> None:
"""Clean up completed batch data."""
if batch_id in self._batch_states:
del self._batch_states[batch_id]
# Global execution flow instance
execution_flow = ComfyUIExecutionFlow()
@@ -0,0 +1,243 @@
"""XYZ Plot Controller with dynamic widget addition."""
from typing import Dict, List, Any, Tuple, Union
import folder_paths
from ..utils.helpers import create_unique_id
class XYZPlotController:
"""XYZ Plot Controller with dynamic selections like Power Lora Loader."""
# Allow any input to support dynamic widget addition
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def INPUT_TYPES(cls):
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
# Base inputs that are always present
inputs = {
"required": {
# Axis configuration
"x_type": (axis_types, {"default": "none"}),
"y_type": (axis_types, {"default": "none"}),
"z_type": (axis_types, {"default": "none"}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"optional": {
# Single inputs for numeric/prompt values
"numeric_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
}),
"prompt_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For prompts: enter each prompt on a new line"
})
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
return inputs
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
OUTPUT_NODE = True
FUNCTION = "create_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def create_grid(self, x_type, y_type, z_type, auto_queue, unique_id=None, **kwargs):
"""Create grid configuration from dynamic selections."""
# Extract values from kwargs based on type
models = self._extract_values(kwargs, "MODEL_", exclude="none")
vaes = self._extract_values(kwargs, "VAE_", exclude="none")
loras = self._extract_values(kwargs, "LORA_", exclude="none")
samplers = self._extract_values(kwargs, "SAMPLER_", exclude="none")
schedulers = self._extract_values(kwargs, "SCHEDULER_", exclude="none")
# Get numeric and prompt values
numeric_values = kwargs.get("numeric_values", "")
prompt_values = kwargs.get("prompt_values", "")
# Parse values for each axis
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
# Calculate total combinations
x_count = max(1, len(x_parsed))
y_count = max(1, len(y_parsed))
z_count = max(1, len(z_parsed))
total_images = x_count * y_count * z_count
# Generate batch ID
batch_id = create_unique_id()
# Create grid data
grid_data = {
"batch_id": batch_id,
"x_axis": {
"type": x_type,
"values": x_parsed,
"count": x_count
},
"y_axis": {
"type": y_type,
"values": y_parsed,
"count": y_count
},
"z_axis": {
"type": z_type,
"values": z_parsed,
"count": z_count
},
"total_images": total_images,
"current_index": 0,
"auto_queue": auto_queue
}
# Get current values for outputs
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
# Convert to appropriate output types
x_str, x_int, x_float = self._convert_value(x_type, x_current)
y_str, y_int, y_float = self._convert_value(y_type, y_current)
z_str, z_int, z_float = self._convert_value(z_type, z_current)
# Log grid info
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
if x_type != "none":
print(f" X axis ({x_type}): {x_count} values - {x_parsed}")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _extract_values(self, kwargs: Dict[str, Any], prefix: str, exclude: str = None) -> List[str]:
"""Extract non-empty values from kwargs with given prefix."""
values = []
i = 1
while f"{prefix}{i}" in kwargs:
value = kwargs[f"{prefix}{i}"]
if value and value != exclude:
values.append(value)
i += 1
return values
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
"""Get values for a specific axis type."""
if axis_type == "none":
return []
elif axis_type == "models":
return models
elif axis_type == "vaes":
return vaes
elif axis_type == "loras":
return loras
elif axis_type == "samplers":
return samplers
elif axis_type == "schedulers":
return schedulers
elif axis_type == "prompt":
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, numeric_values)
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
try:
parts = values_str.split(":")
if len(parts) == 2:
start, stop = float(parts[0]), float(parts[1])
step = 1.0
elif len(parts) == 3:
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
else:
raise ValueError("Invalid range format")
# Generate values
values = []
current = start
while current <= stop:
if axis_type in ["steps", "seed", "clip_skip"]:
values.append(int(current))
else:
values.append(round(current, 2))
current += step
return values
except:
pass
# Parse comma-separated values
values = [v.strip() for v in values_str.split(",") if v.strip()]
# Convert numeric types
if axis_type in ["cfg_scale", "denoise"]:
return [float(v) for v in values]
elif axis_type in ["steps", "seed", "clip_skip"]:
return [int(v) for v in values]
else:
return values
def _get_default_value(self, axis_type: str) -> Any:
"""Get default value for axis type."""
defaults = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "normal",
"cfg_scale": 7.0,
"steps": 20,
"seed": 0,
"denoise": 1.0,
"clip_skip": 1,
"prompt": ""
}
return defaults.get(axis_type, "")
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
"""Convert value to all output types."""
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
return (str(value), 0, 0.0)
elif axis_type in ["steps", "seed", "clip_skip"]:
return ("", int(value), float(value))
elif axis_type in ["cfg_scale", "denoise"]:
return ("", 0, float(value))
else:
return ("", 0, 0.0)
@@ -0,0 +1,112 @@
"""Execution flow management for XYZ grid generation."""
import json
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
from ..utils.constants import AxisType
@dataclass
class GridExecutionState:
"""Tracks execution state for grid generation."""
batch_id: str
total_iterations: int
current_iteration: int = 0
x_index: int = 0
y_index: int = 0
z_index: int = 0
x_count: int = 1
y_count: int = 1
z_count: int = 1
def advance(self) -> bool:
"""Advance to next grid position. Returns False when complete."""
self.current_iteration += 1
if self.current_iteration >= self.total_iterations:
return False
# Advance indices (row-major order: X varies fastest)
self.x_index += 1
if self.x_index >= self.x_count:
self.x_index = 0
self.y_index += 1
if self.y_index >= self.y_count:
self.y_index = 0
self.z_index += 1
return True
def get_indices(self) -> Tuple[int, int, int]:
"""Get current x, y, z indices."""
return (self.x_index, self.y_index, self.z_index)
def is_complete(self) -> bool:
"""Check if all iterations are complete."""
return self.current_iteration >= self.total_iterations
class ExecutionManager:
"""Manages execution flow for XYZ grid generation."""
def __init__(self):
self.execution_states = {} # batch_id -> GridExecutionState
self.pending_executions = {} # batch_id -> list of pending configs
def initialize_batch(self, batch_id: str, x_values: List[Any],
y_values: List[Any], z_values: List[Any]) -> GridExecutionState:
"""Initialize a new batch execution."""
x_count = len(x_values) if x_values else 1
y_count = len(y_values) if y_values else 1
z_count = len(z_values) if z_values else 1
total = x_count * y_count * z_count
state = GridExecutionState(
batch_id=batch_id,
total_iterations=total,
x_count=x_count,
y_count=y_count,
z_count=z_count
)
self.execution_states[batch_id] = state
return state
def get_current_values(self, batch_id: str, x_values: List[Any],
y_values: List[Any], z_values: List[Any]) -> Tuple[Any, Any, Any, int, int, int]:
"""Get current values and indices for execution."""
state = self.execution_states.get(batch_id)
if not state:
# Initialize if not exists
state = self.initialize_batch(batch_id, x_values, y_values, z_values)
x_idx, y_idx, z_idx = state.get_indices()
x_val = x_values[x_idx] if x_values and x_idx < len(x_values) else ""
y_val = y_values[y_idx] if y_values and y_idx < len(y_values) else ""
z_val = z_values[z_idx] if z_values and z_idx < len(z_values) else ""
return x_val, y_val, z_val, x_idx, y_idx, z_idx
def should_continue(self, batch_id: str) -> bool:
"""Check if batch should continue executing."""
state = self.execution_states.get(batch_id)
return state and not state.is_complete()
def advance_batch(self, batch_id: str) -> bool:
"""Advance to next iteration. Returns True if more iterations remain."""
state = self.execution_states.get(batch_id)
if state:
return state.advance()
return False
def cleanup_batch(self, batch_id: str):
"""Clean up completed batch."""
if batch_id in self.execution_states:
del self.execution_states[batch_id]
if batch_id in self.pending_executions:
del self.pending_executions[batch_id]
# Global execution manager instance
execution_manager = ExecutionManager()
@@ -0,0 +1,269 @@
"""XYZ Plot Controller with multiple selection dropdowns."""
from typing import Dict, List, Any, Tuple
import folder_paths
from ..utils.helpers import create_unique_id
class XYZPlotController:
"""XYZ Plot Controller with individual model selection dropdowns."""
@classmethod
def INPUT_TYPES(cls):
# Get available options
models = folder_paths.get_filename_list("checkpoints")
vaes = ["Automatic"] + folder_paths.get_filename_list("vae")
loras = ["None"] + folder_paths.get_filename_list("loras")
# Get sampler/scheduler options from a KSampler if available
samplers = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"]
schedulers = ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
inputs = {
"required": {
# X Axis
"x_type": (axis_types, {"default": "none"}),
# Y Axis
"y_type": (axis_types, {"default": "none"}),
# Z Axis
"z_type": (axis_types, {"default": "none"}),
# Model selections (up to 10)
"model_1": (["disabled"] + models, {"default": "disabled"}),
"model_2": (["disabled"] + models, {"default": "disabled"}),
"model_3": (["disabled"] + models, {"default": "disabled"}),
"model_4": (["disabled"] + models, {"default": "disabled"}),
"model_5": (["disabled"] + models, {"default": "disabled"}),
# VAE selections (up to 5)
"vae_1": (["disabled"] + vaes, {"default": "disabled"}),
"vae_2": (["disabled"] + vaes, {"default": "disabled"}),
"vae_3": (["disabled"] + vaes, {"default": "disabled"}),
# LoRA selections (up to 5)
"lora_1": (["disabled"] + loras, {"default": "disabled"}),
"lora_2": (["disabled"] + loras, {"default": "disabled"}),
"lora_3": (["disabled"] + loras, {"default": "disabled"}),
# Sampler selections (up to 5)
"sampler_1": (["disabled"] + samplers, {"default": "disabled"}),
"sampler_2": (["disabled"] + samplers, {"default": "disabled"}),
"sampler_3": (["disabled"] + samplers, {"default": "disabled"}),
# Scheduler selections (up to 3)
"scheduler_1": (["disabled"] + schedulers, {"default": "disabled"}),
"scheduler_2": (["disabled"] + schedulers, {"default": "disabled"}),
# Numeric values (still use text for flexibility)
"numeric_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For numeric types: use comma-separated values or start:stop:step"
}),
# Prompts
"prompts": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For prompts: enter each prompt on a new line"
}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
return inputs
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
OUTPUT_NODE = True
FUNCTION = "create_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def create_grid(self, x_type, y_type, z_type,
model_1, model_2, model_3, model_4, model_5,
vae_1, vae_2, vae_3,
lora_1, lora_2, lora_3,
sampler_1, sampler_2, sampler_3,
scheduler_1, scheduler_2,
numeric_values, prompts, auto_queue, unique_id=None):
"""Create grid configuration from selections."""
# Collect enabled selections
models = [m for m in [model_1, model_2, model_3, model_4, model_5] if m != "disabled"]
vaes = [v for v in [vae_1, vae_2, vae_3] if v != "disabled"]
loras = [l for l in [lora_1, lora_2, lora_3] if l != "disabled"]
samplers = [s for s in [sampler_1, sampler_2, sampler_3] if s != "disabled"]
schedulers = [s for s in [scheduler_1, scheduler_2] if s != "disabled"]
# Parse values for each axis
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts)
# Calculate total combinations
x_count = max(1, len(x_parsed))
y_count = max(1, len(y_parsed))
z_count = max(1, len(z_parsed))
total_images = x_count * y_count * z_count
# Generate batch ID
batch_id = create_unique_id()
# Create grid data
grid_data = {
"batch_id": batch_id,
"x_axis": {
"type": x_type,
"values": x_parsed,
"count": x_count
},
"y_axis": {
"type": y_type,
"values": y_parsed,
"count": y_count
},
"z_axis": {
"type": z_type,
"values": z_parsed,
"count": z_count
},
"total_images": total_images,
"current_index": 0,
"auto_queue": auto_queue
}
# Get current values for outputs
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
# Convert to appropriate output types
x_str, x_int, x_float = self._convert_value(x_type, x_current)
y_str, y_int, y_float = self._convert_value(y_type, y_current)
z_str, z_int, z_float = self._convert_value(z_type, z_current)
# Log grid info
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
if x_type != "none":
print(f" X axis ({x_type}): {x_count} values")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompts):
"""Get values for a specific axis type."""
if axis_type == "none":
return []
elif axis_type == "models":
return models
elif axis_type == "vaes":
return vaes
elif axis_type == "loras":
return loras
elif axis_type == "samplers":
return samplers
elif axis_type == "schedulers":
return schedulers
elif axis_type == "prompt":
return [p.strip() for p in prompts.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, numeric_values)
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Any]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
try:
parts = values_str.split(":")
if len(parts) == 2:
start, stop = float(parts[0]), float(parts[1])
step = 1.0
elif len(parts) == 3:
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
else:
raise ValueError("Invalid range format")
# Generate values
values = []
current = start
while current <= stop:
if axis_type in ["steps", "seed", "clip_skip"]:
values.append(int(current))
else:
values.append(round(current, 2))
current += step
return values
except:
pass
# Parse comma-separated values
values = [v.strip() for v in values_str.split(",") if v.strip()]
# Convert numeric types
if axis_type in ["cfg_scale", "denoise"]:
return [float(v) for v in values]
elif axis_type in ["steps", "seed", "clip_skip"]:
return [int(v) for v in values]
else:
return values
def _get_default_value(self, axis_type: str) -> Any:
"""Get default value for axis type."""
defaults = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "normal",
"cfg_scale": 7.0,
"steps": 20,
"seed": 0,
"denoise": 1.0,
"clip_skip": 1,
"prompt": ""
}
return defaults.get(axis_type, "")
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
"""Convert value to all output types."""
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
return (str(value), 0, 0.0)
elif axis_type in ["steps", "seed", "clip_skip"]:
return ("", int(value), float(value))
elif axis_type in ["cfg_scale", "denoise"]:
return ("", 0, float(value))
else:
return ("", 0, 0.0)
+139
View File
@@ -0,0 +1,139 @@
"""XYZ Plot Controller node implementation."""
from typing import Dict, List, Any, Tuple, Optional
import json
from ..utils.constants import AxisType, NUMERIC_DEFAULTS
from ..utils.helpers import (
parse_value_string, generate_axis_labels, calculate_grid_dimensions, create_unique_id
)
from .execution import execution_manager
class XYZPlotController:
"""Main configuration node for XYZ grid plotting."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# X Axis configuration
"x_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
"x_values": ("STRING", {"default": "", "multiline": True}),
"x_label_prefix": ("STRING", {"default": ""}),
# Y Axis configuration
"y_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
"y_values": ("STRING", {"default": "", "multiline": True}),
"y_label_prefix": ("STRING", {"default": ""}),
},
"optional": {
# Z Axis configuration (optional)
"z_axis_type": (AxisType.choices(), {"default": AxisType.NONE.value}),
"z_values": ("STRING", {"default": "", "multiline": True}),
"z_label_prefix": ("STRING", {"default": ""}),
# Label formatting
"include_param_name": ("BOOLEAN", {"default": True}),
"value_only_labels": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("XYZ_GRID", "STRING", "STRING", "STRING", "INT", "INT", "INT", "STRING")
RETURN_NAMES = ("grid_data", "x_value", "y_value", "z_value", "x_index", "y_index", "z_index", "batch_id")
FUNCTION = "configure_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def __init__(self):
self.unique_id = None # Set by ComfyUI
def configure_grid(self, x_axis_type, x_values, x_label_prefix,
y_axis_type, y_values, y_label_prefix,
z_axis_type="none", z_values="", z_label_prefix="",
include_param_name=True, value_only_labels=False):
"""Configure and prepare grid generation."""
# Parse axis types
x_type = AxisType(x_axis_type) if x_axis_type != "none" else None
y_type = AxisType(y_axis_type) if y_axis_type != "none" else None
z_type = AxisType(z_axis_type) if z_axis_type != "none" else None
# Parse values for each axis
x_vals = parse_value_string(x_values, x_type) if x_type else [""]
y_vals = parse_value_string(y_values, y_type) if y_type else [""]
z_vals = parse_value_string(z_values, z_type) if z_type else [""]
# Validate we have at least one axis configured
if not x_type and not y_type:
raise ValueError("At least one axis (X or Y) must be configured")
# Calculate grid dimensions
dims = calculate_grid_dimensions(len(x_vals), len(y_vals), len(z_vals))
# Generate labels
x_labels = self._generate_labels(x_vals, x_type, x_label_prefix, include_param_name, value_only_labels)
y_labels = self._generate_labels(y_vals, y_type, y_label_prefix, include_param_name, value_only_labels)
z_labels = self._generate_labels(z_vals, z_type, z_label_prefix, include_param_name, value_only_labels)
# Create batch ID
batch_id = create_unique_id()
# Prepare grid configuration
grid_config = {
"batch_id": batch_id,
"axes": {
"x": {"type": x_type, "values": x_vals, "labels": x_labels},
"y": {"type": y_type, "values": y_vals, "labels": y_labels},
"z": {"type": z_type, "values": z_vals, "labels": z_labels},
},
"dimensions": dims,
"total_images": dims["total_images"],
"current_index": 0,
}
# Get current values from execution manager
x_val, y_val, z_val, x_idx, y_idx, z_idx = execution_manager.get_current_values(
batch_id, x_vals, y_vals, z_vals
)
# Format output values based on type
x_output = self._format_output_value(x_val, x_type)
y_output = self._format_output_value(y_val, y_type)
z_output = self._format_output_value(z_val, z_type)
return (grid_config, x_output, y_output, z_output, x_idx, y_idx, z_idx, batch_id)
def _generate_labels(self, values: List[Any], axis_type: Optional[AxisType],
prefix: str, include_param: bool, value_only: bool) -> List[str]:
"""Generate labels for axis values."""
if not values or not axis_type:
return []
if value_only:
# Just use values as labels
return generate_axis_labels(values, axis_type, "")
elif include_param and not prefix:
# Use parameter name as prefix
param_names = AxisType.display_names()
prefix = param_names.get(axis_type, "") + ": "
return generate_axis_labels(values, axis_type, prefix)
def _format_output_value(self, value: Any, axis_type: Optional[AxisType]) -> str:
"""Format value for output based on axis type."""
if not axis_type:
return ""
# Return appropriate type based on what nodes expect
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
return str(value)
else:
# Numeric types - return as string but nodes can convert
return str(value)
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Force re-execution for grid iterations."""
# This ensures node re-executes for each grid cell
return float("nan")
@@ -0,0 +1,355 @@
"""XYZ Plot Controller with Power Lora Loader-style dynamic widgets."""
from typing import Dict, List, Any, Tuple, Union, Optional
import folder_paths
from ..utils.helpers import create_unique_id
class FlexibleOptionalInputType(dict):
"""Input that allows dynamic widget values from JavaScript."""
def __contains__(self, key):
# Accept any key from JavaScript widgets
return True
def __getitem__(self, key):
# Return a tuple that ComfyUI expects for input types
# This allows the JavaScript to pass widget values
return ("STRING", {"forceInput": False})
class XYZPlotController:
"""XYZ Plot Controller with dynamic widget management."""
@classmethod
def INPUT_TYPES(cls):
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
inputs = {
"required": {
# Axis configuration
"x_type": (axis_types, {"default": "none"}),
"y_type": (axis_types, {"default": "none"}),
"z_type": (axis_types, {"default": "none"}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"optional": {
# Static inputs for numeric/prompt values
"numeric_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For numeric types: use comma-separated values or start:stop:step notation"
}),
"prompt_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "For prompts: enter each prompt on a new line"
})
},
"hidden": {
"unique_id": "UNIQUE_ID",
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO"
}
}
# Use FlexibleOptionalInputType to accept dynamic widget values from JavaScript
# But don't create an actual input connection
inputs["optional"] = FlexibleOptionalInputType()
return inputs
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
OUTPUT_NODE = True
FUNCTION = "create_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def create_grid(self, x_type="none", y_type="none", z_type="none",
auto_queue=True, numeric_values="", prompt_values="",
unique_id=None, prompt=None, extra_pnginfo=None, **kwargs):
"""Create grid configuration from dynamic selections."""
# Extract dynamic values from kwargs
models = []
vaes = []
loras = []
samplers = []
schedulers = []
# Process all kwargs to find dynamic widgets
for key, value in kwargs.items():
if key.startswith("x_") or key.startswith("y_") or key.startswith("z_"):
# Handle dynamic widget values
if isinstance(value, dict) and "on" in value and value["on"]:
# Extract the resource type and axis
parts = key.split("_")
if len(parts) >= 3:
axis = parts[0]
resource_type = parts[1]
# Store the value based on type
if resource_type == "models" and value.get("value") != "none":
models.append(value["value"])
elif resource_type == "vaes" and value.get("value") != "none":
vaes.append(value["value"])
elif resource_type == "loras" and value.get("value") != "none":
# For loras, store both name and strength
lora_data = {
"name": value["value"],
"strength": value.get("strength", 1.0)
}
loras.append(lora_data)
elif resource_type == "samplers" and value.get("value") != "none":
samplers.append(value["value"])
elif resource_type == "schedulers" and value.get("value") != "none":
schedulers.append(value["value"])
# Parse values for each axis
x_parsed = self._get_axis_values(x_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
y_parsed = self._get_axis_values(y_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
z_parsed = self._get_axis_values(z_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values)
# Calculate total combinations
x_count = max(1, len(x_parsed))
y_count = max(1, len(y_parsed))
z_count = max(1, len(z_parsed))
total_images = x_count * y_count * z_count
# Generate batch ID
batch_id = create_unique_id()
# Create grid data
grid_data = {
"batch_id": batch_id,
"x_axis": {
"type": x_type,
"values": x_parsed,
"count": x_count
},
"y_axis": {
"type": y_type,
"values": y_parsed,
"count": y_count
},
"z_axis": {
"type": z_type,
"values": z_parsed,
"count": z_count
},
"axes": {
"x": {
"type": x_type,
"labels": self._create_labels(x_type, x_parsed)
},
"y": {
"type": y_type,
"labels": self._create_labels(y_type, y_parsed)
},
"z": {
"type": z_type,
"labels": self._create_labels(z_type, z_parsed) if z_type != "none" else []
}
},
"dimensions": {
"total_images": total_images,
"x_count": x_count,
"y_count": y_count,
"z_count": z_count,
"cols": x_count, # X axis forms columns
"rows": y_count, # Y axis forms rows
"grids_count": z_count # Z axis creates multiple grids
},
"total_images": total_images, # Keep for backward compatibility
"current_index": 0,
"auto_queue": auto_queue
}
# Get current values for outputs
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
# Convert to appropriate output types
x_str, x_int, x_float = self._convert_value(x_type, x_current)
y_str, y_int, y_float = self._convert_value(y_type, y_current)
z_str, z_int, z_float = self._convert_value(z_type, z_current)
# Log grid info
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
if x_type != "none":
print(f" X axis ({x_type}): {x_count} values - {x_parsed}")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values - {y_parsed}")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values - {z_parsed}")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _get_axis_values(self, axis_type, models, vaes, loras, samplers, schedulers, numeric_values, prompt_values):
"""Get values for a specific axis type."""
if axis_type == "none":
return []
elif axis_type == "models":
return models
elif axis_type == "vaes":
return vaes
elif axis_type == "loras":
return loras
elif axis_type == "samplers":
return samplers
elif axis_type == "schedulers":
return schedulers
elif axis_type == "prompt":
return [p.strip() for p in prompt_values.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, numeric_values)
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
try:
parts = values_str.split(":")
if len(parts) == 2:
start, stop = float(parts[0]), float(parts[1])
step = 1.0
elif len(parts) == 3:
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
else:
raise ValueError("Invalid range format")
# Generate values
values = []
current = start
while current <= stop:
if axis_type in ["steps", "seed", "clip_skip"]:
values.append(int(current))
else:
values.append(round(current, 2))
current += step
return values
except:
pass
# Parse comma-separated values
values = [v.strip() for v in values_str.split(",") if v.strip()]
# Convert numeric types
if axis_type in ["cfg_scale", "denoise"]:
return [float(v) for v in values]
elif axis_type in ["steps", "seed", "clip_skip"]:
return [int(v) for v in values]
else:
return values
def _get_default_value(self, axis_type: str) -> Any:
"""Get default value for axis type."""
defaults = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "normal",
"cfg_scale": 7.0,
"steps": 20,
"seed": 0,
"denoise": 1.0,
"clip_skip": 1,
"prompt": ""
}
return defaults.get(axis_type, "")
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
"""Convert value to all output types."""
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
# For loras, return the name string
if axis_type == "loras" and isinstance(value, dict):
return (value.get("name", ""), 0, 0.0)
return (str(value), 0, 0.0)
elif axis_type in ["steps", "seed", "clip_skip"]:
return ("", int(value), float(value))
elif axis_type in ["cfg_scale", "denoise"]:
return ("", 0, float(value))
else:
return ("", 0, 0.0)
def _create_labels(self, axis_type: str, values: list) -> list:
"""Create human-readable labels for axis values."""
labels = []
for value in values:
if axis_type == "prompt":
# Truncate long prompts
label = str(value)[:30] + "..." if len(str(value)) > 30 else str(value)
elif axis_type in ["models", "vaes", "loras"]:
# Use just the filename without path/extension for resources
if isinstance(value, dict) and "name" in value:
name = value["name"]
else:
name = str(value)
# Remove extension and path
label = name.split("/")[-1].split(".")[0]
elif axis_type in ["cfg_scale", "denoise"]:
# Format floats nicely
label = f"{float(value):.1f}"
elif axis_type in ["steps", "seed", "clip_skip"]:
# Just show the integer
label = str(int(value))
elif axis_type in ["samplers", "schedulers"]:
# Just use the name as-is
label = str(value)
else:
# Default: convert to string
label = str(value)
labels.append(label)
return labels
def _apply_lora(self, model, clip, lora_data: dict):
"""Apply a lora to model and clip."""
try:
# Import LoraLoader from ComfyUI
from nodes import LoraLoader
import folder_paths
lora_name = lora_data.get("name")
strength = lora_data.get("strength", 1.0)
if not lora_name:
return model, clip
# Get the full path to the lora
lora_path = folder_paths.get_full_path("loras", lora_name)
if not lora_path:
print(f"[XYZ Grid] Warning: LoRA '{lora_name}' not found")
return model, clip
# Apply the lora
loader = LoraLoader()
model, clip = loader.load_lora(model, clip, lora_name, strength, strength)
return model, clip
except Exception as e:
print(f"[XYZ Grid] Error applying LoRA: {e}")
return model, clip
@@ -0,0 +1,166 @@
"""Queue management for automated grid execution."""
import asyncio
from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass, field
import uuid
import json
@dataclass
class QueuedExecution:
"""Represents a queued execution for grid generation."""
execution_id: str
batch_id: str
iteration: int
total_iterations: int
x_value: Any
y_value: Any
z_value: Any
x_index: int
y_index: int
z_index: int
workflow_data: Dict = field(default_factory=dict)
def to_dict(self) -> Dict:
"""Convert to dictionary for serialization."""
return {
"execution_id": self.execution_id,
"batch_id": self.batch_id,
"iteration": self.iteration,
"total_iterations": self.total_iterations,
"indices": {
"x": self.x_index,
"y": self.y_index,
"z": self.z_index
},
"values": {
"x": self.x_value,
"y": self.y_value,
"z": self.z_value
}
}
class GridQueueManager:
"""Manages the execution queue for grid generation."""
def __init__(self):
self.execution_queue: Dict[str, List[QueuedExecution]] = {} # batch_id -> executions
self.active_batches: Dict[str, Dict] = {} # batch_id -> batch info
self.completed_iterations: Dict[str, List[int]] = {} # batch_id -> completed iteration indices
def prepare_batch_executions(self, batch_id: str, grid_config: Dict,
node_id: int, workflow: Dict) -> List[QueuedExecution]:
"""Prepare all executions for a batch."""
executions = []
x_values = grid_config["axes"]["x"]["values"]
y_values = grid_config["axes"]["y"]["values"]
z_values = grid_config["axes"]["z"]["values"]
total_iterations = len(x_values) * len(y_values) * len(z_values)
iteration = 0
# Generate all combinations
for z_idx, z_val in enumerate(z_values or [""]):
for y_idx, y_val in enumerate(y_values or [""]):
for x_idx, x_val in enumerate(x_values or [""]):
execution = QueuedExecution(
execution_id=str(uuid.uuid4()),
batch_id=batch_id,
iteration=iteration,
total_iterations=total_iterations,
x_value=x_val,
y_value=y_val,
z_value=z_val,
x_index=x_idx,
y_index=y_idx,
z_index=z_idx,
workflow_data=self._prepare_workflow(workflow, node_id, grid_config)
)
executions.append(execution)
iteration += 1
# Store batch info
self.execution_queue[batch_id] = executions
self.active_batches[batch_id] = {
"total_iterations": total_iterations,
"grid_config": grid_config,
"node_id": node_id
}
self.completed_iterations[batch_id] = []
return executions
def get_next_execution(self, batch_id: str) -> Optional[QueuedExecution]:
"""Get the next execution for a batch."""
if batch_id not in self.execution_queue:
return None
executions = self.execution_queue[batch_id]
completed = self.completed_iterations.get(batch_id, [])
# Find next uncompleted execution
for execution in executions:
if execution.iteration not in completed:
return execution
return None
def mark_iteration_complete(self, batch_id: str, iteration: int):
"""Mark an iteration as complete."""
if batch_id not in self.completed_iterations:
self.completed_iterations[batch_id] = []
if iteration not in self.completed_iterations[batch_id]:
self.completed_iterations[batch_id].append(iteration)
def is_batch_complete(self, batch_id: str) -> bool:
"""Check if all iterations for a batch are complete."""
if batch_id not in self.active_batches:
return True
total = self.active_batches[batch_id]["total_iterations"]
completed = len(self.completed_iterations.get(batch_id, []))
return completed >= total
def cleanup_batch(self, batch_id: str):
"""Clean up a completed batch."""
if batch_id in self.execution_queue:
del self.execution_queue[batch_id]
if batch_id in self.active_batches:
del self.active_batches[batch_id]
if batch_id in self.completed_iterations:
del self.completed_iterations[batch_id]
def _prepare_workflow(self, base_workflow: Dict, node_id: int, grid_config: Dict) -> Dict:
"""Prepare workflow data for execution."""
# This would modify the workflow to set appropriate values
# For now, return a copy of the base workflow
import copy
return copy.deepcopy(base_workflow)
async def execute_batch_async(self, batch_id: str, api_client: Any):
"""Execute all iterations for a batch asynchronously."""
executions = self.execution_queue.get(batch_id, [])
for execution in executions:
if execution.iteration in self.completed_iterations.get(batch_id, []):
continue
# Queue the execution via ComfyUI API
try:
# This would use the actual ComfyUI API client
# await api_client.queue_prompt(execution.workflow_data)
pass
except Exception as e:
print(f"Error queuing execution {execution.execution_id}: {e}")
# Small delay between queuing to avoid overwhelming the system
await asyncio.sleep(0.1)
# Global queue manager instance
queue_manager = GridQueueManager()
@@ -0,0 +1,218 @@
"""Simplified XYZ Plot Controller using native ComfyUI widgets."""
from typing import Dict, List, Any, Tuple
import json
from ..utils.constants import AxisType
from ..utils.helpers import (
get_available_models, get_available_vaes, get_available_loras,
get_sampler_names, get_scheduler_names, parse_value_string,
create_unique_id
)
class XYZPlotController:
"""Simplified XYZ Plot Controller with native widgets."""
@classmethod
def INPUT_TYPES(cls):
# For file-based parameters, we'll use a special format in the values field
axis_types = [
"none",
"model",
"vae",
"lora",
"sampler",
"scheduler",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
return {
"required": {
# X Axis
"x_type": (axis_types, {"default": "none"}),
"x_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Enter values separated by commas or use start:stop:step notation"
}),
# Y Axis
"y_type": (axis_types, {"default": "none"}),
"y_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Enter values separated by commas or use start:stop:step notation"
}),
# Z Axis (optional)
"z_type": (axis_types, {"default": "none"}),
"z_values": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Enter values separated by commas or use start:stop:step notation"
}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
OUTPUT_NODE = True
FUNCTION = "create_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def create_grid(self, x_type, x_values, y_type, y_values, z_type, z_values, auto_queue, unique_id=None):
"""Create grid configuration."""
# Parse values for each axis
x_parsed = self._parse_axis_values(x_type, x_values) if x_type != "none" else []
y_parsed = self._parse_axis_values(y_type, y_values) if y_type != "none" else []
z_parsed = self._parse_axis_values(z_type, z_values) if z_type != "none" else []
# Calculate total combinations
x_count = max(1, len(x_parsed))
y_count = max(1, len(y_parsed))
z_count = max(1, len(z_parsed))
total_images = x_count * y_count * z_count
# Generate batch ID
batch_id = create_unique_id()
# Create grid data
grid_data = {
"batch_id": batch_id,
"x_axis": {
"type": x_type,
"values": x_parsed,
"count": x_count
},
"y_axis": {
"type": y_type,
"values": y_parsed,
"count": y_count
},
"z_axis": {
"type": z_type,
"values": z_parsed,
"count": z_count
},
"total_images": total_images,
"current_index": 0,
"auto_queue": auto_queue
}
# Get current values for outputs
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
# Convert to appropriate output types
x_str, x_int, x_float = self._convert_value(x_type, x_current)
y_str, y_int, y_float = self._convert_value(y_type, y_current)
z_str, z_int, z_float = self._convert_value(z_type, z_current)
# Store grid data for execution
if hasattr(self, '_grids'):
self._grids[batch_id] = grid_data
else:
self._grids = {batch_id: grid_data}
# Log grid info
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
if x_type != "none":
print(f" X axis ({x_type}): {x_count} values")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _parse_axis_values(self, axis_type: str, values_str: str) -> List[Any]:
"""Parse axis values based on type."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str and axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
try:
parts = values_str.split(":")
if len(parts) == 2:
start, stop = float(parts[0]), float(parts[1])
step = 1.0
elif len(parts) == 3:
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
else:
raise ValueError("Invalid range format")
# Generate values
values = []
current = start
while current <= stop:
if axis_type in ["steps", "seed", "clip_skip"]:
values.append(int(current))
else:
values.append(round(current, 2))
current += step
return values
except:
pass
# Parse comma-separated values
if axis_type == "prompt":
# For prompts, split by newline instead of comma
return [v.strip() for v in values_str.split("\n") if v.strip()]
else:
# For everything else, split by comma
values = [v.strip() for v in values_str.split(",") if v.strip()]
# Convert numeric types
if axis_type in ["cfg_scale", "denoise"]:
return [float(v) for v in values]
elif axis_type in ["steps", "seed", "clip_skip"]:
return [int(v) for v in values]
else:
return values
def _get_default_value(self, axis_type: str) -> Any:
"""Get default value for axis type."""
defaults = {
"model": "",
"vae": "Automatic",
"lora": "None",
"sampler": "euler",
"scheduler": "normal",
"cfg_scale": 7.0,
"steps": 20,
"seed": 0,
"denoise": 1.0,
"clip_skip": 1,
"prompt": ""
}
return defaults.get(axis_type, "")
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
"""Convert value to all output types."""
if axis_type in ["model", "vae", "lora", "sampler", "scheduler", "prompt"]:
return (str(value), 0, 0.0)
elif axis_type in ["steps", "seed", "clip_skip"]:
return ("", int(value), float(value))
elif axis_type in ["cfg_scale", "denoise"]:
return ("", 0, float(value))
else:
return ("", 0, 0.0)
# For backward compatibility
XYZPlotControllerAdvanced = XYZPlotController
@@ -0,0 +1,257 @@
"""XYZ Plot Controller with Power Lora Loader-style dynamic widget management."""
from typing import Dict, List, Any, Tuple, Union, Optional
# Remove complex imports to avoid circular dependencies
import uuid
# Import folder_paths only when needed
try:
import folder_paths
except ImportError:
folder_paths = None
def create_unique_id() -> str:
"""Create unique ID for a grid batch."""
return str(uuid.uuid4())[:8]
class AnyType(str):
"""A special class that is always equal in not equal comparisons."""
def __ne__(self, __value: object) -> bool:
return False
class FlexibleOptionalInputType(dict):
"""
A special class to make flexible nodes that pass data to our python handlers.
This allows dynamic inputs from the JavaScript side.
"""
def __init__(self, input_type):
super().__init__()
self.type = input_type
def __contains__(self, key):
# Always return True to accept any input
return True
def __getitem__(self, key):
# Return a tuple that ComfyUI expects for input types
return (self.type,)
# Create any_type instance
any_type = AnyType("*")
class XYZPlotController:
"""XYZ Plot Controller with dynamic widget management inspired by Power Lora Loader."""
@classmethod
def INPUT_TYPES(cls):
axis_types = [
"none",
"models",
"vaes",
"loras",
"samplers",
"schedulers",
"cfg_scale",
"steps",
"seed",
"denoise",
"clip_skip",
"prompt"
]
return {
"required": {
# Axis configuration
"x_type": (axis_types, {"default": "none"}),
"y_type": (axis_types, {"default": "none"}),
"z_type": (axis_types, {"default": "none"}),
# Control
"auto_queue": ("BOOLEAN", {"default": True}),
},
# Accept any number of dynamic inputs from JavaScript
"optional": {},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("XYZ_GRID", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING", "INT", "FLOAT", "STRING")
RETURN_NAMES = ("grid_data", "x_string", "x_int", "x_float", "y_string", "y_int", "y_float", "z_string", "z_int", "z_float", "batch_id")
OUTPUT_NODE = True
FUNCTION = "create_grid"
CATEGORY = "ComfyAssets/XYZ Grid"
def create_grid(self, x_type="none", y_type="none", z_type="none", auto_queue=True, unique_id=None, **kwargs):
"""Create grid configuration from dynamic selections."""
# Initialize collections for each axis
axis_values = {
"x": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
"y": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""},
"z": {"models": [], "vaes": [], "loras": [], "samplers": [], "schedulers": [], "numeric": "", "prompt": ""}
}
# Process all kwargs to extract dynamic widget values
for key, value in kwargs.items():
# Handle dynamic model/vae/lora/sampler/scheduler widgets
# Format: x_models_1, y_vaes_2, etc.
parts = key.split("_")
if len(parts) >= 3 and parts[0] in ["x", "y", "z"]:
axis = parts[0]
widget_type = parts[1]
if widget_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
if isinstance(value, dict) and value.get("on", True) and value.get("value"):
axis_values[axis][widget_type].append(value["value"])
elif widget_type == "numeric":
axis_values[axis]["numeric"] = value
elif widget_type == "prompt":
axis_values[axis]["prompt"] = value
# Get parsed values for each axis based on type
x_parsed = self._get_axis_values(x_type, axis_values["x"])
y_parsed = self._get_axis_values(y_type, axis_values["y"])
z_parsed = self._get_axis_values(z_type, axis_values["z"])
# Calculate total combinations
x_count = max(1, len(x_parsed))
y_count = max(1, len(y_parsed))
z_count = max(1, len(z_parsed))
total_images = x_count * y_count * z_count
# Generate batch ID
batch_id = create_unique_id()
# Create grid data
grid_data = {
"batch_id": batch_id,
"x_axis": {
"type": x_type,
"values": x_parsed,
"count": x_count
},
"y_axis": {
"type": y_type,
"values": y_parsed,
"count": y_count
},
"z_axis": {
"type": z_type,
"values": z_parsed,
"count": z_count
},
"total_images": total_images,
"current_index": 0,
"auto_queue": auto_queue
}
# Get current values for outputs
x_current = x_parsed[0] if x_parsed else self._get_default_value(x_type)
y_current = y_parsed[0] if y_parsed else self._get_default_value(y_type)
z_current = z_parsed[0] if z_parsed else self._get_default_value(z_type)
# Convert to appropriate output types
x_str, x_int, x_float = self._convert_value(x_type, x_current)
y_str, y_int, y_float = self._convert_value(y_type, y_current)
z_str, z_int, z_float = self._convert_value(z_type, z_current)
# Log grid info
print(f"\n[XYZ Grid] Created grid with {total_images} total combinations:")
if x_type != "none":
print(f" X axis ({x_type}): {x_count} values")
if y_type != "none":
print(f" Y axis ({y_type}): {y_count} values")
if z_type != "none":
print(f" Z axis ({z_type}): {z_count} values")
return (grid_data, x_str, x_int, x_float, y_str, y_int, y_float, z_str, z_int, z_float, batch_id)
def _get_axis_values(self, axis_type: str, axis_data: Dict) -> List[Any]:
"""Get values for a specific axis type from collected data."""
if axis_type == "none":
return []
elif axis_type in ["models", "vaes", "loras", "samplers", "schedulers"]:
return axis_data.get(axis_type, [])
elif axis_type == "prompt":
prompt_text = axis_data.get("prompt", "")
return [p.strip() for p in prompt_text.split("\n") if p.strip()]
elif axis_type in ["cfg_scale", "steps", "seed", "denoise", "clip_skip"]:
return self._parse_numeric_values(axis_type, axis_data.get("numeric", ""))
else:
return []
def _parse_numeric_values(self, axis_type: str, values_str: str) -> List[Union[int, float]]:
"""Parse numeric values with range support."""
if not values_str.strip():
return []
# Handle range notation (start:stop:step)
if ":" in values_str:
try:
parts = values_str.split(":")
if len(parts) == 2:
start, stop = float(parts[0]), float(parts[1])
step = 1.0
elif len(parts) == 3:
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
else:
raise ValueError("Invalid range format")
# Generate values
values = []
current = start
while current <= stop:
if axis_type in ["steps", "seed", "clip_skip"]:
values.append(int(current))
else:
values.append(round(current, 2))
current += step
return values
except:
pass
# Parse comma-separated values
values = [v.strip() for v in values_str.split(",") if v.strip()]
# Convert numeric types
if axis_type in ["cfg_scale", "denoise"]:
return [float(v) for v in values]
elif axis_type in ["steps", "seed", "clip_skip"]:
return [int(v) for v in values]
else:
return values
def _get_default_value(self, axis_type: str) -> Any:
"""Get default value for axis type."""
defaults = {
"models": "",
"vaes": "Automatic",
"loras": "None",
"samplers": "euler",
"schedulers": "normal",
"cfg_scale": 7.0,
"steps": 20,
"seed": 0,
"denoise": 1.0,
"clip_skip": 1,
"prompt": ""
}
return defaults.get(axis_type, "")
def _convert_value(self, axis_type: str, value: Any) -> Tuple[str, int, float]:
"""Convert value to all output types."""
if axis_type in ["models", "vaes", "loras", "samplers", "schedulers", "prompt"]:
return (str(value), 0, 0.0)
elif axis_type in ["steps", "seed", "clip_skip"]:
return ("", int(value), float(value))
elif axis_type in ["cfg_scale", "denoise"]:
return ("", 0, float(value))
else:
return ("", 0, 0.0)
@@ -0,0 +1,5 @@
"""XYZ Prompt module."""
from .node import XYZPrompt
__all__ = ["XYZPrompt"]
+107
View File
@@ -0,0 +1,107 @@
"""XYZ Prompt node for managing multiple prompt variations."""
from typing import Dict, List, Any, Tuple
class FlexibleOptionalInputType(dict):
"""Special input type that accepts any dynamic widget values from JavaScript."""
def __contains__(self, key):
return True
def __getitem__(self, key):
# Accept string inputs for dynamic prompts
return ("STRING", {"multiline": True, "forceInput": False})
class XYZPrompt:
"""XYZ Prompt node for creating prompt variations for grid generation."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"include_negative": ("BOOLEAN", {
"default": True,
"tooltip": "Include negative prompt inputs"
}),
"repeat_negative": ("BOOLEAN", {
"default": True,
"tooltip": "Use the first negative prompt for all variations"
}),
},
"optional": FlexibleOptionalInputType()
}
RETURN_TYPES = ("XYZ_PROMPTS", "STRING", "STRING", "INT")
RETURN_NAMES = ("prompts", "positive", "negative", "count")
OUTPUT_NODE = True
FUNCTION = "process_prompts"
CATEGORY = "ComfyAssets/XYZ Grid"
def process_prompts(self, include_negative=True, repeat_negative=True, **kwargs):
"""Process all prompt inputs and return them formatted for XYZ grid.
Args:
include_negative: Whether to include negative prompts
repeat_negative: Whether to use first negative for all prompts
**kwargs: Dynamic prompt inputs from JavaScript
Returns:
Tuple of (prompts dict, first positive, first negative, count)
"""
# Debug: Log all received kwargs
print(f"XYZPrompt.process_prompts - Received kwargs: {kwargs}")
print(f"XYZPrompt.process_prompts - include_negative: {include_negative}, repeat_negative: {repeat_negative}")
prompts = []
first_negative = ""
# Collect all prompt pairs from kwargs
prompt_index = 0
while True:
pos_key = f"positive_{prompt_index}"
neg_key = f"negative_{prompt_index}"
if pos_key not in kwargs:
break
positive = kwargs.get(pos_key, "")
# Handle negative prompt based on settings
if include_negative:
if repeat_negative:
# Use first negative for all
if prompt_index == 0:
first_negative = kwargs.get(neg_key, "")
negative = first_negative
else:
# Each prompt has its own negative
negative = kwargs.get(neg_key, "")
else:
negative = ""
if positive: # Only add if positive prompt exists
prompts.append({
"positive": positive,
"negative": negative
})
prompt_index += 1
# Prepare outputs
first_positive = prompts[0]["positive"] if prompts else ""
first_negative = prompts[0]["negative"] if prompts else ""
result = {
"prompts": prompts,
"include_negative": include_negative,
"count": len(prompts)
}
# Return for UI display
return {
"ui": {
"prompts": result
},
"result": (result, first_positive, first_negative, len(prompts))
}
@@ -0,0 +1 @@
# XYZ Grid utilities
@@ -0,0 +1,252 @@
"""Model and resource caching for performance optimization."""
import gc
import torch
from typing import Dict, Any, Optional, List, Tuple
from collections import OrderedDict
import psutil
try:
import folder_paths
import comfy.model_management
except ImportError:
# Not in ComfyUI environment
folder_paths = None
comfy = None
class ModelCacheManager:
"""Manages model caching for XYZ grid generation."""
def __init__(self, max_cache_size: int = 3):
"""Initialize cache manager.
Args:
max_cache_size: Maximum number of models to keep in cache
"""
self.max_cache_size = max_cache_size
self.model_cache: OrderedDict[str, Any] = OrderedDict()
self.vae_cache: OrderedDict[str, Any] = OrderedDict()
self.lora_cache: OrderedDict[str, Any] = OrderedDict()
self.memory_threshold = 0.85 # Use up to 85% of VRAM
def get_available_memory(self) -> Tuple[int, int]:
"""Get available GPU memory in bytes.
Returns:
Tuple of (free_memory, total_memory)
"""
try:
if torch.cuda.is_available():
free, total = torch.cuda.mem_get_info()
return free, total
else:
# Fallback to system RAM
mem = psutil.virtual_memory()
return mem.available, mem.total
except:
return 0, 0
def should_cache(self, model_size_estimate: int = 2 * 1024**3) -> bool:
"""Check if we should cache based on available memory.
Args:
model_size_estimate: Estimated model size in bytes (default 2GB)
Returns:
True if caching is safe
"""
free, total = self.get_available_memory()
if total == 0:
return False
# Check if we have enough free memory
usage_after_cache = (total - free + model_size_estimate) / total
return usage_after_cache < self.memory_threshold
def cache_model(self, model_name: str, model: Any) -> bool:
"""Cache a model if memory allows.
Args:
model_name: Name/path of the model
model: The loaded model object
Returns:
True if cached successfully
"""
if not self.should_cache():
return False
# Remove oldest if cache is full
if len(self.model_cache) >= self.max_cache_size:
oldest = next(iter(self.model_cache))
self.uncache_model(oldest)
self.model_cache[model_name] = model
self.model_cache.move_to_end(model_name) # Mark as recently used
return True
def get_cached_model(self, model_name: str) -> Optional[Any]:
"""Get a model from cache if available.
Args:
model_name: Name/path of the model
Returns:
Cached model or None
"""
if model_name in self.model_cache:
self.model_cache.move_to_end(model_name) # Mark as recently used
return self.model_cache[model_name]
return None
def uncache_model(self, model_name: str) -> None:
"""Remove a model from cache and free memory.
Args:
model_name: Name/path of the model to remove
"""
if model_name in self.model_cache:
model = self.model_cache.pop(model_name)
# Attempt to free GPU memory
if hasattr(model, 'to'):
try:
model.to('cpu')
except:
pass
del model
# Force garbage collection
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def cache_vae(self, vae_name: str, vae: Any) -> bool:
"""Cache a VAE model."""
if not self.should_cache(model_size_estimate=500 * 1024**2): # VAEs are smaller
return False
if len(self.vae_cache) >= self.max_cache_size:
oldest = next(iter(self.vae_cache))
self.uncache_vae(oldest)
self.vae_cache[vae_name] = vae
self.vae_cache.move_to_end(vae_name)
return True
def get_cached_vae(self, vae_name: str) -> Optional[Any]:
"""Get a VAE from cache."""
if vae_name in self.vae_cache:
self.vae_cache.move_to_end(vae_name)
return self.vae_cache[vae_name]
return None
def uncache_vae(self, vae_name: str) -> None:
"""Remove a VAE from cache."""
if vae_name in self.vae_cache:
vae = self.vae_cache.pop(vae_name)
del vae
gc.collect()
def optimize_for_grid(self, model_names: List[str], vae_names: List[str]) -> Dict[str, Any]:
"""Pre-optimize caching for a grid generation.
Args:
model_names: List of models that will be used
vae_names: List of VAEs that will be used
Returns:
Dict with optimization suggestions
"""
suggestions = {
"cache_all_models": False,
"cache_all_vaes": False,
"recommended_order": [],
"memory_sufficient": True
}
# Estimate total memory needed
model_count = len(set(model_names))
vae_count = len(set(vae_names))
estimated_model_size = model_count * 2 * 1024**3 # 2GB per model
estimated_vae_size = vae_count * 500 * 1024**2 # 500MB per VAE
total_needed = estimated_model_size + estimated_vae_size
free, total = self.get_available_memory()
if free > total_needed * 1.2: # 20% safety margin
suggestions["cache_all_models"] = True
suggestions["cache_all_vaes"] = True
elif free > estimated_model_size * 1.2:
suggestions["cache_all_models"] = True
else:
suggestions["memory_sufficient"] = False
# Suggest loading order to minimize switches
model_order = self._optimize_load_order(model_names)
suggestions["recommended_order"] = model_order
return suggestions
def _optimize_load_order(self, items: List[str]) -> List[str]:
"""Optimize loading order to minimize model switches.
Args:
items: List of items (may have duplicates)
Returns:
Optimized order
"""
# Group consecutive items together
optimized = []
seen = set()
for item in items:
if item not in seen:
# Add all instances of this item consecutively
count = items.count(item)
optimized.extend([item] * count)
seen.add(item)
return optimized
def clear_cache(self) -> None:
"""Clear all caches and free memory."""
# Clear model cache
for model_name in list(self.model_cache.keys()):
self.uncache_model(model_name)
# Clear VAE cache
for vae_name in list(self.vae_cache.keys()):
self.uncache_vae(vae_name)
# Clear LoRA cache
self.lora_cache.clear()
# Force cleanup
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def get_cache_stats(self) -> Dict[str, Any]:
"""Get current cache statistics."""
free, total = self.get_available_memory()
return {
"models_cached": len(self.model_cache),
"vaes_cached": len(self.vae_cache),
"loras_cached": len(self.lora_cache),
"memory_free": free,
"memory_total": total,
"memory_usage": (total - free) / total if total > 0 else 0,
"cache_names": {
"models": list(self.model_cache.keys()),
"vaes": list(self.vae_cache.keys()),
"loras": list(self.lora_cache.keys())
}
}
# Global cache manager instance
cache_manager = ModelCacheManager()
@@ -0,0 +1,65 @@
"""Constants for XYZ Grid nodes."""
from enum import Enum
class AxisType(Enum):
"""Available parameter types for grid axes."""
NONE = "none"
MODEL = "model"
SAMPLER = "sampler"
SCHEDULER = "scheduler"
CFG_SCALE = "cfg_scale"
STEPS = "steps"
CLIP_SKIP = "clip_skip"
VAE = "vae"
LORA = "lora"
PROMPT = "prompt"
SEED = "seed"
FLUX_GUIDANCE = "flux_guidance"
DENOISE = "denoise"
@classmethod
def choices(cls):
"""Get list of choices for ComfyUI dropdown."""
return [member.value for member in cls]
@classmethod
def display_names(cls):
"""Get display names for UI."""
return {
cls.NONE: "None",
cls.MODEL: "Model/Checkpoint",
cls.SAMPLER: "Sampler",
cls.SCHEDULER: "Scheduler",
cls.CFG_SCALE: "CFG Scale",
cls.STEPS: "Steps",
cls.CLIP_SKIP: "Clip Skip",
cls.VAE: "VAE",
cls.LORA: "LoRA",
cls.PROMPT: "Prompt",
cls.SEED: "Seed",
cls.FLUX_GUIDANCE: "Flux Guidance",
cls.DENOISE: "Denoise",
}
# Default values for numeric parameters
NUMERIC_DEFAULTS = {
AxisType.CFG_SCALE: {"default": 7.0, "min": 0.0, "max": 30.0, "step": 0.5},
AxisType.STEPS: {"default": 20, "min": 1, "max": 150, "step": 1},
AxisType.CLIP_SKIP: {"default": 1, "min": 1, "max": 12, "step": 1},
AxisType.SEED: {"default": 0, "min": 0, "max": 0xffffffffffffffff},
AxisType.FLUX_GUIDANCE: {"default": 3.5, "min": 0.0, "max": 10.0, "step": 0.1},
AxisType.DENOISE: {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05},
}
# Grid styling defaults
GRID_DEFAULTS = {
"font_size": 20,
"grid_gap": 10,
"label_height": 30,
"label_color": (255, 255, 255),
"label_bg_color": (0, 0, 0, 180),
"max_label_length": 30,
}
@@ -0,0 +1,216 @@
"""Value converters for different parameter types."""
from typing import Any, Union, List, Optional
from .constants import AxisType
class ParameterConverter:
"""Converts axis values to appropriate types for ComfyUI nodes."""
@staticmethod
def convert_value(value: Any, axis_type: AxisType) -> Any:
"""Convert a value based on its axis type.
Args:
value: Raw value from axis configuration
axis_type: Type of parameter
Returns:
Converted value suitable for ComfyUI node input
"""
if not axis_type or axis_type == AxisType.NONE:
return value
# String-based parameters
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
return str(value)
# Integer parameters
elif axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
try:
return int(float(value))
except (ValueError, TypeError):
return 0
# Float parameters
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
try:
return float(value)
except (ValueError, TypeError):
return 0.0
return value
@staticmethod
def format_for_display(value: Any, axis_type: AxisType) -> str:
"""Format a value for display in labels.
Args:
value: Value to format
axis_type: Type of parameter
Returns:
Formatted string for display
"""
if axis_type == AxisType.MODEL:
# Remove extension and path
import os
return os.path.splitext(os.path.basename(str(value)))[0]
elif axis_type == AxisType.PROMPT:
# Truncate long prompts
s = str(value)
return s[:25] + "..." if len(s) > 25 else s
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
# Format floats nicely
return f"{float(value):.1f}"
elif axis_type == AxisType.SEED:
# Format large numbers
return f"{int(value):,}"
return str(value)
@staticmethod
def get_output_type(axis_type: AxisType) -> str:
"""Get the ComfyUI output type for an axis type.
Args:
axis_type: Type of parameter
Returns:
ComfyUI type string (e.g., "STRING", "INT", "FLOAT")
"""
if axis_type in (AxisType.MODEL, AxisType.VAE, AxisType.LORA,
AxisType.SAMPLER, AxisType.SCHEDULER, AxisType.PROMPT):
return "STRING"
elif axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
return "INT"
elif axis_type in (AxisType.CFG_SCALE, AxisType.FLUX_GUIDANCE, AxisType.DENOISE):
return "FLOAT"
return "STRING"
@staticmethod
def validate_value(value: Any, axis_type: AxisType) -> tuple[bool, Optional[str]]:
"""Validate a value for an axis type.
Args:
value: Value to validate
axis_type: Type of parameter
Returns:
Tuple of (is_valid, error_message)
"""
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP):
try:
val = int(float(value))
if val < 1:
return False, f"Value must be positive (got {val})"
except:
return False, f"Invalid integer value: {value}"
elif axis_type == AxisType.CFG_SCALE:
try:
val = float(value)
if val < 0:
return False, f"CFG scale must be non-negative (got {val})"
except:
return False, f"Invalid float value: {value}"
elif axis_type == AxisType.DENOISE:
try:
val = float(value)
if not 0 <= val <= 1:
return False, f"Denoise must be between 0 and 1 (got {val})"
except:
return False, f"Invalid float value: {value}"
return True, None
class OutputConnector:
"""Handles connecting XYZ outputs to various node inputs."""
@staticmethod
def get_connection_info(axis_type: AxisType) -> dict:
"""Get information about how to connect this axis type.
Args:
axis_type: Type of parameter
Returns:
Dict with connection information
"""
connection_map = {
AxisType.MODEL: {
"target_node": "CheckpointLoaderSimple",
"target_input": "ckpt_name",
"type": "STRING"
},
AxisType.VAE: {
"target_node": "VAELoader",
"target_input": "vae_name",
"type": "STRING"
},
AxisType.SAMPLER: {
"target_node": "KSampler",
"target_input": "sampler_name",
"type": "combo"
},
AxisType.SCHEDULER: {
"target_node": "KSampler",
"target_input": "scheduler",
"type": "combo"
},
AxisType.CFG_SCALE: {
"target_node": "KSampler",
"target_input": "cfg",
"type": "FLOAT"
},
AxisType.STEPS: {
"target_node": "KSampler",
"target_input": "steps",
"type": "INT"
},
AxisType.SEED: {
"target_node": "KSampler",
"target_input": "seed",
"type": "INT"
},
AxisType.DENOISE: {
"target_node": "KSampler",
"target_input": "denoise",
"type": "FLOAT"
},
AxisType.CLIP_SKIP: {
"target_node": "CLIPSetLastLayer",
"target_input": "stop_at_clip_layer",
"type": "INT"
},
AxisType.LORA: {
"target_node": "LoraLoader",
"target_input": "lora_name",
"type": "STRING"
},
AxisType.PROMPT: {
"target_node": "CLIPTextEncode",
"target_input": "text",
"type": "STRING"
},
AxisType.FLUX_GUIDANCE: {
"target_node": "FluxGuidance", # Hypothetical node
"target_input": "guidance",
"type": "FLOAT"
}
}
return connection_map.get(axis_type, {
"target_node": "Unknown",
"target_input": "value",
"type": "STRING"
})
+180
View File
@@ -0,0 +1,180 @@
"""Helper utilities for XYZ Grid nodes."""
import os
from typing import List, Dict, Any, Tuple, Optional
from .constants import AxisType, NUMERIC_DEFAULTS
def get_available_models() -> List[str]:
"""Get list of available checkpoint models."""
try:
import folder_paths
model_dir = folder_paths.get_folder_paths("checkpoints")[0]
models = []
for file in os.listdir(model_dir):
if file.endswith(('.ckpt', '.safetensors', '.pt', '.pth')):
models.append(file)
return sorted(models)
except:
return ["No models found"]
def get_available_vaes() -> List[str]:
"""Get list of available VAE models."""
try:
import folder_paths
vae_dir = folder_paths.get_folder_paths("vae")[0]
vaes = ["Automatic"]
for file in os.listdir(vae_dir):
if file.endswith(('.ckpt', '.safetensors', '.pt', '.pth')):
vaes.append(file)
return vaes
except:
return ["Automatic"]
def get_available_loras() -> List[str]:
"""Get list of available LoRA models."""
try:
import folder_paths
lora_dir = folder_paths.get_folder_paths("loras")[0]
loras = ["None"]
for file in os.listdir(lora_dir):
if file.endswith(('.safetensors', '.pt', '.pth')):
loras.append(file)
return loras
except:
return ["None"]
def get_sampler_names() -> List[str]:
"""Get list of available sampler names."""
try:
import nodes
return nodes.KSampler.SAMPLERS
except:
# Fallback list of common samplers
return ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc", "uni_pc_bh2"]
def get_scheduler_names() -> List[str]:
"""Get list of available scheduler names."""
try:
import nodes
return nodes.KSampler.SCHEDULERS
except:
# Fallback list
return ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform"]
def parse_value_string(value_str: str, axis_type: AxisType) -> List[Any]:
"""Parse a string of values based on axis type.
Args:
value_str: String containing values (comma-separated or range syntax)
axis_type: Type of parameter to parse for
Returns:
List of parsed values
"""
if not value_str or not value_str.strip():
return []
values = []
# Handle numeric types with range syntax
if axis_type in NUMERIC_DEFAULTS:
# Check for range syntax (start:stop:step)
if ':' in value_str:
parts = value_str.split(':')
if len(parts) == 2:
start, stop = float(parts[0]), float(parts[1])
step = 1.0 if axis_type == AxisType.CFG_SCALE else 1
elif len(parts) == 3:
start, stop, step = float(parts[0]), float(parts[1]), float(parts[2])
else:
raise ValueError(f"Invalid range syntax: {value_str}")
# Generate range values
current = start
while current <= stop:
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
values.append(int(current))
else:
values.append(round(current, 2))
current += step
else:
# Parse comma-separated values
for val in value_str.split(','):
val = val.strip()
if val:
if axis_type in (AxisType.STEPS, AxisType.CLIP_SKIP, AxisType.SEED):
values.append(int(val))
else:
values.append(float(val))
else:
# String-based parameters (split by comma)
values = [v.strip() for v in value_str.split(',') if v.strip()]
return values
def generate_axis_labels(values: List[Any], axis_type: AxisType, prefix: str = "") -> List[str]:
"""Generate labels for axis values.
Args:
values: List of axis values
axis_type: Type of parameter
prefix: Optional prefix for labels
Returns:
List of label strings
"""
labels = []
for value in values:
if axis_type == AxisType.MODEL:
# Strip extension and path for models
label = os.path.splitext(os.path.basename(str(value)))[0]
elif axis_type == AxisType.PROMPT:
# Truncate long prompts
label = str(value)[:30] + "..." if len(str(value)) > 30 else str(value)
else:
label = str(value)
if prefix:
label = f"{prefix}{label}"
labels.append(label)
return labels
def calculate_grid_dimensions(x_count: int, y_count: int, z_count: int = 1) -> Dict[str, int]:
"""Calculate total images and grid dimensions.
Args:
x_count: Number of X axis values
y_count: Number of Y axis values
z_count: Number of Z axis values (default 1)
Returns:
Dict with total_images, grids_count, cols, rows
"""
total_images = x_count * y_count * z_count
grids_count = z_count if z_count > 0 else 1
return {
"total_images": total_images,
"grids_count": grids_count,
"cols": x_count,
"rows": y_count,
}
def create_unique_id() -> str:
"""Create unique ID for a grid batch."""
import uuid
return str(uuid.uuid4())[:8]
@@ -0,0 +1,265 @@
"""Progress tracking and preview capabilities for XYZ grids."""
import time
from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass, field
from datetime import datetime
import json
import asyncio
@dataclass
class GridProgress:
"""Tracks progress for a single grid generation."""
batch_id: str
total_images: int
completed_images: int = 0
start_time: float = field(default_factory=time.time)
end_time: Optional[float] = None
current_labels: Dict[str, str] = field(default_factory=dict)
preview_images: List[Any] = field(default_factory=list)
status: str = "initializing" # initializing, running, completed, error
error_message: Optional[str] = None
@property
def progress_percent(self) -> float:
"""Get progress as percentage."""
if self.total_images == 0:
return 0.0
return (self.completed_images / self.total_images) * 100
@property
def elapsed_time(self) -> float:
"""Get elapsed time in seconds."""
end = self.end_time or time.time()
return end - self.start_time
@property
def estimated_remaining(self) -> Optional[float]:
"""Estimate remaining time in seconds."""
if self.completed_images == 0:
return None
avg_time_per_image = self.elapsed_time / self.completed_images
remaining_images = self.total_images - self.completed_images
return avg_time_per_image * remaining_images
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"batch_id": self.batch_id,
"total_images": self.total_images,
"completed_images": self.completed_images,
"progress_percent": round(self.progress_percent, 1),
"elapsed_time": round(self.elapsed_time, 1),
"estimated_remaining": round(self.estimated_remaining, 1) if self.estimated_remaining else None,
"current_labels": self.current_labels,
"status": self.status,
"error_message": self.error_message,
"preview_count": len(self.preview_images)
}
class ProgressTracker:
"""Manages progress tracking for all grid generations."""
def __init__(self):
self.active_grids: Dict[str, GridProgress] = {}
self.completed_grids: List[GridProgress] = []
self.progress_callbacks: List[Callable] = []
self.websocket_handler = None
def start_grid(self, batch_id: str, total_images: int) -> GridProgress:
"""Start tracking a new grid generation."""
progress = GridProgress(
batch_id=batch_id,
total_images=total_images,
status="running"
)
self.active_grids[batch_id] = progress
self._notify_progress(progress)
return progress
def update_progress(self, batch_id: str, completed: int = None,
current_labels: Dict[str, str] = None,
preview_image: Any = None) -> Optional[GridProgress]:
"""Update progress for a grid."""
if batch_id not in self.active_grids:
return None
progress = self.active_grids[batch_id]
if completed is not None:
progress.completed_images = completed
else:
progress.completed_images += 1
if current_labels:
progress.current_labels = current_labels
if preview_image is not None:
progress.preview_images.append(preview_image)
# Keep only last N previews to save memory
if len(progress.preview_images) > 5:
progress.preview_images.pop(0)
self._notify_progress(progress)
# Check if completed
if progress.completed_images >= progress.total_images:
self.complete_grid(batch_id)
return progress
def complete_grid(self, batch_id: str) -> Optional[GridProgress]:
"""Mark a grid as completed."""
if batch_id not in self.active_grids:
return None
progress = self.active_grids[batch_id]
progress.status = "completed"
progress.end_time = time.time()
# Move to completed list
self.completed_grids.append(progress)
del self.active_grids[batch_id]
# Keep only last N completed grids
if len(self.completed_grids) > 10:
self.completed_grids.pop(0)
self._notify_progress(progress)
return progress
def error_grid(self, batch_id: str, error_message: str) -> Optional[GridProgress]:
"""Mark a grid as errored."""
if batch_id not in self.active_grids:
return None
progress = self.active_grids[batch_id]
progress.status = "error"
progress.error_message = error_message
progress.end_time = time.time()
# Move to completed list (with error status)
self.completed_grids.append(progress)
del self.active_grids[batch_id]
self._notify_progress(progress)
return progress
def get_progress(self, batch_id: str) -> Optional[GridProgress]:
"""Get progress for a specific grid."""
if batch_id in self.active_grids:
return self.active_grids[batch_id]
# Check completed grids
for grid in self.completed_grids:
if grid.batch_id == batch_id:
return grid
return None
def get_all_active(self) -> List[GridProgress]:
"""Get all active grid progress."""
return list(self.active_grids.values())
def register_callback(self, callback: Callable[[GridProgress], None]) -> None:
"""Register a progress callback."""
self.progress_callbacks.append(callback)
def set_websocket_handler(self, handler: Any) -> None:
"""Set WebSocket handler for real-time updates."""
self.websocket_handler = handler
def _notify_progress(self, progress: GridProgress) -> None:
"""Notify all registered callbacks of progress update."""
# Call registered callbacks
for callback in self.progress_callbacks:
try:
callback(progress)
except Exception as e:
print(f"Error in progress callback: {e}")
# Send WebSocket update if available
if self.websocket_handler:
try:
self._send_websocket_update(progress)
except Exception as e:
print(f"Error sending WebSocket update: {e}")
def _send_websocket_update(self, progress: GridProgress) -> None:
"""Send progress update via WebSocket."""
if not self.websocket_handler:
return
message = {
"type": "xyz_grid_progress",
"data": progress.to_dict()
}
# This would integrate with ComfyUI's server
try:
from server import PromptServer
if PromptServer:
server = PromptServer.instance
if server:
server.send_sync("xyz_grid_progress", message["data"])
except:
pass
def get_summary(self) -> Dict[str, Any]:
"""Get summary of all progress."""
return {
"active_grids": [p.to_dict() for p in self.active_grids.values()],
"completed_grids": [p.to_dict() for p in self.completed_grids[-5:]], # Last 5
"total_active": len(self.active_grids),
"total_completed": len(self.completed_grids)
}
# Global progress tracker instance
progress_tracker = ProgressTracker()
class ProgressWebSocketHandler:
"""WebSocket handler for progress updates."""
def __init__(self):
self.clients = set()
async def handle_client(self, websocket, path):
"""Handle a WebSocket client connection."""
self.clients.add(websocket)
try:
# Send initial state
summary = progress_tracker.get_summary()
await websocket.send(json.dumps({
"type": "xyz_grid_init",
"data": summary
}))
# Keep connection alive
async for message in websocket:
# Handle any client messages if needed
pass
finally:
self.clients.remove(websocket)
async def broadcast_progress(self, progress: GridProgress):
"""Broadcast progress to all connected clients."""
if self.clients:
message = json.dumps({
"type": "xyz_grid_progress",
"data": progress.to_dict()
})
# Send to all connected clients
disconnected = set()
for client in self.clients:
try:
await client.send(message)
except:
disconnected.add(client)
# Remove disconnected clients
self.clients -= disconnected
+194
View File
@@ -0,0 +1,194 @@
# ComfyUI-KikoTools XYZ Grid Development Plan
## Current Session Context (2025-08-05)
### Working Branch: `feature/xyz-nodes`
### Completed Work
#### 1. XYZ Plot Controller
- ✅ Implemented dynamic widget management with RGThree-style interface
- ✅ Added right-click context menus (Toggle, Move Up/Down, Remove)
- ✅ Fixed text input removal that was leaving DOM elements behind
- ✅ Added placeholder hints for text inputs
- ✅ Auto-resize nodes when adding widgets
- ✅ Removed unwanted "input" connection from node
- ✅ Fixed image count calculation for step ranges (e.g., "10:50:5")
- ✅ Added callbacks to update node title with image count
#### 2. XYZ Prompt Node
- ✅ Created separate node for prompt management
- ✅ Implemented dynamic prompt set addition/removal
- ✅ Added include_negative toggle for showing/hiding negative prompts
- ✅ Added repeat_negative feature (use first negative for all variations)
- ✅ Fixed spacing issues with protected button containers
- ✅ Fixed widget values not passing to Python backend (added FlexibleOptionalInputType)
- ✅ Visual styling: green background for positive, red for negative prompts
#### 3. ImageGridCombiner
- ✅ Fixed grid_data structure mismatch with controller
- ✅ Added proper dimensions object (cols, rows, grids_count)
- ✅ Added axes object with human-readable labels
- ✅ Created _create_labels method for formatting axis values
### Current Issues
#### 1. XYZ Prompt Widget Restoration Bug
**Problem**: When refreshing the page, prompts aren't properly restored
- Negative prompt appears at top with saved value
- Positive prompts are lost
- Widget restoration from widgets_values array not working correctly
**Current Fix Attempt**:
- Modified onConfigure to properly clean up dynamic widgets
- Added debug logging to trace restoration
- Using promptData to track number of prompt sets
- Need to properly handle widgets_values array restoration
#### 2. Pending Tasks (from todo list)
- Complete queue implementation for actual ComfyUI API integration
- Remove debug logging from production JavaScript
- Add validation for invalid axis combinations
### File Structure
```
ComfyUI-KikoTools/
├── kikotools/
│ └── tools/
│ └── xyz_grid/
│ ├── controller/
│ │ ├── power_node.py (Main XYZ Plot Controller)
│ │ ├── queue_manager.py (Placeholder - needs implementation)
│ │ └── execution.py
│ ├── prompt/
│ │ └── node.py (XYZ Prompt node)
│ ├── combiner/
│ │ └── node.py (ImageGridCombiner)
│ └── __init__.py
├── web/
│ ├── xyz_plot_controller.js (Dynamic widget UI)
│ ├── xyz_prompt.js (Prompt management UI)
│ └── disabled/ (Old implementations)
└── examples/
└── xyz_grid_test_workflow.json (Test workflow)
```
### Key Technical Patterns
#### Python Node Pattern
```python
class FlexibleOptionalInputType(dict):
"""Accepts dynamic widget values from JavaScript."""
def __contains__(self, key):
return True
def __getitem__(self, key):
return ("STRING", {"multiline": True, "forceInput": False})
# In INPUT_TYPES:
"optional": FlexibleOptionalInputType()
```
#### JavaScript Widget Creation
```javascript
const widget = ComfyWidgets.STRING(
this,
widgetName,
["STRING", config],
app
).widget;
```
#### RGThree-style Context Menu
```javascript
getSlotInPosition(x, y) {
// Return fake slot with widget for context menu
const widget = this.findWidgetAtPosition(x, y);
if (widget) {
return {
slot_index: -1,
widget: widget
};
}
}
getSlotMenuOptions(slot) {
if (slot?.widget) {
return this.getWidgetMenuOptions(slot.widget);
}
}
```
### Git Commands for Session Recovery
```bash
# Switch to working branch
git checkout feature/xyz-nodes
# Check current status
git status
# Recent commits
git log --oneline -10
# Current changes
git diff
```
### Testing Instructions
1. Load ComfyUI
2. Refresh browser (F5)
3. Add XYZ Prompt node
4. Add multiple prompts
5. Save workflow
6. Refresh page
7. Check if prompts are restored correctly
### Debug Points
1. Check browser console for debug logs from:
- `XYZ Prompt onConfigure`
- `XYZ Prompt serialize`
- Widget creation logs
2. Monitor Python console for:
- `XYZPrompt.process_prompts` kwargs
- Grid data structure output
### Next Steps
1. **Fix widget restoration**:
- Properly handle widgets_values array
- Ensure widget values are restored in correct order
- Test with multiple prompt sets
2. **Clean up debug code**:
- Remove console.log statements
- Remove print statements in Python
3. **Complete queue manager**:
- Implement actual ComfyUI API integration
- Handle batch execution properly
4. **Add validation**:
- Prevent same parameter on multiple axes
- Validate numeric ranges
- Check model/VAE/LoRA availability
### Important Notes
- CLAUDE.md is in .gitignore (local only)
- Main branch is `main` for PRs
- Test with actual checkpoint files before merging
- Memory management for large grids needs optimization
- Performance concerns with many dynamic widgets
### Session Recovery Command
To continue work in new terminal:
```bash
cd /home/vito/code/personal/ComfyUI-KikoTools
git checkout feature/xyz-nodes
# Check this plan.md for context
```
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.7"
version = "1.0.10"
license = {text = "MIT"}
dependencies = []
@@ -0,0 +1,170 @@
"""Unit tests for ImageScaleDownBy tool."""
import pytest
import torch
from kikotools.tools.image_scale_down_by.logic import scale_down_image
from kikotools.tools.image_scale_down_by.node import ImageScaleDownByNode
class TestImageScaleDownByLogic:
"""Test the core logic for scaling down images."""
def test_scale_down_by_half(self):
"""Test scaling down an image by 0.5."""
# Create a test image (batch=1, height=512, width=512, channels=3)
image = torch.randn(1, 512, 512, 3)
scale_by = 0.5
result = scale_down_image(image, scale_by)
assert result.shape == (1, 256, 256, 3)
def test_scale_down_by_quarter(self):
"""Test scaling down an image by 0.25."""
image = torch.randn(1, 1024, 768, 3)
scale_by = 0.25
result = scale_down_image(image, scale_by)
assert result.shape == (1, 256, 192, 3)
def test_scale_down_by_custom_factor(self):
"""Test scaling down by a custom factor."""
image = torch.randn(1, 800, 600, 3)
scale_by = 0.75
result = scale_down_image(image, scale_by)
assert result.shape == (1, 600, 450, 3)
def test_scale_down_maintains_batch_size(self):
"""Test that batch size is maintained."""
# Test with batch size > 1
image = torch.randn(4, 512, 512, 3)
scale_by = 0.5
result = scale_down_image(image, scale_by)
assert result.shape == (4, 256, 256, 3)
def test_scale_by_one_returns_same_size(self):
"""Test that scale_by=1.0 returns the same size."""
image = torch.randn(1, 512, 512, 3)
scale_by = 1.0
result = scale_down_image(image, scale_by)
assert result.shape == image.shape
def test_non_square_image(self):
"""Test scaling non-square images."""
image = torch.randn(1, 720, 1280, 3)
scale_by = 0.5
result = scale_down_image(image, scale_by)
assert result.shape == (1, 360, 640, 3)
def test_small_scale_factor(self):
"""Test with very small scale factor."""
image = torch.randn(1, 1000, 1000, 3)
scale_by = 0.01
result = scale_down_image(image, scale_by)
assert result.shape == (1, 10, 10, 3)
class TestImageScaleDownByNode:
"""Test the ComfyUI node implementation."""
@pytest.fixture
def node(self):
"""Create a node instance."""
return ImageScaleDownByNode()
def test_input_types(self):
"""Test that INPUT_TYPES is properly defined."""
input_types = ImageScaleDownByNode.INPUT_TYPES()
assert "required" in input_types
assert "images" in input_types["required"]
assert input_types["required"]["images"] == ("IMAGE",)
assert "scale_by" in input_types["required"]
# Check scale_by configuration
scale_config = input_types["required"]["scale_by"]
assert scale_config[0] == "FLOAT"
assert scale_config[1]["default"] == 0.5
assert scale_config[1]["min"] == 0.01
assert scale_config[1]["max"] == 1.0
assert scale_config[1]["step"] == 0.01
def test_return_types(self):
"""Test that return types are properly defined."""
assert ImageScaleDownByNode.RETURN_TYPES == ("IMAGE",)
assert ImageScaleDownByNode.RETURN_NAMES == ("images",)
assert ImageScaleDownByNode.FUNCTION == "scale_down"
def test_scale_down_execution(self, node):
"""Test the scale_down method."""
images = torch.randn(1, 512, 512, 3)
scale_by = 0.5
result = node.scale_down(images, scale_by)
assert isinstance(result, tuple)
assert len(result) == 1
assert result[0].shape == (1, 256, 256, 3)
def test_input_validation_no_images(self, node):
"""Test validation with missing images."""
with pytest.raises(ValueError, match="Images input is required"):
node.validate_inputs(images=None, scale_by=0.5)
def test_input_validation_invalid_tensor_shape(self, node):
"""Test validation with invalid tensor shape."""
invalid_image = torch.randn(512, 512, 3) # Missing batch dimension
with pytest.raises(ValueError, match="Expected image tensor with shape"):
node.validate_inputs(images=invalid_image, scale_by=0.5)
def test_input_validation_scale_too_small(self, node):
"""Test validation with scale_by too small."""
images = torch.randn(1, 512, 512, 3)
with pytest.raises(ValueError, match="scale_by must be between"):
node.validate_inputs(images=images, scale_by=0.0)
def test_input_validation_scale_too_large(self, node):
"""Test validation with scale_by too large."""
images = torch.randn(1, 512, 512, 3)
with pytest.raises(ValueError, match="scale_by must be between"):
node.validate_inputs(images=images, scale_by=1.5)
def test_category_is_comfyassets(self):
"""Test that the node is in the ComfyAssets category."""
assert ImageScaleDownByNode.CATEGORY == "ComfyAssets"
def test_scale_down_with_batch(self, node):
"""Test scaling down with batch of images."""
images = torch.randn(3, 640, 480, 3)
scale_by = 0.25
result = node.scale_down(images, scale_by)
assert result[0].shape == (3, 160, 120, 3)
def test_error_handling(self, node, mocker):
"""Test that errors are properly handled."""
# Mock the scale_down_image function to raise an exception
mocker.patch(
"kikotools.tools.image_scale_down_by.node.scale_down_image",
side_effect=RuntimeError("Test error"),
)
images = torch.randn(1, 512, 512, 3)
with pytest.raises(ValueError, match="Failed to scale down images"):
node.scale_down(images, 0.5)
+1
View File
@@ -0,0 +1 @@
# XYZ Grid tests
@@ -0,0 +1,209 @@
"""Tests for cache manager."""
import pytest
from unittest.mock import Mock, patch, MagicMock
import torch
from kikotools.tools.xyz_grid.utils.cache_manager import ModelCacheManager
class TestModelCacheManager:
"""Test ModelCacheManager class."""
@patch('torch.cuda.is_available')
@patch('torch.cuda.mem_get_info')
def test_get_available_memory_gpu(self, mock_mem_info, mock_cuda_available):
"""Test GPU memory detection."""
mock_cuda_available.return_value = True
mock_mem_info.return_value = (4 * 1024**3, 8 * 1024**3) # 4GB free, 8GB total
manager = ModelCacheManager()
free, total = manager.get_available_memory()
assert free == 4 * 1024**3
assert total == 8 * 1024**3
@patch('torch.cuda.is_available')
@patch('psutil.virtual_memory')
def test_get_available_memory_cpu(self, mock_vm, mock_cuda_available):
"""Test CPU memory fallback."""
mock_cuda_available.return_value = False
mock_vm.return_value = MagicMock(available=16 * 1024**3, total=32 * 1024**3)
manager = ModelCacheManager()
free, total = manager.get_available_memory()
assert free == 16 * 1024**3
assert total == 32 * 1024**3
@patch.object(ModelCacheManager, 'get_available_memory')
def test_should_cache(self, mock_memory):
"""Test cache decision logic."""
manager = ModelCacheManager()
# Plenty of memory available
mock_memory.return_value = (6 * 1024**3, 8 * 1024**3) # 6GB free, 8GB total
assert manager.should_cache(2 * 1024**3) # 2GB model
# Not enough memory
mock_memory.return_value = (1 * 1024**3, 8 * 1024**3) # 1GB free, 8GB total
assert not manager.should_cache(2 * 1024**3) # 2GB model would exceed threshold
# No memory info
mock_memory.return_value = (0, 0)
assert not manager.should_cache()
@patch.object(ModelCacheManager, 'should_cache')
def test_cache_model(self, mock_should_cache):
"""Test model caching."""
manager = ModelCacheManager(max_cache_size=2)
mock_should_cache.return_value = True
# Cache first model
model1 = Mock()
assert manager.cache_model("model1", model1)
assert manager.get_cached_model("model1") == model1
# Cache second model
model2 = Mock()
assert manager.cache_model("model2", model2)
assert len(manager.model_cache) == 2
# Cache third model - should evict oldest
model3 = Mock()
assert manager.cache_model("model3", model3)
assert len(manager.model_cache) == 2
assert "model1" not in manager.model_cache
assert "model3" in manager.model_cache
def test_get_cached_model_updates_lru(self):
"""Test that getting a model updates LRU order."""
manager = ModelCacheManager(max_cache_size=2)
# Add two models
with patch.object(manager, 'should_cache', return_value=True):
manager.cache_model("model1", "m1")
manager.cache_model("model2", "m2")
# Access model1 to make it most recent
manager.get_cached_model("model1")
# Add third model - should evict model2, not model1
with patch.object(manager, 'should_cache', return_value=True):
manager.cache_model("model3", "m3")
assert "model1" in manager.model_cache
assert "model2" not in manager.model_cache
assert "model3" in manager.model_cache
@patch('gc.collect')
@patch('torch.cuda.empty_cache')
@patch('torch.cuda.is_available')
def test_uncache_model(self, mock_cuda, mock_empty_cache, mock_gc):
"""Test model uncaching and cleanup."""
mock_cuda.return_value = True
manager = ModelCacheManager()
# Create mock model with 'to' method
model = Mock()
model.to = Mock()
with patch.object(manager, 'should_cache', return_value=True):
manager.cache_model("model1", model)
# Uncache
manager.uncache_model("model1")
# Verify cleanup
assert "model1" not in manager.model_cache
model.to.assert_called_with('cpu')
mock_gc.assert_called_once()
mock_empty_cache.assert_called_once()
def test_optimize_for_grid(self):
"""Test grid optimization suggestions."""
manager = ModelCacheManager()
with patch.object(manager, 'get_available_memory') as mock_memory:
# Enough memory for everything
mock_memory.return_value = (10 * 1024**3, 16 * 1024**3)
suggestions = manager.optimize_for_grid(
["model1", "model2", "model1"],
["vae1", "vae1", "vae1"]
)
assert suggestions["cache_all_models"]
assert suggestions["cache_all_vaes"]
assert suggestions["memory_sufficient"]
# Not enough memory
mock_memory.return_value = (1 * 1024**3, 8 * 1024**3)
suggestions = manager.optimize_for_grid(
["model1", "model2", "model3"],
["vae1", "vae2"]
)
assert not suggestions["cache_all_models"]
assert not suggestions["memory_sufficient"]
assert len(suggestions["recommended_order"]) > 0
def test_optimize_load_order(self):
"""Test load order optimization."""
manager = ModelCacheManager()
# Test grouping
items = ["a", "b", "a", "c", "b", "a"]
optimized = manager._optimize_load_order(items)
# Should group all a's, then b's, then c
assert optimized == ["a", "a", "a", "b", "b", "c"]
# Test with single item type
items = ["x", "x", "x"]
optimized = manager._optimize_load_order(items)
assert optimized == ["x", "x", "x"]
@patch('gc.collect')
@patch('torch.cuda.empty_cache')
@patch('torch.cuda.is_available')
def test_clear_cache(self, mock_cuda, mock_empty_cache, mock_gc):
"""Test clearing all caches."""
mock_cuda.return_value = True
manager = ModelCacheManager()
# Add some items to caches
with patch.object(manager, 'should_cache', return_value=True):
manager.cache_model("model1", Mock())
manager.cache_vae("vae1", Mock())
manager.lora_cache["lora1"] = Mock()
# Clear all
manager.clear_cache()
assert len(manager.model_cache) == 0
assert len(manager.vae_cache) == 0
assert len(manager.lora_cache) == 0
assert mock_gc.called
assert mock_empty_cache.called
def test_get_cache_stats(self):
"""Test cache statistics."""
manager = ModelCacheManager()
with patch.object(manager, 'get_available_memory') as mock_memory:
mock_memory.return_value = (4 * 1024**3, 8 * 1024**3)
# Add some cached items
with patch.object(manager, 'should_cache', return_value=True):
manager.cache_model("model1", Mock())
manager.cache_vae("vae1", Mock())
stats = manager.get_cache_stats()
assert stats["models_cached"] == 1
assert stats["vaes_cached"] == 1
assert stats["memory_free"] == 4 * 1024**3
assert stats["memory_total"] == 8 * 1024**3
assert stats["memory_usage"] == 0.5
assert "model1" in stats["cache_names"]["models"]
assert "vae1" in stats["cache_names"]["vaes"]
@@ -0,0 +1,127 @@
"""Tests for parameter converters."""
import pytest
from kikotools.tools.xyz_grid.utils.constants import AxisType
from kikotools.tools.xyz_grid.utils.converters import ParameterConverter, OutputConnector
class TestParameterConverter:
"""Test parameter value conversion."""
def test_convert_string_types(self):
"""Test conversion of string-based parameters."""
assert ParameterConverter.convert_value("model.ckpt", AxisType.MODEL) == "model.ckpt"
assert ParameterConverter.convert_value("euler", AxisType.SAMPLER) == "euler"
assert ParameterConverter.convert_value("My prompt", AxisType.PROMPT) == "My prompt"
def test_convert_integer_types(self):
"""Test conversion of integer parameters."""
assert ParameterConverter.convert_value("20", AxisType.STEPS) == 20
assert ParameterConverter.convert_value("3", AxisType.CLIP_SKIP) == 3
assert ParameterConverter.convert_value("12345", AxisType.SEED) == 12345
# Test float to int conversion
assert ParameterConverter.convert_value("20.5", AxisType.STEPS) == 20
assert ParameterConverter.convert_value(20.7, AxisType.STEPS) == 20
def test_convert_float_types(self):
"""Test conversion of float parameters."""
assert ParameterConverter.convert_value("7.5", AxisType.CFG_SCALE) == 7.5
assert ParameterConverter.convert_value("3.5", AxisType.FLUX_GUIDANCE) == 3.5
assert ParameterConverter.convert_value("0.8", AxisType.DENOISE) == 0.8
# Test integer to float
assert ParameterConverter.convert_value(7, AxisType.CFG_SCALE) == 7.0
def test_convert_invalid_values(self):
"""Test conversion of invalid values."""
# Invalid integers default to 0
assert ParameterConverter.convert_value("abc", AxisType.STEPS) == 0
assert ParameterConverter.convert_value("", AxisType.STEPS) == 0
# Invalid floats default to 0.0
assert ParameterConverter.convert_value("xyz", AxisType.CFG_SCALE) == 0.0
assert ParameterConverter.convert_value(None, AxisType.CFG_SCALE) == 0.0
def test_format_for_display(self):
"""Test display formatting."""
# Model names strip extension
assert ParameterConverter.format_for_display("model.safetensors", AxisType.MODEL) == "model"
assert ParameterConverter.format_for_display("path/to/checkpoint.ckpt", AxisType.MODEL) == "checkpoint"
# Floats format with one decimal
assert ParameterConverter.format_for_display(7.5, AxisType.CFG_SCALE) == "7.5"
assert ParameterConverter.format_for_display(10.0, AxisType.CFG_SCALE) == "10.0"
# Large numbers get commas
assert ParameterConverter.format_for_display(1234567, AxisType.SEED) == "1,234,567"
# Long prompts truncate
long_text = "This is a very long prompt that exceeds the display limit"
formatted = ParameterConverter.format_for_display(long_text, AxisType.PROMPT)
assert len(formatted) <= 28 # 25 + "..."
def test_get_output_type(self):
"""Test output type detection."""
# String types
assert ParameterConverter.get_output_type(AxisType.MODEL) == "STRING"
assert ParameterConverter.get_output_type(AxisType.SAMPLER) == "STRING"
assert ParameterConverter.get_output_type(AxisType.PROMPT) == "STRING"
# Integer types
assert ParameterConverter.get_output_type(AxisType.STEPS) == "INT"
assert ParameterConverter.get_output_type(AxisType.CLIP_SKIP) == "INT"
assert ParameterConverter.get_output_type(AxisType.SEED) == "INT"
# Float types
assert ParameterConverter.get_output_type(AxisType.CFG_SCALE) == "FLOAT"
assert ParameterConverter.get_output_type(AxisType.FLUX_GUIDANCE) == "FLOAT"
assert ParameterConverter.get_output_type(AxisType.DENOISE) == "FLOAT"
def test_validate_values(self):
"""Test value validation."""
# Valid values
assert ParameterConverter.validate_value(20, AxisType.STEPS) == (True, None)
assert ParameterConverter.validate_value(7.5, AxisType.CFG_SCALE) == (True, None)
assert ParameterConverter.validate_value(0.5, AxisType.DENOISE) == (True, None)
# Invalid values
valid, msg = ParameterConverter.validate_value(-5, AxisType.STEPS)
assert not valid
assert "positive" in msg
valid, msg = ParameterConverter.validate_value(-2.5, AxisType.CFG_SCALE)
assert not valid
assert "non-negative" in msg
valid, msg = ParameterConverter.validate_value(1.5, AxisType.DENOISE)
assert not valid
assert "between 0 and 1" in msg
class TestOutputConnector:
"""Test output connection information."""
def test_get_connection_info(self):
"""Test connection info for different parameter types."""
# Model connection
info = OutputConnector.get_connection_info(AxisType.MODEL)
assert info["target_node"] == "CheckpointLoaderSimple"
assert info["target_input"] == "ckpt_name"
assert info["type"] == "STRING"
# Sampler connection
info = OutputConnector.get_connection_info(AxisType.SAMPLER)
assert info["target_node"] == "KSampler"
assert info["target_input"] == "sampler_name"
# CFG connection
info = OutputConnector.get_connection_info(AxisType.CFG_SCALE)
assert info["target_node"] == "KSampler"
assert info["target_input"] == "cfg"
assert info["type"] == "FLOAT"
# Prompt connection
info = OutputConnector.get_connection_info(AxisType.PROMPT)
assert info["target_node"] == "CLIPTextEncode"
assert info["target_input"] == "text"
+275
View File
@@ -0,0 +1,275 @@
"""Tests for execution flow components."""
import pytest
from unittest.mock import Mock, patch, MagicMock
from kikotools.tools.xyz_grid.controller.execution import (
GridExecutionState, ExecutionManager
)
from kikotools.tools.xyz_grid.controller.queue_manager import (
QueuedExecution, GridQueueManager
)
class TestGridExecutionState:
"""Test GridExecutionState class."""
def test_initialization(self):
"""Test state initialization."""
state = GridExecutionState(
batch_id="test123",
total_iterations=12,
x_count=3,
y_count=4,
z_count=1
)
assert state.batch_id == "test123"
assert state.total_iterations == 12
assert state.current_iteration == 0
assert state.x_index == 0
assert state.y_index == 0
assert state.z_index == 0
def test_advance_simple(self):
"""Test advancing through iterations."""
state = GridExecutionState(
batch_id="test",
total_iterations=6,
x_count=2,
y_count=3,
z_count=1
)
# Test advancing through all positions
positions = []
for i in range(6):
positions.append(state.get_indices())
state.advance()
expected = [
(0, 0, 0), (1, 0, 0), # First row
(0, 1, 0), (1, 1, 0), # Second row
(0, 2, 0), (1, 2, 0), # Third row
]
assert positions == expected
def test_advance_with_z(self):
"""Test advancing with Z axis."""
state = GridExecutionState(
batch_id="test",
total_iterations=8,
x_count=2,
y_count=2,
z_count=2
)
# Advance through first grid
for _ in range(4):
state.advance()
# Should now be at start of second Z
assert state.get_indices() == (0, 0, 1)
def test_is_complete(self):
"""Test completion detection."""
state = GridExecutionState(
batch_id="test",
total_iterations=2,
x_count=2,
y_count=1
)
assert not state.is_complete()
state.advance()
assert not state.is_complete()
state.advance()
assert state.is_complete()
class TestExecutionManager:
"""Test ExecutionManager class."""
def test_initialize_batch(self):
"""Test batch initialization."""
manager = ExecutionManager()
x_vals = ["a", "b", "c"]
y_vals = [1, 2]
z_vals = ["z1"]
state = manager.initialize_batch("batch1", x_vals, y_vals, z_vals)
assert state.batch_id == "batch1"
assert state.total_iterations == 6 # 3 * 2 * 1
assert state.x_count == 3
assert state.y_count == 2
assert state.z_count == 1
def test_get_current_values(self):
"""Test getting current values."""
manager = ExecutionManager()
x_vals = ["model1", "model2"]
y_vals = [5.0, 7.5]
z_vals = [""]
# First call should initialize
x, y, z, xi, yi, zi = manager.get_current_values(
"batch1", x_vals, y_vals, z_vals
)
assert x == "model1"
assert y == 5.0
assert z == ""
assert (xi, yi, zi) == (0, 0, 0)
# Advance and get next
manager.advance_batch("batch1")
x, y, z, xi, yi, zi = manager.get_current_values(
"batch1", x_vals, y_vals, z_vals
)
assert x == "model2"
assert y == 5.0
assert (xi, yi, zi) == (1, 0, 0)
def test_should_continue(self):
"""Test continuation checking."""
manager = ExecutionManager()
# Non-existent batch
assert not manager.should_continue("nonexistent")
# Initialize small batch
manager.initialize_batch("batch1", ["a"], ["b"], [""])
assert manager.should_continue("batch1")
# Complete the batch
state = manager.execution_states["batch1"]
state.current_iteration = state.total_iterations
assert not manager.should_continue("batch1")
def test_cleanup_batch(self):
"""Test batch cleanup."""
manager = ExecutionManager()
manager.initialize_batch("batch1", ["a"], ["b"], ["c"])
assert "batch1" in manager.execution_states
manager.cleanup_batch("batch1")
assert "batch1" not in manager.execution_states
class TestGridQueueManager:
"""Test GridQueueManager class."""
def test_prepare_batch_executions(self):
"""Test preparing batch executions."""
manager = GridQueueManager()
grid_config = {
"axes": {
"x": {"values": ["v1", "v2"], "labels": ["V1", "V2"]},
"y": {"values": [1, 2, 3], "labels": ["1", "2", "3"]},
"z": {"values": [""], "labels": [""]},
},
"dimensions": {"total_images": 6}
}
workflow = {"test": "workflow"}
executions = manager.prepare_batch_executions(
"batch1", grid_config, 123, workflow
)
assert len(executions) == 6
assert all(isinstance(e, QueuedExecution) for e in executions)
# Check first execution
first = executions[0]
assert first.batch_id == "batch1"
assert first.iteration == 0
assert first.total_iterations == 6
assert first.x_value == "v1"
assert first.y_value == 1
assert first.x_index == 0
assert first.y_index == 0
def test_get_next_execution(self):
"""Test getting next execution."""
manager = GridQueueManager()
# No executions
assert manager.get_next_execution("batch1") is None
# Prepare batch
grid_config = {
"axes": {
"x": {"values": ["a", "b"], "labels": []},
"y": {"values": [1], "labels": []},
"z": {"values": [""], "labels": []},
},
"dimensions": {"total_images": 2}
}
manager.prepare_batch_executions("batch1", grid_config, 1, {})
# Get first execution
execution = manager.get_next_execution("batch1")
assert execution is not None
assert execution.iteration == 0
# Mark as complete
manager.mark_iteration_complete("batch1", 0)
# Get second execution
execution = manager.get_next_execution("batch1")
assert execution.iteration == 1
def test_is_batch_complete(self):
"""Test batch completion checking."""
manager = GridQueueManager()
# Unknown batch is complete
assert manager.is_batch_complete("unknown")
# Prepare batch
grid_config = {
"axes": {
"x": {"values": ["a"], "labels": []},
"y": {"values": [1, 2], "labels": []},
"z": {"values": [""], "labels": []},
},
"dimensions": {"total_images": 2}
}
manager.prepare_batch_executions("batch1", grid_config, 1, {})
assert not manager.is_batch_complete("batch1")
# Complete all iterations
manager.mark_iteration_complete("batch1", 0)
manager.mark_iteration_complete("batch1", 1)
assert manager.is_batch_complete("batch1")
def test_batch_optimization(self):
"""Test batch optimization logic."""
manager = GridQueueManager()
# Test that batch preparation preserves order
grid_config = {
"axes": {
"x": {"values": ["a", "b", "a"], "labels": []},
"y": {"values": [1], "labels": []},
"z": {"values": [""], "labels": []},
},
"dimensions": {"total_images": 3}
}
executions = manager.prepare_batch_executions("batch1", grid_config, 1, {})
# Check order is preserved
assert len(executions) == 3
assert executions[0].x_value == "a"
assert executions[1].x_value == "b"
assert executions[2].x_value == "a"
+130
View File
@@ -0,0 +1,130 @@
"""Tests for XYZ grid helper utilities."""
import pytest
from kikotools.tools.xyz_grid.utils.constants import AxisType
from kikotools.tools.xyz_grid.utils.helpers import (
parse_value_string,
generate_axis_labels,
calculate_grid_dimensions,
create_unique_id
)
class TestParseValueString:
"""Test value string parsing."""
def test_parse_comma_separated_strings(self):
"""Test parsing comma-separated string values."""
result = parse_value_string("model1.ckpt, model2.safetensors, model3.pt", AxisType.MODEL)
assert result == ["model1.ckpt", "model2.safetensors", "model3.pt"]
def test_parse_comma_separated_numbers(self):
"""Test parsing comma-separated numeric values."""
result = parse_value_string("5, 7.5, 10", AxisType.CFG_SCALE)
assert result == [5.0, 7.5, 10.0]
result = parse_value_string("20, 30, 40", AxisType.STEPS)
assert result == [20, 30, 40]
def test_parse_range_syntax(self):
"""Test parsing range syntax."""
# Float range
result = parse_value_string("5:10:1", AxisType.CFG_SCALE)
assert result == [5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
# Integer range
result = parse_value_string("10:30:10", AxisType.STEPS)
assert result == [10, 20, 30]
# Two-part range (default step)
result = parse_value_string("1:5", AxisType.CLIP_SKIP)
assert result == [1, 2, 3, 4, 5]
def test_parse_empty_string(self):
"""Test parsing empty or whitespace strings."""
assert parse_value_string("", AxisType.MODEL) == []
assert parse_value_string(" ", AxisType.MODEL) == []
assert parse_value_string("\n\t", AxisType.MODEL) == []
def test_parse_single_value(self):
"""Test parsing single values."""
assert parse_value_string("euler", AxisType.SAMPLER) == ["euler"]
assert parse_value_string("7.5", AxisType.CFG_SCALE) == [7.5]
assert parse_value_string("42", AxisType.SEED) == [42]
class TestGenerateAxisLabels:
"""Test label generation."""
def test_basic_labels(self):
"""Test basic label generation."""
values = ["euler", "dpm++", "ddim"]
labels = generate_axis_labels(values, AxisType.SAMPLER)
assert labels == ["euler", "dpm++", "ddim"]
def test_labels_with_prefix(self):
"""Test labels with prefix."""
values = [5, 10, 15]
labels = generate_axis_labels(values, AxisType.CFG_SCALE, prefix="CFG=")
assert labels == ["CFG=5", "CFG=10", "CFG=15"]
def test_model_labels_strip_extension(self):
"""Test model labels strip file extensions."""
values = ["model1.ckpt", "model2.safetensors", "checkpoint.pt"]
labels = generate_axis_labels(values, AxisType.MODEL)
assert labels == ["model1", "model2", "checkpoint"]
def test_prompt_labels_truncate(self):
"""Test prompt labels truncate long text."""
long_prompt = "This is a very long prompt that should be truncated for display purposes"
values = [long_prompt, "Short prompt"]
labels = generate_axis_labels(values, AxisType.PROMPT)
assert len(labels[0]) <= 33 # 30 chars + "..."
assert labels[1] == "Short prompt"
class TestCalculateGridDimensions:
"""Test grid dimension calculations."""
def test_2d_grid(self):
"""Test 2D grid calculations."""
result = calculate_grid_dimensions(3, 4)
assert result == {
"total_images": 12,
"grids_count": 1,
"cols": 3,
"rows": 4
}
def test_3d_grid(self):
"""Test 3D grid calculations."""
result = calculate_grid_dimensions(2, 3, 4)
assert result == {
"total_images": 24,
"grids_count": 4,
"cols": 2,
"rows": 3
}
def test_single_axis(self):
"""Test single axis grid."""
result = calculate_grid_dimensions(5, 1)
assert result["total_images"] == 5
assert result["cols"] == 5
assert result["rows"] == 1
class TestCreateUniqueId:
"""Test unique ID generation."""
def test_unique_ids_are_different(self):
"""Test that generated IDs are unique."""
ids = [create_unique_id() for _ in range(100)]
assert len(set(ids)) == 100
def test_id_format(self):
"""Test ID format is consistent."""
uid = create_unique_id()
assert isinstance(uid, str)
assert len(uid) == 8 # Should be 8 characters
assert uid.replace("-", "").isalnum() # Should be alphanumeric (with possible hyphens)
@@ -0,0 +1,257 @@
"""Tests for progress tracking."""
import pytest
import time
from unittest.mock import Mock, patch, MagicMock
from kikotools.tools.xyz_grid.utils.progress_tracker import (
GridProgress, ProgressTracker
)
class TestGridProgress:
"""Test GridProgress class."""
def test_initialization(self):
"""Test progress initialization."""
progress = GridProgress(
batch_id="test123",
total_images=10
)
assert progress.batch_id == "test123"
assert progress.total_images == 10
assert progress.completed_images == 0
assert progress.status == "initializing"
assert progress.progress_percent == 0.0
def test_progress_percent(self):
"""Test progress percentage calculation."""
progress = GridProgress(batch_id="test", total_images=4)
assert progress.progress_percent == 0.0
progress.completed_images = 1
assert progress.progress_percent == 25.0
progress.completed_images = 2
assert progress.progress_percent == 50.0
progress.completed_images = 4
assert progress.progress_percent == 100.0
def test_elapsed_time(self):
"""Test elapsed time calculation."""
# Create progress with known start time
progress = GridProgress(batch_id="test", total_images=10)
progress.start_time = 100.0
# Mock current time for elapsed calculation
with patch('time.time', return_value=110.5):
assert progress.elapsed_time == 10.5
# Set end time
progress.end_time = 115.0
assert progress.elapsed_time == 15.0
def test_estimated_remaining(self):
"""Test remaining time estimation."""
progress = GridProgress(batch_id="test", total_images=10)
progress.start_time = 100.0
# No images completed yet
assert progress.estimated_remaining is None
# Complete 2 images in 10 seconds
progress.completed_images = 2
# Mock current time for calculation
with patch('time.time', return_value=110.0):
# 5 seconds per image, 8 remaining = 40 seconds
assert progress.estimated_remaining == 40.0
def test_to_dict(self):
"""Test dictionary conversion."""
progress = GridProgress(
batch_id="test",
total_images=10,
completed_images=5
)
progress.current_labels = {"x": "Model A", "y": "CFG 7.5"}
data = progress.to_dict()
assert data["batch_id"] == "test"
assert data["total_images"] == 10
assert data["completed_images"] == 5
assert data["progress_percent"] == 50.0
assert data["current_labels"] == {"x": "Model A", "y": "CFG 7.5"}
assert data["status"] == "initializing"
assert "elapsed_time" in data
class TestProgressTracker:
"""Test ProgressTracker class."""
def test_start_grid(self):
"""Test starting a new grid."""
tracker = ProgressTracker()
progress = tracker.start_grid("batch1", 20)
assert progress.batch_id == "batch1"
assert progress.total_images == 20
assert progress.status == "running"
assert "batch1" in tracker.active_grids
def test_update_progress(self):
"""Test updating progress."""
tracker = ProgressTracker()
tracker.start_grid("batch1", 5)
# Update with increment
progress = tracker.update_progress("batch1")
assert progress.completed_images == 1
# Update with specific count
progress = tracker.update_progress("batch1", completed=3)
assert progress.completed_images == 3
# Update with labels
labels = {"x": "Model B", "y": "Steps 30"}
progress = tracker.update_progress("batch1", current_labels=labels)
assert progress.current_labels == labels
def test_update_with_preview(self):
"""Test updating with preview images."""
tracker = ProgressTracker()
tracker.start_grid("batch1", 10)
# Add previews
for i in range(7):
tracker.update_progress("batch1", preview_image=f"image_{i}")
progress = tracker.get_progress("batch1")
# Should only keep last 5
assert len(progress.preview_images) == 5
assert progress.preview_images[-1] == "image_6"
def test_complete_grid(self):
"""Test completing a grid."""
tracker = ProgressTracker()
tracker.start_grid("batch1", 2)
# Complete all images
tracker.update_progress("batch1", completed=2)
# Should auto-complete
assert "batch1" not in tracker.active_grids
assert len(tracker.completed_grids) == 1
completed = tracker.completed_grids[0]
assert completed.status == "completed"
assert completed.end_time is not None
def test_error_grid(self):
"""Test error handling."""
tracker = ProgressTracker()
tracker.start_grid("batch1", 10)
progress = tracker.error_grid("batch1", "CUDA out of memory")
assert progress.status == "error"
assert progress.error_message == "CUDA out of memory"
assert "batch1" not in tracker.active_grids
assert len(tracker.completed_grids) == 1
def test_get_progress(self):
"""Test getting progress for specific batch."""
tracker = ProgressTracker()
# Non-existent batch
assert tracker.get_progress("unknown") is None
# Active batch
tracker.start_grid("batch1", 10)
progress = tracker.get_progress("batch1")
assert progress is not None
assert progress.batch_id == "batch1"
# Completed batch
tracker.complete_grid("batch1")
progress = tracker.get_progress("batch1")
assert progress is not None
assert progress.status == "completed"
def test_callbacks(self):
"""Test progress callbacks."""
tracker = ProgressTracker()
callback_data = []
def test_callback(progress):
callback_data.append(progress.to_dict())
tracker.register_callback(test_callback)
# Start should trigger callback
tracker.start_grid("batch1", 5)
assert len(callback_data) == 1
assert callback_data[0]["batch_id"] == "batch1"
# Update should trigger callback
tracker.update_progress("batch1")
assert len(callback_data) == 2
assert callback_data[1]["completed_images"] == 1
def test_websocket_notification(self):
"""Test WebSocket notifications."""
tracker = ProgressTracker()
# Test with mock websocket handler
mock_handler = Mock()
tracker.set_websocket_handler(mock_handler)
# Test callback gets called
callback_called = False
def test_callback(progress):
nonlocal callback_called
callback_called = True
tracker.register_callback(test_callback)
tracker.start_grid("batch1", 10)
assert callback_called
def test_get_summary(self):
"""Test getting progress summary."""
tracker = ProgressTracker()
# Add some grids
tracker.start_grid("batch1", 10)
tracker.start_grid("batch2", 20)
# Complete one
tracker.complete_grid("batch1")
summary = tracker.get_summary()
assert summary["total_active"] == 1
assert summary["total_completed"] == 1
assert len(summary["active_grids"]) == 1
assert len(summary["completed_grids"]) == 1
assert summary["active_grids"][0]["batch_id"] == "batch2"
def test_completed_grids_limit(self):
"""Test that completed grids list has a limit."""
tracker = ProgressTracker()
# Complete many grids
for i in range(15):
tracker.start_grid(f"batch{i}", 5)
tracker.complete_grid(f"batch{i}")
# Should only keep last 10
assert len(tracker.completed_grids) == 10
# Check it's the most recent ones
last_batch_id = tracker.completed_grids[-1].batch_id
assert last_batch_id == "batch14"
+247
View File
@@ -0,0 +1,247 @@
/* XYZ Grid Styles */
.xyz-value-selector {
position: relative;
margin: 5px 0;
}
.xyz-value-selector button {
transition: all 0.2s ease;
}
.xyz-value-selector button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3);
}
.xyz-value-selector button:active {
transform: translateY(0);
}
/* Multi-select dialog animations */
.xyz-multiselect-overlay {
animation: fadeIn 0.2s ease;
}
.xyz-multiselect-dialog {
animation: slideIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Example helper styles */
.xyz-example-helper {
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
margin: 5px 0;
font-size: 11px;
color: #aaa;
position: relative;
overflow: hidden;
}
.xyz-example-helper::before {
content: "Example";
position: absolute;
top: 4px;
right: 8px;
font-size: 9px;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
.xyz-example-helper pre {
margin: 0;
font-family: 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
line-height: 1.4;
}
/* Value input validation styles */
.xyz-value-input {
transition: border-color 0.2s ease;
}
.xyz-value-input.valid {
border-color: #4ecdc4 !important;
}
.xyz-value-input.invalid {
border-color: #ff6b6b !important;
}
/* Progress indicator */
.xyz-progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, #4ecdc4 0%, #44a3aa 100%);
transition: width 0.3s ease;
border-radius: 0 0 4px 4px;
}
/* Grid info display */
.xyz-grid-info {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
padding: 8px 12px;
margin: 5px 0;
font-size: 12px;
color: #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.xyz-grid-info .dimensions {
color: #4ecdc4;
font-weight: bold;
}
.xyz-grid-info .warning {
color: #ff6b6b;
font-size: 11px;
}
/* Checkbox styling in multi-select */
.xyz-checkbox-option {
display: flex;
align-items: center;
padding: 6px 8px;
margin: 2px 0;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.xyz-checkbox-option:hover {
background-color: rgba(74, 144, 226, 0.1);
}
.xyz-checkbox-option input[type="checkbox"] {
margin-right: 10px;
width: 16px;
height: 16px;
cursor: pointer;
}
.xyz-checkbox-option label {
flex: 1;
cursor: pointer;
user-select: none;
}
/* Search input styling */
.xyz-search-input {
width: 100%;
padding: 10px 12px;
margin-bottom: 12px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 14px;
transition: all 0.2s ease;
}
.xyz-search-input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.xyz-search-input::placeholder {
color: #666;
}
/* Button group styling */
.xyz-button-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.xyz-button-group button {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.xyz-button-primary {
background: #4a90e2;
color: white;
}
.xyz-button-primary:hover {
background: #357abd;
}
.xyz-button-secondary {
background: #666;
color: white;
}
.xyz-button-secondary:hover {
background: #555;
}
/* Tooltip for parameter info */
.xyz-param-tooltip {
position: absolute;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 4px;
padding: 8px 12px;
font-size: 11px;
color: #ddd;
z-index: 10001;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.xyz-param-tooltip.visible {
opacity: 1;
}
.xyz-param-tooltip::before {
content: '';
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid #444;
}
File diff suppressed because it is too large Load Diff
+781
View File
@@ -0,0 +1,781 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { api } from "../../scripts/api.js";
// Counter for unique widget names
let widgetCounter = 0;
// Helper text for different parameter types
const PARAM_HELP = {
cfg_scale: "Enter values separated by commas: 5, 7.5, 10\nOr use range: 5:15:2.5",
steps: "Enter values separated by commas: 20, 30, 40\nOr use range: 10:50:10",
seed: "Enter values separated by commas: 42, 123, 456\nOr use range: 0:1000:100",
denoise: "Enter values separated by commas: 0.3, 0.5, 0.7\nOr use range: 0.2:1.0:0.2",
clip_skip: "Enter values separated by commas: 1, 2\nCommon values for SDXL",
prompt: "Enter prompts separated by new lines:\nbeautiful sunset\nmystical forest\nfuturistic city"
};
// Helper function to check if we're in low quality mode
function isLowQuality() {
const canvas = app.canvas;
return ((canvas.ds?.scale) || 1) <= 0.5;
}
// Optimized toggle drawing function
function drawTogglePart(ctx, options) {
const lowQuality = isLowQuality();
ctx.save();
const { posX, posY, height, value } = options;
const toggleRadius = height * 0.36;
const toggleBgWidth = height * 1.5;
if (!lowQuality) {
ctx.beginPath();
ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
}
ctx.fillStyle = value ? "#89B" : "#888";
const toggleX = lowQuality || !value
? posX + height * 0.5
: posX + height;
ctx.beginPath();
ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return [posX, toggleBgWidth];
}
// Custom dynamic widget class with RGThree-style UI
class XYZDynamicWidget {
constructor(name, value) {
this.name = name;
this._value = value;
this.type = "xyz_dynamic_widget";
this.y = 0;
this.options = {};
// Mouse state for drag operations
this.mouseState = {
dragging: false,
startX: 0,
startValue: 0,
lastClickTime: 0
};
}
get value() {
return this._value;
}
set value(v) {
this._value = v;
}
serializeValue(node, index) {
return this._value ? { ...this._value } : null;
}
draw(ctx, node, width, y) {
const margin = 10;
const innerMargin = 3;
const height = LiteGraph.NODE_WIDGET_HEIGHT;
const midY = y + height / 2;
const lowQuality = isLowQuality();
let posX = margin;
ctx.save();
// Background - skip complex drawing in low quality
if (!lowQuality) {
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.roundRect(posX, y + 2, width - margin * 2, height - 4, [height * 0.5]);
ctx.fill();
}
// Draw toggle with optimized function
this.toggleBounds = drawTogglePart(ctx, { posX, posY: y, height, value: this.value.on });
posX += this.toggleBounds[1] + innerMargin;
// Skip text drawing in low quality mode
if (lowQuality) {
ctx.restore();
return;
}
// Apply opacity if disabled
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Draw name/value - for dropdown types, show the selected value
const displayText = this.value.value || this.value.name || "None";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
// Fit text if too long
const maxTextWidth = width - posX - margin;
const textWidth = ctx.measureText(displayText).width;
if (textWidth > maxTextWidth) {
// Use ellipsis for long text
let truncated = displayText;
while (truncated.length > 0 && ctx.measureText(truncated + "…").width > maxTextWidth) {
truncated = truncated.slice(0, -1);
}
ctx.fillText(truncated + "…", posX, midY);
this.nameBounds = [posX, ctx.measureText(truncated + "…").width];
} else {
ctx.fillText(displayText, posX, midY);
this.nameBounds = [posX, textWidth];
}
ctx.restore();
}
mouse(event, pos, node) {
const margin = 10;
const localX = pos[0] - margin;
if (event.type === "mousedown") {
// Toggle click
if (localX >= this.toggleBounds[0] &&
localX <= this.toggleBounds[0] + this.toggleBounds[1]) {
this.value.on = !this.value.on;
node.setDirtyCanvas(true, true);
updateNodeTitle(node);
return true;
}
// Click on name/value to show dropdown
if (this.nameBounds && localX >= this.nameBounds[0] &&
localX <= this.nameBounds[0] + this.nameBounds[1]) {
// Show dropdown menu for selection
if (this.value.options && this.value.options.length > 0) {
const menu = new LiteGraph.ContextMenu(
this.value.options,
{
event: event,
callback: (value) => {
this.value.value = value;
node.setDirtyCanvas(true, true);
}
}
);
return true;
}
}
}
return false;
}
computeSize(width) {
return [width || 300, LiteGraph.NODE_WIDGET_HEIGHT];
}
}
app.registerExtension({
name: "ComfyAssets.XYZPlotController",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "XYZPlotController") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Enable widget serialization
this.serialize_widgets = true;
// Track widget visibility
this.hiddenWidgets = new Set();
// Initialize storage for dynamic widgets
if (!node.dynamicWidgets) {
node.dynamicWidgets = {
x: [],
y: [],
z: []
};
}
// Store references to add buttons
if (!node.addButtons) {
node.addButtons = {};
}
// Store references to text widgets
if (!node.textWidgets) {
node.textWidgets = {};
}
// Setup axis type callbacks
["x_type", "y_type", "z_type"].forEach(widgetName => {
const widget = node.widgets.find(w => w.name === widgetName);
if (widget) {
const originalCallback = widget.callback;
widget.callback = function(value) {
if (originalCallback) originalCallback.call(this, value);
updateAxisWidgets(node, widgetName.split("_")[0], value);
updateNodeTitle(node);
};
}
});
// Pre-fetch common options to cache them
setTimeout(() => {
getSamplers();
getSchedulers();
}, 100);
// Override configuration for proper restoration
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
this._configured = true;
// Save widget values before ComfyUI modifies them
const savedWidgetValues = [...(info.widgets_values || [])];
// Clear for fresh restoration
if (!this.hiddenWidgets) {
this.hiddenWidgets = new Set();
}
this.dynamicWidgets = { x: [], y: [], z: [] };
this.addButtons = {};
this.textWidgets = {};
// Let ComfyUI restore base widgets
if (onConfigure) {
onConfigure.call(this, info);
}
// Restore dynamic widgets
let widgetIndex = this.widgets.length;
for (let i = widgetIndex; i < savedWidgetValues.length; i++) {
const value = savedWidgetValues[i];
if (value && typeof value === 'object' && value._axis) {
const widget = new XYZDynamicWidget(
`dynamic_${widgetCounter++}`,
value
);
this.addCustomWidget(widget);
if (this.dynamicWidgets[value._axis]) {
this.dynamicWidgets[value._axis].push(widget);
}
}
}
// Restore text widget values
for (let i = 0; i < this.widgets.length && i < savedWidgetValues.length; i++) {
const widget = this.widgets[i];
const savedValue = savedWidgetValues[i];
if (widget && typeof savedValue === 'string' && savedValue !== '') {
widget.value = savedValue;
if (widget.inputEl) {
widget.inputEl.value = savedValue;
}
}
}
// Update UI based on restored state
for (const axis of ['x', 'y', 'z']) {
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
if (typeWidget) {
updateAxisWidgets(this, axis, typeWidget.value, true);
}
}
updateNodeTitle(this);
};
// Override serialization to fix widget value persistence
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
if (origOnSerialize) {
origOnSerialize.call(this, info);
}
// Fix empty text widget values
if (info.widgets_values && this.widgets) {
for (let i = 0; i < this.widgets.length && i < info.widgets_values.length; i++) {
const widget = this.widgets[i];
const serializedValue = info.widgets_values[i];
if ((serializedValue === '' || serializedValue === null) &&
widget && widget.value !== '' && widget.value !== null) {
info.widgets_values[i] = widget.value;
}
if (widget && widget.inputEl && widget.inputEl.value &&
(serializedValue === '' || serializedValue === null)) {
info.widgets_values[i] = widget.inputEl.value;
}
}
}
};
// Implement right-click context menu
implementContextMenu(node);
// Override getExtraMenuOptions to handle execution
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(_, options) {
if (origGetExtraMenuOptions) {
origGetExtraMenuOptions.call(this, _, options);
}
// Prepare widget values for execution
const executeCallback = () => {
// Format dynamic widget values for Python backend
const values = {};
for (const axis of ['x', 'y', 'z']) {
// Add dynamic widgets
const widgets = this.dynamicWidgets[axis];
widgets.forEach((widget, index) => {
const key = `${axis}_${widget.value._type}_${index}`;
values[key] = {
on: widget.value.on,
value: widget.value.value
};
});
// Add text widget values
const typeWidget = this.widgets.find(w => w.name === `${axis}_type`);
if (typeWidget && needsTextWidget(typeWidget.value)) {
const textWidget = this.textWidgets[axis];
if (textWidget) {
if (typeWidget.value === "prompt") {
values[`${axis}_prompt`] = textWidget.value || "";
} else {
values[`${axis}_numeric`] = textWidget.value || "";
}
}
}
}
// Add to widgets_values for proper serialization
if (this.widgets_values) {
Object.assign(this.widgets_values, values);
}
};
// Hook into execution
if (this.mode === 0) { // Only in active mode
executeCallback();
}
};
};
}
}
});
// Helper function to update widgets based on axis type
function updateAxisWidgets(node, axis, type, skipClear = false) {
if (!skipClear) {
// Hide text widgets and buttons for this axis (but not the type dropdown!)
node.widgets?.forEach(widget => {
if (widget.name?.includes(`${axis}_`) && widget.name !== `${axis}_type`) {
// Only hide if it's a text widget or add button
if (widget.type === "text" || widget.type === "button" || widget.name.includes("_select")) {
widget.hidden = true;
widget.computeSize = () => [0, 0];
node.hiddenWidgets?.add(widget.name);
}
}
});
// Clear dynamic widgets
if (node.dynamicWidgets[axis]) {
while (node.dynamicWidgets[axis].length > 0) {
const widget = node.dynamicWidgets[axis].pop();
const index = node.widgets.indexOf(widget);
if (index > -1) {
node.widgets.splice(index, 1);
}
}
}
// Remove add button
if (node.addButtons[axis]) {
const index = node.widgets.indexOf(node.addButtons[axis]);
if (index > -1) {
node.widgets.splice(index, 1);
}
node.addButtons[axis] = null;
}
}
// If type is "none", don't add any widgets
if (type === "none") {
return;
}
// Add widgets based on type
if (needsTextWidget(type)) {
const widgetName = `${axis}_${type}`;
let existingWidget = node.widgets?.find(w => w.name === widgetName);
if (!existingWidget) {
const textWidget = ComfyWidgets.STRING(node, widgetName, ["STRING", {
default: "",
multiline: type === "prompt",
placeholder: PARAM_HELP[type] || ""
}]);
node.textWidgets[axis] = textWidget.widget;
// Set placeholder
if (textWidget.widget.inputEl && PARAM_HELP[type]) {
textWidget.widget.inputEl.placeholder = PARAM_HELP[type];
}
} else {
existingWidget.hidden = false;
existingWidget.computeSize = () => [node.size[0] - 20,
type === "prompt" ? LiteGraph.NODE_WIDGET_HEIGHT * 3 : LiteGraph.NODE_WIDGET_HEIGHT];
node.hiddenWidgets?.delete(existingWidget.name);
node.textWidgets[axis] = existingWidget;
}
} else if (needsDropdownWidget(type)) {
// Add button for dynamic widgets
if (!node.addButtons[axis]) {
const buttonLabel = `➕ Add ${type.charAt(0).toUpperCase() + type.slice(1, -1)}`;
const button = node.addWidget("button", buttonLabel, null, (value, canvas, node, pos, event) => {
// Store event for context menu positioning
window.lastButtonEvent = event || window.event;
addDynamicWidget(node, axis, type);
});
node.addButtons[axis] = button;
}
}
}
// Helper function to check if type needs text widget
function needsTextWidget(type) {
return ["cfg_scale", "steps", "seed", "denoise", "clip_skip", "prompt"].includes(type);
}
// Helper function to check if type needs dropdown widgets
function needsDropdownWidget(type) {
return ["models", "vaes", "loras", "samplers", "schedulers"].includes(type);
}
// Cache for options to avoid repeated API calls
const optionsCache = {
models: null,
vaes: null,
loras: null,
samplers: null,
schedulers: null
};
// Helper function to add dynamic widget
async function addDynamicWidget(node, axis, type) {
// Get available options based on type - use cache if available
let options = optionsCache[type];
if (!options) {
if (type === "models") {
options = await getCheckpoints();
} else if (type === "vaes") {
options = await getVAEs();
} else if (type === "loras") {
options = await getLoRAs();
} else if (type === "samplers") {
options = await getSamplers();
} else if (type === "schedulers") {
options = await getSchedulers();
}
// Cache the results
optionsCache[type] = options;
}
// Show selection dialog
if (!options || options.length === 0) {
alert(`No ${type} found`);
return;
}
// Create context menu for selection
const menu = new LiteGraph.ContextMenu(
options,
{
event: window.lastButtonEvent || window.event,
callback: (selectedValue) => {
// Create dynamic widget with selected value
const widget = new XYZDynamicWidget(
`${axis}_${type}_${widgetCounter++}`,
{
on: true,
value: selectedValue,
options: options,
_axis: axis,
_type: type
}
);
node.addCustomWidget(widget);
node.dynamicWidgets[axis].push(widget);
updateNodeTitle(node);
}
}
);
}
// Helper function to update node title with count
function updateNodeTitle(node) {
let xCount = 1, yCount = 1, zCount = 1;
// Count values for each axis
for (const axis of ['x', 'y', 'z']) {
const typeWidget = node.widgets.find(w => w.name === `${axis}_type`);
if (typeWidget && typeWidget.value !== "none") {
const type = typeWidget.value;
if (needsTextWidget(type)) {
const textWidget = node.textWidgets[axis];
if (textWidget && textWidget.value) {
const values = parseAxisValues(textWidget.value, type);
if (axis === 'x') xCount = values.length;
else if (axis === 'y') yCount = values.length;
else if (axis === 'z') zCount = values.length;
}
} else if (needsDropdownWidget(type)) {
const enabledWidgets = node.dynamicWidgets[axis].filter(w => w.value.on);
if (axis === 'x') xCount = Math.max(1, enabledWidgets.length);
else if (axis === 'y') yCount = Math.max(1, enabledWidgets.length);
else if (axis === 'z') zCount = Math.max(1, enabledWidgets.length);
}
}
}
const total = xCount * yCount * zCount;
node.title = `XYZ Plot Controller (${total} images)`;
}
// Helper function to parse axis values
function parseAxisValues(text, type) {
if (!text) return [];
if (type === "prompt") {
return text.split('\n').filter(line => line.trim());
} else {
// Handle comma-separated and range syntax
const values = [];
const parts = text.split(',').map(s => s.trim());
for (const part of parts) {
if (part.includes(':')) {
// Range syntax: start:end:step
const [start, end, step] = part.split(':').map(s => parseFloat(s));
if (!isNaN(start) && !isNaN(end) && !isNaN(step) && step > 0) {
for (let v = start; v <= end; v += step) {
values.push(v);
}
}
} else {
const val = parseFloat(part);
if (!isNaN(val)) {
values.push(val);
}
}
}
return values;
}
}
// Helper function to implement context menu
function implementContextMenu(node) {
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(x, y) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.call(this, x, y) : null;
if (!slot) {
const localX = x - this.pos[0];
const localY = y - this.pos[1];
for (const w of this.widgets || []) {
if (w.type === "xyz_dynamic_widget" && w.y &&
localY > w.y && localY < w.y + LiteGraph.NODE_WIDGET_HEIGHT) {
if (w.nameBounds && localX >= w.nameBounds[0] &&
localX <= w.nameBounds[0] + w.nameBounds[1]) {
return { widget: w, output: { type: "XYZ_WIDGET" } };
}
}
}
}
return slot;
};
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot?.output?.type === "XYZ_WIDGET") {
const widget = slot.widget;
const axis = widget.value._axis;
const array = this.dynamicWidgets[axis];
const currentIndex = array.indexOf(widget);
const menuItems = [
{
content: `${widget.value.on ? "⚫" : "🟢"} Toggle ${widget.value.on ? "Off" : "On"}`,
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
updateNodeTitle(this);
}
},
{
content: `⬆️ Move Up`,
disabled: currentIndex === 0,
callback: () => {
if (currentIndex > 0) {
[array[currentIndex - 1], array[currentIndex]] =
[array[currentIndex], array[currentIndex - 1]];
const widgetIndex = this.widgets.indexOf(widget);
const prevWidget = array[currentIndex];
const prevIndex = this.widgets.indexOf(prevWidget);
if (widgetIndex > -1 && prevIndex > -1) {
[this.widgets[prevIndex], this.widgets[widgetIndex]] =
[this.widgets[widgetIndex], this.widgets[prevIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
{
content: `⬇️ Move Down`,
disabled: currentIndex === array.length - 1,
callback: () => {
if (currentIndex < array.length - 1) {
[array[currentIndex], array[currentIndex + 1]] =
[array[currentIndex + 1], array[currentIndex]];
const widgetIndex = this.widgets.indexOf(widget);
const nextWidget = array[currentIndex];
const nextIndex = this.widgets.indexOf(nextWidget);
if (widgetIndex > -1 && nextIndex > -1) {
[this.widgets[widgetIndex], this.widgets[nextIndex]] =
[this.widgets[nextIndex], this.widgets[widgetIndex]];
}
this.setDirtyCanvas(true, true);
}
}
},
null, // Separator
{
content: `🗑️ Remove`,
callback: () => {
// Remove from array
const index = array.indexOf(widget);
if (index > -1) {
array.splice(index, 1);
}
// Remove widget
const wIndex = this.widgets.indexOf(widget);
if (wIndex > -1) {
this.widgets.splice(wIndex, 1);
}
this.setDirtyCanvas(true, true);
updateNodeTitle(this);
}
}
];
new LiteGraph.ContextMenu(menuItems, {
title: "WIDGET OPTIONS",
event: app.canvas.last_mouse_event || window.event
});
return null;
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.call(this, slot) : null;
};
}
// API helper functions
async function getCheckpoints() {
try {
// Get checkpoints from CheckpointLoaderSimple node definition
const nodeData = await api.getNodeDefs();
if (nodeData.CheckpointLoaderSimple &&
nodeData.CheckpointLoaderSimple.input.required.ckpt_name) {
return nodeData.CheckpointLoaderSimple.input.required.ckpt_name[0];
}
} catch (e) {
console.error("Error getting checkpoints:", e);
}
return ["None"];
}
async function getVAEs() {
try {
// Get VAEs from VAELoader node definition
const nodeData = await api.getNodeDefs();
if (nodeData.VAELoader &&
nodeData.VAELoader.input.required.vae_name) {
return ["Automatic", ...nodeData.VAELoader.input.required.vae_name[0]];
}
} catch (e) {
console.error("Error getting VAEs:", e);
}
return ["Automatic"];
}
async function getLoRAs() {
try {
// Get LoRAs from LoraLoader node definition
const nodeData = await api.getNodeDefs();
if (nodeData.LoraLoader &&
nodeData.LoraLoader.input.required.lora_name) {
return ["None", ...nodeData.LoraLoader.input.required.lora_name[0]];
}
} catch (e) {
console.error("Error getting LoRAs:", e);
}
return ["None"];
}
async function getSamplers() {
// Get samplers from KSampler node definition
try {
const nodeData = await api.getNodeDefs();
if (nodeData.KSampler && nodeData.KSampler.input.required.sampler_name) {
return nodeData.KSampler.input.required.sampler_name[0];
}
} catch {}
return ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_sde", "dpmpp_2m", "dpmpp_3m_sde"];
}
async function getSchedulers() {
// Get schedulers from KSampler node definition
try {
const nodeData = await api.getNodeDefs();
if (nodeData.KSampler && nodeData.KSampler.input.required.scheduler) {
return nodeData.KSampler.input.required.scheduler[0];
}
} catch {}
return ["normal", "karras", "exponential", "simple", "ddim_uniform"];
}
+131
View File
@@ -0,0 +1,131 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "ComfyAssets.DisplayAny",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayAny") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
// Update the display widget with the value
this.updateDisplay(displayText);
// Also show a condensed version in the title
const condensed = displayText.length > 20
? displayText.substring(0, 20) + "..."
: displayText;
this.title = `DisplayAny: ${condensed}`;
}
};
nodeType.prototype.updateDisplay = function(text) {
// Remove existing display widget if any
const existingWidget = this.widgets?.find(w => w.name === "display_value");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Create display widget
const widget = {
type: "custom_display",
name: "display_value",
size: [this.size[0] - 20, 80],
displayText: text,
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 16;
const minHeight = 60;
// Calculate needed height based on text
ctx.font = "12px monospace";
const lines = this.displayText ? this.displayText.split('\n') : [""];
const textHeight = Math.max(minHeight, lines.length * lineHeight + padding * 2);
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, textHeight);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, textHeight);
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, textHeight - 2);
// Prepare text
ctx.fillStyle = "#ddd";
ctx.textAlign = "left";
ctx.textBaseline = "top";
// Draw each line
const maxWidth = widget_width - margin * 2 - padding * 2;
let currentY = y + padding;
for (let i = 0; i < lines.length && i < 3; i++) { // Show max 3 lines
let line = lines[i];
const metrics = ctx.measureText(line);
if (metrics.width > maxWidth) {
// Truncate line to fit
while (ctx.measureText(line + "...").width > maxWidth && line.length > 0) {
line = line.slice(0, -1);
}
line = line + "...";
}
ctx.fillText(line, margin + padding, currentY);
currentY += lineHeight;
}
if (lines.length > 3) {
ctx.fillStyle = "#888";
ctx.fillText("...", margin + padding, currentY);
}
return textHeight;
},
computeSize: function(width) {
const lines = this.displayText ? this.displayText.split('\n') : [""];
const lineHeight = 16;
const padding = 10;
const minHeight = 60;
const textHeight = Math.max(minHeight, Math.min(lines.length, 3) * lineHeight + padding * 2);
return [width, textHeight];
}
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size
this.computeSize();
this.setDirtyCanvas(true);
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size
this.size[0] = Math.max(this.size[0], 250);
this.size[1] = Math.max(this.size[1], 150);
// Add placeholder text
this.updateDisplay("Value will appear here...");
};
}
}
});
+595
View File
@@ -0,0 +1,595 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.DisplayText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
// Create or update the text widget
this.updateTextDisplay(message.text[0]);
}
};
nodeType.prototype.updateTextDisplay = function(text) {
// Remove existing text widget if any
const existingWidget = this.widgets?.find(w => w.name === "displayed_text");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Parse the text to detect positive/negative prompt format
function parsePrompts(text) {
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract just the prompt content, stopping at the first ** marker
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers and everything after them
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
return {
type: 'prompts',
positive: positiveText,
negative: negativeText
};
}
return {
type: 'text',
content: text
};
}
const parsedContent = parsePrompts(text);
// Create custom widget for text display
const widget = {
type: "custom_text_display",
name: "displayed_text",
size: [this.size[0] - 20, this.size[1] - 60], // Adjust for node chrome
parsedContent: parsedContent,
scrollOffset: 0,
posScrollOffset: 0,
negScrollOffset: 0,
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 20;
const buttonHeight = 30;
const buttonWidth = 90;
// Use the actual widget height from node size
const availableHeight = node.size[1] - 60; // Account for node header and margins
this.size[1] = Math.max(100, availableHeight);
// Calculate available width for text
const availableWidth = widget_width - margin * 2 - padding * 2;
// Word wrap function with better performance
function wrapText(text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
ctx.font = "14px monospace";
for (const word of words) {
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, this.size[1]);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, this.size[1]);
if (this.parsedContent.type === 'prompts') {
// Simple split view for positive/negative prompts
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
// Draw positive prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#8f8";
ctx.font = "12px sans-serif";
ctx.fillText("✓ Positive Prompt", margin + padding, y + headerHeight - 7);
// Positive prompt text area
const posTextY = y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, posTextY, widget_width - margin * 2 - 2, posTextHeight);
// Draw separator
const separatorY = y + halfHeight;
ctx.strokeStyle = "#555";
ctx.beginPath();
ctx.moveTo(margin, separatorY);
ctx.lineTo(widget_width - margin, separatorY);
ctx.stroke();
// Draw negative prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, separatorY + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#f88";
ctx.font = "12px sans-serif";
ctx.fillText("✗ Negative Prompt", margin + padding, separatorY + headerHeight - 7);
// Negative prompt text area
const negTextY = separatorY + headerHeight;
const negTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, negTextY, widget_width - margin * 2 - 2, negTextHeight);
// Draw text for both sections
ctx.font = "14px monospace";
ctx.fillStyle = "#ddd";
// Wrap text for both prompts
const posLines = [];
const posParagraphs = this.parsedContent.positive.split('\n');
for (const para of posParagraphs) {
if (para.trim() === '') {
posLines.push('');
} else {
posLines.push(...wrapText(para, availableWidth - 10));
}
}
const negLines = [];
const negParagraphs = this.parsedContent.negative.split('\n');
for (const para of negParagraphs) {
if (para.trim() === '') {
negLines.push('');
} else {
negLines.push(...wrapText(para, availableWidth - 10));
}
}
// Draw positive prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, posTextY + padding, availableWidth - 10, posTextHeight - padding * 2);
ctx.clip();
let currentY = posTextY + padding + lineHeight - 5;
const posVisibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const posStartLine = Math.floor(this.posScrollOffset);
const posEndLine = Math.min(posStartLine + posVisibleLines, posLines.length);
for (let i = posStartLine; i < posEndLine; i++) {
ctx.fillText(posLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw positive scroll indicator if needed
if (posLines.length > posVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = posTextHeight - padding * 2;
const maxScroll = posLines.length - posVisibleLines;
const scrollRatio = this.posScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (posVisibleLines / posLines.length) * scrollBarHeight);
const thumbY = posTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, posTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw negative prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, negTextY + padding, availableWidth - 10, negTextHeight - padding * 2);
ctx.clip();
ctx.fillStyle = "#ddd";
currentY = negTextY + padding + lineHeight - 5;
const negVisibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const negStartLine = Math.floor(this.negScrollOffset);
const negEndLine = Math.min(negStartLine + negVisibleLines, negLines.length);
for (let i = negStartLine; i < negEndLine; i++) {
ctx.fillText(negLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw negative scroll indicator if needed
if (negLines.length > negVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = negTextHeight - padding * 2;
const maxScroll = negLines.length - negVisibleLines;
const scrollRatio = this.negScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (negVisibleLines / negLines.length) * scrollBarHeight);
const thumbY = negTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, negTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy buttons
const buttonY = y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (widget_width - margin * 2) / 2;
// Positive copy button
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.posCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.posCopySuccess ? "✓ Copied!" : "📋 Positive", posButtonX + buttonWidth/2, buttonY + buttonHeight/2);
// Negative copy button
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.negCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(negButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(negButtonX, buttonY, buttonWidth, buttonHeight);
// Ensure text color and alignment are set
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.negCopySuccess ? "✓ Copied!" : "📋 Negative", negButtonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
} else {
// Regular text display
const textAreaHeight = this.size[1] - buttonHeight - padding;
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, textAreaHeight);
// Process text
ctx.font = "14px monospace";
const paragraphs = this.parsedContent.content.split('\n');
const allLines = [];
for (const paragraph of paragraphs) {
if (paragraph.trim() === '') {
allLines.push('');
} else {
const wrappedLines = wrapText(paragraph, availableWidth);
allLines.push(...wrappedLines);
}
}
// Draw text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, y + padding, availableWidth, textAreaHeight - padding * 2);
ctx.clip();
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const maxScroll = Math.max(0, allLines.length - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
ctx.fillStyle = "#ddd";
let currentY = y + padding + lineHeight - 5 - (this.scrollOffset * lineHeight);
for (let i = 0; i < allLines.length; i++) {
if (currentY > y && currentY < y + textAreaHeight) {
ctx.fillText(allLines[i], margin + padding, currentY);
}
currentY += lineHeight;
}
ctx.restore();
// Draw scroll indicator if needed
if (allLines.length > visibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = textAreaHeight - 4;
const thumbHeight = Math.max(20, (visibleLines / allLines.length) * scrollBarHeight);
const thumbY = y + 2 + (this.scrollOffset / maxScroll) * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, y + 2, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy button
const buttonX = widget_width - margin - buttonWidth - padding;
const buttonY = y + textAreaHeight + padding / 2;
ctx.fillStyle = this.copyButtonHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.copySuccess ? "✓ Copied!" : "📋 Copy", buttonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
return this.size[1];
},
mouse: function(event, pos, node) {
const margin = 10;
const padding = 10;
const buttonWidth = 90;
const buttonHeight = 30;
const lineHeight = 20;
// Check if mouse is over the widget
const isOver = pos[1] > this.last_y && pos[1] < this.last_y + this.size[1];
if (!isOver) return false;
if (this.parsedContent.type === 'prompts') {
// Handle split view
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
const posTextY = this.last_y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
const negTextY = this.last_y + halfHeight + headerHeight;
const negTextHeight = halfHeight - headerHeight;
const buttonY = this.last_y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (node.size[0] - margin * 2) / 2;
// Check which section for scrolling
const inPosSection = pos[1] > posTextY && pos[1] < posTextY + posTextHeight;
const inNegSection = pos[1] > negTextY && pos[1] < negTextY + negTextHeight;
// Handle scrolling
if (event.type === "wheel") {
if (inPosSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.posScrollOffset = (this.posScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.positive.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.posScrollOffset = Math.max(0, Math.min(this.posScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
} else if (inNegSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.negScrollOffset = (this.negScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.negative.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.negScrollOffset = Math.max(0, Math.min(this.negScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
}
// Check button hovers
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
const oldPosHover = this.posCopyHovered;
const oldNegHover = this.negCopyHovered;
this.posCopyHovered = pos[0] > posButtonX && pos[0] < posButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
this.negCopyHovered = pos[0] > negButtonX && pos[0] < negButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldPosHover !== this.posCopyHovered || oldNegHover !== this.negCopyHovered) {
node.setDirtyCanvas(true);
}
// Handle button clicks
if (event.type === "pointerdown") {
if (this.posCopyHovered) {
this.copyToClipboard(this.parsedContent.positive, 'positive');
return true;
} else if (this.negCopyHovered) {
this.copyToClipboard(this.parsedContent.negative, 'negative');
return true;
}
}
} else {
// Regular text handling
const textAreaHeight = this.size[1] - buttonHeight - padding;
const buttonX = node.size[0] - margin - buttonWidth - padding;
const buttonY = this.last_y + textAreaHeight + padding / 2;
// Handle scrolling
if (event.type === "wheel" && pos[1] < this.last_y + textAreaHeight) {
const delta = event.deltaY > 0 ? 1 : -1;
this.scrollOffset = (this.scrollOffset || 0) + delta;
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.content.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
// Check button hover
const oldHover = this.copyButtonHovered;
this.copyButtonHovered = pos[0] > buttonX && pos[0] < buttonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldHover !== this.copyButtonHovered) {
node.setDirtyCanvas(true);
}
// Handle button click
if (event.type === "pointerdown" && this.copyButtonHovered) {
this.copyToClipboard(this.parsedContent.content, 'regular');
return true;
}
}
return false;
},
copyToClipboard: function(text, type) {
const node = this._node;
navigator.clipboard.writeText(text).then(() => {
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback copy method
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
}, 1500);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
});
},
computeSize: function(width) {
return [width, this.size[1]];
}
};
// Store reference to node for callbacks
widget._node = this;
// Store the last y position for mouse detection
const originalDraw = widget.draw;
widget.draw = function(ctx, node, widget_width, y, H) {
this.last_y = y;
return originalDraw.call(this, ctx, node, widget_width, y, H);
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size to accommodate the widget
this.computeSize();
};
// Handle node resizing
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
onResize?.apply(this, arguments);
// Update widget size when node is resized
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
if (textWidget) {
textWidget.size[0] = size[0] - 20;
textWidget.size[1] = size[1] - 60;
this.setDirtyCanvas(true);
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 350);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
this.updateTextDisplay("Text will appear here after execution...");
};
}
}
});
+28
View File
@@ -26,6 +26,12 @@ app.registerExtension({
// 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
@@ -94,6 +100,28 @@ app.registerExtension({
// 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() {
+18
View File
@@ -0,0 +1,18 @@
/* XYZ Plot Controller Widget Styles */
.xyz-plot-controller-widget {
display: flex;
align-items: center;
gap: 5px;
}
.xyz-plot-controller-toggle {
width: 16px;
height: 16px;
cursor: pointer;
}
/* Ensure combo widgets don't overflow */
.comfy-multiline-input {
font-family: monospace;
resize: vertical;
}
File diff suppressed because it is too large Load Diff
+381
View File
@@ -0,0 +1,381 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.XYZPrompt",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "XYZPrompt") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
// Track prompt count for each node
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
// Initialize prompt tracking
this.promptCount = 0;
this.promptWidgets = [];
// Store reference to include_negative and repeat_negative widgets
this.includeNegativeWidget = this.widgets.find(w => w.name === "include_negative");
this.repeatNegativeWidget = this.widgets.find(w => w.name === "repeat_negative");
// Add callback to include_negative widget
if (this.includeNegativeWidget) {
const originalCallback = this.includeNegativeWidget.callback;
this.includeNegativeWidget.callback = (value) => {
if (originalCallback) originalCallback.call(this, value);
this.updatePromptWidgets();
};
}
// Add callback to repeat_negative widget
if (this.repeatNegativeWidget) {
const originalCallback = this.repeatNegativeWidget.callback;
this.repeatNegativeWidget.callback = (value) => {
if (originalCallback) originalCallback.call(this, value);
this.updatePromptWidgets();
};
}
// Add the "Add Prompt" button
this.addPromptButton = this.addWidget(
"button",
"➕ Add Prompt",
null,
() => {
this.addPromptSet();
}
);
// Add initial prompt set
this.addPromptSet();
// Update node title
this.updateNodeTitle();
// Initial sizing
setTimeout(() => {
const size = this.computeSize();
this.size[1] = size[1] + 60;
this.setDirtyCanvas(true, true);
}, 50);
return result;
};
// Add method to add a new prompt set
nodeType.prototype.addPromptSet = function() {
const index = this.promptCount;
const includeNegative = this.includeNegativeWidget?.value ?? true;
const repeatNegative = this.repeatNegativeWidget?.value ?? true;
// Determine if we should show negative prompt for this index
const showNegative = includeNegative && (!repeatNegative || index === 0);
// Create positive prompt widget
const positiveWidgetObj = ComfyWidgets.STRING(
this,
`positive_${index}`,
["STRING", {
default: "",
multiline: true,
dynamicPrompts: false
}],
app
);
const positiveWidget = positiveWidgetObj.widget;
// Debug log
console.log(`Created positive_${index} widget:`, positiveWidget);
// Style the positive prompt
if (positiveWidget.inputEl) {
positiveWidget.inputEl.placeholder = `Positive Prompt ${index + 1}`;
positiveWidget.inputEl.style.minHeight = "70px";
positiveWidget.inputEl.style.fontFamily = "monospace";
positiveWidget.inputEl.style.backgroundColor = "#1a3d1a"; // Slight green tint
positiveWidget.inputEl.style.marginBottom = "5px"; // Add spacing below
}
// Add callback to update count
const posOriginalCallback = positiveWidget.callback;
positiveWidget.callback = (value) => {
if (posOriginalCallback) posOriginalCallback.call(this, value);
this.updateNodeTitle();
};
let negativeWidget = null;
if (showNegative) {
// Create negative prompt widget only for first prompt or when not repeating
negativeWidget = ComfyWidgets.STRING(
this,
`negative_${index}`,
["STRING", {
default: "",
multiline: true,
dynamicPrompts: false
}],
app
).widget;
// Style the negative prompt
if (negativeWidget.inputEl) {
negativeWidget.inputEl.placeholder = `Negative Prompt ${index + 1}`;
negativeWidget.inputEl.style.minHeight = "70px";
negativeWidget.inputEl.style.fontFamily = "monospace";
negativeWidget.inputEl.style.backgroundColor = "#3d1a1a"; // Slight red tint
}
// Add callback to update count
const negOriginalCallback = negativeWidget.callback;
negativeWidget.callback = (value) => {
if (negOriginalCallback) negOriginalCallback.call(this, value);
this.updateNodeTitle();
};
}
// Create a container widget for the remove button with protected space
const buttonContainerWidget = {
type: "custom",
name: `button_container_${index}`,
size: [0, 40], // Fixed 40px height for button area
draw: function(ctx, node, width, y, H) {
// Draw a subtle separator line
ctx.strokeStyle = "#444";
ctx.beginPath();
ctx.moveTo(15, y + 5);
ctx.lineTo(width - 15, y + 5);
ctx.stroke();
// Optional: Draw a semi-transparent background for the button area
ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
ctx.fillRect(0, y + 10, width, 30);
},
computeSize: function() {
return [0, 40]; // Fixed height to protect button space
}
};
this.widgets.push(buttonContainerWidget);
// Add remove button for this prompt set
const removeButton = this.addWidget(
"button",
`🗑️ Remove Prompt ${index + 1}`,
null,
() => {
this.removePromptSet(index);
}
);
// Store widgets for this prompt set
this.promptWidgets.push({
index: index,
positive: positiveWidget,
negative: negativeWidget,
removeButton: removeButton,
buttonContainer: buttonContainerWidget
});
this.promptCount++;
// Move the "Add Prompt" button to the bottom
const buttonIndex = this.widgets.indexOf(this.addPromptButton);
if (buttonIndex > -1) {
this.widgets.splice(buttonIndex, 1);
this.widgets.push(this.addPromptButton);
}
// Resize node - force proper recalculation
this.setDirtyCanvas(true, true);
setTimeout(() => {
const size = this.computeSize();
this.size[1] = Math.max(size[1] + 60, this.size[1]); // Ensure enough padding
this.setDirtyCanvas(true, true);
}, 10);
this.updateNodeTitle();
};
// Remove a prompt set
nodeType.prototype.removePromptSet = function(index) {
const promptSet = this.promptWidgets.find(p => p.index === index);
if (!promptSet) return;
// Remove widgets (including button container)
const widgets = [promptSet.positive, promptSet.negative, promptSet.buttonContainer, promptSet.removeButton].filter(w => w);
for (const widget of widgets) {
const widgetIndex = this.widgets.indexOf(widget);
if (widgetIndex > -1) {
// Clean up DOM element if it exists
if (widget.inputEl && widget.inputEl.parentNode) {
widget.inputEl.parentNode.removeChild(widget.inputEl);
}
// Call onRemoved if exists
if (widget.onRemoved) {
widget.onRemoved();
}
this.widgets.splice(widgetIndex, 1);
}
}
// Remove from tracking
const setIndex = this.promptWidgets.indexOf(promptSet);
if (setIndex > -1) {
this.promptWidgets.splice(setIndex, 1);
}
// Resize node
this.setDirtyCanvas(true, true);
const size = this.computeSize();
this.size[1] = size[1];
this.updateNodeTitle();
};
// Update prompt widgets when include_negative or repeat_negative changes
nodeType.prototype.updatePromptWidgets = function() {
const includeNegative = this.includeNegativeWidget?.value ?? true;
const repeatNegative = this.repeatNegativeWidget?.value ?? true;
for (const promptSet of this.promptWidgets) {
const index = promptSet.index;
const shouldHaveNegative = includeNegative && (!repeatNegative || index === 0);
if (shouldHaveNegative && !promptSet.negative) {
// Add negative widget
const index = promptSet.index;
const negativeWidget = ComfyWidgets.STRING(
this,
`negative_${index}`,
["STRING", {
default: "",
multiline: true,
dynamicPrompts: false
}],
app
).widget;
if (negativeWidget.inputEl) {
negativeWidget.inputEl.placeholder = `Negative Prompt ${index + 1}`;
negativeWidget.inputEl.style.minHeight = "70px";
negativeWidget.inputEl.style.fontFamily = "monospace";
negativeWidget.inputEl.style.backgroundColor = "#3d1a1a";
}
const negOriginalCallback = negativeWidget.callback;
negativeWidget.callback = (value) => {
if (negOriginalCallback) negOriginalCallback.call(this, value);
this.updateNodeTitle();
};
promptSet.negative = negativeWidget;
// Move it after the positive widget
const posIndex = this.widgets.indexOf(promptSet.positive);
if (posIndex > -1) {
const widgetIndex = this.widgets.indexOf(negativeWidget);
if (widgetIndex > -1) {
this.widgets.splice(widgetIndex, 1);
this.widgets.splice(posIndex + 1, 0, negativeWidget);
}
}
} else if (!shouldHaveNegative && promptSet.negative) {
// Remove negative widget
const widget = promptSet.negative;
const widgetIndex = this.widgets.indexOf(widget);
if (widgetIndex > -1) {
if (widget.inputEl && widget.inputEl.parentNode) {
widget.inputEl.parentNode.removeChild(widget.inputEl);
}
if (widget.onRemoved) {
widget.onRemoved();
}
this.widgets.splice(widgetIndex, 1);
}
promptSet.negative = null;
}
}
// Resize node - force proper recalculation
this.setDirtyCanvas(true, true);
setTimeout(() => {
const size = this.computeSize();
this.size[1] = Math.max(size[1] + 60, this.size[1]); // Ensure enough padding
this.setDirtyCanvas(true, true);
}, 10);
};
// Update node title with prompt count
nodeType.prototype.updateNodeTitle = function() {
// Count non-empty prompts
let count = 0;
for (const promptSet of this.promptWidgets) {
if (promptSet.positive && promptSet.positive.value && promptSet.positive.value.trim()) {
count++;
}
}
this.title = `XYZ Prompt (${count} prompts)`;
};
// Override serialize to save prompt data
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(info) {
if (onSerialize) onSerialize.call(this, info);
// Debug: Log widget values during serialization
console.log("XYZ Prompt serialize - widgets:", this.widgets.map(w => ({
name: w.name,
value: w.value,
type: w.type
})));
// Save prompt widget data
info.promptData = {
count: this.promptCount,
widgets: this.promptWidgets.map(p => ({
index: p.index,
hasNegative: !!p.negative
}))
};
};
// Override configure to restore prompt data
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
// Clear existing dynamic widgets first
if (this.promptWidgets) {
for (const promptSet of this.promptWidgets) {
const widgets = [promptSet.positive, promptSet.negative, promptSet.removeButton].filter(w => w);
for (const widget of widgets) {
const widgetIndex = this.widgets.indexOf(widget);
if (widgetIndex > -1) {
this.widgets.splice(widgetIndex, 1);
}
}
}
}
this.promptWidgets = [];
this.promptCount = 0;
if (onConfigure) onConfigure.call(this, info);
// Restore prompt widgets
if (info.promptData) {
// Remove the default prompt set if it was added
if (this.promptWidgets.length === 1 && info.promptData.count > 0) {
this.removePromptSet(0);
}
// Restore saved prompt sets
for (let i = 0; i < info.promptData.count; i++) {
this.addPromptSet();
}
}
this.updateNodeTitle();
};
}
}
});
+246
View File
@@ -0,0 +1,246 @@
Project Plan: ComfyUI XYZ Grid Comparison Nodes
Objectives and Requirements
We aim to develop a suite of XYZ Plot custom nodes for ComfyUI that enable easy grid comparisons across multiple parameters with minimal user effort. The goal is to support varying any combination of Models (checkpoints), LoRAs, Samplers, Schedulers, CFG scales, Steps, Clip skip, VAEs, and even new model-specific parameters like Flux guidance, all in a structured X/Y (and optional Z) grid format. Key requirements include:
Simple User Experience: Users should not need to write complex syntax or scripts to set up a grid. All configuration should be via clear UI fields (dropdowns, lists, etc.), unlike TinyTerra’s advanced XYPlot which requires manual text syntax for axes
runcomfy.com
.
Polished UI: Nodes should have an intuitive interface with logical organization. The system will automatically handle labeling of rows/columns with parameter values, adjustable font size, prefixes, etc., to produce presentation-ready grids
github.com
. No manual image combining or external tools should be needed.
Flexibility: Support any sampler or model loader node in ComfyUI – users should not be forced to use a custom KSampler as in the Efficiency node pack (which, while powerful, requires using its custom loader & sampler nodes
reddit.com
). Our solution will integrate with ComfyUI’s standard nodes so any sampler algorithm or model can be used.
Comprehensive Parameter Support: Allow plotting virtually “anything vs anything.” This means an X-axis and Y-axis (and optionally Z for a series of grids) can represent a range of values for any parameter in the workflow – e.g. different sampler names, different checkpoint models, numeric values (like steps or CFG), different prompt texts or LoRAs, etc.
runcomfy.com
. The system should even allow advanced uses like varying ControlNet strength or performing prompt search-and-replace if possible
runcomfy.com
.
Single-Run Automation: The user should be able to hit “Generate” once and get a complete labeled grid (or set of grids). The nodes will handle queuing or looping internally. Unlike some current solutions that require manually queuing multiple runs and hitting reset (e.g. the QQ XY Grid requires clicking reset and adding prompt jobs equal to the grid size
github.com
), our design will automate the iteration over all combinations.
Analysis of Existing Solutions
To design the best XYZ plot nodes, we examined the strengths and weaknesses of existing community solutions:
TinyTerra’s Advanced XY(Z) Plot: This extension provides very powerful and flexible plotting (even Z-axis for multiple grids) and features like search/replace in prompts and appending values
runcomfy.com
. However, it relies on a text-based configuration syntax that users must type into a special node
runcomfy.com
. For example, one must write out axis blocks like <1:label>\n[node_ID:option='value'] etc., which is powerful but complex for users to author correctly. Its UI is not as straightforward for novices, despite offering auto-complete aids
runcomfy.com
. We aim to capture its flexibility (support for many parameter types and even Z-axis) but present it in a friendlier UI with form inputs instead of coding.
KenjiQQ’s QQ-Nodes (XY Grid Helper & Accumulator): This approach uses nodes to build the grid via lists and accumulation. The XY Grid Helper node takes lists of row and column values and will iterate through each combination, outputting the current row/col value for other nodes
github.com
. A separate Accumulator collects images and assembles the final grid, even showing a live preview as images come in
github.com
. It supports custom prefixes, font sizes, and grid gaps for annotation
github.com
. However, the user must perform some manual steps: for example, clicking reset and manually queuing the correct number of prompts to generate all combinations
github.com
. Additionally, because the output row/col values are untyped, one must use extra “Axis To X” converter nodes to plug them into e.g. a model loader or number input
github.com
. Pros: clear separation of concerns (list setup, conversion, accumulation) and customizable layout. Cons: Not entirely automated (requires manual queuing) and the multi-node setup (with converters) is a bit cumbersome UI-wise. We plan to improve upon this by automating the iteration loop and reducing the number of fiddly nodes needed.
Jags’ Efficiency Nodes (XY Plot Script): The Efficiency node pack includes a very convenient X/Y plot capability that is integrated into a modified KSampler. By connecting an XY Plot script node to the custom KSampler, the sampler will generate a grid of images internally
github.com
. This solution is seamless to use and fast – it caches models to avoid reload overhead and can vary many things (including checkpoint models, LoRAs, prompts, etc.) with one click
runcomfy.com
. It also supports special plot types like prompt substitution and ControlNet toggles
runcomfy.com
. Drawback: It forces the use of the “Efficient” Loader & KSampler nodes
reddit.com
, meaning a user must swap out their normal nodes and cannot easily use other samplers or future nodes. This tight coupling is “annoying” for some users
reddit.com
. Our plan draws inspiration from Efficiency’s one-click ease and feature depth, but will remain agnostic to specific sampler implementations. We won’t require a custom sampler – our nodes will coordinate with the standard ComfyUI nodes (ensuring broad compatibility).
ShockZ’s Comfy-Easy-Grids: This extension (a.k.a. ComfyRoll) focuses on simplifying grid generation with automation. The Create Image Grid node lets you specify grid dimensions (X and Y size) and then automatically queues the required number of prompts, outputting the current X and Y index for each iteration
runcomfy.com
. Paired with a Save Image Grid node (a modified saver), it will accumulate images until the grid is complete and then output a combined image, including optional row/column labels provided via inputs (e.g. String lists)
runcomfy.com
. This approach is very user-friendly – you don’t need to manually queue or reset; the node handles looping internally. It also provides utility nodes like FloatList, StringList, and LoRA List to easily supply sequences of values to iterate
runcomfy.com
runcomfy.com
. Pros: True one-click operation and straightforward list-based UI. Cons: Each axis’s values still need to be prepared as list nodes and wired in, and labeling requires manually creating label lists that correspond to the values. There’s no single consolidated UI to choose parameter types and values – it’s still somewhat low-level in that you construct the mechanism with multiple nodes. We intend to build on this by offering a single high-level interface for selecting parameters and values, while likely leveraging a similar under-the-hood approach of automated queuing and image accumulation.
In summary, existing solutions prove that it’s possible to plot “anything vs anything” in ComfyUI, but each has trade-offs. Our project will combine their best ideas: the flexibility of TinyTerra, the grid customization and labeling of QQ/Comfy-Easy-Grids, and the one-click convenience of Efficiency – all while removing the need for coding or rigid custom node dependencies.
Proposed Design and Features
1. Node Architecture
We will implement the XYZ plotting functionality as two main nodes for clarity and modularity (similar to the Create/Save separation in easy-grids):
XYZ Plot Controller Node – This is the primary configuration node where the user chooses what to vary on the X axis, Y axis, and optionally Z axis. It handles generating the sequence of parameter combinations and triggering the image generation for each. Internally, this node will automatically queue the required runs (or otherwise loop through combinations) so that the user only needs to execute once to get all images, much like Create Image Grid does
runcomfy.com
. This node will output:
Index/Value Outputs: For each axis (X, Y, Z) it provides an output that represents the current value for that axis in a given iteration. These outputs can be fed into other nodes in the workflow (e.g., into a checkpoint loader’s model field, into a sampler’s steps or CFG input, into a text concatenation node for prompts, etc.). We will make these outputs type-aware to avoid extra conversion nodes – e.g., X output might be a string type if X is “model name”, or a float if X is a numeric parameter. (If needed, we can internally include conversion logic or expose outputs in multiple types.)
Loop Control/Trigger: It may also output a control signal (like a boolean or trigger) indicating when a batch/grid is complete. This can feed into the second node to signal when to finalize the grid image.
Grid Combiner (Image Grid Output) Node – This node will collect the images produced from each combination and assemble the final grid image (or images). It functions similarly to QQ’s accumulator or Easy-Grids’ save node, holding onto incoming images until the set is complete
github.com
. Once all images for one grid are ready, it will output:
A composite grid image arranged according to X and Y dimensions, with annotated labels.
It will support multiple pages if a Z-axis is used (for example, if Z has 3 values, it could produce 3 grid images – one per Z – or perhaps one big tiled 3D grid, but likely separate images makes more sense). We will likely implement Z as generating multiple grid outputs (perhaps as a list of images or sequential outputs the user can save individually).
Additionally, it could provide a UI preview output for convenience, showing progress as images come in (like QQ’s accumulator does in preview mode
github.com
).
This two-node setup keeps things organized: the Controller focuses on parameter logic, and the Combiner on image assembly and labeling. For basic use, the user would place these two nodes and connect them: the Controller’s image output (from the sampler) goes into the Combiner’s image input, and the Combiner outputs the final grid. We will make sure they sync via the unique batch ID or trigger so that each grid resets properly and doesn’t mix images from different runs (similar to QQ’s unique_id usage to separate batches
runcomfy.com
runcomfy.com
).
2. Parameter Selection UI
The XYZ Plot Controller node’s UI will be the heart of user interaction. We will design it with dynamic, easy-to-use widgets:
Axis Parameter Dropdowns: The node will have sections for X Axis, Y Axis, and Z Axis. For each axis, the user can choose the parameter type from a dropdown menu. For example:
None (if they don’t want to use the Z axis at all, they can disable it),
Model (Stable Diffusion checkpoint model),
Sampler (diffusion sampler algorithm),
Scheduler (if applicable, e.g. scheduler type or noise schedule – we will clarify if this is needed or if “sampler” covers it),
CFG Scale (classifier guidance scale),
Steps (number of diffusion steps),
Clip Skip (text encoder skip levels),
VAE (decoder to use),
LoRA (Low-Rank Adaptation model to apply),
Prompt Text (to vary text or prompt components),
Flux Guidance (if using a Flux model, vary its guidance strength or related parameter),
Seed (to test different random seeds),
etc. – Essentially any parameter that a ComfyUI workflow might want to sweep. We can start with the most common ones and allow extending in future.
Value Input Fields: Depending on the parameter chosen, the UI will present an appropriate input mechanism for the list of values:
For categorical choices (Model, Sampler, VAE, LoRA), we will provide either a multi-select checklist or a list builder. Ideally, we can query the available options dynamically:
Model: Provide a dropdown or list of available checkpoint files in the models folder, so the user can select multiple models to compare. (We will use ComfyUI’s existing model loader API or search the directory for model filenames).
Sampler: A list of sampler names (Euler, Euler a, LMS, DPM++ etc.) – we can fetch these from ComfyUI’s KSampler options or maintain a predefined list if needed.
LoRA: List of available LoRA files (perhaps via the LoRA loader’s listing or a folder scan).
VAE: Similarly, list VAE filenames to choose from.
If multiple selections are allowed, we will allow adding items to the list easily (maybe checkboxes or a plus-button to add another slot).
For numeric ranges (CFG scale, Steps, Clip skip, etc.), provide an input that can accept comma-separated values or a range generator:
We might allow a syntax like start:stop:step to quickly specify a range (and parse it into list of values), or simply ask for a list of numbers (with a helper to fill ranges).
Alternatively, include spin boxes to add values one by one. We can also integrate with the concept of Easy-Grids’ Float List/Int List: for example, let the user enter something like “0, 0.5, 1.0” for flux guidance, or “10,20,30” for steps.
For text prompts variation, we have a couple options:
Simplest: treat the entire prompt as a string and allow the user to input multiple prompt variants (perhaps separated by a special delimiter or via a multiline field where each line is a different prompt). The axis will then swap the entire prompt for each value.
More advanced: allow specifying a search-and-replace or a placeholder in the prompt. E.g., user writes the base prompt in their Text node like “A portrait of [styles] woman” and in the axis values they supply different strings for [styles] (like “a cyberpunk”, “a victorian”, etc.). TinyTerra’s %search;replace% functionality
runcomfy.com
and Efficiency’s Prompt S/R hint at this idea. This might be a stretch goal; initially, we can require full prompt variants or manual setup.
We will ensure that if prompt text is varied, it can feed into the Positive (or Negative) prompt input of the model pipeline easily (likely through a Text concatenation or by directly connecting a Text node input).
For boolean or toggle parameters (if any, like using a feature on/off), we can allow values like True/False in the list.
Axis Labels: By default, the system will generate labels for each value to display on the grid. The user can control this:
There will be a text field for “X Axis Label Prefix” and “Y Axis Label Prefix” (and Z if needed) – if the user wants to prefix the labels (e.g. “CFG=” so the labels read “CFG=7, CFG=10, …”). By default, we might use the parameter name as prefix (e.g. “Model: ” or “Sampler: ”) unless turned off.
We will automatically use either the value itself or a short description as the label. For instance, for model filenames we might strip the extension. For prompts, possibly use a truncated first few words if too long.
We will also include options for label formatting similar to TinyTerra’s tv_label/itv_label concept
github.com
(though we’ll hide complexity from user): essentially provide a toggle between showing just the value vs. showing “parameter: value”. Users who want very compact labels can choose value-only, while others might prefer seeing the parameter name included. By default, we’ll include the name or prefix so grids are self-explanatory.
Font size and max label length can be set in the Combiner node (like QQ’s label_length and font_size
github.com
). We’ll expose those settings in the Combiner UI or let it inherit sensible defaults.
Axis Combination Limits: The node will display the computed total number of images = (#X values × #Y values × #Z values). This gives the user feedback on how many images will be generated (important for performance awareness). If this number is extremely large, we might warn the user or require confirmation to avoid accidental huge runs.
Dynamic Field Enable/Disable: If the user selects “None” for Z axis, the UI for Z values will hide or disable. Similarly, if they only want an X or a 1D plot, we could allow Y to be “None” and then it just generates a 1-row grid. We will make sure the UI updates logically based on selections, using ComfyUI’s capabilities for dynamic widgets (TinyTerra does something similar with dynamic widget showing/hiding
runcomfy.com
).
3. Execution Flow (One-Click Generation)
The behind-the-scenes execution will work as follows:
When the user hits Generate, the Controller node will take the lists of X, Y, Z values and iterate over all combinations. For each combination, it will set the axis outputs to the current values and emit a forward execution. This will propagate through the rest of the graph (where those outputs are connected) and ultimately produce an image from the KSampler (or decoder).
We plan to implement this by leveraging ComfyUI’s queue system or looping mechanism. There are a few possibilities:
Use a technique similar to comfy-easy-grids: internally queue multiple jobs. The Create Image Grid node likely uses api.queuePrompt() calls or similar to push multiple executions. We can replicate that: the first execution of the Controller will schedule the next N-1 executions automatically with the different param values. All images will then be generated in sequence.
Alternatively, implement an internal loop in the node’s execute method (since custom nodes can produce multiple outputs perhaps) – but ComfyUI typically expects one output per execution, so queuing separate executions is safer.
We will assign each grid run a unique ID (much like QQ’s unique_id control
runcomfy.com
) that gets passed to the Combiner, so it knows which images belong together. This prevents mixing images from separate plots if two are run concurrently.
Concurrency & Caching: We will likely generate images sequentially (one combination at a time) to reuse the same pipeline and avoid memory spikes. However, to improve efficiency when switching heavy models (checkpoints, VAEs, etc.), we plan to cache models and other resources. For example, if X axis is models A, B, C, we could load each model once at the start (or keep loaded when switching) to avoid repeatedly loading from disk. Efficiency nodes explicitly had a cache_models option for this
runcomfy.com
; we can implement a lightweight caching strategy: perhaps load each required model in memory, or use ComfyUI’s existing model caching (if any). At minimum, ensure that when we switch model, we do it in a way that ComfyUI’s model loader doesn’t fully unload/reload if not necessary.
The Combiner node will receive each image as it’s produced. It will hold images in a buffer until the expected count is reached, then assemble the grid:
We’ll arrange images in row-major order (X axis varying horizontally, Y axis vertically). The number of columns = number of X values (by default, unless we implement a wrap via a “max columns” option similar to QQ’s max_columns
runcomfy.com
– we might allow the grid to wrap if too wide).
It will draw the labels on the top of each column and/or side of each row. We can use a simple Python imaging library (PIL/Pillow) to overlay text, or possibly leverage the images-grid-comfy-plugin that QQ-nodes uses behind the scenes
github.com
. We may consider using that plugin to avoid reinventing the wheel for combining images and drawing text. (The QQ readme notes that their node injects the LEv145 images-grid plugin for grid assembly
github.com
– perhaps we can directly call that code or include it.)
Font style can be basic (white text with black outline for visibility, for example).
Z-axis output: If Z is used (say Z has 2 values meaning two separate grids for e.g. two different prompts), we have options:
The Combiner could output a list of images (all grids) once done. Or,
It could output one image at a time and require the user to connect a Save node that handles it. But that’s less convenient.
Another approach: output a single large image where grids are concatenated vertically or horizontally with a separator and a label for Z. But that might be unwieldy for many Z values.
Probably simplest: output as a list or just sequentially output multiple images (ComfyUI might not handle multiple outputs from one execution easily, so list is better).
We will likely output a List of images if Z axis is used, along with maybe a text list of Z labels. The user can then handle it (perhaps with a custom save that can save all in a folder). We’ll detail this behavior in documentation so it’s clear.
Using Standard Nodes: Our design ensures the actual image generation still uses standard ComfyUI nodes:
The user’s workflow will include a Checkpoint Loader, Sampler, etc., which produce the image. We simply feed different values into them each iteration. For example, if varying Model, the CheckpointLoader’s ckpt_name field will be driven by our Controller’s X output (string). The user would expose ckpt_name as an input on the loader node and connect the X output to it. Our node will set it to each model filename in turn for each run. Similarly, for numeric parameters like Steps or CFG, we connect to the sampler’s inputs (these are already exposable in ComfyUI). This way, we use “any sampler” – our node doesn’t re-implement sampling, it just feeds values to the existing KSampler. This satisfies the requirement of not being locked into a custom sampler node.
For LoRAs, one approach is to have the LoRA Loader’s “model path” input connected to our output, cycling through LoRA filenames. Alternatively, we could vary prompt text by injecting <lora:name:weight> tokens (some users do LoRA via prompt text). But a cleaner method is connecting to a LoRA apply node. We will investigate how ComfyUI applies LoRAs by node and support that.
For prompts, the user can use a Text node for their prompt and we can connect our output to it (maybe via a Text Concatenator if inserting words). If doing entire prompt variations, it might be easiest to have multiple prompts in a String List node and connect our axis output to the Text node’s text input.
Guidance for Flux: If the parameter is something specific like “Flux guidance strength” (assuming Flux is a model that has a unique guidance parameter separate from CFG), we will allow that to be varied by, say, connecting to a node or API that controls flux guidance. This might require the user to expose that parameter on a Flux-specific node. Since Flux is new, we’ll ensure our design is generic enough to plug into any float or int parameter exposed in the workflow.
4. Labeling and Grid Customization
Producing a nicely annotated grid is a major part of the user experience. Features for this include:
Automatic Label Generation: As mentioned, we’ll derive labels from the values. For each axis value, we generate a label string. The Combiner node will have inputs for row_labels and col_labels (and possibly an overall title or Z labels). By default, our Controller can feed these in automatically:
The Controller can output a list of X labels and list of Y labels once the values are set (similar to how Easy-Grids expects String List inputs for labels
runcomfy.com
, but in our case we can supply them directly).
If needed, we can also allow the user to override these label lists via optional inputs – e.g. if they want abbreviated or custom names not exactly matching the values. If an override is not provided, we use the auto labels.
Styling Options: In the Combiner UI, provide settings for:
Font size (with a reasonable default, e.g. 20px).
Grid cell gap (pixel spacing between images, default perhaps 5-10px)
github.com
.
Maybe font color or style, though likely white text with black outline is universally readable so we might fix that.
Max columns before wrap (if user wants to break a very large grid into multiple rows – but since Y axis already provides rows, “max columns” would effectively limit X length and overflow to additional grid images; this is an advanced feature, we can defer unless needed).
Background color for the label area if we want a band behind the labels (or we can just overlay on images if they have space at edges).
Page Size / Z usage: If Z-axis is large, we might allow showing one “page” (grid) at a time. We can implement a page slider in the UI for preview (this is more advanced and might not be necessary if we output all images at once).
Interactivity vs Static Output: Initially, the output will be static images. A possible enhancement (future) is an interactive HTML output where you can toggle through Z values or view a high-resolution grid in the UI. However, that’s beyond core requirements – we will focus on static image grids that can be saved.
5. Example Use Cases
To illustrate how the new nodes will work (and to guide development):
Example 1: Model vs CFG plot. User wants to compare two models (A and B) at different CFG scales (5, 10, 15) using the same prompt.
They place the Controller node and set X Axis = Model, select A and B; Y Axis = CFG Scale, enter [5, 10, 15]; Z Axis = None.
They connect the Controller’s X output to the Checkpoint Loader’s model name, and the Y output to the KSampler’s CFG input.
They connect KSampler’s image output to the Combiner node.
The Combiner automatically gets X labels (“Model A”, “Model B”) and Y labels (“CFG5”, “CFG10”, “CFG15” or with prefix “CFG=”).
Upon running, it generates 2×3 = 6 images, then Combiner outputs a single 2-column by 3-row grid image with labels on top of each column (Model names) and at the left of each row (CFG values). The user can then preview or save this grid.
What happens behind the scenes: The node will queue 6 runs. It may load model A for first run, then model B for second run, etc., switching back and forth – we might optimize to do AAA… then BBB… to avoid thrashing, but since we need a grid with A and B as columns, we might generate in row-major order (all X for Y1, then Y2). We will consider caching both models in memory to reduce load time.
Example 2: Sampler vs Prompt Variation. User wants to see how different samplers render two different descriptions.
X = Sampler, choose Euler vs DPM++ 2M Karras; Y = Prompt, enter “a sunny landscape” and “a rainy landscape” as two variants (or use prompt placeholder approach).
The user connects X output to the Sampler node’s sampler-name input, and Y output to the positive Prompt text (maybe via a Text node that takes this string).
Grid will be 2×2: columns = {Euler, DPM2M}, rows = {sunny, rainy}. Labels reflect sampler names and an abbreviated prompt label (or user manually sets row labels to “Sunny” / “Rainy” for brevity).
All 4 images are generated and combined.
Example 3: Three-axis plot. User tests two LoRAs at different strengths across three seeds.
X = LoRA, values = {No LoRA, Style1, Style2} (with possibly weight 1.0 for style LoRAs embedded in selection or separate weight axis if needed); Y = Seed, values = {100, 200, 300}; Z = LoRA Strength, values = {0.5, 1.0} (this means two grids, one at each strength level).
This will produce 2 (Z) _ 3 (X) _ 3 (Y) = 18 images, output as 2 separate 3x3 grids. The Combiner could output a list [Grid_strength0.5, Grid_strength1.0].
Each grid’s column labels are LoRA names (with “None” or “No LoRA” for the baseline), row labels are seeds, and each grid has a title or annotation indicating the strength (0.5 or 1.0).
The user can then examine the effect of LoRA and strength on different seeds.
These use cases ensure our design covers multiple parameter types simultaneously, a key advantage. The system should be robust to any mix (within reason – some combinations might not make sense, but we won’t restrict it programmatically).
6. Performance Considerations
Generating large grids (dozens of images) can be slow or memory-heavy. We will incorporate some features to mitigate issues:
Progress Feedback: The Combiner will update a preview as images arrive (like QQ’s accumulator preview output
github.com
). The node UI can also show a counter of how many images have been generated out of total. This way the user knows it’s working and can estimate time remaining.
Cancellation: If the user stops the queue, our nodes should handle it gracefully (partially filled grid will reset on next run as needed). The unique ID mechanism will help ensure a canceled run doesn’t erroneously carry over images to the next.
Memory: For grids with many images, memory might become an issue. We might implement an option akin to QQ’s page_size
github.com
to split a very large grid into smaller chunks (e.g., if generating 100 images, do 25 at a time and output 4 grids). However, by default we assume moderate grid sizes. This is an advanced option for users who truly need it.
Threading: ComfyUI typically runs GPU tasks sequentially unless using batches. We will ensure our queuing doesn’t try to run multiple combos in parallel unintentionally (unless the user specifically wanted to use batch size to generate multiple images simultaneously – that’s a different scenario, possibly outside scope of X/Y plot which usually changes parameters per image, not suitable for same-batch generation).
Model Switching Overhead: As noted, caching models/VAEs will be important if those are varied. We can load all needed models at the start of execution (perhaps by pre-loading them via the Loader node or an API call) to avoid repetitive disk I/O. After the grid, unload any that aren’t needed to free VRAM. This approach will draw from Efficiency’s model caching idea
runcomfy.com
.
Implementation Plan
We will implement the project in stages: Phase 1: Basic X/Y Plot Node (happy path with one grid).
Develop the Controller node with support for two axes (X,Y) initially. Focus on a few core parameter types (models, samplers, CFG, steps) to get the mechanism working.
Implement the queuing of jobs for combinations. Test that we can programmatically queue multiple executions via the ComfyUI API. Ensure the outputs (like different model names) properly feed into the model loader on each run.
Develop a simple Combiner that accumulates images and once all are received, outputs a grid image (we can use PIL to stitch images in a grid layout for now). Add basic label drawing.
Test with simple scenarios (vary model vs CFG, etc.) and verify the grid image output and labels.
Phase 2: Expand to Z axis and more parameters.
Extend the Controller to handle an optional Z axis loop (probably by nesting another loop or running multiple XY cycles). Ensure multiple grid outputs can be handled (perhaps output a list of images, or sequential outputs; we’ll decide based on ComfyUI capabilities).
Add UI support for LoRA, VAE, prompts, seed, etc. – this may involve writing helper code to list files (for models, VAEs, LoRAs) and to parse user input lists for numbers or text.
Incorporate prompt variation logic (maybe a simple whole-prompt swap at first).
Add Clip skip support (this could be done by connecting to the Checkpoint loader’s clip skip input, if exposed).
Ensure that multiple parameter types can be varied at once (like one axis numeric, another axis categorical).
Phase 3: UI Refinement and Polishing.
Improve the node descriptions, tooltips, and default values for a polished feel. For instance, when the user selects a parameter in the dropdown, we can populate a placeholder or example in the values field to guide them (e.g. “enter comma-separated values”).
Implement dynamic showing/hiding of fields (using ComfyUI’s dynamic widget support) so the UI is not cluttered with irrelevant inputs
runcomfy.com
.
Add error handling and validation: e.g., if a user leaves a value list empty for a selected axis, show a warning; if the parameter type requires additional setup (like no Checkpoint Loader connected for a model axis), we can detect that and warn.
Integrate advanced label options: allow the user to specify a custom label list if they really want to override, or perhaps toggle between “Value” vs “Name: Value” label style.
Aesthetic polish: choose a pleasant default color for the nodes, maybe group them in a custom category in ComfyUI (“XYZ Nodes” category) for easy discovery.
Phase 4: Optimization and Edge Cases.
Test edge cases like: only 1 value in an axis (should essentially degenerate to a 1-row or 1-col grid), using the node in an already batched context (probably uncommon), extremely long prompt texts (ensure label doesn’t overflow – wrap text if over a certain length
github.com
).
If performance of assembling images with PIL is slow for high-res images, consider using the optimized library from images-grid plugin (since QQ-nodes mention injecting that for efficient tiling
github.com
).
Verify compatibility with SDXL workflows (SDXL has two model passes – base and refiner. Our design should allow varying things in an SDXL pipeline too. Likely it works similarly, but we may test an SDXL prompt vs. something grid).
Memory test with varying models: ensure model A and B switching does not cause CUDA OOM (maybe unload one before loading next if both can’t reside in VRAM simultaneously – caching on CPU if necessary).
User abort testing: if user stops midway, next run should be able to reset properly (we’ll implement a reset mechanism possibly triggered by a new execution ID).
Phase 5: Documentation and Examples.
Write a user guide with examples (similar to those described above) so users can quickly understand how to use the nodes. Emphasize that no scripting is needed – just connect and go.
Provide example workflows JSON (perhaps include a few typical ones, like “Model_vs_CFG_XY.json”) as part of the repo.
Throughout development, we’ll incorporate feedback from testers to ensure the UI is truly intuitive and that we didn’t miss any important use-case. The result will be a robust “XYZ Plot” feature for ComfyUI that dramatically simplifies experimentation.
Conclusion
By blending the best aspects of existing solutions and focusing on user-centric design, this project will deliver an XYZ grid comparison tool for ComfyUI that is both powerful and easy to use. Creators will be able to set up complex multi-parameter comparisons in just a few clicks – no coding, no manual stitching. The grids produced will be neatly labeled and customizable, suitable for analyzing results or sharing with others. This fills an important gap in ComfyUI’s toolkit, empowering users to explore models, prompts, and settings efficiently and with confidence in the interface. Ultimately, our custom nodes will make advanced AI art workflows more accessible, turning what was once a manual, error-prone process into a streamlined experience. By supporting everything from model and sampler swaps to subtle parameter tweaks, all in a polished UI, we help users focus on creativity and insight rather than technical hassle. This project plan lays out the path to achieve that goal, and with careful implementation, the “best XYZ plot nodes” for ComfyUI will soon become a reality.