Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2db82a80 | ||
|
|
1820fb19ea | ||
|
|
7edf08f1e2 | ||
|
|
1721778cb5 | ||
|
|
6bac864c4d | ||
|
|
8dc81364a0 | ||
|
|
957ca3c142 | ||
|
|
ad73422472 | ||
|
|
1a7e0dfcd7 | ||
|
|
0b85600d38 | ||
|
|
ca65cc7961 | ||
|
|
6adba639bb | ||
|
|
1e86b73168 | ||
|
|
0fa595a9b7 | ||
|
|
f8ce4cb982 | ||
|
|
42b6009f1b | ||
|
|
963ac0a4af | ||
|
|
b4bd8b98b1 | ||
|
|
ed6675721d | ||
|
|
b0c0d53676 | ||
|
|
b9103ad9c4 | ||
|
|
b5694ecfe5 | ||
|
|
1255c4e9cc | ||
|
|
26557a99b6 | ||
|
|
848674d474 |
@@ -23,6 +23,7 @@ CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
tools/
|
||||
.ci-comfyui/
|
||||
coverage/
|
||||
TESTING.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"py3.10 on ubuntu-latest", "py3.11 on ubuntu-latest", "py3.12 on ubuntu-latest", "py3.13 on ubuntu-latest",
|
||||
"py3.10 on windows-latest", "py3.11 on windows-latest", "py3.12 on windows-latest", "py3.13 on windows-latest",
|
||||
"py3.10 on macos-latest", "py3.11 on macos-latest", "py3.12 on macos-latest", "py3.13 on macos-latest",
|
||||
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "code-quality"
|
||||
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "real ComfyUI compatibility", "code-quality"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
|
||||
@@ -54,6 +54,62 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
comfyui:
|
||||
name: real ComfyUI compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/testing/package-lock.json
|
||||
- name: Read the reviewed runtime pins
|
||||
id: runtime
|
||||
run: |
|
||||
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
|
||||
import runtime from './tools/testing/comfyui-runtime.json' with { type: 'json' };
|
||||
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
|
||||
JS
|
||||
- name: Check out pinned ComfyUI
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.runtime.outputs.repository }}
|
||||
ref: ${{ steps.runtime.outputs.revision }}
|
||||
path: .ci-comfyui
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.pythonVersion }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements-dev.txt
|
||||
.ci-comfyui/requirements.txt
|
||||
tools/testing/comfyui-runtime.json
|
||||
- name: Install CPU runtime and test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
|
||||
- run: npm ci --prefix tools/testing --ignore-scripts
|
||||
- run: npx --prefix tools/testing playwright install --with-deps chromium
|
||||
- name: Classic and Nodes 2.0 startup and compatibility
|
||||
env:
|
||||
KIKO_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
|
||||
KIKO_TEST_PYTHON: python
|
||||
run: npm run --prefix tools/testing test:comfyui
|
||||
- name: Upload real-host reports and startup logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: real-comfyui-reports
|
||||
path: tools/testing/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -175,3 +175,6 @@ node_modules/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Disposable pinned host checkout used by CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Unified ComfyAssets UI
|
||||
|
||||
- Consistent blue controls and strong red destructive actions that follow
|
||||
ComfyUI's light/dark theme.
|
||||
- Updated Local Image Loader, Save Image viewer, Seed History, Workflow Timer,
|
||||
dimension swaps, Node Dimensions, Display Text/Any and Embedding Autocomplete.
|
||||
- Larger adjustable gallery thumbnails and saved-image previews that use the
|
||||
available space; responsive layouts, keyboard controls and lifecycle cleanup.
|
||||
- Shared Custom Colors picker with hex entry, HSV sliders and presets.
|
||||
- Restored autocomplete tag management, four-column CSV parsing, worker-based
|
||||
imports and persistent tag caching. Tag packs remain a separate download;
|
||||
see [tag-pack provenance and terms](docs/tag-packs/README.md).
|
||||
- Follow Execution honors live enablement, avoids duplicate subscriptions and
|
||||
safely groups navigation menus.
|
||||
|
||||
### Compatibility and testing
|
||||
|
||||
- Classic and Nodes 2.0 coverage includes fresh startup in each renderer,
|
||||
switching both ways, themes, saved workflows and removal cleanup.
|
||||
- Required CPU-only real-ComfyUI CI joins the existing Python, JavaScript,
|
||||
Chromium, touch, Firefox and WebKit suites. Runtime pins and failure artifacts
|
||||
make compatibility checks repeatable.
|
||||
- Retained [screenshots](docs/screenshots/README.md) and documented
|
||||
[testing commands](TESTING.md).
|
||||
|
||||
### Deprecation and upgrade
|
||||
|
||||
Gemini Prompt Engineer is deprecated and hidden from normal new-node discovery.
|
||||
Its registration, schema and execution remain available for saved workflows;
|
||||
ongoing Google API/model compatibility maintenance has ended.
|
||||
|
||||
Restart ComfyUI and hard-refresh the browser after upgrading. Existing node
|
||||
interfaces, workflow values and setting IDs are retained. No workflow conversion
|
||||
is required. Follow Execution remains opt-in through its Enable setting.
|
||||
@@ -1,7 +1,7 @@
|
||||
# ComfyUI-KikoTools
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -17,6 +17,20 @@ I tried forking, patching, and submitting merge requests, but eventually decided
|
||||
|
||||
I’m sharing them here with the community, and I hope you find them as useful as I do.
|
||||
|
||||
## 1.2.0 — unified ComfyAssets UI
|
||||
|
||||
A consistent interface across the KikoTools controls, with ComfyUI-following
|
||||
light/dark styling and Classic/Nodes 2.0 support. Existing workflow interfaces
|
||||
and settings are retained. See the [release notes](CHANGELOG.md),
|
||||
[migration overview](docs/ui-migration.md) and [screenshots](docs/screenshots/).
|
||||
|
||||
## ComfyUI Classic and Nodes 2.0
|
||||
|
||||
KikoTools supports both ComfyUI Classic and Nodes 2.0. Our shared ComfyAssets
|
||||
controls follow ComfyUI's dark/light theme, with automated tests covering renderer
|
||||
switching, workflow persistence and cleanup. See the retained
|
||||
[UI screenshots](docs/screenshots/) for examples from the migrations.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### ✨ Current Tools
|
||||
@@ -30,7 +44,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | Deprecated; retained for existing workflows | 🧠 Prompts |
|
||||
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
|
||||
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
|
||||
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
|
||||
@@ -176,28 +190,17 @@ Advanced text display node with intelligent formatting and enhanced user interac
|
||||

|
||||
|
||||
#### 🤖 Gemini Prompt Engineer
|
||||
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
|
||||
|
||||
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
|
||||
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
|
||||
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
|
||||
- **Custom System Prompts**: Override templates with your own analysis instructions
|
||||
- **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
|
||||
**Deprecated — retained for existing workflows only.** ComfyUI hides this node
|
||||
from normal new-node discovery using its deprecated-node metadata. The
|
||||
`GeminiPrompt` identifier, inputs, outputs and execution remain available so
|
||||
saved workflows can still load.
|
||||
|
||||
**Use Cases:**
|
||||
- Reverse-engineer prompts from reference images
|
||||
- Convert artistic descriptions between different AI model formats
|
||||
- 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
|
||||
|
||||

|
||||
Google API/model compatibility updates and UI migration have ended. Existing
|
||||
workflows still depend on Google's service and the optional `google-genai`
|
||||
package; deprecation does not guarantee future API compatibility. See the
|
||||
[legacy Gemini documentation](examples/documentation/gemini_prompt.md) for the
|
||||
existing configuration and behavior.
|
||||
|
||||
#### 🔍 Display Any
|
||||
Universal debugging node that displays any type of input value or tensor information.
|
||||
@@ -429,8 +432,6 @@ Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and
|
||||
- Build reproducible workflows with automatic model fetching
|
||||
- Quickly test new models from the community
|
||||
|
||||

|
||||
|
||||
#### ⏱️ Workflow Timer
|
||||
Real-time execution timer that displays workflow duration with millisecond precision.
|
||||
|
||||
@@ -451,6 +452,30 @@ Real-time execution timer that displays workflow duration with millisecond preci
|
||||
- **Workflow Timer: Color** - Custom color picker for timer display
|
||||
- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off
|
||||
|
||||
#### 🎨 Custom Colors
|
||||
|
||||
Right-click a node and choose **🫶 Custom Colors** to change its full color,
|
||||
title or background. The shared picker supports hex entry, HSV sliders and
|
||||
presets, and follows ComfyUI's dark/light theme. Changes apply immediately;
|
||||
**Done** or **Escape** closes the picker. Multiple selected nodes receive the
|
||||
same change. Right-click a group for its color option.
|
||||
|
||||
Existing Custom Colors settings control the available menu options and automatic
|
||||
title shading. Node and group colors are saved with the workflow.
|
||||
|
||||
Screenshots: [Dark](docs/screenshots/custom-colors/dark.png) ·
|
||||
[Light](docs/screenshots/custom-colors/light.png).
|
||||
|
||||
#### 🧭 Follow Execution
|
||||
|
||||
Enable **Follow Execution: Enable** in ComfyUI settings, then right-click the
|
||||
canvas to follow/stop execution, show the executing node, or navigate to a node
|
||||
by type. **Auto-start** begins following when a workflow starts. Disabling the
|
||||
feature stops following immediately; enabling it again waits for a manual start
|
||||
or the next workflow start when Auto-start is enabled. The same host menus work
|
||||
in Classic and Nodes 2.0. See the [Classic](docs/screenshots/follow-execution/classic.png)
|
||||
and [Nodes 2.0](docs/screenshots/follow-execution/nodes2.png) menu screenshots.
|
||||
|
||||
#### 🗂️ Project File Path
|
||||
Builds an organized save prefix to wire into a save node's `filename_prefix`.
|
||||
|
||||
@@ -477,8 +502,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
|
||||
|
||||
**Key Features:**
|
||||
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
|
||||
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
|
||||
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
|
||||
- **Custom Word Lists**: Import CSV/TXT files or browser-accessible URLs, including [versioned Danbooru tag packs](docs/tag-packs/README.md), with local offline caching
|
||||
- **Safe Rendering**: Tag data stays plain text, with validated CSV fields and file-size limits
|
||||
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
|
||||
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
|
||||
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
|
||||
@@ -492,9 +517,9 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
|
||||
- Load custom word lists from URLs with security validation
|
||||
|
||||
**Security Features:**
|
||||
- Validates all loaded content to prevent script injection
|
||||
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
|
||||
- Safe character whitelist for tags
|
||||
- Validates CSV structure, tag names, categories, counts, and file size
|
||||
- Renders tag data as plain text rather than executable HTML or JavaScript
|
||||
- Rejects control characters and HTML error documents
|
||||
- File size limits to prevent memory exhaustion
|
||||
- Clear error messages for rejected content
|
||||
|
||||
@@ -679,6 +704,9 @@ Gemini Prompt → Display Text → Copy to Clipboard
|
||||
**Smart Detection:** Automatically formats SDXL-style prompts
|
||||
|
||||
### Gemini Prompt Engineer Example
|
||||
|
||||
**Legacy example for the deprecated node; existing workflows only.**
|
||||
|
||||
```
|
||||
Load Image → Gemini Prompt → Display Text → Text Generation Model
|
||||
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
|
||||
@@ -773,11 +801,11 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
|
||||
| **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) |
|
||||
| **Gemini Prompt Engineer** | Legacy image analysis and prompt generation | Deprecated | [Docs](examples/documentation/gemini_prompt.md) |
|
||||
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
|
||||
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
|
||||
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
|
||||
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) |
|
||||
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](#-image-scale-down-by) |
|
||||
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](#-film-grain) |
|
||||
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
|
||||
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
|
||||
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
|
||||
@@ -785,8 +813,8 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
|
||||
| **Scheduler Select Helper** | Optimal scheduler selection for samplers | ✅ Complete | [Docs](examples/documentation/scheduler_select_helper.md) |
|
||||
| **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) |
|
||||
| **Local Image Loader** | Visual gallery browser for local media files | ✅ Complete | [Docs](examples/documentation/local_image_loader.md) |
|
||||
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](examples/documentation/model_downloader.md) |
|
||||
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](examples/documentation/workflow_timer.md) |
|
||||
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](#-model-downloader) |
|
||||
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](#️-workflow-timer) |
|
||||
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
|
||||
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
|
||||
|
||||
@@ -942,7 +970,7 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.10+
|
||||
- ComfyUI installation
|
||||
- PyTorch 2.0+
|
||||
|
||||
@@ -1080,21 +1108,28 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 21 (15 core tools + 6 xyz-helpers)
|
||||
- **Features**: Embedding Autocomplete (settings-based, not a node)
|
||||
- **Categories**: 9 emoji-based categories for better organization
|
||||
- **Registered Nodes**: 33, including the deprecated Gemini node
|
||||
- **Extensions**: Embedding Autocomplete, Custom Colors and Follow Execution
|
||||
- **Organization**: Tools grouped under ComfyAssets
|
||||
- **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs)
|
||||
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
|
||||
- **Presets**: 26 curated resolution presets
|
||||
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
|
||||
- **AI Integration**: Gemini API with 40+ model support
|
||||
- **Legacy AI Integration**: Deprecated Gemini node retained for saved workflows
|
||||
- **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Python Version**: 3.10+
|
||||
- **ComfyUI Compatibility**: Classic and Nodes 2.0; tested runtime pins in [TESTING.md](TESTING.md)
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-genai for Gemini)
|
||||
|
||||
## 🙏 Attribution
|
||||
|
||||
### UI inspiration
|
||||
|
||||
[Pixaroma's ComfyUI toolkit](https://github.com/pixaroma/ComfyUI-Pixaroma)
|
||||
inspired the reusable controls and editor organization. ComfyAssets uses its own
|
||||
implementation and styling; no Pixaroma toolkit code or assets are vendored.
|
||||
|
||||
|
||||
### xyz-helpers Tools
|
||||
The xyz-helpers collection was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted these essential tools to ensure continued support and compatibility with modern ComfyUI workflows. We're grateful for cubiq's original work and contributions to the ComfyUI community.
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ original implementation, then run the relevant suites after the fix.
|
||||
| --- | --- | --- |
|
||||
| Python | Tool logic, tensors, image files, API validation | ComfyUI services and external models where needed |
|
||||
| JavaScript | Production extension hooks and callback behavior | Minimal host objects; controlled timers for focused regressions |
|
||||
| Browser | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
|
||||
| Browser fixtures | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
|
||||
| Real ComfyUI | Pinned server/frontend, LiteGraph, native menus, DOM widgets, initial renderers, switching and workflow serialization | Disposable media routes; simulated execution/progress events; model/service boundaries |
|
||||
|
||||
Browser workflows cover swaps, local image browsing/selection, pagination,
|
||||
sorting, hidden folders, directory autocomplete, saved paths,
|
||||
@@ -67,50 +68,89 @@ CI artifacts include Python JUnit results, Python HTML/XML coverage, and
|
||||
and screenshots. Open traces using Playwright's `show-trace` command. Local
|
||||
frontend coverage is at `coverage/js/index.html`.
|
||||
|
||||
Real ComfyUI/LiteGraph compatibility, GPU execution, cloud inference, network
|
||||
model downloads, and visual design still need targeted live checks when changed.
|
||||
The browser host is a test double; these workflows do not run the real ComfyUI
|
||||
frontend or certify every node's UI. Type checking and security jobs retain their
|
||||
existing advisory behavior.
|
||||
The fixture suite covers plugin behavior without a real ComfyUI frontend. The
|
||||
separate required real-host job covers the integration described below. GPU/model
|
||||
execution, cloud inference and model downloads still need targeted checks when
|
||||
changed; the compatibility suite does not queue workflows or download weights.
|
||||
Type checking and security jobs retain their existing advisory behavior.
|
||||
|
||||
### Actual ComfyUI frontend smoke test
|
||||
## Pinned real-ComfyUI compatibility
|
||||
|
||||
The Local Image Loader also has an opt-in suite using the actual ComfyUI frontend,
|
||||
graph, DOM widgets, renderer switch and palette settings. Start a local ComfyUI
|
||||
0.39.0 with frontend 1.55.14 and KikoTools installed, then run:
|
||||
The required **real ComfyUI compatibility** CI job provisions a CPU-only host.
|
||||
`tools/testing/comfyui-runtime.json` pins upstream commit
|
||||
`e8665ca8e4089ab917567cdb491308e1397663d4` (server 0.39.0), frontend 1.55.14,
|
||||
Python 3.12 and the CPU PyTorch/torchvision pair. The launcher rejects a different
|
||||
revision or modified tracked upstream files. The suite verifies server/frontend
|
||||
versions and attaches them to each test's report.
|
||||
|
||||
Two projects, `classic-start` and `nodes2-start`, select the renderer during
|
||||
startup, before creating nodes. Both run the complete suite, including live
|
||||
renderer switching, zoom/resize, palette changes, workflow save/reload and
|
||||
cleanup. Test-local settings start from a blank workspace and skip first-run
|
||||
onboarding. They do not change the server's saved preferences.
|
||||
|
||||
The migrated controls covered are Local Image Loader, Save Image, Seed History,
|
||||
Workflow Timer and its color picker, both dimension swap nodes, Node Dimensions,
|
||||
Display Text/Any, Embedding Autocomplete/settings/tag imports, Custom Colors and
|
||||
Follow Execution. Native canvas menus are opened with actual right clicks.
|
||||
Execution/progress events are simulated; generation is never queued.
|
||||
|
||||
### Managed local host
|
||||
|
||||
Use a separate clean checkout of the pinned ComfyUI commit and a Python 3.12
|
||||
environment containing its `requirements.txt` and this repository's
|
||||
`requirements-dev.txt`. Install the CPU torch/torchvision versions from the
|
||||
runtime manifest first, using the official CPU wheel index. The CI workflow in
|
||||
`.github/workflows/tests.yml` contains the full provisioning recipe.
|
||||
|
||||
```sh
|
||||
KIKO_COMFY_URL=http://127.0.0.1:8188/ node \
|
||||
tools/testing/node_modules/@playwright/test/cli.js test \
|
||||
--config tools/testing/comfyui.config.mjs
|
||||
KIKO_COMFY_ROOT=/path/to/pinned/ComfyUI \
|
||||
KIKO_TEST_PYTHON=/path/to/cpu-venv/bin/python \
|
||||
npm run --prefix tools/testing test:comfyui
|
||||
```
|
||||
|
||||
The suite requires those exact versions and records them in its artifacts. It
|
||||
serves the current checkout's extensions to a separate browser context and loads
|
||||
only KikoTools extensions. Media requests use the existing real Python routes
|
||||
with disposable fixture data on port 8771. Other HTTP writes are intercepted,
|
||||
so user settings, saved workflows and selections are not written to the live
|
||||
server. It does not execute a workflow or download models.
|
||||
Playwright owns this host on `127.0.0.1:8189` and a fixture server on port 8771.
|
||||
The launcher creates a temporary base/user/model directory, installs only a
|
||||
symlink to the current KikoTools checkout, disables other custom nodes and
|
||||
partner services, and uses an in-memory database. It removes owned state after
|
||||
normal shutdown, startup failure or process termination. Existing installations
|
||||
and model folders are not used. An occupied port fails instead of reusing an
|
||||
unrelated server.
|
||||
|
||||
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
|
||||
and removal for the loader. Save Image additionally checks real image output,
|
||||
window close/reopen through the node control across renderer switches, theme
|
||||
changes, exclusion of the launcher from serialized values and owner removal.
|
||||
Its `/view` requests also use disposable fixture output rather than live files.
|
||||
Seed History checks keyboard-operable rows through renderer switches, list growth
|
||||
and scrolling on resize, palettes, saved history/node dimensions, serialization
|
||||
and polling cleanup on removal.
|
||||
Workflow Timer covers execution events, Classic/Nodes 2.0 switching, themes,
|
||||
custom node colors, persisted time/dimensions and the actual settings picker.
|
||||
Screenshots/traces appear in
|
||||
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
|
||||
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
|
||||
alongside other node packs remain follow-up work in issue #81.
|
||||
### Existing local host
|
||||
|
||||
An opt-in mode still supports an already running host with the same pinned
|
||||
versions:
|
||||
|
||||
```sh
|
||||
KIKO_COMFY_URL=http://127.0.0.1:8188/ \
|
||||
npm run --prefix tools/testing test:comfyui
|
||||
```
|
||||
|
||||
Only loopback URLs are accepted. A separate browser context serves the current
|
||||
checkout's extensions. Media requests use real plugin Python routes with
|
||||
disposable fixture data. Other HTTP writes are intercepted, preserving server
|
||||
settings, saved workflows and selections. This mode does not start or stop the
|
||||
existing server.
|
||||
|
||||
### Reports and pin updates
|
||||
|
||||
CI retains `real-comfyui-reports` for 14 days, including server startup logs at
|
||||
`tools/testing/test-results/comfyui-host.log`, browser traces, screenshots and
|
||||
version attachments. Startup logs survive failure. Reviewed captures copied to
|
||||
[docs/screenshots](docs/screenshots/README.md) are committed separately from
|
||||
throwaway test artifacts.
|
||||
|
||||
To update the host, review and change the runtime manifest, provision the new
|
||||
checkout/dependencies, and run both startup projects before merging. A version
|
||||
assertion update alone is not compatibility validation. Cross-pack installation
|
||||
and shared-source version compatibility remain tracked in #81.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
|
||||
Python coverage, frontend syntax, frontend workflows/coverage, and the existing
|
||||
Python coverage, frontend syntax, frontend workflows/coverage, real ComfyUI
|
||||
compatibility, and the existing
|
||||
`code-quality` job (including Black and fatal lint checks). Administrators follow
|
||||
the same checks and branches must be current with main. No extra human approval
|
||||
is required. Workflows run on every pull request without path filters.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# ComfyAssets UI screenshots
|
||||
|
||||
Retained captures from the KikoTools UI migrations. Real-ComfyUI tests cover
|
||||
Classic and Nodes 2.0, renderer switching, saved workflows and cleanup.
|
||||
|
||||
| Interface | Captures |
|
||||
| --- | --- |
|
||||
| Local Image Loader | [Classic](live-image-loader/classic.png), [Nodes 2.0](live-image-loader/nodes2.png) |
|
||||
| Saved-image viewer | [Dark](save-image-viewer/dark.png), [Light](save-image-viewer/light.png) |
|
||||
| Seed History | [Dark](seed-history/dark.png), [Light](seed-history/light.png) |
|
||||
| Workflow Timer | [Dark](workflow-timer/dark.png), [Light](workflow-timer/light.png), [Color picker](workflow-timer/color-picker.png) |
|
||||
| Dimension swap | [Selector dark](dimension-swap/selector-dark.png), [Selector light](dimension-swap/selector-light.png), [Latent dark](dimension-swap/latent-dark.png), [Latent light](dimension-swap/latent-light.png) |
|
||||
| Node Dimensions | [Dark](node-dimensions/dark.png), [Light](node-dimensions/light.png) |
|
||||
| Display Text | [Dark](display-output/text-dark.png), [Light](display-output/text-light.png) |
|
||||
| Display Any | [Dark](display-output/any-dark.png), [Light](display-output/any-light.png) |
|
||||
| Embedding Autocomplete | [Suggestions dark](embedding-autocomplete/suggestions-dark.png), [Suggestions light](embedding-autocomplete/suggestions-light.png), [Settings dark](embedding-autocomplete/settings-dark.png), [Settings light](embedding-autocomplete/settings-light.png) |
|
||||
| Custom Colors | [Dark](custom-colors/dark.png), [Light](custom-colors/light.png) |
|
||||
| Follow Execution | [Classic](follow-execution/classic.png), [Nodes 2.0](follow-execution/nodes2.png) |
|
||||
|
||||
Screenshots record the tested appearance at capture time. Updated Playwright
|
||||
captures are temporary test artifacts until copied into this directory and
|
||||
committed for review.
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,48 @@
|
||||
# Autocomplete tag packs
|
||||
|
||||
ComfyAssets prepares filtered autocomplete data from Danbooru's public
|
||||
`tags.json` and `tag_aliases.json` APIs. Dataset releases are versioned separately
|
||||
from KikoTools and stored as release assets, keeping bulk data out of Git history.
|
||||
|
||||
## Danbooru — Illustrious / NoobAI, 2026-10-16
|
||||
|
||||
[Download the CSV and manifest](https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16).
|
||||
This snapshot contains 165,158 canonical tags, their categories, post counts, and
|
||||
aliases. The adjacent manifest records the retrieval date, model targets,
|
||||
selection filters, schema, byte size, and SHA-256 checksum.
|
||||
|
||||
The UTF-8 CSV has no header. Columns are `tag,category,post_count,aliases`.
|
||||
Aliases share a comma-separated field; use a CSV parser that respects quoting.
|
||||
An empty alias field means no aliases. Category codes are 0 (general), 1
|
||||
(artist), 3 (copyright), 4 (character), and 5 (meta).
|
||||
|
||||
In ComfyUI Settings, search **Embedding Autocomplete**, open its settings,
|
||||
then download the pack and choose the CSV under **Tag list file**. Enable
|
||||
**Show Custom Words** and select **Save Settings**. Importing stages the list;
|
||||
Cancel leaves the active list unchanged. The browser caches the saved file in
|
||||
IndexedDB for offline use and restores it after a refresh. Clearing browser site
|
||||
data removes this cache. There is no server-side autocomplete folder to populate.
|
||||
|
||||
Alternatively, enter a CSV/TXT URL and select **Load URL**, then save. Remote
|
||||
servers must allow browser cross-origin requests; for GitHub release assets,
|
||||
download and import the file locally. Existing one-column word lists and
|
||||
`tag,frequency` lists remain supported. The default URL is unchanged.
|
||||
|
||||
Parsing runs in a Web Worker with a 32 MB UTF-8 limit. Search checks canonical
|
||||
names and aliases, inserts the canonical tag, and ranks exact matches ahead of
|
||||
prefixes and substrings, then by descending post count. The number of displayed
|
||||
results is bounded by Maximum suggestions. Categories are retained in records;
|
||||
category-specific controls are not currently exposed.
|
||||
|
||||
## Source and terms
|
||||
|
||||
Source data: [Danbooru](https://danbooru.donmai.us/), collected 2026-10-16.
|
||||
Selection and packaging: ComfyAssets. This is an independent filtered snapshot,
|
||||
not an official Danbooru release or a guarantee of model compatibility.
|
||||
|
||||
Refer to [Danbooru's Terms of Service](https://danbooru.donmai.us/terms) and the
|
||||
[official terms template](https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb).
|
||||
The template's Copyright Infringement section identifies tags as factual
|
||||
information that is generally not copyrightable. Its API Usage section requires
|
||||
compliance with API rate limits. This reference does not claim an explicit
|
||||
dataset license or extend KikoTools' software license to upstream data.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"source": "danbooru.donmai.us tags.json + tag_aliases.json",
|
||||
"pulled": "2026-10-16",
|
||||
"target_models": "Illustrious 1.0-3.x, NoobAI-XL",
|
||||
"filters": {
|
||||
"created_before": "2025-01-01",
|
||||
"min_post_count": {
|
||||
"general": 50,
|
||||
"artist": 30,
|
||||
"copyright": 20,
|
||||
"character": 20
|
||||
},
|
||||
"artists_kept": true,
|
||||
"deprecated_removed": true,
|
||||
"meta_tags": "removed except small allowlist"
|
||||
},
|
||||
"tag_count": 165158,
|
||||
"schema_version": 1,
|
||||
"file": "danbooru-illustrious-noobai-2026-10-16.csv",
|
||||
"encoding": "UTF-8",
|
||||
"header_row": false,
|
||||
"columns": [
|
||||
"tag",
|
||||
"category",
|
||||
"post_count",
|
||||
"aliases"
|
||||
],
|
||||
"aliases_format": "Comma-separated inside a CSV-quoted field when necessary",
|
||||
"size_bytes": 3979593,
|
||||
"sha256": "733531a60f2e2d38925f53381b18cff21513ef14550d1f2ca670a0f55b86b798",
|
||||
"prepared_by": "ComfyAssets",
|
||||
"terms_url": "https://danbooru.donmai.us/terms",
|
||||
"terms_source_url": "https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb",
|
||||
"release_tag": "tags-2026-10-16"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# KikoTools UI migration
|
||||
|
||||
KikoTools' custom controls use the original ComfyAssets foundation in `web/ui/`.
|
||||
They share blue primary actions, red destructive actions, keyboard controls and
|
||||
ComfyUI-following dark/light colors. Layouts resize with the available space.
|
||||
Pixaroma's toolkit inspired the organization; its code/assets are not vendored.
|
||||
|
||||
| Interface | Shared implementation | Retained examples |
|
||||
| --- | --- | --- |
|
||||
| Local Image Loader | `views/image-browser.js`, controls and scoped controller | [Loader](screenshots/live-image-loader/) |
|
||||
| Save Image | Floating window, batch selection and responsive preview | [Viewer](screenshots/save-image-viewer/) |
|
||||
| Seed History | `views/seed-history.js`, scoped watcher and disclosure | [History](screenshots/seed-history/) |
|
||||
| Workflow Timer | Shared clock, controls and color picker | [Timer](screenshots/workflow-timer/) |
|
||||
| Width Height / Empty Latent swaps | `ui/dimension-swap.js`, `ui/dimensions.js` | [Swaps](screenshots/dimension-swap/) |
|
||||
| Node Dimensions | `views/node-dimensions.js`, native dialog shell | [Dimensions](screenshots/node-dimensions/) |
|
||||
| Display Text / Any | Shared output view, controller and widget adapter | [Output](screenshots/display-output/) |
|
||||
| Embedding Autocomplete | Shared suggestions/settings, worker parsing and cache | [Autocomplete](screenshots/embedding-autocomplete/) |
|
||||
| Custom Colors | Shared color picker, native node/group menus | [Colors](screenshots/custom-colors/) |
|
||||
| Follow Execution | Native host menus/settings, idempotent execution listeners | [Menus](screenshots/follow-execution/) |
|
||||
|
||||
`createScope()` owns listeners, observers and delayed work. Node removal preserves
|
||||
previous lifecycle hooks while disposing owned resources. Host-specific text
|
||||
widget handling lives in `ui/text-widget-adapter.js`; floating windows and native
|
||||
dialogs manage their own focus and cleanup. See [module notes](../web/ui/README.md).
|
||||
|
||||
Pure Python nodes continue using ComfyUI's native widgets. Gemini Prompt
|
||||
Engineer retains its legacy UI for saved workflows under the agreed deprecation;
|
||||
its UI migration was cancelled.
|
||||
|
||||
The [test guide](../TESTING.md) distinguishes fixture coverage, real-host
|
||||
compatibility and model/GPU behavior. Required real-host CI uses a reviewed
|
||||
upstream commit, fresh Classic/Nodes 2.0 starts and disposable CPU-only state.
|
||||
|
||||
## Remaining shared-toolset work
|
||||
|
||||
Before PromptManager adopts the foundation, extract a versioned shared source
|
||||
package with reproducible browser artifacts. Then test both node packs alone and
|
||||
together, including different bundled foundation versions, isolated settings
|
||||
and identical styling. These requirements remain open in issue #81; the KikoTools
|
||||
migration does not establish cross-pack compatibility by itself.
|
||||
|
||||
The approved design-preview draft [#82](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
|
||||
records the original direction. This inventory describes the shipped interfaces
|
||||
and supersedes that draft's implementation sequence.
|
||||
@@ -1,4 +1,15 @@
|
||||
# Gemini Prompt Engineer
|
||||
# Gemini Prompt Engineer (Deprecated)
|
||||
|
||||
**Legacy documentation for existing workflows.** This node is soft-deprecated
|
||||
and hidden from normal new-node discovery through ComfyUI's deprecated-node
|
||||
metadata. Users who enable deprecated-node visibility may still find it.
|
||||
|
||||
The `GeminiPrompt` registration, display name, input/output schema and execution
|
||||
remain available for saved workflows. Existing configuration and model-refresh
|
||||
behavior remain in place. Google API/model compatibility updates and UI migration
|
||||
have ended; the historical model and setup details below may no longer match
|
||||
Google's current service. No replacement or automatic workflow conversion is
|
||||
provided.
|
||||
|
||||
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from .models import get_available_models
|
||||
class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
|
||||
|
||||
DEPRECATED = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
@@ -54,6 +56,9 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
|
||||
DESCRIPTION = """
|
||||
Deprecated: retained for existing workflows only. This node no longer receives
|
||||
Google API/model compatibility updates.
|
||||
|
||||
Analyzes images using Google's Gemini AI to generate optimized prompts.
|
||||
|
||||
Supports multiple prompt formats:
|
||||
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.1.2"
|
||||
version = "1.2.0"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -2,26 +2,35 @@ import { test, expect } from '../../tools/testing/node_modules/@playwright/test/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import runtime from '../../tools/testing/comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
const fixture = 'http://127.0.0.1:8771';
|
||||
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
|
||||
|
||||
test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
|
||||
const info = await (await request.get(`${baseURL}system_stats`)).json();
|
||||
expect(info.system.comfyui_version).toBe('0.39.0');
|
||||
expect(info.system.required_frontend_version).toBe('1.55.14');
|
||||
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe('1.55.14');
|
||||
expect(info.system.comfyui_version).toBe(runtime.serverVersion);
|
||||
if (process.env.KIKO_COMFY_ROOT) expect(info.system.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.system.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
|
||||
await testInfo.attach('host-versions.json', { body: JSON.stringify(info.system, null, 2), contentType: 'application/json' });
|
||||
await expect(await request.post(`${fixture}/__test__/reset`)).toBeOK();
|
||||
await context.route('**/*', async route => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/api\//, '/');
|
||||
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
|
||||
if (path.startsWith('/kiko_local_image_loader/') || path.startsWith('/kikotools/autocomplete/') || path === '/view') {
|
||||
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) return route.fulfill({ json: {} });
|
||||
if (path === '/settings') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
if (path === '/extensions') {
|
||||
const response = await route.fetch();
|
||||
const extensions = (await response.json()).filter(path => path.startsWith('/extensions/kikotools/'));
|
||||
@@ -35,18 +44,102 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.KikoLocalImageLoader;
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
});
|
||||
});
|
||||
|
||||
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
|
||||
test(`${name}: shared swap survives renderers, zoom, themes and workflow reload`, async ({ page }, testInfo) => {
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(name);
|
||||
app.graph.add(node);
|
||||
const preset = node.widgets.find(widget => widget.name === 'preset');
|
||||
const options = typeof preset.options.values === 'function' ? preset.options.values() : preset.options.values;
|
||||
preset.value = options.find(option => option.startsWith('832×1216'));
|
||||
preset.callback(preset.value, app.canvas, node, [0, 0], null);
|
||||
const batch = node.widgets.find(widget => widget.name === 'batch_size');
|
||||
if (batch) batch.value = 3;
|
||||
app.canvas.centerOnNode(node);
|
||||
}, name);
|
||||
const panel = page.locator('.ca-dimension-swap');
|
||||
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
for (const renderer of [false, true, false]) {
|
||||
const before = await page.evaluate(async ({ name, renderer }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
app.canvas.ds.scale = .7;
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
|
||||
}, { name, renderer });
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await button.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph._nodes.find(node => node.type === name).widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
|
||||
}, name)).toEqual([...before].reverse());
|
||||
}
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
node.setSize([220, 280]);
|
||||
app.canvas.ds.scale = 1;
|
||||
app.canvas.centerOnNode(node);
|
||||
}, name);
|
||||
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
const narrow = await button.boundingBox();
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === name).setSize([500, 300]);
|
||||
}, name);
|
||||
await expect.poll(async () => (await button.boundingBox()).width).toBeGreaterThan(narrow.width);
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await expect(panel).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
|
||||
}
|
||||
const restored = await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
const saved = workflow.nodes.find(node => node.type === name);
|
||||
const nativeCount = name === 'EmptyLatentBatch' ? 4 : 3;
|
||||
if (saved.widgets_values.slice(nativeCount).some(value => value !== '' && value != null)) throw new Error('Swap UI leaked into workflow values');
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
app.canvas.centerOnNode(node);
|
||||
return { saved: saved.widgets_values.slice(0, nativeCount), restored: node.widgets.filter(widget => ['preset', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
|
||||
}, name);
|
||||
expect(restored.restored).toEqual(restored.saved);
|
||||
expect(restored.size).toEqual(restored.savedSize);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === name);
|
||||
window.removedSwap = node;
|
||||
app.graph.remove(node);
|
||||
}, name);
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedSwap.swapScope.signal.aborted)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('workflow timer preserves appearance and elapsed time through renderer switching and settings', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
@@ -315,3 +408,470 @@ test('actual host preserves the widget through renderer changes, theme changes a
|
||||
});
|
||||
await expect(loader).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Node Dimensions applies and persists across Classic and Nodes 2.0', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('WidthHeightSelector');
|
||||
app.graph.add(node);
|
||||
node.pos = [300, 250];
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const dialog = page.getByRole('dialog', { name: 'Node Dimensions', exact: true });
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const point = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
const canvas = app.canvas.canvas.getBoundingClientRect();
|
||||
return { x: canvas.left + (node.pos[0] + app.canvas.ds.offset[0] + 60) * app.canvas.ds.scale, y: canvas.top + (node.pos[1] + app.canvas.ds.offset[1] - 15) * app.canvas.ds.scale };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y, { button: 'right' });
|
||||
await page.getByText('🎨 Node Dimensions', { exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('900');
|
||||
await dialog.getByLabel('Height (px)', { exact: true }).fill('600');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
})).toEqual([900, 600]);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
|
||||
});
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('1200');
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
})).toEqual([900, 600]);
|
||||
}
|
||||
const size = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
});
|
||||
expect(size).toEqual([900, 600]);
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`node-dimensions-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph._nodes[0]);
|
||||
});
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const name of ['DisplayText', 'DisplayAny']) {
|
||||
test(`${name}: shared output persists through renderers, themes and legacy reload`, async ({ page }, testInfo) => {
|
||||
const output = name === 'DisplayText'
|
||||
? 'Positive prompt: A blue cabin beside a quiet lake, soft morning light, detailed reflections.\nNegative prompt: blur, noise, watermark'
|
||||
: Array.from({ length: 45 }, (_, index) => `Line ${index}: tensor / diagnostic output`).join('\n');
|
||||
await page.evaluate(async ({ name, output }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(name);
|
||||
app.graph.add(node);
|
||||
node.setSize([560, 520]);
|
||||
const mode = node.widgets.find(widget => widget.name === 'mode');
|
||||
if (mode) mode.value = 'tensor shape';
|
||||
node.onExecuted({ text: [output] });
|
||||
app.canvas.centerOnNode(node);
|
||||
window.displayCopies = [];
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.displayCopies.push(text) } });
|
||||
}, { name, output });
|
||||
const panel = page.locator('.ca-text-output');
|
||||
await expect(panel).toHaveCount(1);
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.ds.scale = .85;
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveCount(1);
|
||||
await panel.getByRole('button', { name: name === 'DisplayText' ? 'Copy negative' : 'Copy value', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(panel.getByRole('status')).toHaveText('Copied.');
|
||||
}
|
||||
expect(await page.evaluate(() => window.displayCopies)).toEqual(Array(3).fill(name === 'DisplayText' ? 'blur, noise, watermark' : output));
|
||||
const restored = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
const saved = workflow.nodes[0];
|
||||
await app.loadGraphData(workflow);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
return { snapshot: saved.kikoDisplay, size: Array.from(app.graph._nodes[0].size), mode: app.graph._nodes[0].widgets.find(widget => widget.name === 'mode')?.value };
|
||||
});
|
||||
expect(restored.snapshot).toEqual({ version: 1, text: output });
|
||||
expect(restored.size).toEqual([560, 520]);
|
||||
if (name === 'DisplayAny') {
|
||||
expect(restored.mode).toBe('tensor shape');
|
||||
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue(output);
|
||||
} else {
|
||||
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('A blue cabin beside a quiet lake, soft morning light, detailed reflections.');
|
||||
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('blur, noise, watermark');
|
||||
}
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await expect(panel).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes[0].setSize([280, 520]);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
});
|
||||
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await page.evaluate(async name => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
delete workflow.nodes[0].kikoDisplay;
|
||||
workflow.nodes[0].widgets_values = name === 'DisplayText'
|
||||
? ['✓ Positive Prompt', 'legacy positive', null, '✗ Negative Prompt', 'legacy negative', null]
|
||||
: ['tensor shape', 'legacy first', 'legacy second', null];
|
||||
await app.loadGraphData(workflow);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, name);
|
||||
await expect(panel).toHaveCount(1);
|
||||
if (name === 'DisplayText') {
|
||||
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('legacy positive');
|
||||
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('legacy negative');
|
||||
} else {
|
||||
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue('legacy first\nlegacy second');
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph._nodes[0].widgets.find(widget => widget.name === 'mode').value;
|
||||
})).toBe('tensor shape');
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
window.removedDisplay = node;
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => window.removedDisplay.displayOutput.scope.signal.aborted)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test('embedding suggestions survive real renderers, zoom, themes and workflow reload', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('TextInput');
|
||||
app.graph.add(node);
|
||||
node.setSize([550, 350]);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
|
||||
const prompt = page.locator('textarea:visible');
|
||||
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
|
||||
for (const [renderer, zoom] of [[false, .65], [true, 1.1], [false, .9]]) {
|
||||
await page.evaluate(async ({ renderer, zoom }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.ds.scale = zoom;
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, { renderer, zoom });
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await prompt.fill('embedding:ca');
|
||||
await expect(list).toBeVisible();
|
||||
await prompt.press('ArrowDown');
|
||||
await expect(list.getByRole('option', { selected: true })).toHaveCount(1);
|
||||
await expect(prompt).toBeFocused();
|
||||
const box = await list.boundingBox();
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(1600);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(1100);
|
||||
await prompt.press('Enter');
|
||||
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
|
||||
await prompt.fill('<lora:in');
|
||||
await list.getByRole('option').click();
|
||||
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
});
|
||||
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await prompt.fill('em');
|
||||
await prompt.press('ArrowDown');
|
||||
await expect(list).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`suggestions-${theme}.png`) });
|
||||
await prompt.press('Escape');
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
window.removedCompletionWidget = node.widgets.find(widget => widget.inputEl);
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(list).toBeHidden();
|
||||
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedCompletionWidget))).toBe(false);
|
||||
});
|
||||
|
||||
test('embedding settings use the real host launcher and preserve preferences', async ({ page }, testInfo) => {
|
||||
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
|
||||
const search = page.getByPlaceholder(/search/i);
|
||||
await search.fill('Embedding Autocomplete');
|
||||
const trigger = page.getByRole('button', { name: 'Open Settings', exact: true });
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`settings-${theme}.png`) });
|
||||
}
|
||||
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
|
||||
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).showLoras)).toBe(false);
|
||||
await trigger.click();
|
||||
await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked();
|
||||
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).check();
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(false);
|
||||
});
|
||||
|
||||
test('embedding tag files import through real settings and complete canonical tags in both renderers', async ({ page }, testInfo) => {
|
||||
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
|
||||
await page.getByPlaceholder(/search/i).fill('Embedding Autocomplete');
|
||||
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
|
||||
const input = process.env.KIKO_TAG_CSV || { name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('highres,5,100,"high_res,high_resolution"\nblue_eyes,0,90,azure') };
|
||||
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles(input);
|
||||
await expect(dialog.getByRole('status')).toContainText('tags ready', { timeout: 15000 });
|
||||
await testInfo.attach('import-status.txt', { body: await dialog.getByRole('status').innerText(), contentType: 'text/plain' });
|
||||
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
|
||||
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
|
||||
await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck();
|
||||
await page.screenshot({ path: testInfo.outputPath('tag-import.png') });
|
||||
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('TextInput');
|
||||
app.graph.add(node);
|
||||
node.setSize([550, 350]);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const prompt = page.locator('textarea:visible');
|
||||
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await prompt.fill('high_resolution');
|
||||
await expect(list.getByRole('option').first()).toContainText('highres');
|
||||
await prompt.press('ArrowDown');
|
||||
await prompt.press('Tab');
|
||||
await expect(prompt).toHaveValue('highres, ');
|
||||
}
|
||||
});
|
||||
|
||||
test('embedding Unicode and emoticon completion preserves prompt boundaries in both renderers', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js');
|
||||
const autocomplete = window.kikotools.autocomplete;
|
||||
autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3');
|
||||
Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false });
|
||||
const node = LiteGraph.createNode('TextInput');
|
||||
app.graph.add(node);
|
||||
node.setSize([550, 350]);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const prompt = page.locator('textarea:visible');
|
||||
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await prompt.focus();
|
||||
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
|
||||
for (const [query, result] of [['sky, 日本', 'sky, 日本語, '], ['sky, <3', 'sky, heart, ']]) {
|
||||
await prompt.fill(query);
|
||||
await expect(list.getByRole('option')).toHaveCount(1);
|
||||
await prompt.press('ArrowDown');
|
||||
await prompt.press('Tab');
|
||||
await expect(prompt).toHaveValue(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Custom Colors preserves node colors across renderers and workflow reload', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('EmptyLatentImage');
|
||||
app.graph.add(node);
|
||||
node.bgcolor = '#123456';
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
const menu = app.collectNodeMenuItems(node).find(item => item?.content === '🫶 Custom Colors');
|
||||
menu.submenu.options.find(item => item.content === '🫶 Custom Full').callback();
|
||||
});
|
||||
const dialog = page.getByRole('dialog', { name: 'Custom full color', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Hex color', { exact: true }).fill('#112233');
|
||||
await dialog.getByLabel('Hex color', { exact: true }).press('Tab');
|
||||
if (renderer) {
|
||||
for (const [color, theme] of [['#fff', 'light'], ['#202020', 'dark']]) {
|
||||
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
|
||||
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`custom-colors-${theme}.png`) });
|
||||
}
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('ca-color-picker')).toHaveCount(0);
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes[0];
|
||||
return { color: node.color, bgcolor: node.bgcolor };
|
||||
})).toEqual({ color: '#253647', bgcolor: '#112233' });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
app.collectNodeMenuItems(node).find(item => item?.content === '🫶 Custom Colors').submenu.options[0].callback();
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(page.locator('dialog.ca-color-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Follow Execution honors live settings and host menus in both renderers', async ({ page }, testInfo) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('EmptyLatentImage');
|
||||
app.graph.add(node);
|
||||
await app.ui.settings.setSettingValue('kikotools.follow_execution.auto_start', false);
|
||||
});
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes[0].pos[0] += 10000;
|
||||
await app.ui.settings.setSettingValue('kikotools.follow_execution.enabled', true);
|
||||
});
|
||||
const canvas = page.locator('#graph-canvas');
|
||||
await canvas.click({ button: 'right', position: { x: 800, y: 500 } });
|
||||
await expect(page.getByText('🫶 Follow execution', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('🫶 Go to node', { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath(renderer ? 'follow-execution-nodes2.png' : 'follow-execution-classic.png') });
|
||||
await canvas.click({ position: { x: 100, y: 800 } });
|
||||
await expect(page.getByText('🫶 Follow execution', { exact: true })).toBeHidden();
|
||||
const result = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const { api } = await import('/scripts/api.js');
|
||||
const node = app.graph._nodes[0];
|
||||
const original = app.canvas.centerOnNode;
|
||||
const centered = [];
|
||||
app.canvas.centerOnNode = function (node) { centered.push(node.id); return original.apply(this, arguments); };
|
||||
const set = (key, value) => app.ui.settings.setSettingValue(`kikotools.follow_execution.${key}`, value);
|
||||
const menu = () => app.collectCanvasMenuItems(app.canvas);
|
||||
const toggle = () => menu().find(item => /🫶 (Stop following execution|Follow execution)/.test(item?.content)).callback();
|
||||
const emit = () => api.dispatchEvent(new CustomEvent('executing', { detail: String(node.id) }));
|
||||
const progress = nodes => api.dispatchEvent(new CustomEvent('progress_state', { detail: { prompt_id: 'follow-execution-test', nodes } }));
|
||||
try {
|
||||
await set('enabled', true);
|
||||
await set('auto_start', false);
|
||||
progress({});
|
||||
await new Promise(requestAnimationFrame);
|
||||
const extension = app.extensions.find(item => item.name === 'kikotools.followExecution');
|
||||
await extension.setup();
|
||||
await extension.setup();
|
||||
toggle();
|
||||
emit();
|
||||
const manual = centered.length;
|
||||
await set('enabled', false);
|
||||
emit();
|
||||
const disabled = centered.length;
|
||||
await set('enabled', true);
|
||||
emit();
|
||||
const reenabled = centered.length;
|
||||
await set('auto_start', true);
|
||||
api.dispatchEvent(new CustomEvent('execution_start', { detail: { prompt_id: 'follow-execution-test' } }));
|
||||
emit();
|
||||
const autoStart = centered.length;
|
||||
toggle();
|
||||
emit();
|
||||
const stopped = centered.length;
|
||||
progress({ [node.id]: { state: 'running', value: 0, max: 1, node_id: String(node.id), prompt_id: 'follow-execution-test' } });
|
||||
await new Promise(requestAnimationFrame);
|
||||
const showItem = menu().find(item => item?.content === '🫶 Show executing node');
|
||||
if (!showItem) throw new Error(JSON.stringify({ running: app.runningNodeId, menus: menu().map(item => item?.content) }));
|
||||
showItem.callback();
|
||||
const show = centered.length;
|
||||
menu().find(item => item?.content === '🫶 Go to node').submenu.options
|
||||
.find(item => item.content === 'EmptyLatentImage').submenu.options[0].callback();
|
||||
const goTo = centered.length;
|
||||
await set('show_goto_node', false);
|
||||
const hideGoTo = !menu().some(item => item?.content === '🫶 Go to node');
|
||||
await set('show_goto_node', true);
|
||||
await set('enabled', false);
|
||||
progress({});
|
||||
api.dispatchEvent(new CustomEvent('executing', { detail: null }));
|
||||
return { manual, disabled, reenabled, autoStart, stopped, show, goTo, hideGoTo };
|
||||
} finally {
|
||||
app.canvas.centerOnNode = original;
|
||||
}
|
||||
});
|
||||
expect(result).toEqual({ manual: 1, disabled: 1, reenabled: 1, autoStart: 2, stopped: 2, show: 3, goTo: 4, hideGoTo: true });
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readTagResponse } from '../../web/autocomplete/tag-download.js';
|
||||
|
||||
function response(chunks, headers = {}) {
|
||||
let index = 0, cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) { if (index < chunks.length) controller.enqueue(chunks[index++]); else controller.close(); },
|
||||
cancel() { cancelled = true; },
|
||||
}, { highWaterMark: 0 });
|
||||
return { value: new Response(body, { headers }), cancelled: () => cancelled, read: () => index };
|
||||
}
|
||||
const encode = value => new TextEncoder().encode(value);
|
||||
|
||||
test('tag downloads stop reading at the byte limit with missing or dishonest headers', async () => {
|
||||
for (const headers of [{}, { 'content-length': '1' }]) {
|
||||
const stream = response([encode('1234'), encode('56789'), encode('unread')], headers);
|
||||
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
|
||||
assert.equal(stream.cancelled(), true);
|
||||
assert.equal(stream.read(), 2);
|
||||
assert.equal(stream.value.body.locked, false);
|
||||
}
|
||||
});
|
||||
test('oversized declared downloads are cancelled without reading their contents', async () => {
|
||||
const stream = response([encode('unread')], { 'content-length': '100' });
|
||||
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
|
||||
assert.equal(stream.read(), 0);
|
||||
assert.equal(stream.cancelled(), true);
|
||||
});
|
||||
test('streamed UTF-8 survives split code points and an exact byte limit', async () => {
|
||||
const bytes = encode('日本語');
|
||||
const stream = response([bytes.slice(0, 2), bytes.slice(2, 5), bytes.slice(5)]);
|
||||
assert.equal(await readTagResponse(stream.value, { maxBytes: bytes.length }), '日本語');
|
||||
assert.equal(stream.value.body.locked, false);
|
||||
assert.equal(await readTagResponse(new Response(null)), '');
|
||||
});
|
||||
test('aborted or failed downloads release their reader and preserve the error', { timeout: 2000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream({ cancel() { cancelled = true; } });
|
||||
const value = new Response(stream);
|
||||
const pending = readTagResponse(value, { signal: controller.signal });
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(value.body.locked, false);
|
||||
const stopped = response([encode('unread')]);
|
||||
await assert.rejects(readTagResponse(stopped.value, { signal: AbortSignal.abort() }), { name: 'AbortError' });
|
||||
assert.equal(stopped.cancelled(), true);
|
||||
const error = new Error('Network interrupted');
|
||||
const failed = new Response(new ReadableStream({ start(stream) { stream.error(error); } }));
|
||||
await assert.rejects(readTagResponse(failed), /Network interrupted/);
|
||||
assert.equal(failed.body.locked, false);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { suggestionPosition } from '../../web/ui/autocomplete-suggestions.js';
|
||||
|
||||
test('completion popup stays within the viewport and flips above a low field', () => {
|
||||
assert.deepEqual(suggestionPosition({ left: 20, top: 40, bottom: 80 }, { width: 220, height: 150 }, { width: 800, height: 600 }), { left: 20, top: 84 });
|
||||
assert.deepEqual(suggestionPosition({ left: 300, top: 400, bottom: 440 }, { width: 220, height: 150 }, { width: 320, height: 480 }), { left: 92, top: 246 });
|
||||
assert.deepEqual(suggestionPosition({ left: -30, top: 2, bottom: 10 }, { width: 300, height: 300 }, { width: 240, height: 200 }), { left: 8, top: 8 });
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tagCache, parseTagFile } from '../../web/autocomplete/tag-storage.js';
|
||||
|
||||
test('a blocked cache operation cannot write after its caller has received an error', async () => {
|
||||
const request = {};
|
||||
let closed = false, wrote = false;
|
||||
globalThis.indexedDB = { open: () => request };
|
||||
try {
|
||||
const operation = tagCache({ source: 'new-file' });
|
||||
request.onblocked();
|
||||
await assert.rejects(operation, /blocked/);
|
||||
request.result = { close: () => { closed = true; }, transaction: () => { wrote = true; throw new Error('Abandoned operation wrote data'); } };
|
||||
request.onsuccess();
|
||||
assert.equal(closed, true);
|
||||
assert.equal(wrote, false);
|
||||
} finally { delete globalThis.indexedDB; }
|
||||
});
|
||||
|
||||
test('cancelled imports never start workers, and running workers terminate on cancellation or failure', async () => {
|
||||
await assert.rejects(parseTagFile('tag', AbortSignal.abort()), { name: 'AbortError' });
|
||||
let current;
|
||||
globalThis.Worker = class {
|
||||
constructor() { current = this; this.terminated = false; }
|
||||
postMessage() {}
|
||||
terminate() { this.terminated = true; }
|
||||
};
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const aborted = parseTagFile('tag', controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(aborted, { name: 'AbortError' });
|
||||
assert.equal(current.terminated, true);
|
||||
const failed = parseTagFile('tag', new AbortController().signal);
|
||||
current.onerror();
|
||||
await assert.rejects(failed, /Could not parse/);
|
||||
assert.equal(current.terminated, true);
|
||||
} finally { delete globalThis.Worker; }
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseTagList, searchTags, tagQuery } from '../../web/autocomplete/tags.js';
|
||||
|
||||
test('four-column packs preserve categories, popularity and quoted aliases', () => {
|
||||
const tags = parseTagList('\ufefftag,category,post_count,aliases\r\nblue_eyes,0,900,"blue_eye,azure_eyes"\r\nartist_name,1,700,"artist,artist_alias"\r\n');
|
||||
assert.deepEqual(tags.map(({ name, category, frequency, aliases }) => ({ name, category, frequency, aliases })), [
|
||||
{ name: 'blue_eyes', category: 0, frequency: 900, aliases: ['blue_eye', 'azure_eyes'] },
|
||||
{ name: 'artist_name', category: 1, frequency: 700, aliases: ['artist', 'artist_alias'] },
|
||||
]);
|
||||
assert.equal(searchTags(tags, 'azure', 5)[0].value, 'blue_eyes');
|
||||
});
|
||||
test('legacy words, frequencies and headerless packs remain compatible', () => {
|
||||
assert.deepEqual(parseTagList('low,2\nhigh,100\nplain\n').map(tag => [tag.name, tag.frequency]), [['high', 100], ['low', 2], ['plain', 0]]);
|
||||
assert.equal(parseTagList('long_hair,0,6317335,')[0].frequency, 6317335);
|
||||
assert.equal(parseTagList('"say""hello",0,12,"nickname"')[0].name, 'say"hello');
|
||||
});
|
||||
test('invalid data fails clearly instead of replacing a valid list with garbage', () => {
|
||||
for (const text of ['', '<!doctype html><html>error</html>', '<script>alert(1)</script>', 'x,0,NaN,', 'x,2,12,', 'x,-1', '"unfinished', 'x,0,1,a,extra', 'x,0,1,"a"tail', 'name,category,post_count,aliases\n']) {
|
||||
assert.throws(() => parseTagList(text), Error, text);
|
||||
}
|
||||
assert.throws(() => parseTagList('x'.repeat(32 * 1024 * 1024 + 1)), /large/i);
|
||||
});
|
||||
test('duplicates merge aliases while retaining the highest frequency', () => {
|
||||
const tags = parseTagList('blue_eyes,0,100,azure\nblue_eyes,0,200,blue_eye');
|
||||
assert.equal(tags.length, 1);
|
||||
assert.equal(tags[0].frequency, 200);
|
||||
assert.deepEqual(tags[0].aliases, ['azure', 'blue_eye']);
|
||||
});
|
||||
test('search ranks exact canonical and alias matches, prefixes, then substrings by popularity', () => {
|
||||
const tags = parseTagList('blue_sky,0,100,azure\nblue_eyes,0,900,\nlight_blue,0,500,\nblue,0,1,\n');
|
||||
assert.deepEqual(searchTags(tags, 'blue', 3).map(tag => tag.name), ['blue', 'blue_eyes', 'blue_sky']);
|
||||
assert.equal(searchTags(tags, 'AZURE', 1)[0].name, 'blue_sky');
|
||||
assert.deepEqual(searchTags(tags, 'missing', 3), []);
|
||||
assert.deepEqual(searchTags(tags, '', 0), []);
|
||||
assert.deepEqual(searchTags(tags, '', 2).map(tag => tag.name), ['blue_eyes', 'light_blue']);
|
||||
});
|
||||
test('search handles 300,000 tags with bounded results and canonical alias insertion', () => {
|
||||
const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n');
|
||||
const tags = parseTagList(text);
|
||||
assert.equal(tags.length, 300000);
|
||||
const matches = searchTags(tags, 'alias_299999', 20);
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0].value, 'tag_299999');
|
||||
});
|
||||
|
||||
test('tag data supports Unicode, emoticons and quoted commas as plain text', () => {
|
||||
const tags = parseTagList('heart,0,50,<3\n>_<,0,20,\n日本語,3,10,"別名,にほんご"');
|
||||
assert.equal(searchTags(tags, '<3', 1)[0].value, 'heart');
|
||||
assert.equal(searchTags(tags, 'にほんご', 1)[0].value, '日本語');
|
||||
assert.equal(searchTags(tags, '>_<', 1)[0].value, '>_<');
|
||||
});
|
||||
|
||||
test('worker reports parsed tag records and errors through its message boundary', async () => {
|
||||
const messages = [];
|
||||
globalThis.self = { postMessage: message => messages.push(message) };
|
||||
try {
|
||||
await import('../../web/autocomplete/tag-worker.js');
|
||||
self.onmessage({ data: 'blue_eyes,0,20,azure' });
|
||||
self.onmessage({ data: 'bad,0,invalid,' });
|
||||
assert.equal(messages[0].tags[0].frequency, 20);
|
||||
assert.match(messages[1].error, /post count/);
|
||||
} finally { delete globalThis.self; }
|
||||
});
|
||||
|
||||
test('prompt query boundaries preserve Unicode, punctuation and surrounding weighting syntax', () => {
|
||||
assert.deepEqual(tagQuery('sky, 日本'), { prefix: '日本', start: 5 });
|
||||
assert.deepEqual(tagQuery('(o_o)'), { prefix: '(o_o)', start: 0 });
|
||||
assert.deepEqual(tagQuery('<3'), { prefix: '<3', start: 0 });
|
||||
assert.deepEqual(tagQuery('sky, (日本', true), { prefix: '日本', start: 6 });
|
||||
assert.deepEqual(tagQuery('hatsune_miku_(vocaloid'), { prefix: 'hatsune_miku_(vocaloid', start: 0 });
|
||||
assert.deepEqual(tagQuery('foo\nmiku/vocal'), { prefix: 'miku/vocal', start: 4 });
|
||||
assert.deepEqual(tagQuery('🎨🎨'), { prefix: '🎨🎨', start: 0 });
|
||||
assert.equal(tagQuery('sky, '), null);
|
||||
assert.equal(tagQuery('(', true), null);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
|
||||
const output = execFileSync(process.execPath, ['tools/testing/node_modules/@playwright/test/cli.js',
|
||||
'test', '--config', 'tools/testing/comfyui.config.mjs', '--list'], {
|
||||
cwd: root, encoding: 'utf8', env: { ...process.env,
|
||||
KIKO_COMFY_ROOT: '', KIKO_COMFY_URL: 'http://127.0.0.1:8199/' },
|
||||
});
|
||||
assert.match(output, /\[classic-start\]/);
|
||||
assert.match(output, /\[nodes2-start\]/);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
import { launchComfyHost } from '../../tools/testing/comfyui-host.mjs';
|
||||
|
||||
async function fixture(t, program) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'kiko-host-test-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const checkout = join(directory, 'host');
|
||||
const repository = join(directory, 'plugin');
|
||||
await mkdir(checkout);
|
||||
await mkdir(repository);
|
||||
await writeFile(join(repository, '__init__.py'), '');
|
||||
await writeFile(join(checkout, 'main.py'), program);
|
||||
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
git(['init', '--quiet']);
|
||||
git(['add', 'main.py']);
|
||||
git(['-c', 'user.name=Kiko Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
|
||||
return { checkout, repository, python: process.execPath, port: 8199,
|
||||
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
|
||||
}
|
||||
|
||||
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
|
||||
|
||||
test('managed ComfyUI host uses disposable CPU/offline state and preserves source directories', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await once(host.child.stdout, 'data');
|
||||
const args = host.child.spawnargs;
|
||||
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
|
||||
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
|
||||
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
|
||||
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'kikotools');
|
||||
assert.equal(await realpath(join(host.stateDirectory, 'custom_nodes/kikotools')), await realpath(options.repository));
|
||||
await access(join(host.stateDirectory, 'user'));
|
||||
await host.stop();
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
await access(join(options.checkout, 'main.py'));
|
||||
await access(join(options.repository, '__init__.py'));
|
||||
});
|
||||
|
||||
test('managed ComfyUI host rejects a mismatched or modified upstream checkout', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
await assert.rejects(launchComfyHost({ ...options, pins: { revision: '0'.repeat(40) } }), /revision/i);
|
||||
await writeFile(join(options.checkout, 'main.py'), 'modified');
|
||||
await assert.rejects(launchComfyHost(options), /modified|dirty/i);
|
||||
});
|
||||
|
||||
test('managed ComfyUI host preserves crash diagnostics, exit status and cleanup', async t => {
|
||||
const options = await fixture(t, `console.error('startup failed'); process.exit(7);`);
|
||||
const host = await launchComfyHost(options);
|
||||
const result = await host.finished;
|
||||
assert.equal(result.code, 7);
|
||||
assert.match(await readFile(options.logPath, 'utf8'), /startup failed/);
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('managed ComfyUI host reports spawn failures and releases its temporary state', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost({ ...options, python: join(options.checkout, 'missing-python') });
|
||||
const result = await host.finished;
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.error.message, /ENOENT/);
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('managed ComfyUI host fails before spawning when its log cannot be opened', async t => {
|
||||
const options = await fixture(t, `require('node:fs').writeFileSync(process.argv[1] + '.started', 'yes');`);
|
||||
await assert.rejects(launchComfyHost({ ...options, logPath: options.checkout }), /EISDIR|directory/i);
|
||||
await assert.rejects(access(join(options.checkout, 'main.py.started')), { code: 'ENOENT' });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { bindDimensionPresets, dimensionsFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
|
||||
|
||||
const app = { canvas: {} };
|
||||
|
||||
test('resolution parsing accepts supplied formats and rejects invalid dimensions', () => {
|
||||
assert.deepEqual(dimensionsFromPreset(' 928x1664 - Qwen'), [928, 1664]);
|
||||
assert.deepEqual(dimensionsFromPreset('1664×928'), [1664, 928]);
|
||||
for (const value of [null, {}, 'custom', '0x100', '12.5x100', 'x100', '999999999999999999999x100']) assert.equal(dimensionsFromPreset(value), null);
|
||||
});
|
||||
|
||||
test('preset callbacks preserve returns and default host context without changing custom dimensions', () => {
|
||||
const calls = [];
|
||||
const node = { widgets: [
|
||||
{ name: 'preset', callback() { return 'original'; } },
|
||||
{ name: 'width', value: 64, callback(value, canvas, owner) { calls.push([this.name, value, canvas, owner]); } },
|
||||
{ name: 'height', value: 128 },
|
||||
] };
|
||||
bindDimensionPresets(node, app);
|
||||
assert.equal(node.widgets[0].callback('custom'), 'original');
|
||||
assert.equal(node.widgets[0].callback('928×1664 - Qwen'), 'original');
|
||||
assert.deepEqual(calls, [['width', 928, app.canvas, node]]);
|
||||
assert.equal(node.widgets[2].value, 1664);
|
||||
});
|
||||
|
||||
test('incomplete widgets and unavailable reverse options are safe', () => {
|
||||
bindDimensionPresets({ widgets: [] }, app);
|
||||
swapDimensions({ widgets: [] }, app);
|
||||
const node = { widgets: [{ name: 'preset', value: '640×960' }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
|
||||
bindDimensionPresets(node, app);
|
||||
swapDimensions(node, app);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 960, 640]);
|
||||
node.widgets.pop();
|
||||
node.widgets[0].callback('640×960');
|
||||
swapDimensions(node, app);
|
||||
assert.equal(node.widgets[1].value, 960);
|
||||
});
|
||||
|
||||
test('dynamic and array option lists match reverse dimensions', () => {
|
||||
for (const options of [['custom', '960x640'], { values: () => ['custom', '960×640'] }]) {
|
||||
const node = { widgets: [{ name: 'preset', value: '640×960', options }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
|
||||
bindDimensionPresets(node, app);
|
||||
swapDimensions(node, app);
|
||||
assert.equal(node.widgets[0].value, Array.isArray(options) ? '960x640' : '960×640');
|
||||
}
|
||||
});
|
||||
|
||||
test('a throwing or recursive preset callback does not leave swapping locked', () => {
|
||||
let fail = true;
|
||||
const node = { widgets: [
|
||||
{ name: 'preset', value: '640×960', options: { values: ['640×960', '960×640'] }, callback() { swapDimensions(node, app); if (fail) throw new Error('callback failed'); } },
|
||||
{ name: 'width', value: 640 }, { name: 'height', value: 960 },
|
||||
] };
|
||||
bindDimensionPresets(node, app);
|
||||
assert.throws(() => swapDimensions(node, app), /callback failed/);
|
||||
fail = false;
|
||||
swapDimensions(node, app);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['640×960', 640, 960]);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { outputSections } from '../../web/views/text-output.js';
|
||||
import { restoreDisplayOutput } from '../../web/controllers/display-node.js';
|
||||
|
||||
test('display text recognizes paired prompts and preserves ordinary text verbatim', () => {
|
||||
assert.deepEqual(outputSections('text', 'Positive prompt: bright ** metadata\nNegative prompt: blur ** metadata').map(section => section.text), ['bright', 'blur']);
|
||||
assert.deepEqual(outputSections('text', 'POSITIVE PROMPT: sun\nNEGATIVE PROMPT: rain').map(section => section.text), ['sun', 'rain']);
|
||||
for (const text of ['', 'Positive prompt: alone', '<script>hello</script>\nnext']) {
|
||||
assert.deepEqual(outputSections('text', text).map(section => section.text), [text]);
|
||||
}
|
||||
const long = Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n');
|
||||
assert.deepEqual(outputSections('any', long).map(section => section.text), [long]);
|
||||
});
|
||||
|
||||
test('versioned display snapshots preserve complete output including empty text', () => {
|
||||
for (const kind of ['text', 'any']) {
|
||||
for (const text of ['', 'mode\nfull\noutput']) {
|
||||
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 1, text }, widgets_values: ['stale'] }, kind, false), text);
|
||||
}
|
||||
assert.equal(restoreDisplayOutput({}, kind, false), null);
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: [] }, kind, false), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy display workflows restore prompt pairs, converted inputs and every chunk', () => {
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: ['✓ Positive Prompt', 'sun', null, '✗ Negative Prompt', 'rain', null] }, 'text', false), 'Positive prompt: sun\nNegative prompt: rain');
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'saved text', null] }, 'text', true), 'saved text');
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: ['raw value', 'first\nchunk', 'second\nchunk', null] }, 'any', false), 'first\nchunk\nsecond\nchunk');
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'tensor shape', 'shape', null] }, 'any', true), 'shape');
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: ['tensor shape'] }, 'any', false), null);
|
||||
assert.equal(restoreDisplayOutput({ widgets_values: [''] }, 'text', false), '');
|
||||
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 99, text: 'unknown' }, widgets_values: ['legacy'] }, 'text', false), 'legacy');
|
||||
});
|
||||
|
||||
test('prompt detection retains leading markdown markers accepted by existing workflows', () => {
|
||||
assert.equal(outputSections('text', 'Positive prompt: **sun\nNegative prompt: rain')[0].text, '**sun');
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
|
||||
|
||||
function load(file, extra = {}) {
|
||||
const extensions = [];
|
||||
@@ -19,8 +20,8 @@ function load(file, extra = {}) {
|
||||
return { extension: extensions[0], app, timers, context };
|
||||
}
|
||||
|
||||
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape']) {
|
||||
const { extension, app } = load('width_height_swap.js');
|
||||
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
|
||||
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||
let originalCreated = 0;
|
||||
class Node {
|
||||
constructor() {
|
||||
@@ -33,15 +34,17 @@ async function dimensionNode(preset, options = ['custom', '832×1216 - portrait'
|
||||
}
|
||||
onNodeCreated() { originalCreated++; }
|
||||
}
|
||||
await extension.beforeRegisterNodeDef(Node, { name: 'WidthHeightSelector' }, app);
|
||||
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
|
||||
Node.prototype.createSwapButton = () => {};
|
||||
const node = new Node();
|
||||
node.onNodeCreated();
|
||||
assert.equal(originalCreated, 1);
|
||||
return node;
|
||||
}
|
||||
|
||||
test('custom dimensions swap and callbacks receive the actual node', async () => {
|
||||
const node = await dimensionNode('custom');
|
||||
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
|
||||
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
|
||||
const node = await dimensionNode('custom', undefined, file);
|
||||
const calls = [];
|
||||
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
|
||||
node.swapDimensions();
|
||||
@@ -50,34 +53,83 @@ test('custom dimensions swap and callbacks receive the actual node', async () =>
|
||||
for (const call of calls) assert.equal(call.owner, node);
|
||||
});
|
||||
|
||||
test('formatted preset swaps to its matching landscape preset without throwing', async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait');
|
||||
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', undefined, file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
|
||||
});
|
||||
|
||||
test('a preset without a reverse switches to custom dimensions', async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
|
||||
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
|
||||
});
|
||||
|
||||
test('preset changes preserve original callbacks and update both dimensions', async () => {
|
||||
const node = await dimensionNode('custom');
|
||||
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
|
||||
const node = await dimensionNode('custom', undefined, file);
|
||||
node.widgets[0].callback('1216×832 - landscape', {}, node);
|
||||
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
|
||||
assert.equal(node.extractResolutionFromPreset('custom'), null);
|
||||
assert.equal(node.originalPresetCalls, 1);
|
||||
});
|
||||
|
||||
test('unrelated node definitions retain their lifecycle methods', async () => {
|
||||
const { extension, app } = load('width_height_swap.js');
|
||||
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
|
||||
const node = await dimensionNode('832×1216 - portrait', undefined, file);
|
||||
const calls = [];
|
||||
for (const widget of node.widgets.slice(1)) widget.callback = function (value, canvas, owner) { calls.push({ name: this.name, value, owner }); };
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(calls.map(call => [call.name, call.value]), [['width', 1216], ['height', 832]]);
|
||||
assert.equal(node.originalPresetCalls, 1);
|
||||
for (const call of calls) assert.equal(call.owner, node);
|
||||
});
|
||||
|
||||
test(`${file}: ASCII resolutions match reversed presets and unfamiliar formatted presets update dimensions`, async () => {
|
||||
const node = await dimensionNode('832x1216 - portrait', ['custom', '832x1216 - portrait', '1216x832 - landscape'], file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216x832 - landscape', 1216, 832]);
|
||||
node.widgets[0].callback('2040×1080 - future preset', {}, node);
|
||||
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [2040, 1080]);
|
||||
});
|
||||
|
||||
test(`${file}: malformed presets do not write invalid dimensions`, async () => {
|
||||
const node = await dimensionNode('invalidx960', undefined, file);
|
||||
node.swapDimensions();
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['invalidx960', 832, 1216]);
|
||||
});
|
||||
|
||||
test(`${file}: unrelated node definitions retain their lifecycle methods`, async () => {
|
||||
const { extension, app } = load(file);
|
||||
class Node { onNodeCreated() {} }
|
||||
const original = Node.prototype.onNodeCreated;
|
||||
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
|
||||
assert.equal(Node.prototype.onNodeCreated, original);
|
||||
});
|
||||
|
||||
test(`${file}: creation preserves hook arguments, results and existing canvas hooks`, async () => {
|
||||
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||
class Node {
|
||||
constructor() { this.widgets = []; }
|
||||
onNodeCreated(value) { this.received = value; return 'original result'; }
|
||||
onDrawForeground() {}
|
||||
onMouseDown() {}
|
||||
onMouseMove() {}
|
||||
}
|
||||
const hooks = [Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove];
|
||||
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
|
||||
const node = new Node();
|
||||
assert.equal(node.onNodeCreated('original argument'), 'original result');
|
||||
assert.equal(node.received, 'original argument');
|
||||
assert.deepEqual([Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove], hooks);
|
||||
});
|
||||
|
||||
test(`${file}: nodes without an existing creation hook initialize safely`, async () => {
|
||||
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||
class Node { constructor() { this.widgets = []; } }
|
||||
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
|
||||
assert.equal(new Node().onNodeCreated(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
|
||||
const { extension, app, timers } = load('gemini_prompt_ui.js');
|
||||
let removed = 0;
|
||||
@@ -158,3 +210,102 @@ test('gallery HTTP errors cover null bodies, message details, and generic status
|
||||
[503, {}, 'Request failed (HTTP 503)'],
|
||||
]) assert.equal(await describe({ status, json: async () => body }), expected);
|
||||
});
|
||||
|
||||
async function followFixture() {
|
||||
const settings = new Map();
|
||||
const definitions = new Map();
|
||||
const centered = [];
|
||||
const api = new EventTarget();
|
||||
let extension;
|
||||
const node = { id: '7', type: 'DisplayAny', pos: [10, 20], getTitle: () => 'Display' };
|
||||
const app = {
|
||||
registerExtension: value => { extension = value; },
|
||||
ui: { settings: {
|
||||
addSetting(value) { definitions.set(value.id, value); settings.set(value.id, value.defaultValue); },
|
||||
getSettingValue: key => settings.get(key),
|
||||
} },
|
||||
graph: { _nodes: [node], getNodeById: id => app.graph._nodes.find(node => node.id === id) },
|
||||
canvas: { centerOnNode: node => centered.push(node) }, runningNodeId: '7',
|
||||
};
|
||||
load('kiko_follow_execution.js', { app, api });
|
||||
await extension.init();
|
||||
function set(key, value) {
|
||||
const id = `kikotools.follow_execution.${key}`;
|
||||
settings.set(id, value);
|
||||
definitions.get(id).onChange?.(value);
|
||||
}
|
||||
const emit = (type, detail = null) => {
|
||||
const event = new Event(type);
|
||||
event.detail = detail;
|
||||
api.dispatchEvent(event);
|
||||
};
|
||||
return { extension, app, node, settings, centered, emit, set };
|
||||
}
|
||||
|
||||
test('Follow Execution stops immediately when disabled without a menu or execution restart', async () => {
|
||||
const f = await followFixture();
|
||||
f.set('enabled', true);
|
||||
f.set('auto_start', true);
|
||||
await f.extension.setup();
|
||||
f.emit('executing', '7');
|
||||
assert.equal(f.centered.length, 1);
|
||||
f.set('enabled', false);
|
||||
f.emit('executing', '7');
|
||||
assert.equal(f.centered.length, 1);
|
||||
f.set('enabled', true);
|
||||
f.emit('executing', '7');
|
||||
assert.equal(f.centered.length, 1);
|
||||
f.emit('execution_start');
|
||||
f.emit('executing', '7');
|
||||
assert.equal(f.centered.length, 2);
|
||||
});
|
||||
|
||||
test('Follow Execution setup subscribes once per event across repeated setup', async () => {
|
||||
const f = await followFixture();
|
||||
f.set('enabled', true);
|
||||
f.set('auto_start', true);
|
||||
for (let i = 0; i < 3; i++) await f.extension.setup();
|
||||
f.emit('executing', '7');
|
||||
assert.deepEqual(f.centered, [f.node]);
|
||||
});
|
||||
|
||||
test('Follow Execution menus safely group prototype-like types and sort nodes by position', async () => {
|
||||
const f = await followFixture();
|
||||
f.set('enabled', true);
|
||||
await f.extension.setup();
|
||||
f.app.graph._nodes = ['constructor', '__proto__', 'toString', 'constructor'].map((type, index) => ({
|
||||
id: String(index), type, pos: [30 - index * 10, 0], getTitle: () => type,
|
||||
}));
|
||||
const menu = f.extension.getCanvasMenuItems().find(item => item?.content === '🫶 Go to node');
|
||||
const options = menu.submenu.options;
|
||||
assert.deepEqual(Array.from(options, item => item.content), ['__proto__', 'constructor', 'toString']);
|
||||
const constructors = options[1].submenu.options;
|
||||
assert.match(constructors[0].content, /#3 \(0, 0\)/);
|
||||
assert.match(constructors[1].content, /#0 \(30, 0\)/);
|
||||
constructors[0].callback();
|
||||
assert.equal(f.centered[0], f.app.graph._nodes[3]);
|
||||
});
|
||||
|
||||
test('Follow Execution ignores a stale follow menu after disabling the feature', async () => {
|
||||
const f = await followFixture();
|
||||
f.set('enabled', true);
|
||||
await f.extension.setup();
|
||||
const follow = f.extension.getCanvasMenuItems()[1];
|
||||
f.set('enabled', false);
|
||||
follow.callback();
|
||||
assert.equal(f.centered.length, 0);
|
||||
f.set('enabled', true);
|
||||
f.emit('executing', '7');
|
||||
assert.equal(f.centered.length, 0);
|
||||
});
|
||||
|
||||
test('Follow Execution does not resume after disabling and re-enabling between events', async () => {
|
||||
const f = await followFixture();
|
||||
f.set('enabled', true);
|
||||
f.set('auto_start', true);
|
||||
await f.extension.setup();
|
||||
f.set('enabled', false);
|
||||
f.set('enabled', true);
|
||||
f.emit('executing', '7');
|
||||
assert.equal(f.centered.length, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveNodeSize } from '../../web/views/node-dimensions.js';
|
||||
|
||||
test('node dimensions accept whole pixels and respect content minimums', () => {
|
||||
assert.deepEqual(resolveNodeSize(['640', '480'], [180, 120]), [640, 480]);
|
||||
assert.deepEqual(resolveNodeSize(['1', '10'], [180, 120]), [180, 120]);
|
||||
});
|
||||
|
||||
test('node dimensions reject missing, fractional, unsafe and nonpositive values', () => {
|
||||
for (const value of ['', ' ', '0', '-1', '1.5', 'Infinity', 'NaN', '9007199254740992']) {
|
||||
assert.equal(resolveNodeSize([value, '480'], [180, 120]), null, value);
|
||||
assert.equal(resolveNodeSize(['640', value], [180, 120]), null, value);
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,29 @@ from kikotools.tools.gemini_prompt.prompts import (
|
||||
class TestGeminiPromptNode:
|
||||
"""Test cases for GeminiPromptNode."""
|
||||
|
||||
def test_deprecated_registration_preserves_legacy_workflow_lookup(self) -> None:
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
cls = NODE_CLASS_MAPPINGS["GeminiPrompt"]
|
||||
assert cls is GeminiPromptNode
|
||||
assert NODE_DISPLAY_NAME_MAPPINGS["GeminiPrompt"] == "Gemini Prompt Engineer"
|
||||
assert getattr(cls, "DEPRECATED", False) is True
|
||||
assert "existing workflows" in cls.DESCRIPTION.lower()
|
||||
assert "no longer receives" in cls.DESCRIPTION.lower()
|
||||
|
||||
def test_legacy_workflow_schema_is_retained(self) -> None:
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.get_available_models",
|
||||
return_value=(["legacy-model"], None),
|
||||
):
|
||||
inputs = GeminiPromptNode.INPUT_TYPES()
|
||||
assert set(inputs["required"]) == {"image", "prompt_type", "model"}
|
||||
assert set(inputs["optional"]) == {"api_key", "custom_prompt", "refresh_models"}
|
||||
assert inputs["required"]["model"][0] == ["legacy-model"]
|
||||
assert callable(getattr(GeminiPromptNode(), GeminiPromptNode.FUNCTION))
|
||||
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
|
||||
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node has correct properties."""
|
||||
assert GeminiPromptNode.CATEGORY == "🫶 ComfyAssets/🧠 Prompts"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, access, open } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import runtime from './comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
export async function launchComfyHost({ checkout, repository = repositoryRoot,
|
||||
python = process.env.KIKO_TEST_PYTHON || 'python', port = 8189, pins = runtime,
|
||||
logPath = join(repositoryRoot, 'tools/testing/test-results/comfyui-host.log'), forwardOutput = true }) {
|
||||
if (!checkout) throw new Error('Set KIKO_COMFY_ROOT to the pinned ComfyUI checkout');
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Use an unprivileged ComfyUI port');
|
||||
checkout = await realpath(checkout);
|
||||
repository = await realpath(repository);
|
||||
await access(join(checkout, 'main.py'));
|
||||
await access(join(repository, '__init__.py'));
|
||||
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
if (git(['rev-parse', 'HEAD']) !== pins.revision) throw new Error(`ComfyUI revision must be ${pins.revision}`);
|
||||
try { git(['diff', '--quiet', 'HEAD', '--']); }
|
||||
catch { throw new Error('ComfyUI checkout has modified tracked files'); }
|
||||
const stateDirectory = await mkdtemp(join(tmpdir(), 'kiko-comfy-host-'));
|
||||
let log, child;
|
||||
try {
|
||||
for (const directory of ['user', 'input', 'output', 'temp', 'custom_nodes']) await mkdir(join(stateDirectory, directory));
|
||||
await symlink(repository, join(stateDirectory, 'custom_nodes/kikotools'), 'junction');
|
||||
await mkdir(dirname(logPath), { recursive: true });
|
||||
const handle = await open(logPath, 'w');
|
||||
log = handle.createWriteStream();
|
||||
child = spawn(python, [join(checkout, 'main.py'), '--cpu', '--offline',
|
||||
'--listen', '127.0.0.1', '--port', String(port), '--base-directory', stateDirectory,
|
||||
'--user-directory', join(stateDirectory, 'user'), '--database-url', 'sqlite:///:memory:',
|
||||
'--disable-all-custom-nodes', '--whitelist-custom-nodes', 'kikotools'], {
|
||||
cwd: checkout, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONUNBUFFERED: '1', HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1' },
|
||||
});
|
||||
} catch (error) {
|
||||
log?.end();
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
let error, stopping = false;
|
||||
for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
|
||||
stream.on('data', chunk => { log.write(chunk); if (forwardOutput) output.write(chunk); });
|
||||
}
|
||||
child.on('error', value => { error = value; log.write(`ComfyUI failed to start: ${value.message}\n`); });
|
||||
const finished = new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal })))
|
||||
.then(async result => {
|
||||
const code = error ? 1 : result.code ?? (stopping ? 0 : 1);
|
||||
log.write(`ComfyUI stopped: exit=${code}, signal=${result.signal || 'none'}\n`);
|
||||
await new Promise(resolve => log.end(resolve));
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
return { ...result, code, error };
|
||||
});
|
||||
return { child, stateDirectory, finished, async stop() {
|
||||
stopping = true;
|
||||
if (child.pid && child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||
const deadline = setTimeout(() => child.kill('SIGKILL'), 5000);
|
||||
deadline.unref();
|
||||
try { return await finished; }
|
||||
finally { clearTimeout(deadline); }
|
||||
} };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const host = await launchComfyHost({ checkout: process.env.KIKO_COMFY_ROOT,
|
||||
port: Number(process.env.KIKO_COMFY_PORT || 8189) });
|
||||
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
const result = await host.finished;
|
||||
process.removeListener('SIGINT', stop);
|
||||
process.removeListener('SIGTERM', stop);
|
||||
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/testing/test-results/comfyui-host.log`);
|
||||
process.exitCode = result.code;
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"repository": "Comfy-Org/ComfyUI",
|
||||
"revision": "e8665ca8e4089ab917567cdb491308e1397663d4",
|
||||
"serverVersion": "0.39.0",
|
||||
"frontendVersion": "1.55.14",
|
||||
"pythonVersion": "3.12",
|
||||
"torchVersion": "2.11.0",
|
||||
"torchvisionVersion": "0.26.0"
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
if (!process.env.KIKO_COMFY_URL) throw new Error('Set KIKO_COMFY_URL to a local ComfyUI instance');
|
||||
const host = new URL(process.env.KIKO_COMFY_URL);
|
||||
if (!['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
|
||||
const managed = Boolean(process.env.KIKO_COMFY_ROOT);
|
||||
const hostURL = process.env.KIKO_COMFY_URL || (managed ? 'http://127.0.0.1:8189/' : null);
|
||||
if (!hostURL) throw new Error('Set KIKO_COMFY_URL for an existing host or KIKO_COMFY_ROOT for a managed host');
|
||||
const host = new URL(hostURL);
|
||||
if (!['http:', 'https:'].includes(host.protocol) || !['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
|
||||
if (managed && (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port)) throw new Error('A managed host needs an HTTP 127.0.0.1 URL with an explicit port');
|
||||
const servers = [{
|
||||
command: 'node tools/testing/start-browser-server.mjs --port 8771',
|
||||
cwd: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
url: 'http://127.0.0.1:8771/__test__/', reuseExistingServer: false,
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
|
||||
}];
|
||||
if (managed) servers.unshift({
|
||||
command: 'node tools/testing/comfyui-host.mjs',
|
||||
cwd: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
|
||||
env: { KIKO_COMFY_PORT: host.port },
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '../../tests/comfyui',
|
||||
@@ -13,11 +29,9 @@ export default defineConfig({
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: 0,
|
||||
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
|
||||
webServer: {
|
||||
command: 'node tools/testing/start-browser-server.mjs --port 8771',
|
||||
cwd: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
url: 'http://127.0.0.1:8771/__test__/',
|
||||
reuseExistingServer: false,
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
|
||||
},
|
||||
webServer: servers,
|
||||
projects: [
|
||||
{ name: 'classic-start', metadata: { initialRenderer: false } },
|
||||
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,21 +1,51 @@
|
||||
{
|
||||
"autocomplete/tag-download.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"autocomplete/tag-storage.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 74,
|
||||
"statements": 98
|
||||
},
|
||||
"autocomplete/tag-worker.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"autocomplete/tags.js": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"controllers/display-node.js": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 86,
|
||||
"statements": 98
|
||||
},
|
||||
"display_any.js": {
|
||||
"lines": 84,
|
||||
"branches": 66,
|
||||
"functions": 83,
|
||||
"statements": 84
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"display_text.js": {
|
||||
"lines": 71,
|
||||
"branches": 74,
|
||||
"functions": 42,
|
||||
"statements": 71
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"empty_latent_batch_swap.js": {
|
||||
"lines": 41,
|
||||
"branches": 91,
|
||||
"functions": 64,
|
||||
"statements": 41
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"gemini_prompt_ui.js": {
|
||||
"lines": 52,
|
||||
@@ -24,10 +54,10 @@
|
||||
"statements": 52
|
||||
},
|
||||
"kikoEmbeddingAutocomplete.js": {
|
||||
"lines": 44,
|
||||
"branches": 43,
|
||||
"functions": 59,
|
||||
"statements": 44
|
||||
"lines": 82,
|
||||
"branches": 74,
|
||||
"functions": 92,
|
||||
"statements": 82
|
||||
},
|
||||
"kiko_custom_colors.js": {
|
||||
"lines": 46,
|
||||
@@ -60,10 +90,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"kikotools_extensions.js": {
|
||||
"lines": 12,
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 0,
|
||||
"statements": 12
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"seed_history_ui.js": {
|
||||
"lines": 95,
|
||||
@@ -71,6 +101,12 @@
|
||||
"functions": 88,
|
||||
"statements": 95
|
||||
},
|
||||
"ui/autocomplete-suggestions.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/color-picker.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
@@ -89,34 +125,76 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/dialog.js": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/dimension-swap.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 48,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/dimensions.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/floating-window.js": {
|
||||
"lines": 93,
|
||||
"branches": 80,
|
||||
"functions": 98,
|
||||
"statements": 93
|
||||
},
|
||||
"ui/text-widget-adapter.js": {
|
||||
"lines": 98,
|
||||
"branches": 89,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/workflow-clock.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/autocomplete-settings.js": {
|
||||
"lines": 98,
|
||||
"branches": 83,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/image-browser.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/node-dimensions.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/seed-history.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/text-output.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"width_height_swap.js": {
|
||||
"lines": 60,
|
||||
"branches": 79,
|
||||
"functions": 73,
|
||||
"statements": 60
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"engines": { "node": ">=24" },
|
||||
"scripts": {
|
||||
"test": "node --test ../../tests/js/*.test.mjs",
|
||||
"test:comfyui": "playwright test --config comfyui.config.mjs",
|
||||
"test:browser": "playwright test --config playwright.config.mjs",
|
||||
"test:coverage": "node frontend-coverage.mjs",
|
||||
"coverage:update": "node frontend-coverage.mjs --update-baseline"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MAX_TAG_BYTES } from './tags.js';
|
||||
|
||||
export async function readTagResponse(response, { maxBytes = MAX_TAG_BYTES, signal } = {}) {
|
||||
const tooLarge = () => new Error(`Tag file too large. Maximum size is ${maxBytes === MAX_TAG_BYTES ? '32 MB' : `${maxBytes} bytes`}.`);
|
||||
if (signal?.aborted) {
|
||||
await response.body?.cancel();
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
if (Number(response.headers.get('content-length')) > maxBytes) {
|
||||
await response.body?.cancel();
|
||||
throw tooLarge();
|
||||
}
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader();
|
||||
const abort = () => { reader.cancel().catch(() => {}); };
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const decoder = new TextDecoder();
|
||||
const parts = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
signal?.throwIfAborted();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw tooLarge();
|
||||
}
|
||||
parts.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
parts.push(decoder.decode());
|
||||
return parts.join('');
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function tagCache(value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('kikotools-autocomplete', 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('tags');
|
||||
request.onerror = () => reject(request.error);
|
||||
let blocked = false;
|
||||
request.onblocked = () => { blocked = true; reject(new Error('Tag storage is blocked by another browser tab.')); };
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
if (blocked) { db.close(); return; }
|
||||
db.onversionchange = () => db.close();
|
||||
const transaction = db.transaction('tags', value === undefined ? 'readonly' : 'readwrite');
|
||||
const store = transaction.objectStore('tags');
|
||||
const result = value === undefined ? store.get('active') : store.put(value, 'active');
|
||||
transaction.oncomplete = () => { db.close(); resolve(result.result); };
|
||||
transaction.onabort = transaction.onerror = () => { db.close(); reject(transaction.error || new Error('Could not access tag storage.')); };
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTagFile(text, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) { reject(new DOMException('Import cancelled.', 'AbortError')); return; }
|
||||
const worker = new Worker(new URL('./tag-worker.js', import.meta.url), { type: 'module' });
|
||||
const finish = (callback, value) => {
|
||||
worker.terminate();
|
||||
signal.removeEventListener('abort', abort);
|
||||
callback(value);
|
||||
};
|
||||
const abort = () => finish(reject, new DOMException('Import cancelled.', 'AbortError'));
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
worker.onmessage = ({ data }) => data.error ? finish(reject, new Error(data.error)) : finish(resolve, data.tags);
|
||||
worker.onerror = () => finish(reject, new Error('Could not parse tag file.'));
|
||||
worker.postMessage(text);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { parseTagList } from './tags.js';
|
||||
self.onmessage = ({ data }) => {
|
||||
try { self.postMessage({ tags: parseTagList(data) }); }
|
||||
catch (error) { self.postMessage({ error: error.message }); }
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
export const MAX_TAG_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
function* csvRows(text) {
|
||||
let row = [], field = '', quoted = false, closed = false;
|
||||
for (let i = 0; i <= text.length; i++) {
|
||||
const char = text[i];
|
||||
if (quoted) {
|
||||
if (char === undefined) throw new Error('Unclosed CSV quote.');
|
||||
if (char === '"') {
|
||||
if (text[i + 1] === '"') { field += '"'; i++; }
|
||||
else { quoted = false; closed = true; }
|
||||
} else field += char;
|
||||
} else if (char === ',' || char === '\n' || char === undefined) {
|
||||
row.push(field.trim());
|
||||
field = ''; closed = false;
|
||||
if (char !== ',') {
|
||||
if (row.some(value => value !== '')) yield row;
|
||||
row = [];
|
||||
}
|
||||
} else if (char === '\r' && (text[i + 1] === '\n' || i === text.length - 1)) {
|
||||
continue;
|
||||
} else if (char === '"' && !field && !closed) quoted = true;
|
||||
else {
|
||||
if (closed && char.trim()) throw new Error('Unexpected text after CSV quote.');
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTagList(text) {
|
||||
if (new TextEncoder().encode(text).byteLength > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.');
|
||||
if (/^\s*<(?:!doctype|html|script|body|iframe)\b/i.test(text)) throw new Error('Expected a tag list, received HTML or script content.');
|
||||
const unique = new Map();
|
||||
let first = true;
|
||||
for (const row of csvRows(text.replace(/^\ufeff/, ''))) {
|
||||
if (first && ['tag', 'name'].includes(row[0].toLowerCase()) && ['category', 'frequency', 'post_count'].includes(row[1]?.toLowerCase())) {
|
||||
first = false; continue;
|
||||
}
|
||||
first = false;
|
||||
if (![1, 2, 4].includes(row.length)) throw new Error('Expected tag,frequency or tag,category,post_count,aliases.');
|
||||
const name = row[0];
|
||||
if (!name || name.length > 500 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('Invalid tag name.');
|
||||
const count = row.length === 4 ? row[2] : row[1] || '0';
|
||||
if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count))) throw new Error(`Invalid post count for ${name}.`);
|
||||
const category = row.length === 4 ? Number(row[1]) : null;
|
||||
if (row.length === 4 && (!/^[01345]$/.test(row[1]))) throw new Error(`Invalid category for ${name}.`);
|
||||
const aliases = row.length === 4 ? [...new Set(row[3].split(',').map(value => value.trim()).filter(Boolean))] : [];
|
||||
if (aliases.some(alias => alias.length > 500 || /[\u0000-\u001f\u007f]/.test(alias))) throw new Error(`Invalid alias for ${name}.`);
|
||||
const existing = unique.get(name);
|
||||
if (existing) {
|
||||
existing.frequency = Math.max(existing.frequency, Number(count));
|
||||
existing.aliases = [...new Set([...existing.aliases, ...aliases])];
|
||||
} else unique.set(name, { name, category, frequency: Number(count), aliases });
|
||||
}
|
||||
if (!unique.size) throw new Error('No tags found in the file.');
|
||||
for (const tag of unique.values()) tag.searchText = [tag.name, ...tag.aliases].join('\n').toLowerCase();
|
||||
return [...unique.values()].sort((a, b) => b.frequency - a.frequency || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function searchTags(tags, prefix, limit) {
|
||||
if (limit <= 0) return [];
|
||||
prefix = prefix.toLowerCase();
|
||||
const buckets = [[], [], []];
|
||||
for (const tag of tags) {
|
||||
if (tag.searchText && !tag.searchText.includes(prefix)) continue;
|
||||
const names = [tag.name, ...(tag.aliases || [])];
|
||||
let tier = 3;
|
||||
for (const name of names) {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === prefix) { tier = 0; break; }
|
||||
if (lower.startsWith(prefix)) tier = Math.min(tier, 1);
|
||||
else if (lower.includes(prefix)) tier = Math.min(tier, 2);
|
||||
}
|
||||
if (tier < 3 && buckets[tier].length < limit) buckets[tier].push(tag);
|
||||
if (buckets[0].length >= limit) break;
|
||||
}
|
||||
return buckets.flat().slice(0, limit).map(tag => ({ ...tag, fullPath: tag.name, type: 'custom', display: tag.name, value: tag.name }));
|
||||
}
|
||||
|
||||
export function tagQuery(text, weighted = false) {
|
||||
const match = text.match(/[^\s,]+$/u);
|
||||
if (!match) return null;
|
||||
const leading = weighted ? match[0].match(/^[\[(]*/)[0].length : 0;
|
||||
const prefix = match[0].slice(leading);
|
||||
return prefix ? { prefix, start: match.index + leading } : null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createScope } from '../ui/core.js';
|
||||
import { createTextOutputView } from '../views/text-output.js';
|
||||
|
||||
export function restoreDisplayOutput(data, kind, hasInputWidget) {
|
||||
if (data.kikoDisplay?.version === 1 && typeof data.kikoDisplay.text === 'string') return data.kikoDisplay.text;
|
||||
if (!Array.isArray(data.widgets_values)) return null;
|
||||
const values = data.widgets_values.slice(hasInputWidget && data.widgets_values.length > 1 ? 1 : 0);
|
||||
if (kind === 'any' && ['raw value', 'tensor shape'].includes(values[0])) values.shift();
|
||||
const strings = values.filter(value => typeof value === 'string');
|
||||
if (!strings.length) return null;
|
||||
if (kind === 'text' && strings[0] === '✓ Positive Prompt' && strings[2] === '✗ Negative Prompt') {
|
||||
return `Positive prompt: ${strings[1]}\nNegative prompt: ${strings[3]}`;
|
||||
}
|
||||
return kind === 'any' ? strings.join('\n') : strings[0];
|
||||
}
|
||||
|
||||
function updateOutput(node, text) {
|
||||
if (node.displayOutput.scope.signal.aborted) return;
|
||||
node.displayOutput.text = text;
|
||||
node.displayOutput.view.update(text);
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
export function registerDisplayNode(nodeType, app, kind) {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
const scope = createScope();
|
||||
const view = createTextOutputView(scope, kind, app.ui.settings);
|
||||
this.displayOutput = { scope, view, text: '' };
|
||||
this.addDOMWidget('kiko_display_output', 'div', view.element, {
|
||||
serialize: false, hideOnZoom: false,
|
||||
getMinHeight: () => kind === 'text' ? 240 : 180,
|
||||
});
|
||||
this.setSize([Math.max(this.size[0], kind === 'text' ? 400 : 450), Math.max(this.size[1], kind === 'text' ? 300 : 250)]);
|
||||
updateOutput(this, kind === 'text' ? 'Text will appear here after execution...' : 'Value will appear here...');
|
||||
return result;
|
||||
};
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
const result = onExecuted?.apply(this, arguments);
|
||||
if (typeof message?.text?.[0] === 'string') updateOutput(this, message.text[0]);
|
||||
return result;
|
||||
};
|
||||
const configure = nodeType.prototype.configure;
|
||||
nodeType.prototype.configure = function (data) {
|
||||
const text = restoreDisplayOutput(data, kind, Boolean(this.inputs?.[0]?.widget));
|
||||
const result = configure?.apply(this, arguments);
|
||||
if (text !== null) updateOutput(this, text);
|
||||
return result;
|
||||
};
|
||||
const onSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function (data) {
|
||||
const result = onSerialize?.apply(this, arguments);
|
||||
data.kikoDisplay = { version: 1, text: this.displayOutput.text };
|
||||
return result;
|
||||
};
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
this.displayOutput.scope.dispose();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
@@ -1,126 +1,9 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { registerDisplayNode } from './controllers/display-node.js';
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.DisplayAny",
|
||||
name: 'ComfyAssets.DisplayAny',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "DisplayAny") {
|
||||
function populate(text) {
|
||||
// Remove existing display widgets
|
||||
if (this.widgets) {
|
||||
const toRemove = [];
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
if (this.widgets[i].name?.startsWith("display_") || this.widgets[i].name === "copy_value") {
|
||||
this.widgets[i].onRemove?.();
|
||||
toRemove.push(i);
|
||||
}
|
||||
}
|
||||
// Remove in reverse order to maintain indices
|
||||
for (let i = toRemove.length - 1; i >= 0; i--) {
|
||||
this.widgets.splice(toRemove[i], 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Split text into multiple widgets if it's too long or has multiple lines
|
||||
const lines = text ? text.split('\n') : [''];
|
||||
const maxLinesPerWidget = 20;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
|
||||
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
|
||||
}
|
||||
|
||||
// Create a widget for each chunk
|
||||
chunks.forEach((chunk, index) => {
|
||||
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
|
||||
w.inputEl.readOnly = true;
|
||||
w.inputEl.style.opacity = 0.8;
|
||||
w.inputEl.style.fontFamily = "monospace";
|
||||
w.value = chunk;
|
||||
});
|
||||
|
||||
// Add copy button widget
|
||||
const copyWidget = {
|
||||
type: "button",
|
||||
name: "copy_value",
|
||||
text: "📋 Copy Value",
|
||||
callback: () => {
|
||||
navigator.clipboard.writeText(text || '').then(() => {
|
||||
copyWidget.text = "✓ Copied!";
|
||||
this.setDirtyCanvas(true);
|
||||
setTimeout(() => {
|
||||
copyWidget.text = "📋 Copy Value";
|
||||
this.setDirtyCanvas(true);
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
this.addCustomWidget(copyWidget);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const sz = this.computeSize();
|
||||
if (sz[0] < this.size[0]) {
|
||||
sz[0] = this.size[0];
|
||||
}
|
||||
if (sz[1] < this.size[1]) {
|
||||
sz[1] = this.size[1];
|
||||
}
|
||||
this.onResize?.(sz);
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
});
|
||||
}
|
||||
|
||||
// When the node is executed we will be sent the display text
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
console.log("DisplayAny onExecuted message:", message);
|
||||
|
||||
if (message?.text && message.text.length > 0) {
|
||||
const displayText = message.text[0];
|
||||
console.log("DisplayAny displayText:", displayText);
|
||||
populate.call(this, displayText);
|
||||
} else {
|
||||
console.log("DisplayAny no text in message");
|
||||
}
|
||||
};
|
||||
|
||||
const VALUES = Symbol();
|
||||
const configure = nodeType.prototype.configure;
|
||||
nodeType.prototype.configure = function () {
|
||||
this[VALUES] = arguments[0]?.widgets_values;
|
||||
return configure?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
onConfigure?.apply(this, arguments);
|
||||
const widgets_values = this[VALUES];
|
||||
if (widgets_values?.length) {
|
||||
requestAnimationFrame(() => {
|
||||
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
|
||||
if (widgets_values[startIdx]) {
|
||||
populate.call(this, widgets_values[startIdx]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on node creation
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Set minimum size - make it wider for better JSON display
|
||||
this.size[0] = Math.max(this.size[0], 450);
|
||||
this.size[1] = Math.max(this.size[1], 250);
|
||||
|
||||
// Add placeholder text
|
||||
populate.call(this, "Value will appear here...");
|
||||
};
|
||||
}
|
||||
}
|
||||
if (nodeData.name === 'DisplayAny') registerDisplayNode(nodeType, app, 'any');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,199 +1,9 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { registerDisplayNode } from './controllers/display-node.js';
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.DisplayText",
|
||||
name: 'ComfyAssets.DisplayText',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "DisplayText") {
|
||||
function populate(text) {
|
||||
// Remove existing widgets
|
||||
if (this.widgets) {
|
||||
const toRemove = [];
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
if (this.widgets[i].name?.startsWith("text_") || this.widgets[i].name?.startsWith("copy_")) {
|
||||
this.widgets[i].onRemove?.();
|
||||
toRemove.push(i);
|
||||
}
|
||||
}
|
||||
// Remove in reverse order to maintain indices
|
||||
for (let i = toRemove.length - 1; i >= 0; i--) {
|
||||
this.widgets.splice(toRemove[i], 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the text to detect positive/negative prompt format
|
||||
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
|
||||
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
|
||||
|
||||
if (posMatch && negMatch) {
|
||||
// Extract prompt content
|
||||
let positiveText = posMatch[1].trim();
|
||||
let negativeText = negMatch[1].trim();
|
||||
|
||||
// Remove any trailing ** markers
|
||||
const posEndIndex = positiveText.indexOf('**');
|
||||
if (posEndIndex > 0) {
|
||||
positiveText = positiveText.substring(0, posEndIndex).trim();
|
||||
}
|
||||
|
||||
const negEndIndex = negativeText.indexOf('**');
|
||||
if (negEndIndex > 0) {
|
||||
negativeText = negativeText.substring(0, negEndIndex).trim();
|
||||
}
|
||||
|
||||
// Create header widget for positive prompt
|
||||
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
|
||||
posHeader.inputEl.readOnly = true;
|
||||
posHeader.inputEl.style.opacity = 0.7;
|
||||
posHeader.inputEl.style.backgroundColor = "#2a4a2a";
|
||||
posHeader.inputEl.style.color = "#8f8";
|
||||
posHeader.inputEl.style.fontWeight = "bold";
|
||||
posHeader.value = "✓ Positive Prompt";
|
||||
|
||||
// Create positive prompt widget
|
||||
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
|
||||
posWidget.inputEl.readOnly = true;
|
||||
posWidget.inputEl.style.opacity = 0.9;
|
||||
posWidget.value = positiveText;
|
||||
|
||||
// Create copy button widget for positive prompt
|
||||
const posCopyWidget = {
|
||||
type: "button",
|
||||
name: "copy_positive",
|
||||
text: "📋 Copy Positive",
|
||||
callback: () => {
|
||||
navigator.clipboard.writeText(positiveText).then(() => {
|
||||
posCopyWidget.text = "✓ Copied!";
|
||||
this.setDirtyCanvas(true);
|
||||
setTimeout(() => {
|
||||
posCopyWidget.text = "📋 Copy Positive";
|
||||
this.setDirtyCanvas(true);
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
this.addCustomWidget(posCopyWidget);
|
||||
|
||||
// Create header widget for negative prompt
|
||||
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
|
||||
negHeader.inputEl.readOnly = true;
|
||||
negHeader.inputEl.style.opacity = 0.7;
|
||||
negHeader.inputEl.style.backgroundColor = "#4a2a2a";
|
||||
negHeader.inputEl.style.color = "#f88";
|
||||
negHeader.inputEl.style.fontWeight = "bold";
|
||||
negHeader.value = "✗ Negative Prompt";
|
||||
|
||||
// Create negative prompt widget
|
||||
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
|
||||
negWidget.inputEl.readOnly = true;
|
||||
negWidget.inputEl.style.opacity = 0.9;
|
||||
negWidget.value = negativeText;
|
||||
|
||||
// Create copy button widget for negative prompt
|
||||
const negCopyWidget = {
|
||||
type: "button",
|
||||
name: "copy_negative",
|
||||
text: "📋 Copy Negative",
|
||||
callback: () => {
|
||||
navigator.clipboard.writeText(negativeText).then(() => {
|
||||
negCopyWidget.text = "✓ Copied!";
|
||||
this.setDirtyCanvas(true);
|
||||
setTimeout(() => {
|
||||
negCopyWidget.text = "📋 Copy Negative";
|
||||
this.setDirtyCanvas(true);
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
this.addCustomWidget(negCopyWidget);
|
||||
|
||||
} else {
|
||||
// Single text display with ComfyUI's standard STRING widget
|
||||
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
|
||||
w.inputEl.readOnly = true;
|
||||
w.inputEl.style.opacity = 0.9;
|
||||
w.value = text;
|
||||
|
||||
// Create copy button widget
|
||||
const copyWidget = {
|
||||
type: "button",
|
||||
name: "copy_text",
|
||||
text: "📋 Copy",
|
||||
callback: () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
copyWidget.text = "✓ Copied!";
|
||||
this.setDirtyCanvas(true);
|
||||
setTimeout(() => {
|
||||
copyWidget.text = "📋 Copy";
|
||||
this.setDirtyCanvas(true);
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
this.addCustomWidget(copyWidget);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const sz = this.computeSize();
|
||||
if (sz[0] < this.size[0]) {
|
||||
sz[0] = this.size[0];
|
||||
}
|
||||
if (sz[1] < this.size[1]) {
|
||||
sz[1] = this.size[1];
|
||||
}
|
||||
this.onResize?.(sz);
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
});
|
||||
}
|
||||
|
||||
// When the node is executed we will be sent the input text
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
if (message?.text) {
|
||||
populate.call(this, message.text[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const VALUES = Symbol();
|
||||
const configure = nodeType.prototype.configure;
|
||||
nodeType.prototype.configure = function () {
|
||||
this[VALUES] = arguments[0]?.widgets_values;
|
||||
return configure?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
onConfigure?.apply(this, arguments);
|
||||
const widgets_values = this[VALUES];
|
||||
if (widgets_values?.length) {
|
||||
requestAnimationFrame(() => {
|
||||
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
|
||||
if (widgets_values[startIdx]) {
|
||||
populate.call(this, widgets_values[startIdx]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on node creation
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Set default size
|
||||
this.size[0] = Math.max(this.size[0], 400);
|
||||
this.size[1] = Math.max(this.size[1], 300);
|
||||
|
||||
// Add placeholder text
|
||||
populate.call(this, "Text will appear here after execution...");
|
||||
};
|
||||
}
|
||||
}
|
||||
if (nodeData.name === 'DisplayText') registerDisplayNode(nodeType, app, 'text');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,279 +1,20 @@
|
||||
// ComfyUI-KikoTools - Empty Latent Batch with Swap Button
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
|
||||
import { mountDimensionSwap } from './ui/dimension-swap.js';
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyassets.EmptyLatentBatch",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
if (nodeData.name === "EmptyLatentBatch") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) onNodeCreated.apply(this, []);
|
||||
|
||||
// Helper function to extract resolution from formatted preset string
|
||||
this.extractResolutionFromPreset = function (presetValue) {
|
||||
if (presetValue === "custom") return null;
|
||||
|
||||
// If it contains formatting metadata, extract the resolution part
|
||||
if (presetValue.includes(" - ")) {
|
||||
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
|
||||
return presetValue.split(" - ")[0];
|
||||
}
|
||||
|
||||
// Otherwise assume it's already a raw resolution
|
||||
return presetValue;
|
||||
name: 'comfyassets.EmptyLatentBatch',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== 'EmptyLatentBatch') return;
|
||||
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
this.extractResolutionFromPreset = extractResolutionFromPreset;
|
||||
this.swapDimensions = () => swapDimensions(this, app);
|
||||
bindDimensionPresets(this, app);
|
||||
this.createSwapButton();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Create swap button as DOM widget
|
||||
this.createSwapButton();
|
||||
|
||||
// Override preset callback to update width/height widgets when preset changes
|
||||
const presetWidget = this.widgets.find((w) => w.name === "preset");
|
||||
if (presetWidget) {
|
||||
const originalCallback = presetWidget.callback;
|
||||
presetWidget.callback = function (
|
||||
value,
|
||||
graphcanvas,
|
||||
node,
|
||||
pos,
|
||||
event,
|
||||
) {
|
||||
// Call original callback first
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value, graphcanvas, node, pos, event);
|
||||
}
|
||||
|
||||
// Update width/height widgets based on preset
|
||||
const widthWidget = node.widgets.find((w) => w.name === "width");
|
||||
const heightWidget = node.widgets.find((w) => w.name === "height");
|
||||
|
||||
if (widthWidget && heightWidget && value !== "custom") {
|
||||
// Extract raw resolution from formatted preset
|
||||
const rawResolution = node.extractResolutionFromPreset(value);
|
||||
|
||||
// Define all available presets from our preset system
|
||||
const presetDimensions = {
|
||||
// SDXL Presets
|
||||
"1024×1024": [1024, 1024],
|
||||
"896×1152": [896, 1152],
|
||||
"832×1216": [832, 1216],
|
||||
"768×1344": [768, 1344],
|
||||
"640×1536": [640, 1536],
|
||||
"1152×896": [1152, 896],
|
||||
"1216×832": [1216, 832],
|
||||
"1344×768": [1344, 768],
|
||||
"1536×640": [1536, 640],
|
||||
// FLUX Presets
|
||||
"1920×1080": [1920, 1080],
|
||||
"1536×1536": [1536, 1536],
|
||||
"1280×768": [1280, 768],
|
||||
"768×1280": [768, 1280],
|
||||
"1440×1080": [1440, 1080],
|
||||
"1080×1440": [1080, 1440],
|
||||
"1728×1152": [1728, 1152],
|
||||
"1152×1728": [1152, 1728],
|
||||
// Ultra-Wide Presets
|
||||
"2560×1080": [2560, 1080],
|
||||
"2048×768": [2048, 768],
|
||||
"1792×768": [1792, 768],
|
||||
"2304×768": [2304, 768],
|
||||
"1080×2560": [1080, 2560],
|
||||
"768×2048": [768, 2048],
|
||||
"768×1792": [768, 1792],
|
||||
"768×2304": [768, 2304],
|
||||
// Qwen Presets
|
||||
"1328×1328": [1328, 1328],
|
||||
"1664×928": [1664, 928],
|
||||
"928×1664": [928, 1664],
|
||||
"1472×1104": [1472, 1104],
|
||||
"1104×1472": [1104, 1472],
|
||||
"1584×1056": [1584, 1056],
|
||||
"1056×1584": [1056, 1584],
|
||||
"2080×688": [2080, 688],
|
||||
"688×2080": [688, 2080],
|
||||
};
|
||||
|
||||
if (rawResolution && presetDimensions[rawResolution]) {
|
||||
const [w, h] = presetDimensions[rawResolution];
|
||||
widthWidget.value = w;
|
||||
heightWidget.value = h;
|
||||
|
||||
// Trigger widget callbacks to update the UI
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(w, graphcanvas, node, pos, event);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(h, graphcanvas, node, pos, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add swap functionality
|
||||
this.swapDimensions = function () {
|
||||
const widthWidget = this.widgets.find((w) => w.name === "width");
|
||||
const heightWidget = this.widgets.find((w) => w.name === "height");
|
||||
const presetWidget = this.widgets.find((w) => w.name === "preset");
|
||||
|
||||
if (widthWidget && heightWidget && presetWidget) {
|
||||
// Handle preset swapping first
|
||||
if (presetWidget.value !== "custom") {
|
||||
const currentPreset = presetWidget.value;
|
||||
|
||||
// Extract raw resolution from formatted preset
|
||||
const rawResolution =
|
||||
this.extractResolutionFromPreset(currentPreset);
|
||||
if (!rawResolution) return;
|
||||
|
||||
// Parse current preset dimensions (handle both × and x separators)
|
||||
let w, h;
|
||||
if (rawResolution.includes("×")) {
|
||||
[w, h] = rawResolution.split("×").map((v) => parseInt(v));
|
||||
} else if (rawResolution.includes("x")) {
|
||||
[w, h] = rawResolution.split("x").map((v) => parseInt(v));
|
||||
} else {
|
||||
return; // Invalid preset format
|
||||
}
|
||||
|
||||
const swappedRawPreset = `${h}×${w}`;
|
||||
|
||||
// Find the formatted version of the swapped preset from available options
|
||||
const availablePresets =
|
||||
presetWidget.options.values || presetWidget.options;
|
||||
let swappedFormattedPreset = null;
|
||||
|
||||
for (const option of availablePresets) {
|
||||
if (option === "custom") continue;
|
||||
const extractedRes = this.extractResolutionFromPreset(option);
|
||||
if (extractedRes === swappedRawPreset) {
|
||||
swappedFormattedPreset = option;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (swappedFormattedPreset) {
|
||||
// Swapped preset exists, use the formatted version
|
||||
presetWidget.value = swappedFormattedPreset;
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback(
|
||||
swappedFormattedPreset,
|
||||
app.canvas,
|
||||
this,
|
||||
[0, 0],
|
||||
null
|
||||
);
|
||||
}
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
} else {
|
||||
// Swapped preset doesn't exist, switch to custom and swap manual values
|
||||
presetWidget.value = "custom";
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Custom preset - just swap the width and height values
|
||||
const tempWidth = widthWidget.value;
|
||||
widthWidget.value = heightWidget.value;
|
||||
heightWidget.value = tempWidth;
|
||||
|
||||
// Trigger widget change events
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the graph as changed
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Create swap button as DOM widget
|
||||
nodeType.prototype.createSwapButton = function () {
|
||||
// Create button container
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.style.cssText = `
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
// Create swap button
|
||||
const swapButton = document.createElement("button");
|
||||
swapButton.innerHTML = "↔️ Swap W×H";
|
||||
swapButton.style.cssText = `
|
||||
background: #4A90E2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
`;
|
||||
|
||||
// Button hover effects
|
||||
swapButton.addEventListener("mouseenter", () => {
|
||||
swapButton.style.background = "#5BA0F2";
|
||||
swapButton.style.transform = "translateY(-1px)";
|
||||
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
|
||||
});
|
||||
|
||||
swapButton.addEventListener("mouseleave", () => {
|
||||
swapButton.style.background = "#4A90E2";
|
||||
swapButton.style.transform = "translateY(0)";
|
||||
swapButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
|
||||
});
|
||||
|
||||
// Button click effect and functionality
|
||||
swapButton.addEventListener("mousedown", () => {
|
||||
swapButton.style.background = "#3A80D2";
|
||||
swapButton.style.transform = "translateY(1px)";
|
||||
swapButton.style.boxShadow = "0 1px 2px rgba(0,0,0,0.2)";
|
||||
});
|
||||
|
||||
swapButton.addEventListener("mouseup", () => {
|
||||
swapButton.style.background = "#5BA0F2";
|
||||
swapButton.style.transform = "translateY(-1px)";
|
||||
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
|
||||
});
|
||||
|
||||
// Main click functionality
|
||||
swapButton.addEventListener("click", () => {
|
||||
this.swapDimensions();
|
||||
});
|
||||
|
||||
buttonContainer.appendChild(swapButton);
|
||||
|
||||
// Add as DOM widget
|
||||
this.swapButtonWidget = this.addDOMWidget(
|
||||
"swap_button",
|
||||
"div",
|
||||
buttonContainer
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import { createColorPicker } from "./ui/color-picker.js";
|
||||
|
||||
// Custom colors feature with extended options based on PR #433
|
||||
// Adds custom color pickers for nodes with full, title, and background options
|
||||
|
||||
const colorShade = (col, amt) => {
|
||||
col = col.replace(/^#/, "");
|
||||
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
|
||||
const colorShade = (color, amount) => '#' + color.slice(1).match(/../g)
|
||||
.map(channel => Math.min(255, parseInt(channel, 16) + amount).toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
let [r, g, b] = col.match(/.{2}/g);
|
||||
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
|
||||
|
||||
r = Math.max(Math.min(255, r), 0).toString(16);
|
||||
g = Math.max(Math.min(255, g), 0).toString(16);
|
||||
b = Math.max(Math.min(255, b), 0).toString(16);
|
||||
|
||||
const rr = (r.length < 2 ? "0" : "") + r;
|
||||
const gg = (g.length < 2 ? "0" : "") + g;
|
||||
const bb = (b.length < 2 ? "0" : "") + b;
|
||||
|
||||
return `#${rr}${gg}${bb}`;
|
||||
};
|
||||
|
||||
// One lazily-created hidden color input, shared by every menu entry.
|
||||
// applyPicked holds what the currently open picker should do on change.
|
||||
let picker;
|
||||
let applyPicked;
|
||||
let activePicker;
|
||||
let pickerNode;
|
||||
|
||||
const isEnabled = () => {
|
||||
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
|
||||
@@ -42,9 +26,7 @@ const getSettings = () => ({
|
||||
// Helper function to apply color to node(s)
|
||||
// Uses setColorOption like the built-in colors when possible
|
||||
const applyColorToNodes = (colorValue, colorType, node) => {
|
||||
if (!colorValue) return;
|
||||
|
||||
const graphcanvas = LGraphCanvas.active_canvas;
|
||||
const graphcanvas = globalThis.LGraphCanvas?.active_canvas || app.canvas;
|
||||
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
|
||||
? [node]
|
||||
: Object.values(graphcanvas.selected_nodes);
|
||||
@@ -74,7 +56,6 @@ const applyColorToNodes = (colorValue, colorType, node) => {
|
||||
|
||||
// Groups have a single color
|
||||
const applyColorToGroup = (colorValue, group) => {
|
||||
if (!colorValue) return;
|
||||
if (group.setColorOption) {
|
||||
group.setColorOption({ groupcolor: colorValue });
|
||||
} else {
|
||||
@@ -83,19 +64,26 @@ const applyColorToGroup = (colorValue, group) => {
|
||||
app.canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
// Open the shared hidden color input; apply(color) runs when a color is picked
|
||||
const openPicker = (initialColor, apply) => {
|
||||
if (!picker) {
|
||||
picker = $el("input", {
|
||||
type: "color",
|
||||
parent: document.body,
|
||||
style: { display: "none" },
|
||||
});
|
||||
picker.onchange = () => applyPicked?.(picker.value);
|
||||
}
|
||||
applyPicked = apply;
|
||||
picker.value = initialColor || "#000000";
|
||||
picker.click();
|
||||
const openPicker = (initialColor, apply, node, label) => {
|
||||
activePicker?.remove();
|
||||
const previousFocus = document.activeElement;
|
||||
const expanded = /^#[0-9a-f]{3}$/i.test(initialColor)
|
||||
? '#' + [...initialColor.slice(1)].map(value => value + value).join('') : initialColor;
|
||||
const picker = createColorPicker(expanded, apply, app.ui.settings, label);
|
||||
picker.hidden = true;
|
||||
activePicker = picker;
|
||||
pickerNode = node;
|
||||
document.body.append(picker);
|
||||
picker.dialog.addEventListener('close', () => {
|
||||
picker.remove();
|
||||
if (activePicker === picker) {
|
||||
activePicker = null;
|
||||
pickerNode = null;
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
else app.canvas.canvas?.focus();
|
||||
}
|
||||
}, { once: true });
|
||||
picker.dialog.showModal();
|
||||
};
|
||||
|
||||
// Wrap menu options in the "Custom Colors" submenu (or nothing if empty)
|
||||
@@ -148,6 +136,14 @@ app.registerExtension({
|
||||
});
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType) {
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
if (pickerNode === this) activePicker?.remove();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
|
||||
// New API: return items to append to node right-click menu
|
||||
getNodeMenuItems(node) {
|
||||
if (!isEnabled()) return [];
|
||||
@@ -157,7 +153,10 @@ app.registerExtension({
|
||||
|
||||
const addOption = (label, type, initialColor) => options.push({
|
||||
content: label,
|
||||
callback: () => openPicker(initialColor, (color) => applyColorToNodes(color, type, node)),
|
||||
callback: () => openPicker(
|
||||
initialColor, (color) => applyColorToNodes(color, type, node),
|
||||
node, `Custom ${type === 'bg' ? 'background' : type} color`,
|
||||
),
|
||||
});
|
||||
|
||||
if (settings.showFull) addOption("🫶 Custom Full", 'full', node.bgcolor);
|
||||
@@ -181,7 +180,7 @@ app.registerExtension({
|
||||
|
||||
return customColorsMenu([{
|
||||
content: "🫶 Custom Group Color",
|
||||
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group)),
|
||||
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group), null, 'Group color'),
|
||||
}]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,12 +23,19 @@ const checkAutoStart = () => {
|
||||
};
|
||||
|
||||
const centerNode = (id) => {
|
||||
checkEnabled();
|
||||
if (!followExecution || !id || !isEnabled) return;
|
||||
const node = app.graph.getNodeById(id);
|
||||
if (!node) return;
|
||||
app.canvas.centerOnNode(node);
|
||||
};
|
||||
|
||||
const onExecuting = ({ detail }) => centerNode(detail);
|
||||
const onExecutionStart = () => {
|
||||
checkEnabled();
|
||||
checkAutoStart();
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "kikotools.followExecution",
|
||||
async init() {
|
||||
@@ -39,6 +46,7 @@ app.registerExtension({
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Enable follow execution feature in canvas right-click menu",
|
||||
onChange: checkEnabled,
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
@@ -63,13 +71,10 @@ app.registerExtension({
|
||||
checkAutoStart();
|
||||
|
||||
// Listen for execution events
|
||||
api.addEventListener("executing", ({ detail }) => centerNode(detail));
|
||||
api.addEventListener("executing", onExecuting);
|
||||
|
||||
// Listen for execution start to handle auto-start
|
||||
api.addEventListener("execution_start", () => {
|
||||
checkEnabled();
|
||||
checkAutoStart();
|
||||
});
|
||||
api.addEventListener("execution_start", onExecutionStart);
|
||||
},
|
||||
|
||||
// New API: return items to append to canvas right-click menu
|
||||
@@ -82,6 +87,8 @@ app.registerExtension({
|
||||
{
|
||||
content: followExecution ? "🫶 Stop following execution" : "🫶 Follow execution",
|
||||
callback: () => {
|
||||
checkEnabled();
|
||||
if (!isEnabled) return;
|
||||
followExecution = !followExecution;
|
||||
if (followExecution) {
|
||||
centerNode(app.runningNodeId);
|
||||
@@ -112,7 +119,7 @@ app.registerExtension({
|
||||
p[n.type] = [n];
|
||||
}
|
||||
return p;
|
||||
}, {});
|
||||
}, Object.create(null));
|
||||
|
||||
items.push({
|
||||
content: "🫶 Go to node",
|
||||
|
||||
@@ -1,197 +1,26 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { createLegacyDialog } from './ui/dialog.js';
|
||||
import { showNodeDimensionsDialog } from './views/node-dimensions.js';
|
||||
|
||||
/**
|
||||
* KikoTools Extensions - Adds utility features to all ComfyAssets nodes
|
||||
*/
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.Extensions",
|
||||
|
||||
// New API: return items to append to node right-click menu
|
||||
name: 'ComfyAssets.Extensions',
|
||||
getNodeMenuItems(node) {
|
||||
// Only add our menu items to ComfyAssets nodes
|
||||
if (!node.constructor.category || !node.constructor.category.includes("ComfyAssets")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
null, // separator
|
||||
{
|
||||
content: "🎨 Node Dimensions",
|
||||
callback: () => {
|
||||
KikoToolsExtensions.showNodeDimensionsDialog(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
if (!node.constructor.category || !node.constructor.category.includes('ComfyAssets')) return [];
|
||||
return [null, {
|
||||
content: '🎨 Node Dimensions',
|
||||
callback: () => KikoToolsExtensions.showNodeDimensionsDialog(node),
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* KikoTools Extensions utilities
|
||||
*/
|
||||
class KikoToolsExtensions {
|
||||
/**
|
||||
* Create a dialog for settings
|
||||
*/
|
||||
static createDialog(htmlContent, onOK, onCancel) {
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "kikotools-dialog";
|
||||
dialog.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #202020;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
z-index: 10000;
|
||||
font-family: Arial, sans-serif;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
// innerHTML used here with hardcoded templates only (numeric node dimensions)
|
||||
dialog.innerHTML = htmlContent;
|
||||
|
||||
// Create button container
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.style.cssText = `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
`;
|
||||
|
||||
// Create OK button
|
||||
const okButton = document.createElement("button");
|
||||
okButton.textContent = "OK";
|
||||
okButton.style.cssText = `
|
||||
padding: 8px 20px;
|
||||
background: #4A90E2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
`;
|
||||
okButton.onmouseover = () => okButton.style.background = "#5BA0F2";
|
||||
okButton.onmouseout = () => okButton.style.background = "#4A90E2";
|
||||
|
||||
// Create Cancel button
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.textContent = "Cancel";
|
||||
cancelButton.style.cssText = `
|
||||
padding: 8px 20px;
|
||||
background: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
`;
|
||||
cancelButton.onmouseover = () => cancelButton.style.background = "#777";
|
||||
cancelButton.onmouseout = () => cancelButton.style.background = "#666";
|
||||
|
||||
buttonContainer.appendChild(cancelButton);
|
||||
buttonContainer.appendChild(okButton);
|
||||
dialog.appendChild(buttonContainer);
|
||||
|
||||
// Dialog close function
|
||||
dialog.close = function() {
|
||||
if (dialog.parentNode) {
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
};
|
||||
|
||||
// Get all inputs
|
||||
const inputs = Array.from(dialog.querySelectorAll("input, select"));
|
||||
|
||||
// Handle keyboard events
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener("keydown", function(e) {
|
||||
if (e.keyCode === 27) { // ESC
|
||||
onCancel && onCancel();
|
||||
dialog.close();
|
||||
} else if (e.keyCode === 13) { // Enter
|
||||
onOK && onOK(dialog, inputs.map(input => input.value));
|
||||
dialog.close();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Button click handlers
|
||||
okButton.onclick = () => {
|
||||
onOK && onOK(dialog, inputs.map(input => input.value));
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
cancelButton.onclick = () => {
|
||||
onCancel && onCancel();
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
// Add to document
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// Focus first input
|
||||
if (inputs.length > 0) {
|
||||
inputs[0].focus();
|
||||
inputs[0].select();
|
||||
}
|
||||
|
||||
return dialog;
|
||||
return createLegacyDialog(htmlContent, onOK, onCancel, app.ui.settings, app.canvas.canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show node dimensions dialog
|
||||
*/
|
||||
static showNodeDimensionsDialog(node) {
|
||||
const nodeWidth = Math.round(node.size[0]);
|
||||
const nodeHeight = Math.round(node.size[1]);
|
||||
|
||||
const htmlContent = `
|
||||
<div style="color: #ddd; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #4A90E2;">Node Dimensions</h3>
|
||||
<div style="display: flex; gap: 20px; align-items: center;">
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Width:</label>
|
||||
<input type="number" class="width" value="${nodeWidth}"
|
||||
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Height:</label>
|
||||
<input type="number" class="height" value="${nodeHeight}"
|
||||
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px; font-size: 11px; color: #888;">
|
||||
Tip: Minimum size will be enforced based on node content
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.createDialog(
|
||||
htmlContent,
|
||||
function(dialog, values) {
|
||||
const widthValue = Number(values[0]) || nodeWidth;
|
||||
const heightValue = Number(values[1]) || nodeHeight;
|
||||
|
||||
// Calculate minimum size based on node content
|
||||
const minSize = node.computeSize();
|
||||
|
||||
// Apply new size (respecting minimums)
|
||||
node.setSize([
|
||||
Math.max(minSize[0], widthValue),
|
||||
Math.max(minSize[1], heightValue)
|
||||
]);
|
||||
|
||||
// Mark canvas as dirty to trigger redraw
|
||||
node.setDirtyCanvas(true, true);
|
||||
},
|
||||
null
|
||||
);
|
||||
return showNodeDimensionsDialog(node, app);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for global access
|
||||
window.KikoToolsExtensions = KikoToolsExtensions;
|
||||
|
||||
@@ -86,3 +86,117 @@ The setting renderer reuses its control across value updates. Its dialog lives
|
||||
in the document body so those re-renders preserve focus and native slider
|
||||
interaction. Cleanup waits one microtask to distinguish synchronous reparenting
|
||||
from actually closing/removing the settings control.
|
||||
|
||||
## Dimension swapping
|
||||
|
||||
Width Height Selector and Empty Latent Batch share `dimension-swap.js` and
|
||||
`dimensions.js`. Both use the same native blue Swap W×H button with keyboard and
|
||||
touch behavior. The selector no longer adds canvas drawing or mouse hit areas.
|
||||
Repeated mounting reuses one control; node removal disposes its theme/listeners.
|
||||
|
||||
The supplied preset labels determine dimensions, including Unicode `×`, ASCII
|
||||
`x` and formatted metadata. A matching reversed preset is retained; otherwise
|
||||
the node switches to custom dimensions. Custom values swap directly. Malformed
|
||||
preset dimensions leave the node unchanged. Each width/height callback receives
|
||||
the host context exactly once, and existing preset callback results are retained.
|
||||
|
||||
The control does not change node dimensions, batch size, native input order or
|
||||
backend interfaces. Workflow persistence remains with ComfyUI's native widgets;
|
||||
the DOM control does not serialize its UI state.
|
||||
|
||||
## Node Dimensions and modal dialogs
|
||||
|
||||
The ComfyAssets-only Node Dimensions context menu uses `dialog.js` for a native
|
||||
modal shell, shared theme tokens, focus restoration and scoped cleanup. Native
|
||||
modality contains keyboard focus; Escape and Cancel close without applying.
|
||||
`../views/node-dimensions.js` owns the form and node lifecycle binding.
|
||||
|
||||
Enter or Apply accepts positive whole pixel values and clamps them to the node's
|
||||
computed content minimum. Invalid values keep the form open with an associated
|
||||
error. Opening the same node again reuses its dialog; node removal closes it and
|
||||
preserves the previous removal hook's context, arguments and return value.
|
||||
ComfyUI continues to serialize the resulting node size.
|
||||
|
||||
The global `KikoToolsExtensions.createDialog(htmlContent, onOK, onCancel)` helper
|
||||
remains available for trusted HTML templates. It returns the native dialog with
|
||||
an immediate `close()` method. OK/Enter supplies the dialog and input/select
|
||||
values; Cancel/Escape invokes cancellation once. Manual `close()` dismisses
|
||||
without invoking cancellation. New views should build DOM through shared
|
||||
factories rather than pass HTML to this compatibility helper.
|
||||
|
||||
## Display Text and Display Any
|
||||
|
||||
Both nodes use `../views/text-output.js` for labelled, selectable read-only text
|
||||
areas and shared Copy buttons. Display Text detects paired positive/negative
|
||||
prompts and offers a copy action for each. Display Any preserves the complete
|
||||
formatted output in one scrollable monospace field instead of separate chunks.
|
||||
The view fills the available node area; updating output preserves node size.
|
||||
Copy success and failure appear in a polite status area. Replacement and removal
|
||||
cancel delayed feedback and ignore pending clipboard results from the old view.
|
||||
|
||||
`../controllers/display-node.js` preserves native input/mode widgets and existing
|
||||
lifecycle hook arguments, context and return values. Complete output, including
|
||||
empty text, is saved in a `kikoDisplay: { version: 1, text }` workflow snapshot.
|
||||
Legacy widget values remain readable: converted input prefixes, prompt header
|
||||
pairs, native Display Any modes and all saved output chunks are recognized.
|
||||
The new output DOM widget does not serialize presentation state into native
|
||||
widget values. Node removal disposes the view, its listeners and theme observers.
|
||||
|
||||
## Embedding Autocomplete
|
||||
|
||||
`autocomplete-suggestions.js` supplies the shared themed listbox, type badges,
|
||||
selected state and viewport placement. The controller retains resource fetching,
|
||||
matching, sorting, triggers and insertion preferences. Focus stays in the native
|
||||
textarea; arrow keys select suggestions, configured Enter/Tab keys insert, and
|
||||
Escape dismisses. New input clears the previous selection. Completion names are
|
||||
rendered as text, including directory names, without interpreting markup.
|
||||
|
||||
`text-widget-adapter.js` binds editable Nodes 2.0 replacement textareas through
|
||||
their node ID and associated label. It leaves the original widget/input intact,
|
||||
cleans up disconnected replacements, and preserves Classic behavior when switching
|
||||
back. Read-only and disabled fields are excluded. Each widget owns its listeners,
|
||||
blur work and temporary accessibility attributes; removing a different widget
|
||||
does not dismiss the active list. Page cleanup cancels pending refresh work.
|
||||
|
||||
`../views/autocomplete-settings.js` uses the shared native dialog and switches.
|
||||
Only Save updates the existing `kikotools.embedding_autocomplete.settings` key;
|
||||
Cancel/Escape discards edits. All advanced saved preferences are retained.
|
||||
Storage failures keep the dialog open and restore the previous live preferences.
|
||||
The settings launcher owns dialog cleanup, tolerates synchronous reparenting and
|
||||
returns keyboard focus when the modal closes. The shared modal shell contains
|
||||
pointer events so nested dialogs do not dismiss ComfyUI's parent settings panel.
|
||||
|
||||
### Tag packs and autocomplete preferences
|
||||
|
||||
The settings view stages CSV/TXT file and URL imports until Save Settings.
|
||||
`autocomplete/tags.js` parses quoted four-column Danbooru data and legacy
|
||||
word/frequency lists, validates counts/categories, deduplicates tags and aliases,
|
||||
and limits search results without sorting every matching tag on each keystroke.
|
||||
`autocomplete/tag-worker.js` performs parsing outside the UI thread; its owner
|
||||
terminates it on completion, error, or dialog removal. Records expand to the
|
||||
existing suggestion shape only for returned results.
|
||||
|
||||
`autocomplete/tag-storage.js` persists the active source text in browser
|
||||
IndexedDB. The controller restores matching cached sources before requesting a
|
||||
URL, guards stale loads, and retains the active list when an import fails.
|
||||
Advanced trigger, matching and insertion controls use the existing settings key.
|
||||
For dataset provenance, format and installation, see
|
||||
[Tag packs](../../docs/tag-packs/README.md).
|
||||
|
||||
Node removal uses one hook per node, shared by its active text widgets. Detaching
|
||||
widgets in any order restores the original hook after the last widget leaves.
|
||||
General queries accept Unicode and punctuation between whitespace/comma
|
||||
boundaries. Literal bracketed names are tried before interpreting leading
|
||||
parentheses/brackets as prompt weighting; insertion preserves surrounding
|
||||
weight syntax. URL downloads enforce their byte limit while reading the body,
|
||||
cancel oversized/aborted streams, and decode UTF-8 across chunk boundaries.
|
||||
|
||||
### Custom Colors
|
||||
|
||||
`../kiko_custom_colors.js` opens the same shared color picker as Workflow Timer
|
||||
from the host's node and group context menus. Hex entry, HSV sliders and presets
|
||||
apply immediately. Full/Title/Background options, selected-node behavior, title
|
||||
auto-shading and existing setting IDs are retained. Closing or replacing the
|
||||
picker removes its dialog and theme observers; removing its target node also
|
||||
closes it without replacing other node removal behavior. Keyboard focus returns
|
||||
to the available opener, with the canvas as a fallback.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createScope, mountTheme } from './core.js';
|
||||
|
||||
let nextList = 0;
|
||||
|
||||
export function suggestionPosition(field, popup, viewport) {
|
||||
const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8));
|
||||
const below = field.bottom + 4;
|
||||
const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4;
|
||||
return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) };
|
||||
}
|
||||
|
||||
export function createSuggestionView(scope, settings) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'ca-tools ca-completions kikotools-autocomplete-suggestions';
|
||||
element.id = `kiko-completions-${++nextList}`;
|
||||
element.role = 'listbox';
|
||||
element.setAttribute('aria-label', 'Prompt completions');
|
||||
element.hidden = true;
|
||||
document.body.append(element);
|
||||
mountTheme(element, settings, scope);
|
||||
let optionsScope;
|
||||
scope.cleanup(() => { optionsScope?.dispose(); element.remove(); });
|
||||
return {
|
||||
element,
|
||||
hide() {
|
||||
optionsScope?.dispose();
|
||||
element.hidden = true;
|
||||
element.replaceChildren();
|
||||
},
|
||||
render(suggestions, onSelect, onInsert) {
|
||||
optionsScope?.dispose();
|
||||
optionsScope = createScope();
|
||||
element.replaceChildren();
|
||||
suggestions.forEach((item, index) => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'kikotools-autocomplete-item';
|
||||
option.id = `${element.id}-${index}`;
|
||||
option.role = 'option';
|
||||
option.setAttribute('aria-selected', 'false');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'ca-completion-type';
|
||||
badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ca-completion-name';
|
||||
const path = item.type === 'embedding' ? item.fullPath : null;
|
||||
if (path?.includes('/')) {
|
||||
const directory = document.createElement('span');
|
||||
directory.className = 'ca-completion-directory';
|
||||
directory.textContent = path.slice(0, path.lastIndexOf('/') + 1);
|
||||
name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1)));
|
||||
} else name.textContent = item.name;
|
||||
option.append(badge, name);
|
||||
optionsScope.listen(option, 'pointerdown', event => event.preventDefault());
|
||||
optionsScope.listen(option, 'pointermove', () => onSelect(index));
|
||||
optionsScope.listen(option, 'click', () => onInsert(item));
|
||||
element.append(option);
|
||||
});
|
||||
element.hidden = false;
|
||||
},
|
||||
select(index) {
|
||||
const options = [...element.children];
|
||||
options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index)));
|
||||
options[index]?.scrollIntoView({ block: 'nearest' });
|
||||
return options[index]?.id;
|
||||
},
|
||||
position(field) {
|
||||
const popup = element.getBoundingClientRect();
|
||||
const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight });
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -143,6 +143,7 @@
|
||||
@keyframes ca-timer-glow { 0%, 100% { text-shadow: 0 0 8px currentColor; } 50% { text-shadow: 0 0 16px currentColor; } }
|
||||
@media (prefers-reduced-motion: reduce) { .ca-workflow-timer .kiko-timer-display { animation: none; text-shadow: none; } }
|
||||
.ca-color-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.ca-color-picker[hidden] { display: none; }
|
||||
.ca-color-value { font-size: 12px; color: var(--ca-muted); font-family: ui-monospace, monospace; }
|
||||
.ca-color-swatch { inline-size: 24px; block-size: 20px; border: 1px solid var(--ca-border); border-radius: 3px; background: var(--ca-picked-color); }
|
||||
.ca-color-dialog { box-sizing: border-box; inline-size: 360px; max-inline-size: calc(100dvw - 32px); max-block-size: calc(100dvh - 32px); overflow: auto; padding: 16px; border: 1px solid var(--ca-border); border-radius: 8px; background: var(--ca-surface); color: var(--ca-text); box-shadow: 0 8px 28px #00000040; }
|
||||
@@ -164,8 +165,67 @@
|
||||
.ca-color-dialog button.ca-color-preset[aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.ca-color-done { align-self: end; }
|
||||
@media (pointer: coarse) { .ca-color-dialog button.ca-color-preset { min-block-size: 40px; } }
|
||||
.ca-dimension-swap { box-sizing: border-box; inline-size: 100%; block-size: 100%; display: flex; align-items: center; padding-block: 3px; }
|
||||
.ca-dimension-swap button { inline-size: 100%; }
|
||||
@media (forced-colors: active) {
|
||||
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
|
||||
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
|
||||
.ca-tools button.kiko-selected { outline: 2px solid Highlight; }
|
||||
}
|
||||
|
||||
.ca-dialog { box-sizing: border-box; inline-size: 400px; max-inline-size: calc(100dvw - 24px); max-block-size: calc(100dvh - 24px); padding: 0; border: 1px solid var(--ca-border); border-radius: 8px; overflow: auto; box-shadow: 0 12px 40px #0006; }
|
||||
.ca-dialog[open] { display: flex; flex-direction: column; }
|
||||
.ca-dialog::backdrop { background: #0007; }
|
||||
.ca-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-block-end: 1px solid var(--ca-line); }
|
||||
.ca-dialog h2 { margin: 0; font-size: 14px; font-weight: 550; }
|
||||
.ca-dialog > header span { color: var(--ca-muted); font-size: 10px; }
|
||||
.ca-dialog-body { padding: 16px; min-inline-size: 0; }
|
||||
.ca-dialog > footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-block-start: 1px solid var(--ca-line); }
|
||||
.ca-dialog-hint { color: var(--ca-muted); margin: 0 0 12px; font-size: 12px; }
|
||||
.ca-dimension-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.ca-dimension-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; }
|
||||
.ca-dimension-fields input { inline-size: 100%; }
|
||||
.ca-dialog-error { margin: 12px 0 0; color: var(--ca-error-text); }
|
||||
.ca-dialog-error:empty { display: none; }
|
||||
.ca-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
|
||||
@media (max-width: 360px) { .ca-dimension-fields { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
.ca-text-output { display: flex; flex-direction: column; gap: 10px; inline-size: 100%; block-size: 100%; min-block-size: 180px; padding: 12px; border-radius: 6px; contain: size; overflow: auto; box-sizing: border-box; }
|
||||
.ca-text-output > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-end: 8px; border-block-end: 1px solid var(--ca-line); }
|
||||
.ca-text-output h2 { font-size: 13px; font-weight: 550; margin: 0; }
|
||||
.ca-text-output > header span { color: var(--ca-muted); font-size: 10px; }
|
||||
.ca-output-content { display: flex; flex-direction: column; flex: 1; gap: 12px; min-block-size: 0; overflow: auto; }
|
||||
.ca-output-section { display: flex; flex-direction: column; flex: 1 0 110px; gap: 6px; min-inline-size: 0; }
|
||||
.ca-output-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
.ca-output-toolbar label { font-size: 12px; font-weight: 550; }
|
||||
.ca-text-output textarea { inline-size: 100%; flex: 1; min-block-size: 64px; resize: none; opacity: 1; }
|
||||
.ca-text-output[data-output-kind="any"] textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.ca-text-output > p { font-size: 11px; color: var(--ca-muted); min-block-size: 17px; margin: 0; }
|
||||
.ca-text-output > p[data-error="true"] { color: var(--ca-error-text); }
|
||||
|
||||
.ca-autocomplete-launcher { display: inline-flex; background: transparent; }
|
||||
.ca-autocomplete-settings form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ca-autocomplete-settings .ca-switch { justify-content: flex-start; }
|
||||
.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; }
|
||||
.ca-completions[hidden] { display: none; }
|
||||
.ca-completions .kikotools-autocomplete-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; }
|
||||
.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; }
|
||||
.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.ca-completion-directory { color: var(--ca-muted); }
|
||||
.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; }
|
||||
.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; }
|
||||
@media (pointer: coarse) { .ca-completions .kikotools-autocomplete-item { min-block-size: 40px; } }
|
||||
.ca-autocomplete-settings { inline-size: 520px; }
|
||||
.ca-autocomplete-settings fieldset { min-inline-size: 0; margin: 0; padding: 12px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.ca-autocomplete-settings legend { padding-inline: 4px; font-size: 12px; font-weight: 550; }
|
||||
.ca-autocomplete-settings .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; }
|
||||
.ca-autocomplete-settings input { min-inline-size: 0; max-inline-size: 100%; box-sizing: border-box; }
|
||||
.ca-autocomplete-settings input[type="file"] { inline-size: 100%; }
|
||||
.ca-autocomplete-settings .ca-dialog-hint { margin: 0; }
|
||||
.ca-autocomplete-settings a { color: var(--ca-focus); font-size: 12px; }
|
||||
.ca-autocomplete-settings summary { cursor: pointer; font-size: 12px; padding-block: 6px; }
|
||||
.ca-autocomplete-settings details[open] { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ca-autocomplete-settings .ca-tag-status { margin: 0; min-block-size: 18px; font-size: 12px; overflow-wrap: anywhere; color: var(--ca-muted); }
|
||||
.ca-autocomplete-settings .ca-tag-status[data-error="true"] { color: var(--ca-error-text); }
|
||||
|
||||
.ca-autocomplete-settings .ca-tag-source { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createScope, mountTheme, createButton } from './core.js';
|
||||
|
||||
let nextDialog = 0;
|
||||
|
||||
export function createDialog({ title, settings, fallbackFocus, onCancel }) {
|
||||
const scope = createScope();
|
||||
const element = document.createElement('dialog');
|
||||
element.id = `ca-dialog-${++nextDialog}`;
|
||||
element.className = 'ca-tools ca-dialog kikotools-dialog';
|
||||
const header = document.createElement('header');
|
||||
const heading = document.createElement('h2');
|
||||
heading.id = `${element.id}-title`;
|
||||
heading.textContent = title;
|
||||
element.setAttribute('aria-labelledby', heading.id);
|
||||
const brand = document.createElement('span');
|
||||
brand.textContent = 'ComfyAssets';
|
||||
header.append(heading, brand);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ca-dialog-body';
|
||||
const actions = document.createElement('footer');
|
||||
element.append(header, body, actions);
|
||||
mountTheme(element, settings, scope);
|
||||
let opener;
|
||||
const nativeClose = element.close.bind(element);
|
||||
const finish = reason => {
|
||||
if (scope.signal.aborted) return;
|
||||
scope.dispose();
|
||||
if (opener?.isConnected && opener !== document.body) opener.focus();
|
||||
else fallbackFocus?.focus();
|
||||
if (reason === 'cancel') onCancel?.();
|
||||
};
|
||||
element.close = (reason = 'dismiss') => {
|
||||
if (scope.signal.aborted) return;
|
||||
nativeClose(reason);
|
||||
finish(reason);
|
||||
};
|
||||
scope.cleanup(() => element.remove());
|
||||
scope.listen(element, 'close', () => finish(element.returnValue));
|
||||
scope.listen(element, 'cancel', event => {
|
||||
event.preventDefault();
|
||||
element.close('cancel');
|
||||
});
|
||||
scope.listen(element, 'keydown', event => event.stopPropagation());
|
||||
scope.listen(element, 'keyup', event => event.stopPropagation());
|
||||
for (const event of ['pointerdown', 'mousedown', 'click']) scope.listen(element, event, event => event.stopPropagation());
|
||||
return {
|
||||
element, scope, body, actions,
|
||||
close: element.close,
|
||||
open(initialFocus) {
|
||||
opener = document.activeElement;
|
||||
document.body.append(element);
|
||||
element.showModal();
|
||||
initialFocus?.focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyDialog(htmlContent, onOK, onCancel, settings, fallbackFocus) {
|
||||
const dialog = createDialog({ title: 'KikoTools', settings, fallbackFocus, onCancel });
|
||||
const form = document.createElement('form');
|
||||
form.id = `${dialog.element.id}-form`;
|
||||
form.innerHTML = htmlContent;
|
||||
dialog.body.append(form);
|
||||
const cancel = createButton('Cancel');
|
||||
const ok = createButton('OK', 'ca-primary');
|
||||
ok.type = 'submit';
|
||||
ok.setAttribute('form', form.id);
|
||||
dialog.actions.append(cancel, ok);
|
||||
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
|
||||
dialog.scope.listen(form, 'keydown', event => {
|
||||
if (event.key === 'Enter' && !event.isComposing && event.target.matches('select')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
dialog.scope.listen(form, 'submit', event => {
|
||||
event.preventDefault();
|
||||
onOK?.(dialog.element, [...form.querySelectorAll('input, select')].map(input => input.value));
|
||||
dialog.close('apply');
|
||||
});
|
||||
const firstInput = form.querySelector('input, select');
|
||||
dialog.open(firstInput);
|
||||
if (firstInput?.tagName === 'INPUT') firstInput.select();
|
||||
return dialog.element;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createButton, createScope, mountTheme } from './core.js';
|
||||
|
||||
export function mountDimensionSwap(node, app) {
|
||||
if (node.swapScope && !node.swapScope.signal.aborted) return node.swapButtonWidget;
|
||||
const scope = createScope();
|
||||
node.swapScope = scope;
|
||||
const container = document.createElement('div');
|
||||
container.className = 'ca-tools ca-dimension-swap';
|
||||
const button = createButton('Swap W×H', 'ca-primary');
|
||||
button.setAttribute('aria-label', 'Swap width and height');
|
||||
button.title = 'Swap width and height';
|
||||
container.append(button);
|
||||
mountTheme(container, app.ui.settings, scope);
|
||||
scope.listen(button, 'click', () => node.swapDimensions());
|
||||
node.swapButtonWidget = node.addDOMWidget('swap_button', 'div', container, {
|
||||
serialize: false, hideOnZoom: false, getMinHeight: () => 46, getMaxHeight: () => 46,
|
||||
});
|
||||
scope.cleanup(() => container.remove());
|
||||
const onRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
scope.dispose();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
return node.swapButtonWidget;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const swapping = new WeakSet();
|
||||
|
||||
export function extractResolutionFromPreset(value) {
|
||||
return typeof value === 'string' && value !== 'custom' ? value.split(' - ')[0].trim() : null;
|
||||
}
|
||||
|
||||
export function dimensionsFromPreset(value) {
|
||||
const match = extractResolutionFromPreset(value)?.match(/^(\d+)\s*[×x]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
const dimensions = match.slice(1).map(Number);
|
||||
return dimensions.every(value => Number.isSafeInteger(value) && value > 0) ? dimensions : null;
|
||||
}
|
||||
|
||||
function widgets(node) {
|
||||
return ['preset', 'width', 'height'].map(name => node.widgets.find(widget => widget.name === name));
|
||||
}
|
||||
|
||||
function notifyDimensions(width, height, canvas, node, pos, event) {
|
||||
for (const widget of [width, height]) widget.callback?.(widget.value, canvas, node, pos, event);
|
||||
}
|
||||
|
||||
export function bindDimensionPresets(node, app) {
|
||||
const [preset] = widgets(node);
|
||||
if (!preset) return;
|
||||
const callback = preset.callback;
|
||||
preset.callback = function (value, canvas = app.canvas, owner = node, pos = [0, 0], event = null) {
|
||||
const result = callback?.apply(this, arguments);
|
||||
if (swapping.has(node)) return result;
|
||||
const dimensions = dimensionsFromPreset(value);
|
||||
const [, width, height] = widgets(node);
|
||||
if (dimensions && width && height) {
|
||||
[width.value, height.value] = dimensions;
|
||||
notifyDimensions(width, height, canvas, owner, pos, event);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function swapDimensions(node, app) {
|
||||
const [preset, width, height] = widgets(node);
|
||||
if (!preset || !width || !height || swapping.has(node)) return;
|
||||
const custom = preset.value === 'custom';
|
||||
const dimensions = custom ? [width.value, height.value] : dimensionsFromPreset(preset.value);
|
||||
if (!dimensions) return;
|
||||
const [w, h] = dimensions;
|
||||
let match;
|
||||
if (!custom) {
|
||||
let options = Array.isArray(preset.options) ? preset.options : preset.options?.values;
|
||||
if (typeof options === 'function') options = options.call(preset);
|
||||
match = (Array.isArray(options) ? options : []).find(value => {
|
||||
const parsed = dimensionsFromPreset(value);
|
||||
return parsed?.[0] === h && parsed?.[1] === w;
|
||||
});
|
||||
}
|
||||
swapping.add(node);
|
||||
try {
|
||||
[width.value, height.value] = [h, w];
|
||||
if (!custom) {
|
||||
preset.value = match || 'custom';
|
||||
preset.callback?.(preset.value, app.canvas, node, [0, 0], null);
|
||||
}
|
||||
notifyDimensions(width, height, app.canvas, node, [0, 0], null);
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
} finally {
|
||||
swapping.delete(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function watchRendererTextareas(scope, graph, autocomplete) {
|
||||
const bridges = new Map();
|
||||
const detachMissing = () => {
|
||||
for (const [textarea, bridge] of bridges) {
|
||||
if (textarea.isConnected) continue;
|
||||
autocomplete.detachFromWidget(bridge);
|
||||
bridges.delete(textarea);
|
||||
}
|
||||
};
|
||||
scope.listen(document, 'focusin', event => {
|
||||
const textarea = event.target;
|
||||
if (!(textarea instanceof HTMLTextAreaElement) || textarea.readOnly || textarea.disabled) return;
|
||||
const owner = textarea.closest('[node-id]');
|
||||
const node = owner && graph.getNodeById(owner.getAttribute('node-id'));
|
||||
if (!node) return;
|
||||
const label = textarea.labels?.[0]?.textContent.trim();
|
||||
const widget = node.widgets?.find(widget => (widget.label || widget.name) === label && widget.inputEl?.tagName === 'TEXTAREA');
|
||||
if (!widget || widget.inputEl === textarea || widget.inputEl.readOnly) return;
|
||||
let bridge = bridges.get(textarea);
|
||||
if (!bridge) {
|
||||
bridge = { inputEl: textarea };
|
||||
bridges.set(textarea, bridge);
|
||||
}
|
||||
autocomplete.attachToWidget(bridge, node);
|
||||
});
|
||||
const observer = new MutationObserver(detachMissing);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
scope.cleanup(() => {
|
||||
observer.disconnect();
|
||||
for (const bridge of bridges.values()) autocomplete.detachFromWidget(bridge);
|
||||
bridges.clear();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createButton, createField, createScope, createSwitch, mountTheme } from '../ui/core.js';
|
||||
import { createDialog } from '../ui/dialog.js';
|
||||
import { MAX_TAG_BYTES } from '../autocomplete/tags.js';
|
||||
import { parseTagFile } from '../autocomplete/tag-storage.js';
|
||||
|
||||
export function openAutocompleteSettings(autocomplete, settings, fallbackFocus) {
|
||||
const existing = autocomplete.settingsDialog;
|
||||
if (existing && !existing.scope.signal.aborted) {
|
||||
existing.element.querySelector('input').focus();
|
||||
return existing;
|
||||
}
|
||||
autocomplete.hideSuggestions();
|
||||
const dialog = createDialog({ title: 'Embedding Autocomplete Settings', settings, fallbackFocus });
|
||||
autocomplete.settingsDialog = dialog;
|
||||
dialog.element.classList.add('ca-autocomplete-settings');
|
||||
const form = document.createElement('form');
|
||||
form.id = `${dialog.element.id}-form`;
|
||||
const fields = new Map();
|
||||
const group = (title, parent = form) => {
|
||||
const fieldset = document.createElement('fieldset');
|
||||
const legend = document.createElement('legend');
|
||||
legend.textContent = title;
|
||||
fieldset.append(legend);
|
||||
parent.append(fieldset);
|
||||
return fieldset;
|
||||
};
|
||||
const addSwitch = (parent, key, label) => {
|
||||
const field = createSwitch(label, '', `${form.id}-${key}`);
|
||||
const input = field.querySelector('input');
|
||||
input.checked = autocomplete.settings[key] !== false;
|
||||
fields.set(key, input);
|
||||
parent.append(field);
|
||||
};
|
||||
const addInput = (parent, key, label, type = 'text', limits = {}) => {
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type, value: autocomplete.settings[key] ?? '', ...limits });
|
||||
fields.set(key, input);
|
||||
parent.append(createField(label, input, `${form.id}-${key}`));
|
||||
return input;
|
||||
};
|
||||
const sources = group('Sources');
|
||||
for (const [key, label] of [['enabled', 'Enable Autocomplete'], ['showEmbeddings', 'Show Embeddings'], ['showLoras', 'Show LoRAs'], ['showCustomWords', 'Show Custom Words']]) addSwitch(sources, key, label);
|
||||
const tags = group('Tags and custom words');
|
||||
const count = document.createElement('p');
|
||||
count.className = 'ca-dialog-hint';
|
||||
count.textContent = `${autocomplete.customWords.length.toLocaleString()} tags loaded`;
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'ca-dialog-hint';
|
||||
hint.textContent = 'Import CSV or TXT, or load a URL. Lists replace the current tags when you save. Supports tag,frequency and tag,category,post_count,aliases. Maximum 32 MB.';
|
||||
const pack = document.createElement('a');
|
||||
pack.href = 'https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16';
|
||||
pack.target = '_blank';
|
||||
pack.rel = 'noopener noreferrer';
|
||||
pack.textContent = 'Download the Danbooru tag pack';
|
||||
const source = document.createElement('p');
|
||||
source.className = 'ca-dialog-hint ca-tag-source';
|
||||
source.textContent = `Current source: ${(autocomplete.settings.tagSource || autocomplete.settings.customWordsUrl || 'None').replace(/^file:/, '')}`;
|
||||
tags.append(count, source, hint, pack);
|
||||
const url = addInput(tags, 'customWordsUrl', 'Tag list URL', 'url');
|
||||
const load = createButton('Load URL');
|
||||
const file = document.createElement('input');
|
||||
file.type = 'file';
|
||||
file.accept = '.csv,.txt,text/csv,text/plain';
|
||||
tags.append(load, createField('Tag list file', file, `${form.id}-file`));
|
||||
const advanced = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'Triggers, matching and insertion';
|
||||
advanced.append(summary);
|
||||
form.append(advanced);
|
||||
const matching = group('Triggers and matching', advanced);
|
||||
addInput(matching, 'embeddingTrigger', 'Embedding trigger');
|
||||
addInput(matching, 'loraTrigger', 'LoRA trigger');
|
||||
addInput(matching, 'quickTrigger', 'Quick embedding trigger');
|
||||
addInput(matching, 'minChars', 'Minimum characters', 'number', { min: 1, max: 10, step: 1, required: true });
|
||||
addInput(matching, 'maxSuggestions', 'Maximum suggestions', 'number', { min: 1, max: 100, step: 1, required: true });
|
||||
addSwitch(matching, 'sortByDirectory', 'Group by directory');
|
||||
const insertion = group('Insertion', advanced);
|
||||
for (const [key, label] of [['autoInsertComma', 'Auto-insert comma'], ['replaceUnderscores', 'Replace underscores with spaces'], ['insertOnTab', 'Insert on Tab'], ['insertOnEnter', 'Insert on Enter']]) addSwitch(insertion, key, label);
|
||||
const status = document.createElement('p');
|
||||
status.role = 'status';
|
||||
status.className = 'ca-tag-status';
|
||||
status.textContent = autocomplete.customWordsError || '';
|
||||
tags.append(status);
|
||||
dialog.body.append(form);
|
||||
const cancel = createButton('Cancel');
|
||||
const save = createButton('Save Settings', 'ca-primary');
|
||||
save.type = 'submit';
|
||||
save.setAttribute('form', form.id);
|
||||
dialog.actions.append(cancel, save);
|
||||
let pending, busy = false;
|
||||
const prepare = async callback => {
|
||||
busy = true;
|
||||
load.disabled = file.disabled = save.disabled = true;
|
||||
status.dataset.error = 'false';
|
||||
status.textContent = 'Loading tags…';
|
||||
try {
|
||||
const result = await callback();
|
||||
if (dialog.scope.signal.aborted) return;
|
||||
pending = result;
|
||||
status.textContent = `${pending.tags.length.toLocaleString()} tags ready. Save Settings to apply.`;
|
||||
} catch (error) {
|
||||
if (dialog.scope.signal.aborted) return;
|
||||
status.dataset.error = 'true';
|
||||
status.textContent = `Could not load tags: ${error.message}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
load.disabled = file.disabled = save.disabled = false;
|
||||
}
|
||||
};
|
||||
dialog.scope.listen(load, 'click', () => {
|
||||
if (!url.value.trim() || !url.reportValidity()) {
|
||||
status.dataset.error = 'true';
|
||||
status.textContent = 'Enter a valid tag-list URL.';
|
||||
return;
|
||||
}
|
||||
prepare(() => autocomplete.fetchTagList(url.value.trim(), dialog.scope.signal));
|
||||
});
|
||||
dialog.scope.listen(file, 'change', () => {
|
||||
const selected = file.files[0];
|
||||
if (!selected) return;
|
||||
prepare(async () => {
|
||||
if (selected.size > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.');
|
||||
const text = await selected.text();
|
||||
const tags = await parseTagFile(text, dialog.scope.signal);
|
||||
return { text, tags, source: `file:${selected.name}` };
|
||||
});
|
||||
file.value = '';
|
||||
});
|
||||
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
|
||||
dialog.scope.listen(form, 'submit', async event => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const next = {};
|
||||
for (const [key, input] of fields) next[key] = input.type === 'checkbox' ? input.checked : input.type === 'number' ? Number(input.value) : input.value.trim();
|
||||
next.embeddingTrigger ||= 'embedding:';
|
||||
next.loraTrigger ||= '<lora:';
|
||||
next.tagSource = pending?.source || (next.customWordsUrl !== autocomplete.settings.customWordsUrl ? next.customWordsUrl : autocomplete.settings.tagSource);
|
||||
save.disabled = true;
|
||||
try {
|
||||
await autocomplete.saveTagSettings(next, pending);
|
||||
dialog.close('save');
|
||||
} catch (error) {
|
||||
status.dataset.error = 'true';
|
||||
status.textContent = error.message;
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
dialog.scope.cleanup(() => { if (autocomplete.settingsDialog === dialog) autocomplete.settingsDialog = null; });
|
||||
dialog.open(fields.get('enabled'));
|
||||
return dialog;
|
||||
}
|
||||
|
||||
export function createAutocompleteLauncher(autocomplete, settings, fallbackFocus) {
|
||||
if (!customElements.get('kiko-autocomplete-launcher')) {
|
||||
customElements.define('kiko-autocomplete-launcher', class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (this.scope && !this.scope.signal.aborted) return;
|
||||
this.scope = createScope();
|
||||
this.className = 'ca-tools ca-autocomplete-launcher';
|
||||
mountTheme(this, this.settings, this.scope);
|
||||
const button = createButton('Open Settings', 'ca-primary');
|
||||
this.replaceChildren(button);
|
||||
this.scope.listen(button, 'click', () => {
|
||||
this.dialog = openAutocompleteSettings(this.autocomplete, this.settings, this.fallbackFocus);
|
||||
this.dialog.owner = this;
|
||||
});
|
||||
this.scope.cleanup(() => { if (this.dialog?.owner === this) this.dialog.close('removed'); });
|
||||
}
|
||||
disconnectedCallback() {
|
||||
const scope = this.scope;
|
||||
queueMicrotask(() => { if (!this.isConnected) scope.dispose(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'kikotools-settings-row';
|
||||
const label = document.createElement('td');
|
||||
label.textContent = 'Embedding Autocomplete Configuration';
|
||||
const value = document.createElement('td');
|
||||
const launcher = document.createElement('kiko-autocomplete-launcher');
|
||||
Object.assign(launcher, { autocomplete, settings, fallbackFocus });
|
||||
value.append(launcher);
|
||||
row.append(label, value);
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createButton, createField } from '../ui/core.js';
|
||||
import { createDialog } from '../ui/dialog.js';
|
||||
|
||||
const activeDialogs = new WeakMap();
|
||||
|
||||
export function resolveNodeSize(values, minimum) {
|
||||
const size = values.map(Number);
|
||||
if (values.some(value => String(value).trim() === '') || size.some(value => !Number.isSafeInteger(value) || value <= 0)) return null;
|
||||
return size.map((value, index) => Math.max(value, minimum[index]));
|
||||
}
|
||||
|
||||
export function showNodeDimensionsDialog(node, app) {
|
||||
const existing = activeDialogs.get(node);
|
||||
if (existing) {
|
||||
existing.querySelector('.width').focus();
|
||||
return existing;
|
||||
}
|
||||
const dialog = createDialog({ title: 'Node Dimensions', settings: app.ui.settings, fallbackFocus: app.canvas.canvas });
|
||||
const form = document.createElement('form');
|
||||
form.id = `${dialog.element.id}-form`;
|
||||
form.noValidate = true;
|
||||
const hint = document.createElement('p');
|
||||
hint.id = `${form.id}-hint`;
|
||||
hint.textContent = 'Minimum size is enforced based on node content.';
|
||||
hint.className = 'ca-dialog-hint';
|
||||
const fields = document.createElement('div');
|
||||
fields.className = 'ca-dimension-fields';
|
||||
const error = document.createElement('p');
|
||||
error.id = `${form.id}-error`;
|
||||
error.className = 'ca-dialog-error';
|
||||
error.setAttribute('aria-live', 'polite');
|
||||
const inputs = ['Width', 'Height'].map((name, index) => {
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'number', min: '1', step: '1', required: true, value: String(Math.round(node.size[index])), className: name.toLowerCase() });
|
||||
input.setAttribute('aria-describedby', `${hint.id} ${error.id}`);
|
||||
fields.append(createField(`${name} (px)`, input, `${form.id}-${name.toLowerCase()}`));
|
||||
return input;
|
||||
});
|
||||
form.append(hint, fields, error);
|
||||
dialog.body.append(form);
|
||||
const cancel = createButton('Cancel');
|
||||
const apply = createButton('Apply', 'ca-primary');
|
||||
apply.type = 'submit';
|
||||
apply.setAttribute('form', form.id);
|
||||
dialog.actions.append(cancel, apply);
|
||||
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
|
||||
dialog.scope.listen(form, 'submit', event => {
|
||||
event.preventDefault();
|
||||
const size = resolveNodeSize(inputs.map(input => input.value), node.computeSize());
|
||||
if (!size) {
|
||||
error.textContent = 'Enter positive whole numbers for width and height.';
|
||||
for (const input of inputs) input.setAttribute('aria-invalid', String(!resolveNodeSize([input.value], [1])));
|
||||
inputs.find(input => input.getAttribute('aria-invalid') === 'true').focus();
|
||||
return;
|
||||
}
|
||||
node.setSize(size);
|
||||
node.setDirtyCanvas(true, true);
|
||||
dialog.close('apply');
|
||||
});
|
||||
const original = node.onRemoved;
|
||||
const onRemoved = function (...args) {
|
||||
dialog.close('removed');
|
||||
return original?.apply(this, args);
|
||||
};
|
||||
node.onRemoved = onRemoved;
|
||||
activeDialogs.set(node, dialog.element);
|
||||
dialog.scope.cleanup(() => {
|
||||
activeDialogs.delete(node);
|
||||
if (node.onRemoved === onRemoved) node.onRemoved = original;
|
||||
});
|
||||
dialog.open(inputs[0]);
|
||||
inputs[0].select();
|
||||
return dialog.element;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createButton, createScope, mountTheme } from '../ui/core.js';
|
||||
|
||||
let nextOutput = 0;
|
||||
|
||||
export function outputSections(kind, text) {
|
||||
if (kind === 'text') {
|
||||
const positive = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
|
||||
const negative = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
|
||||
if (positive && negative) {
|
||||
let positiveText = positive[1].trim();
|
||||
const marker = positiveText.indexOf('**');
|
||||
if (marker > 0) positiveText = positiveText.slice(0, marker).trim();
|
||||
return [
|
||||
{ label: 'Positive prompt', copy: 'Copy positive', text: positiveText },
|
||||
{ label: 'Negative prompt', copy: 'Copy negative', text: negative[1].trim() },
|
||||
];
|
||||
}
|
||||
}
|
||||
return [{ label: kind === 'text' ? 'Text output' : 'Value output', copy: kind === 'text' ? 'Copy text' : 'Copy value', text }];
|
||||
}
|
||||
|
||||
export function createTextOutputView(scope, kind, settings) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'ca-tools ca-text-output';
|
||||
element.dataset.outputKind = kind;
|
||||
const header = document.createElement('header');
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = kind === 'text' ? 'Display Text' : 'Display Any';
|
||||
const brand = document.createElement('span');
|
||||
brand.textContent = 'ComfyAssets';
|
||||
header.append(title, brand);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'ca-output-content';
|
||||
const status = document.createElement('p');
|
||||
status.role = 'status';
|
||||
element.append(header, content, status);
|
||||
mountTheme(element, settings, scope);
|
||||
let renderScope;
|
||||
scope.cleanup(() => {
|
||||
renderScope?.dispose();
|
||||
element.remove();
|
||||
});
|
||||
return {
|
||||
element,
|
||||
update(text) {
|
||||
renderScope?.dispose();
|
||||
const current = createScope();
|
||||
renderScope = current;
|
||||
content.replaceChildren();
|
||||
status.textContent = '';
|
||||
delete status.dataset.error;
|
||||
const clearStatus = current.debounce(() => { status.textContent = ''; }, 1500);
|
||||
for (const section of outputSections(kind, text)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ca-output-section';
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ca-output-toolbar';
|
||||
const input = document.createElement('textarea');
|
||||
input.id = `ca-output-${++nextOutput}`;
|
||||
input.readOnly = true;
|
||||
input.spellcheck = false;
|
||||
input.value = section.text;
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = section.label;
|
||||
const copy = createButton(section.copy, 'ca-primary');
|
||||
toolbar.append(label, copy);
|
||||
row.append(toolbar, input);
|
||||
content.append(row);
|
||||
current.listen(copy, 'click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(section.text);
|
||||
if (current.signal.aborted) return;
|
||||
delete status.dataset.error;
|
||||
status.textContent = 'Copied.';
|
||||
clearStatus();
|
||||
} catch {
|
||||
if (current.signal.aborted) return;
|
||||
status.dataset.error = 'true';
|
||||
status.textContent = 'Could not copy. Select the text and copy it manually.';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,324 +1,20 @@
|
||||
// ComfyUI-KikoTools - Width Height Selector with Swap Button
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
|
||||
import { mountDimensionSwap } from './ui/dimension-swap.js';
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyassets.WidthHeightSelector",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
if (nodeData.name === "WidthHeightSelector") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) onNodeCreated.apply(this, []);
|
||||
|
||||
// Track button click state for visual feedback
|
||||
this.swapButtonPressed = false;
|
||||
|
||||
// Helper function to extract resolution from formatted preset string
|
||||
this.extractResolutionFromPreset = function(presetValue) {
|
||||
if (presetValue === "custom") return null;
|
||||
|
||||
// If it contains formatting metadata, extract the resolution part
|
||||
if (presetValue.includes(" - ")) {
|
||||
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
|
||||
return presetValue.split(" - ")[0];
|
||||
}
|
||||
|
||||
// Otherwise assume it's already a raw resolution
|
||||
return presetValue;
|
||||
name: 'comfyassets.WidthHeightSelector',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== 'WidthHeightSelector') return;
|
||||
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
this.extractResolutionFromPreset = extractResolutionFromPreset;
|
||||
this.swapDimensions = () => swapDimensions(this, app);
|
||||
bindDimensionPresets(this, app);
|
||||
this.createSwapButton();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Override preset callback to update width/height widgets when preset changes
|
||||
const presetWidget = this.widgets.find(w => w.name === "preset");
|
||||
if (presetWidget) {
|
||||
const originalCallback = presetWidget.callback;
|
||||
presetWidget.callback = function(value, graphcanvas, node, pos, event) {
|
||||
// Call original callback first
|
||||
if (originalCallback) {
|
||||
originalCallback.call(this, value, graphcanvas, node, pos, event);
|
||||
}
|
||||
|
||||
// Update width/height widgets based on preset
|
||||
const widthWidget = node.widgets.find(w => w.name === "width");
|
||||
const heightWidget = node.widgets.find(w => w.name === "height");
|
||||
|
||||
if (widthWidget && heightWidget && value !== "custom") {
|
||||
// Extract raw resolution from formatted preset
|
||||
const rawResolution = node.extractResolutionFromPreset(value);
|
||||
|
||||
// Define all available presets from our preset system
|
||||
const presetDimensions = {
|
||||
// SDXL Presets
|
||||
"1024×1024": [1024, 1024], "896×1152": [896, 1152], "832×1216": [832, 1216],
|
||||
"768×1344": [768, 1344], "640×1536": [640, 1536], "1152×896": [1152, 896],
|
||||
"1216×832": [1216, 832], "1344×768": [1344, 768], "1536×640": [1536, 640],
|
||||
// FLUX Presets
|
||||
"1920×1080": [1920, 1080], "1536×1536": [1536, 1536], "1280×768": [1280, 768],
|
||||
"768×1280": [768, 1280], "1440×1080": [1440, 1080], "1080×1440": [1080, 1440],
|
||||
"1728×1152": [1728, 1152], "1152×1728": [1152, 1728],
|
||||
// Ultra-Wide Presets
|
||||
"2560×1080": [2560, 1080], "2048×768": [2048, 768], "1792×768": [1792, 768],
|
||||
"2304×768": [2304, 768], "1080×2560": [1080, 2560], "768×2048": [768, 2048],
|
||||
"768×1792": [768, 1792], "768×2304": [768, 2304]
|
||||
};
|
||||
|
||||
if (rawResolution && presetDimensions[rawResolution]) {
|
||||
const [w, h] = presetDimensions[rawResolution];
|
||||
widthWidget.value = w;
|
||||
heightWidget.value = h;
|
||||
|
||||
// Trigger widget callbacks to update the UI
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(w, graphcanvas, node, pos, event);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(h, graphcanvas, node, pos, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add swap functionality
|
||||
this.swapDimensions = function() {
|
||||
const widthWidget = this.widgets.find(w => w.name === "width");
|
||||
const heightWidget = this.widgets.find(w => w.name === "height");
|
||||
const presetWidget = this.widgets.find(w => w.name === "preset");
|
||||
|
||||
if (widthWidget && heightWidget && presetWidget) {
|
||||
// Handle preset swapping first
|
||||
if (presetWidget.value !== "custom") {
|
||||
const currentPreset = presetWidget.value;
|
||||
|
||||
// Extract raw resolution from formatted preset
|
||||
const rawResolution = this.extractResolutionFromPreset(currentPreset);
|
||||
if (!rawResolution) return;
|
||||
|
||||
// Parse current preset dimensions (handle both × and x separators)
|
||||
let w, h;
|
||||
if (rawResolution.includes('×')) {
|
||||
[w, h] = rawResolution.split('×').map(v => parseInt(v));
|
||||
} else if (rawResolution.includes('x')) {
|
||||
[w, h] = rawResolution.split('x').map(v => parseInt(v));
|
||||
} else {
|
||||
return; // Invalid preset format
|
||||
}
|
||||
|
||||
const swappedRawPreset = `${h}×${w}`;
|
||||
|
||||
// Find the formatted version of the swapped preset from available options
|
||||
const availablePresets = presetWidget.options.values || presetWidget.options;
|
||||
let swappedFormattedPreset = null;
|
||||
|
||||
for (const option of availablePresets) {
|
||||
if (option === "custom") continue;
|
||||
const extractedRes = this.extractResolutionFromPreset(option);
|
||||
if (extractedRes === swappedRawPreset) {
|
||||
swappedFormattedPreset = option;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (swappedFormattedPreset) {
|
||||
// Swapped preset exists, use the formatted version
|
||||
presetWidget.value = swappedFormattedPreset;
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback(swappedFormattedPreset, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(h, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(w, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
} else {
|
||||
// Swapped preset doesn't exist, switch to custom and swap manual values
|
||||
presetWidget.value = "custom";
|
||||
widthWidget.value = h;
|
||||
heightWidget.value = w;
|
||||
|
||||
if (presetWidget.callback) {
|
||||
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(h, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(w, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Custom preset - just swap the width and height values
|
||||
const tempWidth = widthWidget.value;
|
||||
widthWidget.value = heightWidget.value;
|
||||
heightWidget.value = tempWidth;
|
||||
|
||||
// Trigger widget change events
|
||||
if (widthWidget.callback) {
|
||||
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
if (heightWidget.callback) {
|
||||
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the graph as changed
|
||||
this.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
if (onDrawForeground) {
|
||||
onDrawForeground.apply(this, arguments);
|
||||
}
|
||||
|
||||
if (this.flags.collapsed) return;
|
||||
|
||||
// Draw swap button with consistent spacing from widgets
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX = this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Calculate button position based on widget spacing rather than bottom margin
|
||||
// Estimate widget area height and add consistent spacing
|
||||
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
|
||||
const topMargin = 35; // Space from top to first widget
|
||||
const buttonSpacing = 10; // Space between last widget and button
|
||||
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
// Button background - change color based on pressed state
|
||||
if (this.swapButtonPressed) {
|
||||
// Darker when pressed
|
||||
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
|
||||
} else {
|
||||
// Normal state
|
||||
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(swapButtonX, swapButtonY, swapButtonSize, swapButtonSize, 4);
|
||||
ctx.fill();
|
||||
|
||||
// Button border with subtle highlight
|
||||
ctx.strokeStyle = this.swapButtonPressed ? "rgba(20, 100, 180, 1.0)" : "rgba(33, 150, 243, 0.9)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw swap icon - modern double arrow design
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
|
||||
const centerX = swapButtonX + 12;
|
||||
const centerY = swapButtonY + 12;
|
||||
|
||||
// Top arrow (pointing right) - width to height
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 7, centerY - 3);
|
||||
ctx.lineTo(centerX + 5, centerY - 3);
|
||||
ctx.stroke();
|
||||
|
||||
// Top arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + 5, centerY - 3);
|
||||
ctx.lineTo(centerX + 2, centerY - 5);
|
||||
ctx.moveTo(centerX + 5, centerY - 3);
|
||||
ctx.lineTo(centerX + 2, centerY - 1);
|
||||
ctx.stroke();
|
||||
|
||||
// Bottom arrow (pointing left) - height to width
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + 5, centerY + 3);
|
||||
ctx.lineTo(centerX - 7, centerY + 3);
|
||||
ctx.stroke();
|
||||
|
||||
// Bottom arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 1);
|
||||
ctx.moveTo(centerX - 7, centerY + 3);
|
||||
ctx.lineTo(centerX - 4, centerY + 5);
|
||||
ctx.stroke();
|
||||
|
||||
// Add subtle tooltip text when hovering (if we had hover state)
|
||||
// This could be extended with hover detection for better UX
|
||||
};
|
||||
|
||||
const onMouseDown = nodeType.prototype.onMouseDown;
|
||||
nodeType.prototype.onMouseDown = function (e) {
|
||||
// Check if click is on swap button
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Use same positioning logic as drawing
|
||||
const estimatedWidgetHeight = 90;
|
||||
const topMargin = 35;
|
||||
const buttonSpacing = 10;
|
||||
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
if (
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
e.canvasY >= swapButtonY &&
|
||||
e.canvasY <= swapButtonY + swapButtonSize
|
||||
) {
|
||||
// Visual feedback - set button as pressed
|
||||
this.swapButtonPressed = true;
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
// Execute swap
|
||||
this.swapDimensions();
|
||||
|
||||
// Reset button state after a short delay for visual feedback
|
||||
setTimeout(() => {
|
||||
this.swapButtonPressed = false;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}, 150);
|
||||
|
||||
return true; // Consume the event
|
||||
}
|
||||
|
||||
// Call original onMouseDown if not clicking swap button
|
||||
if (onMouseDown) {
|
||||
return onMouseDown.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
// Optional: Add hover effect for better user feedback
|
||||
const onMouseMove = nodeType.prototype.onMouseMove;
|
||||
nodeType.prototype.onMouseMove = function (e) {
|
||||
// Check if hovering over swap button
|
||||
const swapButtonSize = 24;
|
||||
const margin = 6;
|
||||
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
|
||||
|
||||
// Use same positioning logic as drawing
|
||||
const estimatedWidgetHeight = 90;
|
||||
const topMargin = 35;
|
||||
const buttonSpacing = 10;
|
||||
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
|
||||
|
||||
const isHovering = (
|
||||
e.canvasX >= swapButtonX &&
|
||||
e.canvasX <= swapButtonX + swapButtonSize &&
|
||||
e.canvasY >= swapButtonY &&
|
||||
e.canvasY <= swapButtonY + swapButtonSize
|
||||
);
|
||||
|
||||
// Update cursor style for better UX (safely)
|
||||
if (isHovering && this.graph && this.graph.canvas && this.graph.canvas.canvas) {
|
||||
this.graph.canvas.canvas.style.cursor = "pointer";
|
||||
} else if (this.graph && this.graph.canvas && this.graph.canvas.canvas) {
|
||||
this.graph.canvas.canvas.style.cursor = "default";
|
||||
}
|
||||
|
||||
// Call original onMouseMove
|
||||
if (onMouseMove) {
|
||||
return onMouseMove.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||