Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e9dc4661 | ||
|
|
60f197274c | ||
|
|
0cd25a1073 | ||
|
|
ef5c9bb85a | ||
|
|
f04f2584a8 | ||
|
|
dfe11928b9 | ||
|
|
959c6b9e9f | ||
|
|
35f4463742 | ||
|
|
b9ca7976aa | ||
|
|
729652facf | ||
|
|
47e95f95c3 | ||
|
|
623915fd7d | ||
|
|
0d3ffcb460 | ||
|
|
03ef5b17d6 | ||
|
|
d55bc72c34 | ||
|
|
eaf03874b3 | ||
|
|
211d69e04e | ||
|
|
cd6d0d9650 | ||
|
|
469d3740b8 | ||
|
|
cb3027717e | ||
|
|
db64038b25 | ||
|
|
61b7d83593 | ||
|
|
3421981dcd | ||
|
|
38f820c8f5 | ||
|
|
baae2a0fac | ||
|
|
f9f9f9a134 | ||
|
|
c8a1823569 | ||
|
|
655049eec8 | ||
|
|
c62f363831 | ||
|
|
97a4c7b78b | ||
|
|
bc4a3f44f9 | ||
|
|
408fc5e2f8 | ||
|
|
83ed5ae42d | ||
|
|
f916636c6a | ||
|
|
2f497cec5a | ||
|
|
a7061a29e6 | ||
|
|
98e18267f5 | ||
|
|
1c31ea118f | ||
|
|
297f2b1863 | ||
|
|
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,58 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Model Resolution Picker
|
||||
|
||||
- New `Model Resolution Picker` node (🖼️ Resolution): pick a native size for a
|
||||
model family from a chip grid of aspect ratios and output an empty latent plus
|
||||
width/height. Fourteen shipped families (SD 1.5, SDXL, Illustrious XL, Anima,
|
||||
Flux.1, Z-Image, Qwen-Image, Flux.2 / Klein / MOSS, Qwen-Image 2.1,
|
||||
HunyuanImage 2.1 and four video keyframe rows), each rounded to its VAE step.
|
||||
- Native `resolution` dropdown that mirrors the chip grid both ways; typed or
|
||||
wired sizes show `custom`.
|
||||
- Shipped family table in `families.json` with a validator, and a user override
|
||||
file at `user/kikotools/model_resolutions.json` that can replace, add or remove
|
||||
families and set the default. Invalid files are ignored whole with a warning
|
||||
in the panel.
|
||||
- The node height follows the panel content, and workflows saved before the
|
||||
dropdown existed still load.
|
||||
- `GET /kikotools/model_resolution/families` serves the merged table; the shared
|
||||
dimension swap now works on nodes without a preset widget.
|
||||
|
||||
## 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,27 @@ 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.3.0 — Model Resolution Picker
|
||||
|
||||
A new node that picks a native resolution for a model family from a chip grid of
|
||||
aspect ratios, with a synced `resolution` dropdown, a shipped family table and a
|
||||
user override file. See the [release notes](CHANGELOG.md) and the
|
||||
[node documentation](examples/documentation/model_resolution_picker.md).
|
||||
|
||||
## 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
|
||||
@@ -28,9 +49,10 @@ I’m sharing them here with the community, and I hope you find them as useful a
|
||||
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | 🌱 Seeds |
|
||||
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | 🌀 Samplers |
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
|
||||
| [🎯 Model Resolution Picker](#-model-resolution-picker) | Pick a model family and size from ratio chips, with latent and width/height outputs | 🖼️ Resolution |
|
||||
| [💾 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 |
|
||||
@@ -141,6 +163,25 @@ Advanced empty latent creation with preset support and batch processing capabili
|
||||
|
||||

|
||||
|
||||
#### 🎯 Model Resolution Picker
|
||||
Choose a model family and a size from ratio chips, and get an empty latent plus width and height.
|
||||
|
||||
- **Model Dropdown**: 14 shipped families (SD 1.5, SDXL, Illustrious XL, Flux.1, Flux.2, Qwen-Image and more) with their preferred sizes
|
||||
- **Chips and List**: Pick an aspect ratio chip, then a size from the list under it; the header shows `W × H · ratio · MP`
|
||||
- **Resolution Dropdown**: A native `resolution` dropdown (`custom` plus size labels like `1216×832 - 3:2 (1.0MP)`) lists the active chip's sizes and stays in sync with the chips; a listed size wins over width and height when the node runs
|
||||
- **Swap W×H**: Exchanges width and height and lands on the mirrored chip
|
||||
- **Custom Ratio and Resolution**: Enter a ratio like 16:9 or type width and height; sizes are rounded to the family's step
|
||||
- **Your Own Sizes**: An optional override file at `ComfyUI/user/kikotools/model_resolutions.json` (under the default user directory) can replace a family by label, add new ones, hide shipped ones with `remove`, or set `default`; an invalid file is ignored and the panel shows why ([details](examples/documentation/model_resolution_picker.md#user-override-file))
|
||||
- **Outputs**: `latent` (4-channel 8x, resizable for 16x and 32x VAEs), `width` and `height`
|
||||
- **Keyframe Rows**: Video families (Wan 2.2, LTX-2, HunyuanVideo 1.5, MiniMax H3) are first/last-frame sizes; the latent is an image latent, never fed to a video model
|
||||
|
||||
**Use Cases:**
|
||||
- Pick the right size for each model without remembering bucket lists
|
||||
- Switch families while keeping a listed size
|
||||
- Feed one node to both the latent and the size inputs of a workflow
|
||||
|
||||

|
||||
|
||||
#### 💾 Kiko Save Image
|
||||
Enhanced image saving with format selection, quality control, and floating popup viewer.
|
||||
|
||||
@@ -176,28 +217,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 +459,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 +479,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 +529,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 +544,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 +731,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..."
|
||||
@@ -771,13 +826,14 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
|
||||
| **Seed History** | Advanced seed tracking with interactive history management | ✅ Complete | [Docs](examples/documentation/seed_history.md) |
|
||||
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
|
||||
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
|
||||
| **Model Resolution Picker** | Model family size picker with ratio chips, empty latent and width/height outputs | ✅ Complete | [Docs](examples/documentation/model_resolution_picker.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 +841,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 +998,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 +1136,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 |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,734 @@
|
||||
# Model Resolution Picker Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A new ComfyAssets node whose model dropdown drives a ratio-chip grid and size list, outputting width, height and an empty latent, with the toolkit Swap W×H.
|
||||
|
||||
**Architecture:** The family table lives in Python (`families.py`) and is served by a GET route; the Python node rounds to the family step and builds the latent with the existing `create_empty_latent_batch`. The frontend is a pure derivation module (`web/ui/resolution-picker.js`), a DOM view (`web/views/resolution-picker.js`) and an extension (`web/model_resolution_picker.js`) that projects the native `model`/`width`/`height` widgets into the panel and writes back through widget callbacks. No panel state is serialized.
|
||||
|
||||
**Tech Stack:** Python 3.10–3.13, torch (CPU), aiohttp routes on `PromptServer`; ES modules on the `web/ui` toolkit; pytest, `node --test`, Playwright fixture host (`tests/browser`), pinned real ComfyUI (`tests/comfyui`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-27-model-resolution-picker-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Node id `ModelResolutionPicker`, display name `Model Resolution Picker`, category `🫶 ComfyAssets/🖼️ Resolution`.
|
||||
- Combo value is the family **label**; labels are stable ids; each family carries `aliases`; unknown label → step 8, logged warning, no re-rounding to another family's step.
|
||||
- Chips, in grid order: `1:1`, `4:5`, `5:4`, `3:4`, `4:3`, `2:3`, `3:2`, `9:16`, `16:9`, `21:9`, `9:21`, then `Custom ratio`. Mirror pairs: 4:5↔5:4, 3:4↔4:3, 2:3↔3:2, 9:16↔16:9, 21:9↔9:21, 1:1↔1:1.
|
||||
- Chip tolerance for derived (non-listed) sizes: **7%** relative to the chip ratio. Authored list membership always wins.
|
||||
- 1 MP = 1,048,576 px. Custom-ratio sizes use core Resolution Selector math: `scale = sqrt(mp·1048576/(rw·rh))`, each side `round(side/step)·step`, never below 64.
|
||||
- Default family `SDXL`, default size 1024×1024, `batch_size` default 1, min 1, max 64. `width`/`height` widgets: default 1024, min 64, max 8192, step 8.
|
||||
- Route: `GET /kikotools/model_resolution/families`, registered at module import like `local_image_loader/node.py`, guarded by `try/except ImportError`.
|
||||
- Only `createScope()`, `mountTheme()`, `createButton()` and `controls.css` tokens for UI; new CSS classes are prefixed `.ca-resolution-`.
|
||||
- Panel DOM widget is added with `serialize: false`; it mounts after the native widgets; new nodes start at 340 × 600.
|
||||
- Never vendor Pixaroma code or assets. No external HTTP.
|
||||
- Python coverage ≥ 85% overall; every new `web/*.js` file gets a row in `tools/testing/frontend-coverage-baseline.json`.
|
||||
- Width Height Selector and Empty Latent Batch tests must keep passing unchanged.
|
||||
- Commits: conventional type with scope, imperative subject, bullet body. No AI attribution lines.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Typed width/height below 64 or not on the family step (e.g. 70×1000 on HunyuanImage 2.1): Python emits ≥ 64 on the step, the header shows the same effective size. Pinned in Task 1 (`round_to_step`) and Task 3 (`test_execution_rounds_to_family_step`).
|
||||
2. Families route unavailable (network error or 500): panel shows one status line, native widgets and Swap still work, no page error. Pinned in Task 7 (`route failure keeps native widgets usable`).
|
||||
3. Workflow saved with a former label or an unknown label: former label resolves through `aliases`; unknown label keeps the saved size (step 8). Pinned in Task 2 (`test_resolve_family_by_alias`) and Task 3 (`test_unknown_label_keeps_size_and_warns`).
|
||||
4. `width` and `height` converted to inputs: chips and list disabled, header says "dimensions are wired in", Swap disabled. Pinned in Task 7 (`wired-in dimensions disable the picker`).
|
||||
5. Custom ratio text such as `0:5`, `16:`, `a:b`, `99999999:1`: rejected with the inline error, previous list kept. Pinned in Task 5 (`parseRatio rejects malformed input`) and Task 7 (`custom ratio field`).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `kikotools/tools/model_resolution_picker/logic.py` | step rounding, size generation, ratio label |
|
||||
| `kikotools/tools/model_resolution_picker/families.py` | `Family` record, `CHIPS`, `MIRROR`, `FAMILIES`, lookup, JSON payload |
|
||||
| `kikotools/tools/model_resolution_picker/node.py` | node class, route handler, module-level registration |
|
||||
| `kikotools/tools/model_resolution_picker/__init__.py` | exports `ModelResolutionPickerNode` |
|
||||
| `kikotools/__init__.py` | registration (modify) |
|
||||
| `web/ui/dimensions.js` | swap without a `preset` widget (modify) |
|
||||
| `web/ui/resolution-picker.js` | pure derivation and ratio math |
|
||||
| `web/views/resolution-picker.js` | DOM view built from toolkit primitives |
|
||||
| `web/model_resolution_picker.js` | extension: fetch families, mount view + swap, bind widgets |
|
||||
| `web/ui/controls.css` | `.ca-resolution-*` rules (modify) |
|
||||
| `tests/unit/tools/model_resolution_picker/` | `test_logic.py`, `test_families.py`, `test_node.py` |
|
||||
| `tests/js/resolution-picker.test.mjs`, `tests/js/dimensions.test.mjs` | JS unit tests |
|
||||
| `tests/browser/workflows.spec.mjs` | fixture-host workflows (modify) |
|
||||
| `tests/comfyui/host-setup.mjs`, `tests/comfyui/resolution-picker.spec.mjs` | real-host coverage |
|
||||
| `tools/testing/frontend-coverage-baseline.json` | floors for the 3 new JS files (modify) |
|
||||
| `README.md`, `docs/ui-migration.md`, `examples/documentation/model_resolution_picker.md`, `docs/screenshots/resolution-picker/` | docs |
|
||||
|
||||
All pytest runs use the scratch venv: `V=/tmp/claude-1000/-home-vito-code-personal-ComfyUI-KikoTools/f79925f2-d12d-4383-ad2e-85d80c922cf7/scratchpad/venv; $V/bin/python -m pytest … -p no:cacheprovider`. Frontend: `npm test --prefix tools/testing`, `npm run --prefix tools/testing test:browser -- --project chromium`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rounding and size generation (`logic.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `kikotools/tools/model_resolution_picker/__init__.py` (empty docstring for now), `kikotools/tools/model_resolution_picker/logic.py`
|
||||
- Test: `tests/unit/tools/model_resolution_picker/__init__.py` (empty), `tests/unit/tools/model_resolution_picker/test_logic.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `round_to_step(value: int, step: int, minimum: int = 64) -> int`; `generate_size(ratio_w: int, ratio_h: int, megapixels: float, step: int) -> tuple[int, int]`; `ratio_label(width: int, height: int) -> str` (reduced `W:H` with aliases `8:5→16:10`, `5:8→10:16`, `7:3→21:9`, `3:7→9:21`); `MEGAPIXEL = 1_048_576`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
from kikotools.tools.model_resolution_picker.logic import (
|
||||
MEGAPIXEL, generate_size, ratio_label, round_to_step)
|
||||
|
||||
def test_round_to_step_rounds_nearest_and_never_below_minimum():
|
||||
assert round_to_step(1000, 32) == 992
|
||||
assert round_to_step(1232, 16) == 1232
|
||||
assert round_to_step(70, 32) == 64
|
||||
assert round_to_step(10, 64) == 64
|
||||
assert round_to_step(1016, 64) == 1024
|
||||
|
||||
def test_generate_size_matches_core_resolution_selector():
|
||||
assert generate_size(1, 1, 1.0, 8) == (1024, 1024)
|
||||
assert generate_size(16, 9, 2.0, 16) == (1936, 1088)
|
||||
assert generate_size(3, 2, 4.0, 32) == (2496, 1664)
|
||||
assert generate_size(21, 9, 0.25, 64) == (768, 320)
|
||||
|
||||
def test_ratio_label_reduces_and_aliases():
|
||||
assert ratio_label(1024, 1024) == "1:1"
|
||||
assert ratio_label(1216, 832) == "19:13"
|
||||
assert ratio_label(1280, 800) == "16:10"
|
||||
assert ratio_label(1344, 576) == "21:9"
|
||||
assert MEGAPIXEL == 1024 * 1024
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_logic.py -p no:cacheprovider -q`
|
||||
Expected: FAIL with `ModuleNotFoundError`
|
||||
|
||||
- [ ] **Step 3: Implement the three functions in `logic.py`**
|
||||
|
||||
`round_to_step`: nearest multiple, then `max(result, ceil(minimum/step)*step)`. `generate_size`: the Global Constraints formula, each side through `round_to_step`. `ratio_label`: `math.gcd`, then the alias dict.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: same command. Expected: `3 passed`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add kikotools/tools/model_resolution_picker/__init__.py kikotools/tools/model_resolution_picker/logic.py tests/unit/tools/model_resolution_picker/
|
||||
git commit -m "feat(model_resolution_picker): add step rounding and size generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: The family table (`families.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `kikotools/tools/model_resolution_picker/families.py`
|
||||
- Test: `tests/unit/tools/model_resolution_picker/test_families.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `generate_size` from Task 1 (used once, by hand, to author the generated lists; not imported by `families.py`).
|
||||
- Produces:
|
||||
- `CHIPS: tuple[str, ...]` in the Global Constraints order (11 chips, without `Custom ratio`).
|
||||
- `MIRROR: dict[str, str]`.
|
||||
- `class Family(NamedTuple)`: `label: str`, `aliases: tuple[str, ...]`, `models: str`, `group: str` (`"image"`|`"video"`), `vae_ratio: int`, `step: int`, `megapixels: tuple[float, ...]`, `default: tuple[int, int]`, `sizes: dict[str, tuple[tuple[int, int], ...]]`.
|
||||
- `FAMILIES: tuple[Family, ...]`, `DEFAULT_FAMILY = "SDXL"`.
|
||||
- `family_labels() -> list[str]`, `resolve_family(label: str) -> Family | None` (label or alias), `families_payload() -> dict` with keys `chips`, `mirror`, `default`, `families` (each: `label, aliases, models, group, vaeRatio, step, megapixels, default: [w, h], sizes: {chip: [[w, h], …]}`).
|
||||
|
||||
Authored data (copy verbatim; order of `FAMILIES` is this order):
|
||||
|
||||
| label | group | vae | step | MP tiers | default | official sizes to list verbatim; everything else generated with `generate_size` at each tier and chip |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `SD 1.5` | image | 8 | 64 | 0.25, 0.5 | 512×512 | must include 512², 768², 512×768, 768×512 |
|
||||
| `SDXL` (models: "SDXL, Pony and other SDXL finetunes") | image | 8 | 64 | 1.0 | 1024×1024 | 1:1 1024²; 4:5 832×1024; 5:4 1024×832; 3:4 896×1152; 4:3 1152×896; 2:3 832×1216; 3:2 1216×832; 9:16 768×1344; 16:9 1344×768; 21:9 1536×640; 9:21 640×1536. No generated entries. |
|
||||
| `Illustrious XL` (aliases: none; models: "Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes") | image | 8 | 32 | 1.0, 1.5, 2.25 | 1024×1024 | all SDXL entries, plus 1:1 1536²; 4:5 1248×1536; 5:4 1536×1248; 3:4 1344×1792; 4:3 1792×1344; 2:3 1024×1536, 1248×1824; 3:2 1536×1024, 1824×1248; 9:16 1152×2048; 16:9 2048×1152. No generated entries. |
|
||||
| `Anima` | image | 8 | 16 | 0.5, 1.0, 1.5 | 1024×1024 | generated; drop any entry with a side < 512 or > 1536 |
|
||||
| `Flux.1` (models: "Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2") | image | 8 | 16 | 0.5, 1.0, 1.5, 2.0 | 1024×1024 | generated; must include 16:9 1920×1088 and 9:16 1088×1920 (replace the 2.0 MP generated entry) |
|
||||
| `Z-Image` (models: "Z-Image Turbo and Z-Image Base") | image | 8 | 16 | 1.0, 1.6 | 1024×1024 | 1:1 1024², 1280²; 4:3 1152×896, 1152×864, 1440×1120, 1472×1104; 3:4 896×1152, 864×1152, 1120×1440, 1104×1472; 3:2 1248×832, 1536×1024; 2:3 832×1248, 1024×1536; 16:9 1280×720, 1600×896; 9:16 720×1280, 896×1600; 21:9 1344×576, 1680×720; 9:21 576×1344, 720×1680. 4:5 and 5:4 empty. |
|
||||
| `Qwen-Image` (models: "Qwen-Image and Qwen-Image 2512") | image | 8 | 16 | 1.76 | 1328×1328 | 1:1 1328²; 16:9 1664×928; 9:16 928×1664; 4:3 1472×1104; 3:4 1104×1472; 3:2 1584×1056; 2:3 1056×1584. Other chips empty. |
|
||||
| `Flux.2 / Klein / MOSS` (models: "Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4") | image | 16 | 16 | 1.0, 2.0, 4.0 | 1024×1024 | generated; must include 3:2 1248×832, 2:3 832×1248, 1:1 2048², 16:9 1920×1088, 9:16 1088×1920 |
|
||||
| `Qwen-Image 2.1` (models: "Qwen-Image 2.1 and 2.1 Turbo") | image | 16 | 32 | 1.0, 4.0 | 1024×1024 | 1:1 2048²; 4:3 2400×1792; 3:4 1792×2400; 3:2 2528×1696; 2:3 1696×2528; 16:9 2752×1536; 9:16 1536×2752; plus generated 1.0 MP entries for every chip |
|
||||
| `HunyuanImage 2.1` | image | 32 | 32 | 4.0 | 2048×2048 | 1:1 2048²; 16:9 2560×1536; 9:16 1536×2560; 4:3 2304×1792; 3:4 1792×2304; 3:2 2496×1664; 2:3 1664×2496. Other chips empty. |
|
||||
| `Wan 2.2 (keyframes)` | video | 16 | 16 | 0.4, 0.9 | 1280×720 | 16:9 832×480, 1280×720, 1280×704; 9:16 480×832, 720×1280, 704×1280; 1:1 640×640, 960×960; other chips generated |
|
||||
| `LTX-2 (keyframes)` | video | 32 | 32 | 0.4, 0.9 | 768×512 | 3:2 768×512; 2:3 512×768; 16:9 960×544; 9:16 544×960; other chips generated |
|
||||
| `HunyuanVideo 1.5 (keyframes)` | video | 16 | 16 | 0.4, 0.9 | 1280×720 | 16:9 848×480, 1280×720; 9:16 480×848, 720×1280; other chips generated |
|
||||
| `MiniMax H3 (keyframes)` | video | 16 | 32 | 0.4, 1.0 | 864×480 | generated |
|
||||
|
||||
Every list is sorted by pixel count ascending; mirrored chips hold mirrored entries.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
from kikotools.tools.model_resolution_picker.families import (
|
||||
CHIPS, DEFAULT_FAMILY, FAMILIES, MIRROR, families_payload, family_labels, resolve_family)
|
||||
|
||||
CHIP_RATIO = {c: int(c.split(":")[0]) / int(c.split(":")[1]) for c in CHIPS}
|
||||
|
||||
def test_chip_order_and_mirrors():
|
||||
assert CHIPS == ("1:1", "4:5", "5:4", "3:4", "4:3", "2:3", "3:2", "9:16", "16:9", "21:9", "9:21")
|
||||
assert all(MIRROR[MIRROR[c]] == c for c in CHIPS)
|
||||
|
||||
def test_every_size_is_on_step_and_within_seven_percent_of_its_chip():
|
||||
for family in FAMILIES:
|
||||
for chip, entries in family.sizes.items():
|
||||
for w, h in entries:
|
||||
assert w % family.step == 0 and h % family.step == 0, (family.label, w, h)
|
||||
assert abs(w / h - CHIP_RATIO[chip]) / CHIP_RATIO[chip] <= 0.07, (family.label, chip, w, h)
|
||||
|
||||
def test_every_entry_has_its_mirror_and_lists_are_sorted():
|
||||
for family in FAMILIES:
|
||||
for chip, entries in family.sizes.items():
|
||||
assert list(entries) == sorted(entries, key=lambda s: s[0] * s[1])
|
||||
for w, h in entries:
|
||||
assert (h, w) in family.sizes.get(MIRROR[chip], ()), (family.label, chip, w, h)
|
||||
|
||||
def test_defaults_labels_and_must_includes():
|
||||
labels = family_labels()
|
||||
assert labels[0] == "SD 1.5" and "SDXL" in labels and DEFAULT_FAMILY == "SDXL"
|
||||
assert len(set(labels)) == len(labels)
|
||||
for family in FAMILIES:
|
||||
assert any(family.default in entries for entries in family.sizes.values()), family.label
|
||||
must = {"SDXL": ("16:9", (1344, 768)), "Flux.1": ("16:9", (1920, 1088)),
|
||||
"Flux.2 / Klein / MOSS": ("3:2", (1248, 832)), "Z-Image": ("21:9", (1680, 720)),
|
||||
"Qwen-Image": ("16:9", (1664, 928)), "Qwen-Image 2.1": ("16:9", (2752, 1536)),
|
||||
"HunyuanImage 2.1": ("16:9", (2560, 1536)), "Illustrious XL": ("2:3", (1248, 1824)),
|
||||
"Wan 2.2 (keyframes)": ("16:9", (1280, 704)), "MiniMax H3 (keyframes)": ("16:9", (864, 480))}
|
||||
for label, (chip, size) in must.items():
|
||||
assert size in resolve_family(label).sizes[chip], label
|
||||
|
||||
def test_resolve_family_by_alias_and_unknown():
|
||||
assert resolve_family("SDXL").step == 64
|
||||
assert resolve_family("nope") is None
|
||||
|
||||
def test_payload_is_json_shaped():
|
||||
payload = families_payload()
|
||||
assert payload["default"] == "SDXL" and payload["chips"] == list(CHIPS)
|
||||
sdxl = next(f for f in payload["families"] if f["label"] == "SDXL")
|
||||
assert sdxl["sizes"]["3:2"] == [[1216, 832]] and sdxl["vaeRatio"] == 8 and sdxl["group"] == "image"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_families.py -p no:cacheprovider -q`
|
||||
Expected: FAIL with `ModuleNotFoundError`
|
||||
|
||||
- [ ] **Step 3: Author `families.py`**
|
||||
|
||||
Produce generated entries with a one-off snippet calling `generate_size` for each chip and tier, apply the "must include" replacements and the Anima range filter, sort, then paste the literals. `families_payload()` converts tuples to lists.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: same command. Expected: `6 passed`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add kikotools/tools/model_resolution_picker/families.py tests/unit/tools/model_resolution_picker/test_families.py
|
||||
git commit -m "feat(model_resolution_picker): add the model family resolution table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Node, route and registration
|
||||
|
||||
**Files:**
|
||||
- Create: `kikotools/tools/model_resolution_picker/node.py`
|
||||
- Modify: `kikotools/tools/model_resolution_picker/__init__.py`, `kikotools/__init__.py` (import + both mapping dicts, next to `EmptyLatentBatch`)
|
||||
- Test: `tests/unit/tools/model_resolution_picker/test_node.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `resolve_family`, `family_labels`, `families_payload`, `DEFAULT_FAMILY` (Task 2); `round_to_step` (Task 1); `create_empty_latent_batch(width, height, batch_size)` from `kikotools/tools/empty_latent_batch/logic.py`.
|
||||
- Produces: `class ModelResolutionPickerNode(ComfyAssetsBaseNode)` with `INPUT_TYPES` (`model` COMBO of `family_labels()` default `DEFAULT_FAMILY`, tooltip "Model family; the chips list its preferred sizes", `width`, `height`, `batch_size` per Global Constraints), `RETURN_TYPES = ("LATENT", "INT", "INT")`, `RETURN_NAMES = ("latent", "width", "height")`, `FUNCTION = "create"`, `create(self, model, width, height, batch_size) -> tuple[dict, int, int]`; `async def get_families(request) -> web.Response` (JSON of `families_payload()`); module-level registration of `GET /kikotools/model_resolution/families` in a `try: import server … except ImportError: pass` block.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
import asyncio, json, logging
|
||||
from kikotools.tools.model_resolution_picker.node import ModelResolutionPickerNode, get_families
|
||||
|
||||
def test_inputs_and_outputs():
|
||||
inputs = ModelResolutionPickerNode.INPUT_TYPES()["required"]
|
||||
assert inputs["model"][1]["default"] == "SDXL" and "HunyuanImage 2.1" in inputs["model"][0]
|
||||
width = inputs["width"][1]
|
||||
assert (width["default"], width["min"], width["max"], width["step"]) == (1024, 64, 8192, 8)
|
||||
assert ModelResolutionPickerNode.RETURN_TYPES == ("LATENT", "INT", "INT")
|
||||
assert ModelResolutionPickerNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def test_execution_rounds_to_family_step_and_marks_latent():
|
||||
latent, w, h = ModelResolutionPickerNode().create("HunyuanImage 2.1", 70, 1000, 2)
|
||||
assert (w, h) == (64, 992)
|
||||
assert latent["samples"].shape == (2, 4, 124, 8) and latent["downscale_ratio_spacial"] == 8
|
||||
|
||||
def test_exact_sizes_pass_through():
|
||||
_, w, h = ModelResolutionPickerNode().create("Flux.2 / Klein / MOSS", 1248, 832, 1)
|
||||
assert (w, h) == (1248, 832)
|
||||
|
||||
def test_unknown_label_keeps_size_and_warns(caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_, w, h = ModelResolutionPickerNode().create("Renamed Family", 1000, 1000, 1)
|
||||
assert (w, h) == (1000, 1000) and "Renamed Family" in caplog.text
|
||||
|
||||
def test_families_route_returns_payload():
|
||||
response = asyncio.run(get_families(None))
|
||||
assert response.status == 200 and json.loads(response.text)["default"] == "SDXL"
|
||||
|
||||
def test_registered_in_kikotools():
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
assert NODE_CLASS_MAPPINGS["ModelResolutionPicker"] is ModelResolutionPickerNode
|
||||
assert NODE_DISPLAY_NAME_MAPPINGS["ModelResolutionPicker"] == "Model Resolution Picker"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_node.py -p no:cacheprovider -q`
|
||||
Expected: FAIL with `ImportError`
|
||||
|
||||
- [ ] **Step 3: Implement `node.py`, export it, register it**
|
||||
|
||||
`create`: resolve → `step = family.step if family else 8`, warning via `logger.warning` when `None`; round both sides; `log_info` when a value changed; build the latent; return. `get_families` returns `web.json_response(families_payload())`.
|
||||
|
||||
- [ ] **Step 4: Run the new tests, then the whole Python suite with coverage**
|
||||
|
||||
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker -p no:cacheprovider -q` → `6 passed`
|
||||
Run: `$V/bin/python -m pytest tests -p no:cacheprovider -q --cov=kikotools | tail -3` → all passed, coverage ≥ 85%
|
||||
Run: `$V/bin/black --check kikotools tests && $V/bin/flake8 kikotools/tools/model_resolution_picker` → clean
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add kikotools/tools/model_resolution_picker kikotools/__init__.py tests/unit/tools/model_resolution_picker/test_node.py
|
||||
git commit -m "feat(model_resolution_picker): add node, families route and registration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Swap without a preset widget (`web/ui/dimensions.js`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/ui/dimensions.js:904-932` (`swapDimensions`)
|
||||
- Test: `tests/js/dimensions.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `swapDimensions(node, app)` now swaps when the node has `width` and `height` widgets but no `preset` widget, taking the existing custom path (direct swap, callbacks once each, dirty canvas). Behavior with a `preset` widget is unchanged.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
test('nodes without a preset widget swap width and height directly', () => {
|
||||
const calls = [];
|
||||
const node = { widgets: [
|
||||
{ name: 'width', value: 1344, callback(value) { calls.push(['width', value]); } },
|
||||
{ name: 'height', value: 768, callback(value) { calls.push(['height', value]); } },
|
||||
], graph: { setDirtyCanvas() { calls.push(['dirty']); } } };
|
||||
swapDimensions(node, app);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), [768, 1344]);
|
||||
assert.deepEqual(calls, [['width', 768], ['height', 1344], ['dirty']]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `npm test --prefix tools/testing -- ../../tests/js/dimensions.test.mjs` (or `node --test tests/js/dimensions.test.mjs`)
|
||||
Expected: FAIL, values unchanged
|
||||
|
||||
- [ ] **Step 3: Change the guard**
|
||||
|
||||
`if (!width || !height || swapping.has(node)) return; const custom = !preset || preset.value === 'custom';` and leave every `preset.*` access behind `!custom`.
|
||||
|
||||
- [ ] **Step 4: Run the JS unit tests**
|
||||
|
||||
Run: `npm test --prefix tools/testing` Expected: all pass, including the existing dimensions tests
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/ui/dimensions.js tests/js/dimensions.test.mjs
|
||||
git commit -m "feat(ui): let swapDimensions work on nodes without a preset widget"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Derivation module (`web/ui/resolution-picker.js`)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/ui/resolution-picker.js`
|
||||
- Test: `tests/js/resolution-picker.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the families payload shape from Task 2.
|
||||
- Produces (all pure, no DOM):
|
||||
- `parseRatio(text) -> [w, h] | null` — accepts `W:H`, `W/H`, `WxH`, `W×H`, surrounding whitespace; integers 1–10000; else `null`.
|
||||
- `reducedRatio(width, height) -> string` — same rule and aliases as Python `ratio_label`.
|
||||
- `findFamily(payload, label) -> family | null` — label or alias.
|
||||
- `deriveSelection(payload, label, width, height) -> { family, chip, entryIndex, custom, ratioText }` — `chip` is the chip whose list holds `[width, height]`; else nearest chip within 7%; else `null` with `custom: true` and `ratioText` from `reducedRatio`. `entryIndex` is the index in `family.sizes[chip]` or `-1`.
|
||||
- `sizesForRatio(family, [rw, rh]) -> [[w, h], …]` — one per tier, Global Constraints formula, deduped, ascending.
|
||||
- `defaultEntry(family, chip) -> [w, h] | null` — entry of `family.sizes[chip]` with pixel count closest to `family.default`'s.
|
||||
- `effectiveSize(family, width, height) -> [w, h]` — rounded to `family.step` (8 when `family` is null), ≥ 64.
|
||||
- `megapixels(width, height) -> string` — one decimal, e.g. `"1.0"`.
|
||||
- `sizeOnModelChange(payload, newLabel, width, height, chip) -> [w, h]` — keep if listed in the new family; else `defaultEntry(newFamily, chip)` when non-null; else `newFamily.default`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (use a fixture payload with `SDXL` (step 64, default 1024², sizes 1:1 1024², 3:2 1216×832, 2:3 832×1216, 16:9 1344×768, 9:16 768×1344) and `HunyuanImage 2.1` (step 32, default 2048², 1:1 2048², 16:9 2560×1536, 9:16 1536×2560), plus `aliases: ['SDXL 1.0']` on SDXL)
|
||||
|
||||
```js
|
||||
test('parseRatio accepts the documented forms and rejects malformed input', () => {
|
||||
for (const text of ['16:9', ' 16 / 9 ', '16x9', '16×9']) assert.deepEqual(parseRatio(text), [16, 9]);
|
||||
for (const text of ['0:5', '16:', 'a:b', '99999999:1', '', null]) assert.equal(parseRatio(text), null);
|
||||
});
|
||||
test('deriveSelection prefers authored membership, then the 7% band, then custom', () => {
|
||||
assert.deepEqual(deriveSelection(payload, 'HunyuanImage 2.1', 2560, 1536), { family: payload.families[1], chip: '16:9', entryIndex: 0, custom: false, ratioText: '5:3' });
|
||||
assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).chip, '3:2'); // 1.5 exactly, not listed
|
||||
assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).entryIndex, -1);
|
||||
const custom = deriveSelection(payload, 'SDXL', 1600, 800); // 2:1 is >7% from every chip
|
||||
assert.deepEqual([custom.chip, custom.custom, custom.ratioText], [null, true, '2:1']);
|
||||
assert.equal(deriveSelection(payload, 'SDXL 1.0', 1024, 1024).chip, '1:1'); // alias
|
||||
assert.equal(deriveSelection(payload, 'nope', 1024, 1024).family, null);
|
||||
});
|
||||
test('sizesForRatio follows the core formula per tier and effectiveSize rounds to the step', () => {
|
||||
assert.deepEqual(sizesForRatio({ step: 16, megapixels: [1, 2] }, [16, 9]), [[1360, 768], [1936, 1088]]);
|
||||
assert.deepEqual(effectiveSize(payload.families[1], 70, 1000), [64, 992]);
|
||||
assert.deepEqual(effectiveSize(null, 1001, 1001), [1000, 1000]);
|
||||
assert.equal(megapixels(1216, 832), '1.0');
|
||||
});
|
||||
test('model change keeps listed sizes, else the same chip default, else the family default', () => {
|
||||
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 2560, 1536, '16:9'), [2560, 1536]);
|
||||
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1344, 768, '16:9'), [2560, 1536]);
|
||||
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1216, 832, '3:2'), [2048, 2048]);
|
||||
assert.deepEqual(defaultEntry(payload.families[0], '21:9'), null);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure** — `node --test tests/js/resolution-picker.test.mjs` → FAIL (module missing)
|
||||
|
||||
- [ ] **Step 3: Implement the module** — plain functions; `sizesForRatio` uses `Math.round(side / step) * step` with the 64 floor; nearest-chip selection picks the smallest relative error.
|
||||
|
||||
- [ ] **Step 4: Run** — `npm test --prefix tools/testing` → all pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/ui/resolution-picker.js tests/js/resolution-picker.test.mjs
|
||||
git commit -m "feat(ui): add resolution picker derivation module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: View, extension and first browser workflows
|
||||
|
||||
**Files:**
|
||||
- Create: `web/views/resolution-picker.js`, `web/model_resolution_picker.js`
|
||||
- Modify: `web/ui/controls.css` (append `.ca-resolution-*` rules), `tests/browser/workflows.spec.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 5 functions; `createScope`, `mountTheme`, `createButton` from `web/ui/core.js`; `mountDimensionSwap` from `web/ui/dimension-swap.js`; `swapDimensions` (Task 4); `api.fetchApi` from `../../scripts/api.js`.
|
||||
- Produces:
|
||||
- `createResolutionPickerView(scope, actions) -> { element, render(state), setStatus(text, isError) }` in `web/views/resolution-picker.js`. `actions = { selectChip(chip), openCustom(), commitRatio(text), selectSize(w, h) }`. `state = { disabled: boolean, header: string, chips: [{ id, label, active, disabled }], customOpen: boolean, ratioText: string, entries: [{ w, h, active }], error: string | null }`. Markup: `element.className = 'ca-tools ca-resolution-picker'`; header `<p class="ca-resolution-header">`; chips in `<div class="ca-resolution-chips" role="group" aria-label="Aspect ratio">` as `<button type="button" aria-pressed>` with text `1:1` … and a last chip `Custom ratio`; sizes in `<div class="ca-resolution-sizes" role="group" aria-label="Resolution">` as `<button type="button" aria-pressed>` with text `1216 × 832`; custom mode shows `<input aria-label="Custom ratio" placeholder="16:9">` (commit on Enter and on `change`) above the generated sizes; status `<p class="ca-resolution-status" role="status">`.
|
||||
- Extension `comfyassets.ModelResolutionPicker` in `web/model_resolution_picker.js`: `beforeRegisterNodeDef` for `nodeData.name === 'ModelResolutionPicker'`; in `onNodeCreated`: `this.setSize([340, 600])`, create scope, mount view with `this.addDOMWidget('resolution_picker', 'div', view.element, { serialize: false, hideOnZoom: false, getMinHeight: () => 320 })`, `mountTheme`, `this.swapDimensions = () => { swapDimensions(this, app); render(); }`, `this.createSwapButton = () => mountDimensionSwap(this, app)` then call it, wrap the `model`/`width`/`height` widget callbacks to call the original then `render()`, `onRemoved` disposes the scope and preserves the previous hook's return. Families are loaded once per page via a module-level `loadFamilies(api)` promise using `api.fetchApi('/kikotools/model_resolution/families')`; while loading the view renders `disabled: true` with header `Loading sizes…`; on failure header `Sizes unavailable; type width and height` via `setStatus(…, true)` and chips stay disabled. Custom-ratio open flag and pending text live on the node instance (`this.resolutionCustom = { open, text }`), never serialized. Wired-in check: `node.inputs?.some(input => input.widget?.name === name && input.link != null)` for `width` or `height` → `disabled: true`, header `Dimensions are wired in`, and the swap button `disabled`.
|
||||
- Writing a size: set `width.value`/`height.value`, call each widget's `callback(value, app.canvas, node, [0, 0], null)` once, `node.graph?.setDirtyCanvas?.(true, true)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing browser tests** (append to `tests/browser/workflows.spec.mjs`; the fixture host creates text inputs for the initial keys and serves the real route because `node.py` registered it on the stubbed `PromptServer`)
|
||||
|
||||
```js
|
||||
test('resolution picker renders chips from the families route and writes sizes to native widgets', async ({ page, request }) => {
|
||||
await openHost(page);
|
||||
const payload = await (await request.get('/kikotools/model_resolution/families')).json();
|
||||
expect(payload.default).toBe('SDXL');
|
||||
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(panel.locator('.ca-resolution-header')).toHaveText('1024 × 1024 · 1:1 · 1.0 MP');
|
||||
await panel.getByRole('button', { name: '3:2', exact: true }).click();
|
||||
const node = page.locator('[data-node="ModelResolutionPicker"]');
|
||||
await expect(node.getByLabel('width', { exact: true })).toHaveValue('1216');
|
||||
await expect(node.getByLabel('height', { exact: true })).toHaveValue('832');
|
||||
await expect(panel.getByRole('button', { name: '1216 × 832', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(panel.getByRole('button', { name: '21:9', exact: true })).toBeEnabled();
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#fff' : '#202020'), theme);
|
||||
await expect(panel).toHaveAttribute('data-ca-theme', theme);
|
||||
}
|
||||
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('resolution picker mounting is idempotent and removal disposes its scope', async ({ page }) => {
|
||||
await openHost(page);
|
||||
const id = await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
|
||||
await page.evaluate(id => { const node = kikoHost.nodes.get(String(id)); node.createSwapButton(); node.createSwapButton(); }, id);
|
||||
await expect(page.locator('.ca-resolution-picker')).toHaveCount(1);
|
||||
await expect(page.locator('.ca-dimension-swap')).toHaveCount(1);
|
||||
const aborted = await page.evaluate(id => { const node = kikoHost.nodes.get(String(id)); kikoHost.removeNode(id); return node.resolutionScope.signal.aborted; }, id);
|
||||
expect(aborted).toBe(true);
|
||||
await expect(page.locator('.ca-resolution-picker')).toHaveCount(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure** — `npm run --prefix tools/testing test:browser -- --project chromium -g "resolution picker"` → FAIL (no panel)
|
||||
|
||||
- [ ] **Step 3: Implement the view, the CSS and the extension**
|
||||
|
||||
CSS: chips grid `grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px`; active chip and active size use `--ca-accent` background with `#fff` text (same as `.ca-primary`); size list is a single column of full-width buttons; header uses `--ca-muted`; everything inherits the `.ca-tools` tokens so light/dark follow `data-ca-theme`. Expose the scope as `node.resolutionScope` for the disposal test.
|
||||
|
||||
- [ ] **Step 4: Run the browser suite and the JS unit suite** — `npm run --prefix tools/testing test:browser -- --project chromium` and `npm test --prefix tools/testing` → all pass, no page errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/views/resolution-picker.js web/model_resolution_picker.js web/ui/controls.css tests/browser/workflows.spec.mjs
|
||||
git commit -m "feat(model_resolution_picker): add the chip grid panel and extension"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Swap, model change, custom ratio, wired-in, route failure; coverage floors
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/model_resolution_picker.js`, `web/views/resolution-picker.js` (only as the tests demand), `tests/browser/workflows.spec.mjs`, `tools/testing/frontend-coverage-baseline.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 6 extension and view; `sizeOnModelChange`, `sizesForRatio`, `parseRatio` (Task 5).
|
||||
|
||||
- [ ] **Step 1: Write the failing browser tests**
|
||||
|
||||
```js
|
||||
test('resolution picker swap lands on the mirrored chip and entry', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1344, height: 768, batch_size: 1 });
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click();
|
||||
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(panel.getByRole('button', { name: '768 × 1344', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('resolution picker model change keeps listed sizes or falls back to the chip default', async ({ page }) => {
|
||||
await openHost(page);
|
||||
const id = await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1344, height: 768, batch_size: 1 });
|
||||
const node = page.locator('[data-node="ModelResolutionPicker"]');
|
||||
await node.getByLabel('model', { exact: true }).fill('HunyuanImage 2.1');
|
||||
await node.getByLabel('model', { exact: true }).dispatchEvent('change');
|
||||
await expect(node.getByLabel('width', { exact: true })).toHaveValue('2560');
|
||||
await expect(node.getByLabel('height', { exact: true })).toHaveValue('1536');
|
||||
await expect(page.locator('.ca-resolution-header')).toHaveText('2560 × 1536 · 16:9 · 3.8 MP');
|
||||
});
|
||||
|
||||
test('resolution picker custom ratio field computes tiers and rejects bad input', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await createNode(page, 'ModelResolutionPicker', { model: 'Flux.1', width: 1024, height: 1024, batch_size: 1 });
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await panel.getByRole('button', { name: 'Custom ratio', exact: true }).click();
|
||||
const field = panel.getByLabel('Custom ratio', { exact: true });
|
||||
await expect(field).toHaveValue('1:1');
|
||||
await field.fill('16:10'); await field.press('Enter');
|
||||
await expect(panel.getByRole('button', { name: '1296 × 816', exact: true })).toBeVisible(); // 1.0 MP, step 16
|
||||
await panel.getByRole('button', { name: '1296 × 816', exact: true }).click();
|
||||
await expect(page.locator('[data-node="ModelResolutionPicker"]').getByLabel('width', { exact: true })).toHaveValue('1296');
|
||||
await field.fill('a:b'); await field.press('Enter');
|
||||
await expect(panel.locator('.ca-resolution-status')).toHaveText(/Enter a ratio like 16:9/);
|
||||
await expect(panel.getByRole('button', { name: '1296 × 816', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('wired-in dimensions disable the picker and swap', async ({ page }) => {
|
||||
await openHost(page);
|
||||
const id = await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
|
||||
await page.evaluate(id => { const node = kikoHost.nodes.get(String(id)); node.inputs.push({ name: 'width', widget: { name: 'width' }, link: 7 }); node.widgets.find(w => w.name === 'width').callback('1024', kikoHost.app.canvas, node, [0, 0], null); }, id);
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await expect(panel.locator('.ca-resolution-header')).toHaveText('Dimensions are wired in');
|
||||
await expect(panel.getByRole('button', { name: '3:2', exact: true })).toBeDisabled();
|
||||
await expect(page.locator('.ca-dimension-swap').getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('route failure keeps native widgets usable', async ({ page }) => {
|
||||
await page.route('**/kikotools/model_resolution/families', route => route.fulfill({ status: 500, body: 'nope' }));
|
||||
await openHost(page);
|
||||
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await expect(panel.locator('.ca-resolution-status')).toHaveText('Sizes unavailable; type width and height');
|
||||
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toBeDisabled();
|
||||
await page.locator('.ca-dimension-swap').getByRole('button').click();
|
||||
await expect(page.locator('[data-node="ModelResolutionPicker"]').getByLabel('width', { exact: true })).toHaveValue('1024');
|
||||
});
|
||||
```
|
||||
|
||||
The 500 response must not be counted as a page error: the fixture's `afterEach` collects responses ≥ 500, so this test registers its route before `openHost` and removes the 500 URL from `page.errors` at the end (`page.errors = page.errors.filter(e => !e.includes('families'))`).
|
||||
|
||||
- [ ] **Step 2: Run to verify failure** — `npm run --prefix tools/testing test:browser -- --project chromium -g "resolution picker|wired-in|route failure"` → the new tests FAIL
|
||||
|
||||
- [ ] **Step 3: Implement the remaining behavior** — model-change handler uses `sizeOnModelChange`; custom field uses `parseRatio` + `sizesForRatio`, error copy `Enter a ratio like 16:9`; the fixture model widget is a text input, so the extension must listen to the widget callback, not to a `<select>`.
|
||||
|
||||
- [ ] **Step 4: Add coverage floors and run everything frontend**
|
||||
|
||||
Add rows for `model_resolution_picker.js`, `ui/resolution-picker.js`, `views/resolution-picker.js` to `tools/testing/frontend-coverage-baseline.json`, then:
|
||||
Run: `npm test --prefix tools/testing && npm run --prefix tools/testing test:browser -- --project chromium && npm run --prefix tools/testing test:coverage`
|
||||
Expected: all pass; set each new row to the measured values rounded down to the nearest whole percent (never below 80 for lines/statements).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/model_resolution_picker.js web/views/resolution-picker.js tests/browser/workflows.spec.mjs tools/testing/frontend-coverage-baseline.json
|
||||
git commit -m "feat(model_resolution_picker): model change, custom ratio, wired-in and failure handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Real-host coverage (both renderers, workflow round-trip)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/comfyui/host-setup.mjs`, `tests/comfyui/resolution-picker.spec.mjs`
|
||||
- Modify: `tests/comfyui/image-browser.spec.mjs:1-60` (import the shared `beforeEach` instead of its inline copy)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `installHost(test, expect)` in `host-setup.mjs` that registers exactly the existing `beforeEach` (versions check, fixture reset, route proxying, settings override, extension filter, `app.graph.clear()`), with `/kikotools/model_resolution/` added to the list of prefixes proxied to the fixture server so the route is deterministic.
|
||||
|
||||
- [ ] **Step 1: Extract the shared setup and confirm the existing spec still passes**
|
||||
|
||||
Run: `KIKO_COMFY_ROOT=… npm run --prefix tools/testing test:comfyui` (or against `KIKO_COMFY_URL`; see `TESTING.md`). Expected: unchanged results for `image-browser.spec.mjs`.
|
||||
|
||||
- [ ] **Step 2: Write the failing real-host test** (mirrors the sibling swap test at `image-browser.spec.mjs:61-150`)
|
||||
|
||||
```js
|
||||
test('resolution picker survives renderers, swap, themes and workflow reload', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); const node = LiteGraph.createNode('ModelResolutionPicker'); app.graph.add(node); app.canvas.centerOnNode(node); });
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await panel.getByRole('button', { name: '16:9', exact: true }).click();
|
||||
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 expect(panel).toHaveCount(1);
|
||||
await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click();
|
||||
}
|
||||
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true'); // three swaps from 16:9
|
||||
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(`ModelResolutionPicker-${theme}.png`) });
|
||||
}
|
||||
const restored = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
const saved = workflow.nodes.find(node => node.type === 'ModelResolutionPicker');
|
||||
if (saved.widgets_values.slice(4).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values');
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker');
|
||||
return { saved: saved.widgets_values.slice(0, 4), restored: node.widgets.filter(w => ['model', 'width', 'height', 'batch_size'].includes(w.name)).map(w => w.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
|
||||
});
|
||||
expect(restored.restored).toEqual(restored.saved);
|
||||
expect(restored.size).toEqual(restored.savedSize);
|
||||
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker'); window.removedPicker = node; app.graph.remove(node); });
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedPicker.resolutionScope.signal.aborted)).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it against the pinned host for both projects** — `npm run --prefix tools/testing test:comfyui` → `classic-start` and `nodes2-start` pass. Fix extension behavior if a renderer differs; do not special-case the test.
|
||||
|
||||
- [ ] **Step 4: Copy the two screenshots** from the test output to `docs/screenshots/resolution-picker/ModelResolutionPicker-{light,dark}.png` (used by Task 9).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/comfyui/host-setup.mjs tests/comfyui/resolution-picker.spec.mjs tests/comfyui/image-browser.spec.mjs docs/screenshots/resolution-picker
|
||||
git commit -m "test(model_resolution_picker): cover both renderers and workflow round-trip on the pinned host"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (tools table after the `Empty Latent Batch` row at line 44; a `#### 🎯 Model Resolution Picker` section after `#### 📦 Empty Latent Batch`; the status table near line 801), `docs/ui-migration.md` (table row `Model Resolution Picker | views/resolution-picker.js, ui/resolution-picker.js, shared swap | [Picker](screenshots/resolution-picker/)`), `web/ui/README.md` (a short "Model Resolution Picker" section stating: derived state only, in-memory custom-ratio flag, families route, disabled chips for empty lists)
|
||||
- Create: `examples/documentation/model_resolution_picker.md` (inputs, outputs, the family table with labels and steps, the keyframe note for video families, the manual GPU check procedure from the spec's "Verification before merge")
|
||||
|
||||
- [ ] **Step 1: Write the docs** — copy the family labels and steps from `families.py`; README section bullets: model dropdown; chips and list; Swap; custom ratio and custom resolution rounding; outputs; "video rows are keyframe sizes, the latent is an image latent".
|
||||
|
||||
- [ ] **Step 2: Verify links** — `grep -n "resolution-picker\|model_resolution_picker" README.md docs/ui-migration.md web/ui/README.md` shows every referenced path exists (`ls` each).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/ui-migration.md web/ui/README.md examples/documentation/model_resolution_picker.md
|
||||
git commit -m "docs(model_resolution_picker): document the node, families and keyframe use"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Full verification and manual GPU check
|
||||
|
||||
**Files:** none new.
|
||||
|
||||
- [ ] **Step 1: Run every suite**
|
||||
|
||||
```
|
||||
$V/bin/python -m pytest tests -p no:cacheprovider -q --cov=kikotools | tail -3
|
||||
$V/bin/black --check . && $V/bin/flake8 kikotools
|
||||
npm test --prefix tools/testing && npm run --prefix tools/testing test:coverage
|
||||
npm run --prefix tools/testing test:browser
|
||||
npm run --prefix tools/testing test:comfyui
|
||||
```
|
||||
Expected: all green; Python coverage ≥ 85%; frontend floors met; Width Height Selector and Empty Latent Batch tests untouched and passing.
|
||||
|
||||
- [ ] **Step 2: Manual generation check on the user's ComfyUI** (spec: "Verification before merge"): one queued image each for `Flux.2 / Klein / MOSS` at 1248×832 and `HunyuanImage 2.1` at 2560×1536 through this node → KSampler → VAE Decode → Save; confirm the saved PNGs are exactly those sizes. Record both results in the PR description. This step needs the user's GPU box; if it cannot run, say so in the PR and mark it as a pre-merge TODO.
|
||||
|
||||
- [ ] **Step 3: Hand off** — use `superpowers:finishing-a-development-branch` (push with the explicit refspec, PR against `main`).
|
||||
|
||||
---
|
||||
|
||||
## Addendum tasks (spec addendum 2026-10-27: JSON table, user override, resolution dropdown)
|
||||
|
||||
Global Constraints additions: the shipped file is `kikotools/tools/model_resolution_picker/families.json`; the override is `<user_dir>/kikotools/model_resolutions.json`; dropdown label format `{w}×{h} - {ratio} ({mp:.1f}MP)` with `custom` first; `resolution` COMBO sits between `model` and `width`; `VALIDATE_INPUTS(cls, model, resolution)` returns True; an invalid override is ignored whole, logged, and surfaced as `warnings` in the payload; workflows serialize five native widgets.
|
||||
|
||||
### Task 11: Shipped JSON table with a runtime validator
|
||||
|
||||
**Files:**
|
||||
- Create: `kikotools/tools/model_resolution_picker/families.json`, `kikotools/tools/model_resolution_picker/table.py`
|
||||
- Modify: `kikotools/tools/model_resolution_picker/families.py` (becomes a thin loader), `pyproject.toml` (package-data for `*.json` under `kikotools`)
|
||||
- Test: `tests/unit/tools/model_resolution_picker/test_table.py`; `test_families.py` keeps passing unchanged
|
||||
|
||||
**Interfaces:**
|
||||
- Produces in `table.py`: `load_table(path: Path) -> dict` (JSON load; raises `ValueError` with the path on bad JSON); `validate_table(data: dict) -> list[str]` (returns human-readable problems, empty when valid; checks: top-level keys and `version == 1`, chips equal `CHIPS`, mirror consistent, unique labels/aliases across families, every chip key present per family, sizes on step, 7% tolerance, exact mirrors, ascending order, one chip per size, default present in sizes, `group` in {image, video}, `vaeRatio` in {8, 16, 32}, `step` > 0, non-empty `megapixels`); `build_families(data: dict) -> tuple[Family, ...]`.
|
||||
- `families.py` keeps exporting `CHIPS`, `MIRROR`, `Family`, `FAMILIES`, `DEFAULT_FAMILY`, `family_labels`, `resolve_family`, `families_payload`, now built from `families.json` at import; a validation failure raises `RuntimeError` listing the problems.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — `test_validate_table_accepts_the_shipped_file` (loads the shipped path, `validate_table` returns `[]`), `test_validate_table_reports_each_invariant` (parametrized: a copy of the shipped data mutated to break one rule each — off-step size, >7% size, missing mirror, unsorted list, duplicate size under two chips, duplicate label, missing chip key, default not listed, bad vaeRatio — must produce exactly one message naming the family and chip), `test_load_table_bad_json_names_the_path`, `test_families_module_is_built_from_json` (`FAMILIES` labels equal the JSON labels in order).
|
||||
- [ ] **Step 2: Run to verify failure** — `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_table.py -p no:cacheprovider -q` → ImportError.
|
||||
- [ ] **Step 3: Generate `families.json` from the current `FAMILIES`** with a one-off snippet (`families_payload()` plus `"version": 1`), indent 2, then rewrite `families.py` to load/validate/build; delete the literals; keep the source comments as a `"notes"` string per family (the validator ignores unknown keys).
|
||||
- [ ] **Step 4: Run** the new tests, `test_families.py`, the full suite with coverage, black and flake8.
|
||||
- [ ] **Step 5: Commit** — `refactor(model_resolution_picker): move the family table to families.json with a validator`.
|
||||
|
||||
### Task 12: User override file
|
||||
|
||||
**Files:**
|
||||
- Create: `kikotools/tools/model_resolution_picker/override.py`
|
||||
- Modify: `families.py` (resolution and payload consult the merged table), `node.py` (route payload includes `warnings`)
|
||||
- Test: `tests/unit/tools/model_resolution_picker/test_override.py`, `test_node.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `override_path() -> Path | None` (uses `folder_paths.get_user_directory()`; `None` when `folder_paths` or the function is unavailable); `merge_override(shipped: dict, override: dict) -> dict` (rules from the spec: replace by label, append new, `remove`, `default`); `current_table() -> tuple[dict, list[str]]` (shipped merged with a valid override, cached by override mtime; returns `(merged_data, warnings)`; an unreadable/invalid override yields the shipped data plus one `"Override ignored: <reason>"` warning and a `logger.warning`).
|
||||
- `families.py`: `resolve_family(label)` and `families_payload()` use `current_table()`; `families_payload()["warnings"]` is the warnings list; `FAMILIES` remains the shipped tuple for tests.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — with `tmp_path` as a fake user directory (patch `folder_paths` in `sys.modules` with `get_user_directory`): override replaces SDXL's `sizes` and `resolve_family("SDXL")` reflects it; a new family `My Model` appears last in `families_payload()["families"]` and resolves; `"remove": ["SD 1.5"]` hides it; invalid JSON → shipped data, `warnings == ["Override ignored: <path>: <json error>"]` and a warning log; an override whose merged result breaks an invariant (e.g. off-step size) → ignored whole with the validator message in the warning; editing the file (newer mtime) is picked up on the next call; no `folder_paths` → no override, no warnings; `get_families` route JSON includes `warnings`.
|
||||
- [ ] **Step 2: Run to verify failure.**
|
||||
- [ ] **Step 3: Implement** `override.py` and wire `families.py`/`node.py`. `current_table()` caches on `(path, mtime_ns)`.
|
||||
- [ ] **Step 4: Run** the picker tests, full suite with coverage, black, flake8.
|
||||
- [ ] **Step 5: Commit** — `feat(model_resolution_picker): merge a user override file into the family table`.
|
||||
|
||||
### Task 13: Native `resolution` dropdown synced with the panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `node.py` (input, `VALIDATE_INPUTS`, execution precedence), `logic.py` (`size_label(w, h, ratio_label) -> str`, `parse_size_label(text) -> tuple[int, int] | None`), `web/ui/resolution-picker.js` (`sizeLabel`, `parseSizeLabel`, `dropdownOptions(payload, label, chip) -> string[]`), `web/model_resolution_picker.js` (rebuild options and sync value; dropdown callback writes size), `web/views/resolution-picker.js` (status shows the first payload warning), `tests/js/resolution-picker.test.mjs`, `tests/browser/workflows.spec.mjs`, `tests/comfyui/resolution-picker.spec.mjs` (five native widgets: `slice(5)`, include `resolution` in the round-trip), `tools/testing/frontend-coverage-baseline.json` if floors move
|
||||
- Test: `test_node.py`, `test_logic.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Python: `INPUT_TYPES` adds `resolution` COMBO (`["custom"] + sorted distinct labels across shipped families`, default `custom`, tooltip "Sizes for the selected model and ratio; custom uses width/height") between `model` and `width`; `create(self, model, resolution, width, height, batch_size)`: when `resolution != "custom"` and `parse_size_label` succeeds, those dims replace `width`/`height` before rounding; `VALIDATE_INPUTS(cls, model, resolution) -> bool`.
|
||||
- JS: `dropdownOptions` returns `['custom', ...labels]` for the active chip's sizes, or all family sizes when `chip` is null/custom; the extension sets `resolution.options.values` (array) on every render where model or active chip changed, sets `resolution.value` to the current size's label or `custom`, and wraps the `resolution` widget callback: a label value writes the parsed size through `width`/`height` callbacks (re-entrancy guarded), `custom` leaves sizes alone.
|
||||
- Fixture host note: the fixture renders `resolution` as a text input; tests set its value and dispatch `change`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — Python: `test_resolution_label_overrides_width_height` (`create("SDXL", "1216×832 - 3:2 (1.0MP)", 1024, 1024, 1)` → `(1216, 832)`), `test_custom_uses_width_height`, `test_validate_inputs_accepts_unlisted_resolution`, `size_label(1216, 832, "3:2") == "1216×832 - 3:2 (1.0MP)"`, `parse_size_label("1216×832 - 3:2 (1.0MP)") == (1216, 832)` and `None` for `custom`/garbage. JS unit: `dropdownOptions(payload, 'SDXL', '3:2')` → `['custom', '1216×832 - 3:2 (1.0MP)']`; `dropdownOptions(payload, 'SDXL', null)` lists all five SDXL sizes with ratio labels; `parseSizeLabel` round-trips `sizeLabel`. Browser: creating the node with `resolution: 'custom'` and clicking `3:2` sets the `resolution` input to `1216×832 - 3:2 (1.0MP)`; setting `resolution` to `1344×768 - 16:9 (1.0MP)` and dispatching `change` writes width/height 1344/768 and activates `16:9`; a swap sets `resolution` to the mirrored label; a typed width of 1000 sets it to `custom`; with the families route fulfilled by a payload carrying `warnings: ['Override ignored: test']` the status line shows that text. Real host: the existing spec asserts `widgets_values.slice(5)` is empty and that `resolution` survives reload.
|
||||
- [ ] **Step 2: Run to verify failure** (Python, JS unit, chromium focused).
|
||||
- [ ] **Step 3: Implement** Python then JS. The option list is set as a plain array on `widget.options.values`; the sibling `dimensions.js` already reads array or function options.
|
||||
- [ ] **Step 3b: Ratio shapes on chips** (spec addendum part 4): in `web/views/resolution-picker.js` prepend to each ratio chip a `<span class="ca-resolution-shape" aria-hidden="true">` with inline `width`/`height` computed from the chip ratio (longest side 18 px, shortest at least 6 px, whole pixels), `1:1` square, none on `Custom ratio`; CSS in `controls.css`: `display:inline-block; border:1px solid currentColor; border-radius:2px; margin-inline-end:6px; vertical-align:middle`. Browser assertion in the existing render test: the `16:9` chip's shape is wider than tall and the `9:16` chip's is taller than wide; the chips' accessible names are unchanged.
|
||||
- [ ] **Step 4: Run** Python suite with coverage, black/flake8, JS unit, chromium+touch browser suite (`env KIKO_TEST_PYTHON=…`), coverage gate, real host on both projects.
|
||||
- [ ] **Step 5: Commit** — `feat(model_resolution_picker): add a resolution dropdown synced with the chip grid`.
|
||||
|
||||
### Task 14: Docs and final verification for the addendum
|
||||
|
||||
- [ ] Update `README.md` section bullets (dropdown; override file path and rules), `examples/documentation/model_resolution_picker.md` (JSON schema, override example with `remove` and a new family, warning behavior, dropdown precedence), `web/ui/README.md` (dropdown sync rule). Verify links.
|
||||
- [ ] Run every suite as in Task 10 Step 1 (Python, black/flake8, JS unit, coverage gate, chromium+touch, real host). WebKit is a known local environment limitation.
|
||||
- [ ] Commit — `docs(model_resolution_picker): document the JSON table, override file and resolution dropdown`.
|
||||
@@ -0,0 +1,399 @@
|
||||
# Model Resolution Picker — design
|
||||
|
||||
Date: 2026-10-27. Status: approved 2026-10-27.
|
||||
|
||||
## Intent
|
||||
|
||||
A new ComfyAssets node that picks an image size the way Pixaroma's
|
||||
"Resolution" node does — a grid of aspect-ratio chips with a list of concrete
|
||||
sizes under the active chip — but driven by a **model dropdown**. Pick the model
|
||||
family first and every chip's size list becomes that family's preferred
|
||||
resolutions. The node outputs `width`, `height` and an empty `LATENT`, and has
|
||||
the toolkit's Swap W×H button, so one node replaces Width Height Selector plus
|
||||
Empty Latent Batch in a workflow.
|
||||
|
||||
This is a **new node**. Width Height Selector and Empty Latent Batch stay as
|
||||
they are.
|
||||
|
||||
Why now: every new model family (Flux.2, Klein, Z-Image, Qwen-Image 2.1,
|
||||
MOSS-Image, Anima, HunyuanImage) has its own preferred sizes and its own VAE
|
||||
step. The existing preset dropdown is one flat SDXL/FLUX list; keeping it
|
||||
current for a dozen families makes a 60-entry combo nobody can scan. The chip
|
||||
grid makes the choice two clicks, and the model dropdown keeps the wrong sizes
|
||||
out of reach.
|
||||
|
||||
## Goals
|
||||
|
||||
- Two clicks from "I loaded Illustrious" to a correct size: model, then chip,
|
||||
then (optionally) a different entry in the list.
|
||||
- Swap W×H flips the dimensions, moves the highlight to the mirrored chip
|
||||
(16:9 → 9:16) and to the matching entry in that chip's list.
|
||||
- Custom Ratio: type any `W:H` and get sizes computed at the family's
|
||||
megapixel tiers, rounded to the family's step.
|
||||
- Custom Resolution: type exact width/height; the node rounds to the family's
|
||||
step and shows the effective size before you queue.
|
||||
- The empty latent is correct for every listed family, including 16x and 32x
|
||||
VAEs, by carrying the `downscale_ratio_spacial` mark that core
|
||||
`EmptyLatentImage` sets (the fix from commit 297f2b1).
|
||||
- Looks and behaves the same in the Classic and Nodes 2.0 frontends, using the
|
||||
existing `web/ui` toolkit.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing or changing Width Height Selector or Empty Latent Batch, their
|
||||
presets, JS, or tests.
|
||||
- Video-length or frame-count handling. Video families are listed for their
|
||||
**frame size** only: the latent output is an image latent for generating
|
||||
first/last keyframes with an image model at the video model's native size.
|
||||
- Per-checkpoint detection. The user picks the family; the node does not
|
||||
inspect the loaded model.
|
||||
- Snap/step toggles on the node. The step is a property of the family.
|
||||
|
||||
## Anti-goals (would make this a failure even if it works)
|
||||
|
||||
- Vendoring or adapting Pixaroma's JS, CSS or assets. `docs/ui-migration.md`
|
||||
records that only the organization was an inspiration; that stays true.
|
||||
- Storing UI state anywhere except ComfyUI's native widgets. The DOM panel is
|
||||
a projection of `model`, `width`, `height`; it must never need its own
|
||||
serialized state to reconstruct itself.
|
||||
- A size table that is "generated from the formula" for a family that has an
|
||||
official list. Official lists are used verbatim.
|
||||
- Breaking the real-host CI (pinned ComfyUI, both renderers).
|
||||
|
||||
## How it looks
|
||||
|
||||
Top to bottom inside the node:
|
||||
|
||||
1. Native `model` combo widget.
|
||||
2. The DOM panel (`.ca-tools`):
|
||||
- A header line: effective size, ratio label and megapixels, e.g.
|
||||
`1216 × 832 · 3:2 · 1.0 MP`. "Effective" means after rounding to the
|
||||
family step, so what you see is what Python will emit.
|
||||
- The chip grid: `1:1`, `4:5`, `5:4`, `3:4`, `4:3`, `2:3`, `3:2`, `9:16`,
|
||||
`16:9`, `21:9`, `9:21`, and `Custom ratio`. Three columns. Chips with no
|
||||
sizes for the selected family are disabled, not hidden, so the grid never
|
||||
reflows. The active chip uses the toolkit's primary (blue) style.
|
||||
- Below the grid, the size list for the active chip, one entry highlighted.
|
||||
With `Custom ratio` active, the list is replaced by a `W:H` text field
|
||||
and the computed sizes for that ratio.
|
||||
- The toolkit Swap W×H button.
|
||||
3. Native `width`, `height`, `batch_size` widgets. `width`/`height` double as
|
||||
the Custom Resolution fields and as the inputs you convert when wiring
|
||||
dimensions in from another node.
|
||||
|
||||
Placement: the panel mounts after the native widgets, which is the toolkit
|
||||
convention used by the two sibling nodes and the behavior `addDOMWidget` gives
|
||||
for free in both renderers. **My call**; the builder may move the panel
|
||||
directly under `model` only if it proves stable in both renderers under the
|
||||
real-host tests.
|
||||
|
||||
New nodes are 340 px wide. The node height is fitted to the panel content (the
|
||||
chip grid plus the active chip's list) after every render, so no empty space is
|
||||
left under the list; it grows and shrinks with the list. Saved workflows keep
|
||||
their width and are re-fitted on load. **Changed by the owner after testing**: the
|
||||
original 340 × 600 default left a large gap under one-entry lists.
|
||||
|
||||
## Behavior
|
||||
|
||||
### State model
|
||||
|
||||
The only persisted state is the four native widgets: `model` (COMBO),
|
||||
`width` (INT), `height` (INT), `batch_size` (INT). Everything the panel
|
||||
shows is derived:
|
||||
|
||||
- **Active chip**: the chip under which the family table lists
|
||||
`width × height` (authored membership always wins; that is how HunyuanImage's
|
||||
2560×1536, a 5:3 size, lives under 16:9). If no list contains it, the
|
||||
nearest chip whose ratio is within 7% of `width / height` (so 1216×832
|
||||
reads as 3:2 and 1536×640 as 21:9). If none, `Custom ratio` is active and
|
||||
the ratio field shows the reduced fraction, with aliases so 8:5 displays as
|
||||
16:10 and 7:3 as 21:9.
|
||||
- **Custom-ratio mode flag**: the panel keeps one in-memory, non-serialized
|
||||
flag "custom ratio open" plus the pending text in the field. Clicking the
|
||||
`Custom ratio` chip sets the flag and prefills the field with the current
|
||||
reduced ratio; it does not change `width`/`height`. Committing a ratio and
|
||||
picking a tier size sets `width`/`height` and keeps the custom list open
|
||||
until another chip is clicked. On reload the flag is gone and the pure
|
||||
derivation above decides, which is the intended behavior. This is the only
|
||||
panel-owned state and it is never needed to reconstruct anything.
|
||||
- **Highlighted list entry**: the entry equal to `width × height`, else none.
|
||||
- **Header**: computed from `width`, `height` and the family step.
|
||||
|
||||
Consequence: reload, undo, copy/paste, and converting `width`/`height` to
|
||||
inputs all work with zero code, because there is nothing to restore. When
|
||||
`width` or `height` is converted to an input, the panel disables the chips
|
||||
and list and shows "dimensions are wired in" in the header; the Swap button
|
||||
stays enabled only when both are widgets.
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Chip click**: sets `width`/`height` to that chip's default entry for the
|
||||
family (the entry closest to the family's default megapixels), unless the
|
||||
current size already belongs to that chip's list.
|
||||
- **List click**: sets `width`/`height` to that entry.
|
||||
- **Custom ratio field**: accepts `W:H` (integers, also `W/H` and `W x H`);
|
||||
on commit, the list shows one size per family megapixel tier, computed with
|
||||
ComfyUI core's Resolution Selector formula (`scale = sqrt(MP·1024² /
|
||||
(w·h))`, each side rounded to the family step). Invalid input keeps the
|
||||
previous list and shows an inline error in the panel's status line.
|
||||
- **Swap W×H**: swaps `width`/`height` through the native widgets with
|
||||
callbacks (so converted inputs and other extensions see it), then the panel
|
||||
re-derives. Because every family list is authored with mirrored pairs, the
|
||||
swapped size lands on the mirrored chip with a highlighted entry.
|
||||
- **Model change**: if the current size is in the new family's lists, keep it.
|
||||
Otherwise pick, in order: the same chip's default entry in the new family;
|
||||
the family default size. Log nothing; the header reflects the change.
|
||||
- **Width/height edited directly**: panel re-derives; the header shows the
|
||||
rounded effective size if the typed value is not on the family step.
|
||||
|
||||
Every width/height change goes through the widgets' callbacks exactly once,
|
||||
as `ui/dimensions.js` already guarantees for the sibling nodes.
|
||||
|
||||
### Python execution
|
||||
|
||||
Inputs: `model`, `width`, `height`, `batch_size`.
|
||||
Outputs: `LATENT` (`latent`), `INT` (`width`), `INT` (`height`).
|
||||
|
||||
1. Resolve the family record. The combo value **is the label**, because a
|
||||
ComfyUI COMBO stores the displayed string in both renderers; labels are
|
||||
therefore short and treated as stable ids (`SDXL`, `Illustrious XL`,
|
||||
`Flux.1`, `Z-Image`, `Qwen-Image`, `Flux.2 / Klein / MOSS`,
|
||||
`Qwen-Image 2.1`, `HunyuanImage 2.1`, `Anima`, `SD 1.5`, and the four
|
||||
video rows suffixed ` (keyframes)`). The model list that a label covers
|
||||
goes in the combo tooltip and the README, not in the label. Each record
|
||||
carries an `aliases` list of former labels so a rename maps forward.
|
||||
An unknown value falls back to a generic 8-pixel step with a logged
|
||||
warning: it never re-rounds a saved size to another family's step.
|
||||
2. Round `width`/`height` to the nearest multiple of the family step, never
|
||||
below 64; log at info level when a value changed.
|
||||
3. Build the latent with the existing
|
||||
`empty_latent_batch.logic.create_empty_latent_batch` (4 channels, 8x,
|
||||
carries `downscale_ratio_spacial: 8`). ComfyUI's sampler resizes it to the
|
||||
family's real latent ratio because of that mark; for 16x and 32x families
|
||||
the step rounding in step 2 is what keeps that resize exact.
|
||||
4. Return `(latent, width, height)` as rounded.
|
||||
|
||||
The family table lives in Python
|
||||
(`kikotools/tools/model_resolution_picker/families.py`) and is the single
|
||||
source of truth. The frontend fetches it once from a GET route
|
||||
`/kikotools/model_resolution/families` registered on `PromptServer` (the
|
||||
project already registers routes for Local Image Loader) and caches it per
|
||||
page. If the fetch fails the panel shows one status line and the native
|
||||
widgets remain fully usable. **My call**; the alternative is duplicating the
|
||||
table in JS, which is exactly the drift this node exists to avoid.
|
||||
|
||||
### Family table schema
|
||||
|
||||
```
|
||||
id: stable string used as the combo value, e.g. "sdxl"
|
||||
label: combo text, e.g. "SDXL / Pony"
|
||||
vae_ratio: 8 | 16 | 32 (informational; the latent is always built at 8)
|
||||
step: pixel multiple for rounding and custom sizes
|
||||
megapixels: list of tiers for Custom ratio, in 1024² units
|
||||
default: (w, h)
|
||||
sizes: { chip: [(w, h), ...] } authored, mirrored pairs across chips
|
||||
```
|
||||
|
||||
The `sizes` lists are **authored literals checked into Python**. For the
|
||||
"generated" families they are produced once by a helper script using the
|
||||
Resolution Selector formula and then hand-adjusted to include the official
|
||||
examples named below (for instance 1920×1088 for Flux 16:9 does not fall out
|
||||
of the formula; it is added by hand). The validator never regenerates them.
|
||||
The live Custom-ratio list does use the formula, so it may offer a slightly
|
||||
different size than the chip list for the same ratio; that is acceptable and
|
||||
the header shows the exact size either way.
|
||||
|
||||
Python unit tests validate the table: every size is a multiple of `step`,
|
||||
every size's ratio is within 7% of its chip, every landscape entry has its
|
||||
portrait mirror under the mirrored chip, defaults are present in `sizes`,
|
||||
labels and aliases are unique, and every "must include" size named in the
|
||||
family table below is present.
|
||||
|
||||
## The families (v1)
|
||||
|
||||
Sources are listed per row. "Official list" means the sizes are copied; for
|
||||
families whose guidance is "any size, multiples of N, within a megapixel
|
||||
range", the sizes are generated with the Resolution Selector formula at the
|
||||
stated tiers and step, then spot-checked against the official examples
|
||||
named. 1 MP = 1,048,576 px.
|
||||
|
||||
| id | Label | VAE | Step | Tiers (MP) | Default | Sizes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `sd15` | SD 1.5 | 8 | 64 | 0.25, 0.5 | 512×512 | generated; 512-class, plus 768×768 |
|
||||
| `sdxl` | SDXL / Pony | 8 | 64 | 1.0 | 1024×1024 | official 1 MP buckets: 1024², 1152×896, 1216×832, 1344×768, 1536×640 and mirrors; 4:5/5:4 as 832×1024 / 1024×832 |
|
||||
| `illustrious` | Illustrious XL (v1+) | 8 | 32 | 1.0, 1.5, 2.25 | 1024×1024 | SDXL buckets plus the 1536 class from the v1.0 model card and paper: 1536², 1024×1536, 1248×1824, 1344×1792, 1152×2048 and mirrors |
|
||||
| `anima` | Anima | 8 | 16 | 0.5, 1.0, 1.5 | 1024×1024 | generated within the documented 512–1536 range; Wan 2.1 latent format in ComfyUI |
|
||||
| `flux1` | Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2 | 8 | 16 | 0.5, 1.0, 1.5, 2.0 | 1024×1024 | generated; must include 1920×1088 / 1088×1920 (BFL's 16:9 example) |
|
||||
| `zimage` | Z-Image Turbo / Base | 8 | 16 | 1.0, 1.6 | 1024×1024 | official 1024 and 1280 sets (11 sizes each) confirmed by Tongyi on the model's HF discussion #28 |
|
||||
| `qwen_image` | Qwen-Image / 2512 | 8 | 16 | 1.76 | 1328×1328 | official training-stage list: 1328², 1664×928, 1472×1104, 1584×1056 and mirrors |
|
||||
| `flux2` | Flux.2 dev / Klein, Flux.3 Klein, MOSS-Image, Ideogram 4 | 16 | 16 | 1.0, 2.0, 4.0 | 1024×1024 | generated; must include 1248×832 (Flux.2 template default) and 2048² (4 MP max) |
|
||||
| `qwen_image_21` | Qwen-Image 2.1 / Turbo | 16 | 32 | 1.0, 4.0 | 1024×1024 | official 2K presets 2048², 2400×1792, 2528×1696, 2752×1536 and mirrors, plus generated 1 MP set (ComfyUI template default) |
|
||||
| `hunyuan_image_21` | HunyuanImage 2.1 | 32 | 32 | 4.0 | 2048×2048 | official: 2048², 2560×1536, 2304×1792 and mirrors; 3:2/2:3 generated at 4 MP. No 1 MP tier: the card says 1K output artifacts |
|
||||
|
||||
Families named in one row share a VAE and step; the combo label lists them so
|
||||
users find their model. Adding a family later is one Python record plus its
|
||||
test row.
|
||||
|
||||
### Video families (frame sizes for first/last-frame generation)
|
||||
|
||||
These rows exist so a keyframe can be generated at the exact size the video
|
||||
model will be run at. The combo groups them under a "Video (keyframes)"
|
||||
suffix in the label. The latent output is still an 8x image latent, which is
|
||||
what the keyframe image model needs; it is never fed to the video model.
|
||||
|
||||
| id | Label | Step | Tiers (MP) | Default | Sizes |
|
||||
|---|---|---|---|---|---|
|
||||
| `wan22` | Wan 2.2 (keyframes) | 16 | 0.4, 0.9 | 1280×720 | official 480p/720p classes: 832×480, 1280×720, 1280×704 (5B TI2V template), 640×640, 960×960 and mirrors |
|
||||
| `ltx2` | LTX-2 (keyframes) | 32 | 0.4, 0.9 | 768×512 | template sizes 768×512, 960×544 (ComfyUI templates), plus generated at 0.9 MP with the 32 step the LTX templates use |
|
||||
| `hunyuan_video_15` | HunyuanVideo 1.5 (keyframes) | 16 | 0.4, 0.9 | 1280×720 | generated; 480p and 720p classes |
|
||||
| `minimax_h3` | MiniMax H3 (keyframes) | 32 | 0.4, 1.0 | 864×480 | generated at 0.4 MP, step 32, which is what every official H3 template sets on Resolution Selector; 1.0 MP tier covers H3-Base's 768p ceiling |
|
||||
|
||||
Video family sizes are validated by the same table tests. They carry no
|
||||
frame-count data by design.
|
||||
|
||||
Deliberately out of v1: Ming Image, SenseNova U1.5 and other pixel-space
|
||||
models (ratio 1, needs its own latent path), HunyuanImage 3.0 (no native
|
||||
ComfyUI support), Flux.3 Image (API only).
|
||||
|
||||
## Decisions
|
||||
|
||||
- Default family on first add: **SDXL** at 1024×1024 (decided 2026-10-27).
|
||||
|
||||
Decided 2026-10-27: video families are in, as keyframe sizes (see above).
|
||||
Builder-check calls made by the author, 2026-10-27: authored chip membership
|
||||
wins over ratio tolerance (7%); combo value is the label with an alias list;
|
||||
custom-ratio mode is an in-memory flag; size lists are authored literals;
|
||||
manual 16x/32x generation check before merge.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
The real-host CI never queues a generation, so the latent path is only
|
||||
unit-tested. Core `EmptyLatentImage` is the same 4-channel, 8x, marked latent
|
||||
this node emits, and the official Anima template already runs that path
|
||||
(channel repeat 4 → 16). The two cases to confirm by hand on the user's GPU
|
||||
box before merge, each with one queued image: a 16x family (Flux.2 or
|
||||
Qwen-Image 2.1) and the 32x family (HunyuanImage 2.1) at a non-square size,
|
||||
checking the saved image's dimensions equal the node's outputs. Record the
|
||||
result in the PR.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Node id `ModelResolutionPicker`, display name "Model Resolution Picker",
|
||||
category `🫶 ComfyAssets/🖼️ Resolution`. Registered in
|
||||
`kikotools/__init__.py` like the others.
|
||||
- Frontend: `web/model_resolution_picker.js` extension plus a view under
|
||||
`web/views/` built only from `web/ui/core.js` primitives and
|
||||
`controls.css` tokens; styling added to `controls.css` under a new
|
||||
`.ca-resolution-*` family. `createScope()` owns listeners, the fetch
|
||||
signal and theme observers; node removal disposes it and preserves the
|
||||
previous `onRemoved` hook's context and return value, as every migrated
|
||||
node does.
|
||||
- Swap reuses `ui/dimension-swap.js` for the button and `ui/dimensions.js`
|
||||
for the swap; the latter gains support for nodes that have `width`/`height`
|
||||
but no `preset` widget (today it returns early).
|
||||
- Tests, per `TESTING.md` tiers: Python unit tests for the table validator,
|
||||
rounding and execution (including the latent mark and the unknown-id
|
||||
fallback); JS unit tests for derivation, ratio parsing, custom-ratio
|
||||
generation and model-change selection; browser-fixture workflows for chip
|
||||
→ widgets, list → widgets, swap → mirrored chip, model change, custom
|
||||
ratio, wired-in dimensions, theme switch, idempotent mount and disposal;
|
||||
real-host coverage of both renderers and workflow round-trip. Python
|
||||
coverage stays above the 85% gate; new JS files get floors in
|
||||
`tools/testing/frontend-coverage-baseline.json`.
|
||||
- README gets a tools-table row and a section with a screenshot under
|
||||
`docs/screenshots/`; `docs/ui-migration.md` gets a row.
|
||||
- Version bump to 1.3.0 (new node), in the release PR, not this one.
|
||||
- No external requests. The only HTTP call is the local families route.
|
||||
|
||||
## Left to the builder
|
||||
|
||||
- Exact chip grid markup (`createSegments` vs buttons with
|
||||
`aria-pressed`), keyboard order, and how disabled chips announce
|
||||
themselves.
|
||||
- The generated-size algorithm's tie-breaking and the spot-check test list.
|
||||
- Whether the families route also serves ETag/caching headers.
|
||||
- Node default size fine-tuning and the status line's wording.
|
||||
- File split inside `kikotools/tools/model_resolution_picker/` (`node.py`,
|
||||
`logic.py`, `families.py`, `routes.py`).
|
||||
|
||||
## Addendum (2026-10-27, after first hands-on test): JSON table, user override, resolution dropdown
|
||||
|
||||
Decided with the owner after testing the first build. Three changes, same branch.
|
||||
|
||||
### 1. The family table is a shipped JSON file
|
||||
|
||||
`kikotools/tools/model_resolution_picker/families.json` replaces the Python
|
||||
literals. Its shape is exactly the payload the route already serves:
|
||||
|
||||
```json
|
||||
{ "version": 1, "chips": [...], "mirror": {...}, "default": "SDXL",
|
||||
"families": [ { "label": "SDXL", "aliases": [], "models": "...", "group": "image",
|
||||
"vaeRatio": 8, "step": 64, "megapixels": [1.0], "default": [1024, 1024],
|
||||
"sizes": { "1:1": [[1024, 1024]], "4:5": [[832, 1024]], ... } } ] }
|
||||
```
|
||||
|
||||
`families.py` loads it at import, validates it with a runtime validator that
|
||||
enforces the same invariants the tests enforce today (step multiples, 7% chip
|
||||
tolerance, exact mirrors, ascending order, one chip per size, unique labels
|
||||
and aliases, default present, every chip key present), and builds the same
|
||||
`Family` records. A shipped file that fails validation is a startup error.
|
||||
The file is package data (`[tool.setuptools.package-data]`), and the git
|
||||
checkout is what ComfyUI loads, so the file simply lives next to the module.
|
||||
|
||||
### 2. A user override file
|
||||
|
||||
`<ComfyUI user directory>/kikotools/model_resolutions.json` (via
|
||||
`folder_paths.get_user_directory()`; when that is unavailable there is no
|
||||
override). Same schema as the shipped file; top-level `chips`/`mirror` are
|
||||
ignored if present. Merge rules, by `label`:
|
||||
|
||||
- same label as a shipped family → the override record **replaces** it whole;
|
||||
- new label → appended after the shipped families, in file order;
|
||||
- optional `"remove": ["label", ...]` hides shipped families;
|
||||
- `default` in the override, if present, becomes the combo default.
|
||||
|
||||
The merged table is validated with the same validator. An unreadable or
|
||||
invalid override is **ignored entirely** (never partially applied), logged at
|
||||
warning level, and reported to the panel: the payload gains
|
||||
`"warnings": ["Override ignored: <reason>"]` and the panel shows the first
|
||||
warning in its status line. The override is re-read when its mtime changes,
|
||||
so edits are picked up on a page reload (or ComfyUI's "Refresh node definitions"), which also refreshes the `model` dropdown, without a restart. The frontend caches the families payload per page.
|
||||
The shipped file is never modified by the node.
|
||||
|
||||
### 3. A native `resolution` dropdown
|
||||
|
||||
A COMBO widget named `resolution` sits between `model` and `width`. It lists
|
||||
the sizes that fit the **active ratio chip** for the selected model, plus
|
||||
`custom` first; with `Custom ratio` active or no chip matched it lists every
|
||||
size for the model. Labels use the old node's format:
|
||||
`1216×832 - 3:2 (1.0MP)` (ratio label = the chip the size is listed under,
|
||||
else the reduced ratio; megapixels to one decimal, 1 MP = 1024²).
|
||||
|
||||
Sync rules (the panel remains a projection of the native widgets):
|
||||
|
||||
- Picking from the dropdown writes `width`/`height` through their callbacks,
|
||||
exactly like a size-list click.
|
||||
- Any size change (chip list, swap, typed width/height, model change) sets
|
||||
`resolution` to the matching label, or `custom` when the size is not in the
|
||||
active list. The dropdown's option list is rebuilt whenever the model or the
|
||||
active chip changes.
|
||||
- Python: when `resolution` is not `custom` and parses as `W×H`, those
|
||||
dimensions win over `width`/`height` (same precedence as the old preset
|
||||
dropdown); then the family-step rounding applies as before. The combo's
|
||||
Python option list is `custom` plus every distinct size label across all
|
||||
shipped families, and `VALIDATE_INPUTS` accepts `resolution` as it does
|
||||
`model`, so override-added sizes and old labels still execute.
|
||||
- Workflows now serialize five native widgets; nothing else changes.
|
||||
|
||||
Non-goals unchanged. The chip grid, Custom ratio and Swap stay as built.
|
||||
|
||||
### 4. Ratio shape on each chip
|
||||
|
||||
Each ratio chip shows a small outlined box shaped like its ratio before the
|
||||
label (wide for 16:9, tall for 9:16, square for 1:1). It is drawn with the
|
||||
toolkit's own markup and tokens: a `<span class="ca-resolution-shape">`
|
||||
sized from the ratio (longest side 18 px, shortest side at least 6 px,
|
||||
rounded to whole pixels), `border: 1px solid currentColor`, with the active
|
||||
chip's white text colour showing through. Purely decorative: `aria-hidden`,
|
||||
no effect on the accessible name, hidden on `Custom ratio`. No Pixaroma
|
||||
code or assets.
|
||||
@@ -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,45 @@
|
||||
# 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/) |
|
||||
| Model Resolution Picker | `views/resolution-picker.js`, `ui/resolution-picker.js`, shared swap | [Picker](screenshots/resolution-picker/) |
|
||||
| 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Model Resolution Picker Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Model Resolution Picker is a ComfyUI node that picks a model family and a size for it, then outputs an empty latent plus the width and height. A chip grid of aspect ratios and a size list under the active chip replace hunting through a long preset dropdown. The sizes come from each family's official lists or from megapixel tiers rounded to the family's grid step.
|
||||
|
||||

|
||||
|
||||
A light-theme capture is at `docs/screenshots/resolution-picker/ModelResolutionPicker-light.png`.
|
||||
|
||||
## Node Interface
|
||||
|
||||
- **Node id**: `ModelResolutionPicker`
|
||||
- **Display name**: Model Resolution Picker
|
||||
- **Category**: `🫶 ComfyAssets/🖼️ Resolution`
|
||||
|
||||
### Inputs
|
||||
- **model**: Dropdown of the 14 shipped family labels (default `SDXL`)
|
||||
- **resolution**: Dropdown of `custom` plus size labels such as `1216×832 - 3:2 (1.0MP)` (default `custom`); see [Resolution dropdown](#resolution-dropdown)
|
||||
- **width**: Width in pixels (64-8192, step 8)
|
||||
- **height**: Height in pixels (64-8192, step 8)
|
||||
- **batch_size**: Number of empty latents (1-64)
|
||||
|
||||
### Outputs
|
||||
- **latent**: Empty latent batch at the rounded size
|
||||
- **width**: Rounded width (INT)
|
||||
- **height**: Rounded height (INT)
|
||||
|
||||
A `resolution` label other than `custom` that parses as `W×H` takes precedence over width and height; `custom` or an unparseable label uses width and height. Width and height are rounded to the selected family's step at execution and never go below 64. An unknown family label (for example from an old workflow) falls back to step 8 and logs a warning.
|
||||
|
||||
The latent is the shared 4-channel 8x empty latent. It carries `downscale_ratio_spacial: 8`, which lets ComfyUI resize it for 16x and 32x VAEs, so the same output works with every family below.
|
||||
|
||||
The frontend reads the family table from `GET /kikotools/model_resolution/families`. The response carries the table, plus a `warnings` list that is empty unless an override file was ignored.
|
||||
|
||||
## Model Families
|
||||
|
||||
| Label | Models covered | VAE ratio | Step | Megapixel tiers | Default size | Group |
|
||||
|---|---|---|---|---|---|---|
|
||||
| SD 1.5 | SD 1.5 and SD 1.5 finetunes | 8x | 64 | 0.25, 0.5 | 512×512 | image |
|
||||
| SDXL | SDXL, Pony and other SDXL finetunes | 8x | 64 | 1.0 | 1024×1024 | image |
|
||||
| Illustrious XL | Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes | 8x | 32 | 1.0, 1.5, 2.25 | 1024×1024 | image |
|
||||
| Anima | Anima | 8x | 16 | 0.5, 1.0, 1.5 | 1024×1024 | image |
|
||||
| Flux.1 | Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2 | 8x | 16 | 0.5, 1.0, 1.5, 2.0 | 1024×1024 | image |
|
||||
| Z-Image | Z-Image Turbo and Z-Image Base | 8x | 16 | 1.0, 1.6 | 1024×1024 | image |
|
||||
| Qwen-Image | Qwen-Image and Qwen-Image 2512 | 8x | 16 | 1.76 | 1328×1328 | image |
|
||||
| Flux.2 / Klein / MOSS | Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4 | 16x | 16 | 1.0, 2.0, 4.0 | 1024×1024 | image |
|
||||
| Qwen-Image 2.1 | Qwen-Image 2.1 and 2.1 Turbo | 16x | 32 | 1.0, 4.0 | 1024×1024 | image |
|
||||
| HunyuanImage 2.1 | HunyuanImage 2.1 | 32x | 32 | 4.0 | 2048×2048 | image |
|
||||
| Wan 2.2 (keyframes) | Wan 2.2 (first/last-frame sizes) | 16x | 16 | 0.4, 0.9 | 1280×720 | video |
|
||||
| LTX-2 (keyframes) | LTX-2 (first/last-frame sizes) | 32x | 32 | 0.4, 0.9 | 768×512 | video |
|
||||
| HunyuanVideo 1.5 (keyframes) | HunyuanVideo 1.5 (first/last-frame sizes) | 16x | 16 | 0.4, 0.9 | 1280×720 | video |
|
||||
| MiniMax H3 (keyframes) | MiniMax H3 (first/last-frame sizes) | 16x | 32 | 0.4, 1.0 | 864×480 | video |
|
||||
|
||||
The family steps and megapixel tiers are the grid the node rounds to and the tiers custom ratios use. Video families are suffixed `(keyframes)`; see the keyframe note below.
|
||||
|
||||
## Family Table File
|
||||
|
||||
The families live in `kikotools/tools/model_resolution_picker/families.json`, loaded and checked by `table.py` when ComfyUI starts. A shipped file that fails the check is a startup error. Top-level keys:
|
||||
|
||||
- `version`: must be `1`
|
||||
- `chips`: exactly the 11 chips listed above, in that order
|
||||
- `mirror`: pairs every chip with its mirrored chip (`4:5` with `5:4`, `1:1` with itself)
|
||||
- `default`: label or alias of the family selected by default
|
||||
- `families`: a non-empty list of family objects
|
||||
|
||||
Each family object has:
|
||||
|
||||
- `label`: unique name shown in the dropdown; `aliases` (optional list) are old names that still resolve to it
|
||||
- `models`: description string; `notes` (optional string) is ignored by the node
|
||||
- `group`: `image` or `video`
|
||||
- `vaeRatio`: `8`, `16` or `32`
|
||||
- `step`: positive integer grid step, a multiple of 8 (at most 4096), so rounded sizes land on the 8-pixel latent grid
|
||||
- `megapixels`: non-empty list of tiers used by custom ratios (each above 0, at most 64)
|
||||
- `default`: a `[width, height]` pair that appears in `sizes`
|
||||
- `sizes`: an object with one key per chip, each a list of `[width, height]` pairs in ascending pixel order (an empty list disables the chip)
|
||||
|
||||
Every size must be a multiple of `step`, within 7% of its chip's ratio, listed under one chip only, and have its swapped size under the mirrored chip. `validate_table` returns every problem as text and never raises.
|
||||
|
||||
## User Override File
|
||||
|
||||
To change sizes without editing the shipped file, create `model_resolutions.json` in a `kikotools` folder inside ComfyUI's user directory (`folder_paths.get_user_directory()`; typically `ComfyUI/user/kikotools/model_resolutions.json`). It uses the same schema as the shipped file, and any top-level `chips` or `mirror` are ignored. Rules:
|
||||
|
||||
- Families are matched by `label`. A family with a shipped label replaces that family whole; a new label is appended after the shipped ones, in file order.
|
||||
- `remove` is a list of labels that deletes shipped families. It wins over `families` when both name the same label.
|
||||
- `default` is an optional label or alias that becomes the default family; it must name a family that survives the merge.
|
||||
- The file is read again when its modification time changes, so a page reload (or ComfyUI's "Refresh node definitions") picks up edits, including the `model` dropdown, without restarting ComfyUI.
|
||||
- The shipped file is never modified.
|
||||
|
||||
This example replaces SDXL with a shorter list, adds a new family, hides two shipped ones and makes the new family the default:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"default": "My SDXL",
|
||||
"remove": ["SD 1.5", "Anima"],
|
||||
"families": [
|
||||
{
|
||||
"label": "SDXL",
|
||||
"aliases": [],
|
||||
"models": "SDXL with a trimmed size list",
|
||||
"group": "image",
|
||||
"vaeRatio": 8,
|
||||
"step": 64,
|
||||
"megapixels": [1.0],
|
||||
"default": [1024, 1024],
|
||||
"sizes": {
|
||||
"1:1": [[1024, 1024]],
|
||||
"4:5": [[832, 1024]],
|
||||
"5:4": [[1024, 832]],
|
||||
"3:4": [], "4:3": [], "2:3": [], "3:2": [],
|
||||
"9:16": [], "16:9": [], "21:9": [], "9:21": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "My SDXL",
|
||||
"aliases": ["my-sdxl"],
|
||||
"models": "My own SDXL finetune",
|
||||
"group": "image",
|
||||
"vaeRatio": 8,
|
||||
"step": 64,
|
||||
"megapixels": [1.0],
|
||||
"default": [1152, 896],
|
||||
"sizes": {
|
||||
"1:1": [[1024, 1024]],
|
||||
"4:5": [[896, 1152]], "5:4": [[1152, 896]],
|
||||
"3:4": [], "4:3": [], "2:3": [], "3:2": [],
|
||||
"9:16": [], "16:9": [], "21:9": [], "9:21": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The merged table is checked with the same `validate_table`. If the file is unreadable, is not valid JSON, nests too deeply, or fails the check, the whole file is ignored and the shipped table is used. ComfyUI logs a warning `Override ignored: <path>: <reason>`, the same text is returned in the `warnings` list of `GET /kikotools/model_resolution/families`, and the panel's status line shows the first warning. Fix the file and reload to clear it.
|
||||
|
||||
## How to Use
|
||||
|
||||
### Chips and size list
|
||||
1. **Pick a model** in the dropdown.
|
||||
2. **Click a ratio chip** (`1:1 4:5 5:4 3:4 4:3 2:3 3:2 9:16 16:9 21:9 9:21`; each shows a small outline of its shape). The size list under the active chip shows that family's sizes for the ratio.
|
||||
3. **Click a size** to write it to width and height. The header shows `W × H · ratio · MP` for the current values.
|
||||
|
||||
A chip is disabled when the family lists no sizes for that ratio.
|
||||
|
||||
### Resolution dropdown
|
||||
The native `resolution` dropdown sits between `model` and `width` and mirrors the panel. Its options are `custom` plus every size label from the shipped families (sorted as text), written `{w}×{h} - {ratio} ({mp}MP)` with 1 MP = 1,048,576 pixels, for example `1216×832 - 3:2 (1.0MP)`.
|
||||
|
||||
- In the UI the dropdown lists the sizes of the active ratio chip, or every size of the family when `Custom ratio` is active or no chip matches.
|
||||
- Picking a label writes width and height and activates the matching chip.
|
||||
- Clicking a chip or a size, or swapping, sets the label to match.
|
||||
- Typing a size that is not in the list, wiring width or height, a families table that is still loading, or a failed load shows `custom`.
|
||||
- On execution a non-`custom` label that parses wins over width and height; `custom` or an unparseable label uses width and height. Labels that only an override file adds are accepted too.
|
||||
|
||||
### Swap
|
||||
The Swap W×H button (the same control Width Height Selector and Empty Latent Batch use) exchanges width and height and lands on the mirrored chip, for example 3:4 becomes 4:3.
|
||||
|
||||
### Changing the model
|
||||
Switching the model keeps the current size if the new family lists it. Otherwise it falls back to the active chip's default size in the new family, then to the family default.
|
||||
|
||||
### Custom ratio
|
||||
Choose the `Custom ratio` chip and type a ratio such as `16:9`. The panel computes sizes at the family's megapixel tiers, rounded to the family's step. Bad input is rejected with "Enter a ratio like 16:9".
|
||||
|
||||
### Custom resolution
|
||||
Type width and height directly in the native widgets. The panel re-derives its chip, list and header from those values. They are rounded to the family's step when the node runs.
|
||||
|
||||
### Wired-in width or height
|
||||
When width or height is driven by another node, the panel and Swap are disabled.
|
||||
|
||||
### Sizes unavailable
|
||||
If the families route fails, the panel shows "Sizes unavailable; type width and height" with the chips disabled. The node still works through the native widgets.
|
||||
|
||||
Panel state is derived from the native widgets, so nothing extra is saved in the workflow. New nodes are 340 px wide and the node height follows the panel content: it is re-fitted whenever the list changes (a chip click, a model change, opening Custom ratio), so there is no empty space under the list. A saved workflow keeps its width; the height is fitted again on load.
|
||||
|
||||
## Keyframe Note for Video Families
|
||||
|
||||
`Wan 2.2`, `LTX-2`, `HunyuanVideo 1.5` and `MiniMax H3` (all suffixed `(keyframes)`) are listed for first/last-frame generation at the video model's frame size. The latent output is still an image latent and is never fed to a video model. The image latent decodes to the listed size when the keyframe image model's VAE ratio divides the video family's step (for example an 8x or 16x image model for Wan 2.2's 1280×720; a 32x model would not land on 720).
|
||||
|
||||
## Manual GPU Check Before Merge
|
||||
|
||||
The real-host CI never queues a generation, so the latent path is only unit-tested. Core `EmptyLatentImage` is the same 4-channel, 8x, marked latent this node emits, and the official Anima template already runs that path (channel repeat 4 → 16). Confirm two cases by hand on a GPU box. Each is one queued image through this node → KSampler → VAE Decode → Save:
|
||||
|
||||
1. `Flux.2 / Klein / MOSS` (16x) at 1248×832.
|
||||
2. `HunyuanImage 2.1` (32x) at 2560×1536.
|
||||
|
||||
For each case, the saved PNG's dimensions must equal the node's width and height outputs. Record both results in the PR description.
|
||||
|
||||
## Related Nodes
|
||||
|
||||
- [Width Height Selector](width_height_selector.md): preset-based dimensions with swap
|
||||
- [Empty Latent Batch](empty_latent_batch.md): empty latent batches with presets
|
||||
@@ -26,6 +26,7 @@ from .tools.local_image_loader import LocalImageLoaderNode
|
||||
from .tools.project_file_path import ProjectFilePathNode
|
||||
from .tools.model_compare_sheet import ModelCompareSheetNode
|
||||
from .tools.model_downloader import ModelDownloaderNode
|
||||
from .tools.model_resolution_picker import ModelResolutionPickerNode
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
|
||||
from .tools.seed_history import SeedHistoryNode
|
||||
@@ -54,6 +55,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"SamplerCombo": SamplerComboNode,
|
||||
"SamplerComboCompact": SamplerComboCompactNode,
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"ModelResolutionPicker": ModelResolutionPickerNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
"ImageScaleDownBy": ImageScaleDownByNode,
|
||||
@@ -92,6 +94,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SamplerCombo": "Sampler Combo",
|
||||
"SamplerComboCompact": "Sampler Combo (Compact)",
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"ModelResolutionPicker": "Model Resolution Picker",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"ImageScaleDownBy": "Image Scale Down By",
|
||||
|
||||
@@ -43,7 +43,8 @@ def create_empty_latent_batch(
|
||||
# Standard VAE uses 4 channels
|
||||
latent_tensor = torch.zeros(batch_size, 4, latent_height, latent_width)
|
||||
|
||||
return {"samples": latent_tensor}
|
||||
# Same mark as core EmptyLatentImage: lets the sampler resize this empty latent for 16x VAEs
|
||||
return {"samples": latent_tensor, "downscale_ratio_spacial": 8}
|
||||
|
||||
|
||||
def validate_dimensions(width: int, height: int) -> bool:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Model Resolution Picker module."""
|
||||
|
||||
from .node import ModelResolutionPickerNode
|
||||
|
||||
__all__ = ["ModelResolutionPickerNode"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Model family table for the Model Resolution Picker node.
|
||||
|
||||
The data lives in ``families.json`` next to this module; it is loaded and
|
||||
validated once at import (see ``table.py``). A shipped file that fails
|
||||
validation is a startup error.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .logic import size_label
|
||||
from .override import current_table
|
||||
from .table import (
|
||||
CHIPS,
|
||||
MIRROR,
|
||||
Family,
|
||||
Size,
|
||||
build_families,
|
||||
load_table,
|
||||
validate_table,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CHIPS",
|
||||
"MIRROR",
|
||||
"Family",
|
||||
"Size",
|
||||
"FAMILIES",
|
||||
"DEFAULT_FAMILY",
|
||||
"family_labels",
|
||||
"default_family",
|
||||
"resolve_family",
|
||||
"families_payload",
|
||||
"resolution_labels",
|
||||
"SHIPPED_DATA",
|
||||
"load_shipped_families",
|
||||
]
|
||||
|
||||
TABLE_PATH = Path(__file__).with_name("families.json")
|
||||
|
||||
|
||||
def load_shipped_families(path: Path = TABLE_PATH) -> Tuple[Tuple[Family, ...], str]:
|
||||
"""Load and validate ``path``; return its families and default label."""
|
||||
data = load_table(path)
|
||||
problems = validate_table(data)
|
||||
if problems:
|
||||
listing = "\n - ".join(problems)
|
||||
raise RuntimeError(f"invalid family table {path}:\n - {listing}")
|
||||
return build_families(data), data["default"]
|
||||
|
||||
|
||||
SHIPPED_DATA = load_table(TABLE_PATH)
|
||||
FAMILIES, DEFAULT_FAMILY = load_shipped_families()
|
||||
|
||||
|
||||
def _current() -> Tuple[Tuple[Family, ...], str, List[str]]:
|
||||
"""Return families, default label and warnings for the merged table."""
|
||||
data, warnings = current_table()
|
||||
if data is SHIPPED_DATA:
|
||||
return FAMILIES, DEFAULT_FAMILY, warnings
|
||||
built = build_families(data)
|
||||
return built, _default_label(built, data["default"]), warnings
|
||||
|
||||
|
||||
def _default_label(built: Tuple[Family, ...], default: str) -> str:
|
||||
"""Return the label of the family named by ``default`` (label or alias)."""
|
||||
for family in built:
|
||||
if default == family.label or default in family.aliases:
|
||||
return family.label
|
||||
return default
|
||||
|
||||
|
||||
def family_labels() -> List[str]:
|
||||
"""Return every family label of the merged table in display order."""
|
||||
return [family.label for family in _current()[0]]
|
||||
|
||||
|
||||
def default_family() -> str:
|
||||
"""Return the merged table's default family label."""
|
||||
return _current()[1]
|
||||
|
||||
|
||||
def resolution_labels() -> List[str]:
|
||||
"""Return the sorted distinct size labels across the shipped families."""
|
||||
labels = {
|
||||
size_label(width, height, chip)
|
||||
for family in FAMILIES
|
||||
for chip, sizes in family.sizes.items()
|
||||
for width, height in sizes
|
||||
}
|
||||
return sorted(labels)
|
||||
|
||||
|
||||
def resolve_family(label: str) -> Optional[Family]:
|
||||
"""Return the family matching ``label`` or one of its aliases, else None."""
|
||||
for family in _current()[0]:
|
||||
if label == family.label or label in family.aliases:
|
||||
return family
|
||||
return None
|
||||
|
||||
|
||||
def families_payload() -> Dict[str, Any]:
|
||||
"""Return the JSON-shaped table the frontend consumes."""
|
||||
current, default, warnings = _current()
|
||||
return {
|
||||
"chips": list(CHIPS),
|
||||
"mirror": dict(MIRROR),
|
||||
"default": default,
|
||||
"warnings": list(warnings),
|
||||
"families": [
|
||||
{
|
||||
"label": family.label,
|
||||
"aliases": list(family.aliases),
|
||||
"models": family.models,
|
||||
"group": family.group,
|
||||
"vaeRatio": family.vae_ratio,
|
||||
"step": family.step,
|
||||
"megapixels": list(family.megapixels),
|
||||
"default": list(family.default),
|
||||
"sizes": {
|
||||
chip: [list(size) for size in family.sizes.get(chip, ())]
|
||||
for chip in CHIPS
|
||||
},
|
||||
}
|
||||
for family in current
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Pure size math for the Model Resolution Picker node."""
|
||||
|
||||
import math
|
||||
import re
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Optional
|
||||
|
||||
from .table import MAX_SIDE, MIN_SIDE
|
||||
|
||||
MEGAPIXEL = 1_048_576
|
||||
|
||||
_ALIASES = {
|
||||
"8:5": "16:10",
|
||||
"5:8": "10:16",
|
||||
"7:3": "21:9",
|
||||
"3:7": "9:21",
|
||||
}
|
||||
|
||||
|
||||
def round_to_step(value: float, step: int, minimum: int = 64) -> int:
|
||||
"""Round ``value`` to the nearest multiple of ``step``, never below ``minimum``.
|
||||
|
||||
The result is also raised to the smallest multiple of ``step`` that is at
|
||||
least ``minimum``.
|
||||
"""
|
||||
nearest = math.floor(value / step + 0.5) * step
|
||||
floor_multiple = math.ceil(minimum / step) * step
|
||||
return max(nearest, floor_multiple)
|
||||
|
||||
|
||||
def generate_size(
|
||||
ratio_w: int, ratio_h: int, megapixels: float, step: int
|
||||
) -> tuple[int, int]:
|
||||
"""Return a ``(width, height)`` near ``megapixels`` with the given aspect ratio.
|
||||
|
||||
Uses the core Resolution Selector scale ``sqrt(mp * MEGAPIXEL / (w * h))``
|
||||
and snaps each side to ``step`` via :func:`round_to_step`.
|
||||
"""
|
||||
scale = math.sqrt(megapixels * MEGAPIXEL / (ratio_w * ratio_h))
|
||||
width = round_to_step(ratio_w * scale, step)
|
||||
height = round_to_step(ratio_h * scale, step)
|
||||
return width, height
|
||||
|
||||
|
||||
def ratio_label(width: int, height: int) -> str:
|
||||
"""Return the reduced ``W:H`` label for a size, with common aliases applied."""
|
||||
divisor = math.gcd(width, height)
|
||||
label = f"{width // divisor}:{height // divisor}"
|
||||
return _ALIASES.get(label, label)
|
||||
|
||||
|
||||
_SIZE_LABEL = re.compile(r"^(\d{1,5})×(\d{1,5}) - \S+ \(\d+\.\dMP\)$")
|
||||
|
||||
|
||||
def size_label(width: int, height: int, ratio_text: str) -> str:
|
||||
"""Return the dropdown label ``W×H - ratio (X.XMP)`` for a size."""
|
||||
exact = Decimal(width * height) / Decimal(MEGAPIXEL) # exact: MEGAPIXEL is 2**20
|
||||
megapixels = exact.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) # as JS toFixed
|
||||
return f"{width}×{height} - {ratio_text} ({megapixels}MP)"
|
||||
|
||||
|
||||
def parse_size_label(text: object) -> Optional[tuple[int, int]]:
|
||||
"""Return ``(width, height)`` from a :func:`size_label` string, else None."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
match = _SIZE_LABEL.match(text)
|
||||
if match is None:
|
||||
return None
|
||||
width, height = int(match.group(1)), int(match.group(2))
|
||||
in_range = MIN_SIDE <= width <= MAX_SIDE and MIN_SIDE <= height <= MAX_SIDE
|
||||
return (width, height) if in_range else None
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Model Resolution Picker node and its families route for ComfyUI."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from ..empty_latent_batch.logic import create_empty_latent_batch
|
||||
from .families import (
|
||||
default_family,
|
||||
families_payload,
|
||||
family_labels,
|
||||
resolution_labels,
|
||||
resolve_family,
|
||||
)
|
||||
from .logic import parse_size_label, round_to_step
|
||||
from .table import MAX_SIDE, MIN_SIDE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_STEP = 8
|
||||
|
||||
|
||||
class ModelResolutionPickerNode(ComfyAssetsBaseNode):
|
||||
"""Pick a model family and size, and get an empty latent plus width/height."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
size_options = {"default": 1024, "min": MIN_SIDE, "max": MAX_SIDE, "step": 8}
|
||||
return {
|
||||
"required": {
|
||||
"model": (
|
||||
family_labels(),
|
||||
{
|
||||
"default": default_family(),
|
||||
"tooltip": "Model family; the chips list its preferred sizes",
|
||||
},
|
||||
),
|
||||
"resolution": (
|
||||
["custom"] + resolution_labels(),
|
||||
{
|
||||
"default": "custom",
|
||||
"tooltip": "Sizes for the selected model and ratio; "
|
||||
"custom uses width/height",
|
||||
},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
**size_options,
|
||||
"tooltip": "Custom width in pixels; rounded to the model "
|
||||
"family's step",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
**size_options,
|
||||
"tooltip": "Custom height in pixels; rounded to the model "
|
||||
"family's step",
|
||||
},
|
||||
),
|
||||
"batch_size": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 64,
|
||||
"step": 1,
|
||||
"tooltip": "Number of empty latents in the batch",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, model: str, resolution: str) -> bool:
|
||||
"""Accept any label so renamed or future families still execute (create() falls back)."""
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT", "INT")
|
||||
RETURN_NAMES = ("latent", "width", "height")
|
||||
FUNCTION = "create"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def create(
|
||||
self, model: str, resolution: str, width: int, height: int, batch_size: int
|
||||
) -> Tuple[Dict[str, Any], int, int]:
|
||||
"""Round the size to the family's step and build the empty latent.
|
||||
|
||||
A listed ``resolution`` label overrides ``width``/``height``; ``custom``
|
||||
or any label that does not parse leaves them alone.
|
||||
"""
|
||||
listed = parse_size_label(resolution) if resolution != "custom" else None
|
||||
if listed is not None:
|
||||
width, height = listed
|
||||
family = resolve_family(model)
|
||||
if family is None:
|
||||
logger.warning(
|
||||
"Unknown model family '%s'; using step %d", model, FALLBACK_STEP
|
||||
)
|
||||
step = family.step if family else FALLBACK_STEP
|
||||
new_width = round_to_step(width, step)
|
||||
new_height = round_to_step(height, step)
|
||||
if (new_width, new_height) != (width, height):
|
||||
self.log_info(
|
||||
f"Rounded {width}x{height} to {new_width}x{new_height} "
|
||||
f"(step {step})"
|
||||
)
|
||||
latent = create_empty_latent_batch(new_width, new_height, batch_size)
|
||||
return (latent, new_width, new_height)
|
||||
|
||||
|
||||
async def get_families(request: Any) -> web.Response:
|
||||
"""Serve the model family table as JSON."""
|
||||
return web.json_response(families_payload())
|
||||
|
||||
|
||||
# Setup API routes
|
||||
try:
|
||||
import server
|
||||
|
||||
server.PromptServer.instance.routes.get("/kikotools/model_resolution/families")(
|
||||
get_families
|
||||
)
|
||||
except ImportError:
|
||||
# Server not available during testing
|
||||
pass
|
||||
@@ -0,0 +1,122 @@
|
||||
"""User override file for the Model Resolution Picker family table.
|
||||
|
||||
``<user dir>/kikotools/model_resolutions.json`` may replace families by label,
|
||||
append new ones, remove shipped ones and change the default. The merged table
|
||||
is validated as a whole; an override that fails to load, merge or validate is
|
||||
ignored entirely and reported as a warning.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .table import load_table, validate_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OVERRIDE_DIR = "kikotools"
|
||||
OVERRIDE_NAME = "model_resolutions.json"
|
||||
MAX_REPORTED_PROBLEMS = 3
|
||||
|
||||
Table = Dict[str, Any]
|
||||
_CacheKey = Tuple[Path, int, int]
|
||||
|
||||
_cache: Optional[Tuple[_CacheKey, Tuple[Table, List[str]]]] = None
|
||||
|
||||
|
||||
def _reset_cache() -> None:
|
||||
"""Forget the cached merge result (used by tests)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def override_path() -> Optional[Path]:
|
||||
"""Return the override file location, or None when no user dir is known."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
user_dir = folder_paths.get_user_directory()
|
||||
return Path(user_dir) / OVERRIDE_DIR / OVERRIDE_NAME
|
||||
except Exception: # ImportError, AttributeError, or a broken host
|
||||
return None
|
||||
|
||||
|
||||
def _check_override_shape(override: Any) -> None:
|
||||
"""Raise ValueError unless ``override`` has the minimal expected shape."""
|
||||
if not isinstance(override, dict):
|
||||
raise ValueError("override must be a JSON object")
|
||||
fams = override.get("families", [])
|
||||
if not isinstance(fams, list):
|
||||
raise ValueError("'families' must be a list")
|
||||
for index, fam in enumerate(fams):
|
||||
if not isinstance(fam, dict) or not isinstance(fam.get("label"), str):
|
||||
raise ValueError(f"families[{index}] must be an object with a string label")
|
||||
remove = override.get("remove", [])
|
||||
if not isinstance(remove, list) or not all(isinstance(r, str) for r in remove):
|
||||
raise ValueError("'remove' must be a list of strings")
|
||||
default = override.get("default")
|
||||
if default is not None and not isinstance(default, str):
|
||||
raise ValueError("'default' must be a string")
|
||||
|
||||
|
||||
def merge_override(shipped: Table, override: Table) -> Table:
|
||||
"""Return a new table: ``shipped`` with ``override`` applied (no mutation)."""
|
||||
_check_override_shape(override)
|
||||
merged = copy.deepcopy(shipped)
|
||||
replacements = {f["label"]: f for f in override.get("families", [])}
|
||||
removed = set(override.get("remove", []))
|
||||
result: List[Any] = []
|
||||
for fam in merged.get("families", []):
|
||||
label = fam.get("label") if isinstance(fam, dict) else None
|
||||
if label in removed:
|
||||
continue
|
||||
result.append(
|
||||
copy.deepcopy(replacements.pop(label)) if label in replacements else fam
|
||||
)
|
||||
result.extend(
|
||||
copy.deepcopy(f) for label, f in replacements.items() if label not in removed
|
||||
)
|
||||
merged["families"] = result
|
||||
if isinstance(override.get("default"), str):
|
||||
merged["default"] = override["default"]
|
||||
return merged
|
||||
|
||||
|
||||
def _ignored(path: Path, reason: str, shipped: Table) -> Tuple[Table, List[str]]:
|
||||
message = f"Override ignored: {path}: {reason}"
|
||||
logger.warning(message)
|
||||
return shipped, [message]
|
||||
|
||||
|
||||
def _build(path: Path, shipped: Table) -> Tuple[Table, List[str]]:
|
||||
"""Load, merge and validate the override at ``path``."""
|
||||
try:
|
||||
merged = merge_override(shipped, load_table(path))
|
||||
problems = validate_table(merged)
|
||||
except (ValueError, RecursionError) as exc:
|
||||
reason = "nested too deeply" if isinstance(exc, RecursionError) else str(exc)
|
||||
return _ignored(path, reason, shipped)
|
||||
if problems:
|
||||
return _ignored(path, "; ".join(problems[:MAX_REPORTED_PROBLEMS]), shipped)
|
||||
return merged, []
|
||||
|
||||
|
||||
def current_table() -> Tuple[Table, List[str]]:
|
||||
"""Return ``(table, warnings)``: shipped data merged with a valid override."""
|
||||
global _cache
|
||||
from .families import SHIPPED_DATA
|
||||
|
||||
path = override_path()
|
||||
if path is None:
|
||||
return SHIPPED_DATA, []
|
||||
try:
|
||||
stat = path.stat()
|
||||
key: _CacheKey = (path, stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
return SHIPPED_DATA, []
|
||||
if _cache is not None and _cache[0] == key:
|
||||
return _cache[1]
|
||||
result = _build(path, SHIPPED_DATA)
|
||||
_cache = (key, result)
|
||||
return result
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Load, validate and build the Model Resolution Picker family table.
|
||||
|
||||
The table is plain JSON (shipped as ``families.json``). ``validate_table`` is a
|
||||
pure function that returns human-readable problems and never raises for data
|
||||
problems, so the same check can guard the shipped file and a user override.
|
||||
Unknown keys, such as a per-family ``"notes"`` string, are ignored.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, NamedTuple, Tuple
|
||||
|
||||
CHIPS: Tuple[str, ...] = (
|
||||
"1:1",
|
||||
"4:5",
|
||||
"5:4",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
"9:21",
|
||||
)
|
||||
MIRROR: Dict[str, str] = {
|
||||
"1:1": "1:1",
|
||||
"4:5": "5:4",
|
||||
"5:4": "4:5",
|
||||
"3:4": "4:3",
|
||||
"4:3": "3:4",
|
||||
"2:3": "3:2",
|
||||
"3:2": "2:3",
|
||||
"9:16": "16:9",
|
||||
"16:9": "9:16",
|
||||
"21:9": "9:21",
|
||||
"9:21": "21:9",
|
||||
}
|
||||
TABLE_VERSION = 1
|
||||
GROUPS = ("image", "video")
|
||||
VAE_RATIOS = (8, 16, 32)
|
||||
CHIP_TOLERANCE = 0.07
|
||||
# Sanity bounds so absurd values are reported instead of overflowing.
|
||||
MAX_STEP = 4096
|
||||
# The empty latent is allocated at 1/8 of the pixel size, so every family step
|
||||
# must keep rounded sizes divisible by 8 (see empty_latent_batch.logic).
|
||||
LATENT_STEP = 8
|
||||
MAX_MEGAPIXELS = 64
|
||||
# Match the node's width/height widget limits.
|
||||
MIN_SIDE = 64
|
||||
MAX_SIDE = 8192
|
||||
|
||||
Size = Tuple[int, int]
|
||||
|
||||
|
||||
class Family(NamedTuple):
|
||||
"""One model family: grid constraints, size tiers and the chip size lists."""
|
||||
|
||||
label: str
|
||||
aliases: Tuple[str, ...]
|
||||
models: str
|
||||
group: str
|
||||
vae_ratio: int
|
||||
step: int
|
||||
megapixels: Tuple[float, ...]
|
||||
default: Size
|
||||
sizes: Dict[str, Tuple[Size, ...]]
|
||||
|
||||
|
||||
def load_table(path: Path) -> Dict[str, Any]:
|
||||
"""Read ``path`` as JSON; raise ``ValueError`` naming the path on failure."""
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as error:
|
||||
raise ValueError(f"cannot read family table {path}: {error}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"family table {path} must be a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def _is_size(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, (list, tuple))
|
||||
and len(value) == 2
|
||||
and all(
|
||||
isinstance(v, int) and not isinstance(v, bool) and MIN_SIDE <= v <= MAX_SIDE
|
||||
for v in value
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _is_finite(value: Any) -> bool:
|
||||
"""Return True for finite numbers; ints are always finite (and may be huge)."""
|
||||
return isinstance(value, int) or math.isfinite(value)
|
||||
|
||||
|
||||
def _is_step(value: Any) -> bool:
|
||||
return (
|
||||
_is_number(value)
|
||||
and _is_finite(value)
|
||||
and 0 < value <= MAX_STEP
|
||||
and value == int(value)
|
||||
)
|
||||
|
||||
|
||||
def _chip_ratio(chip: str) -> float:
|
||||
left, right = chip.split(":")
|
||||
return int(left) / int(right)
|
||||
|
||||
|
||||
def _validate_top_level(data: Dict[str, Any]) -> List[str]:
|
||||
problems: List[str] = []
|
||||
if data.get("version") != TABLE_VERSION:
|
||||
problems.append(f"version must be {TABLE_VERSION}")
|
||||
if data.get("chips") != list(CHIPS):
|
||||
problems.append(f"chips must equal {list(CHIPS)}")
|
||||
if data.get("mirror") != MIRROR:
|
||||
problems.append("mirror must pair every chip with its mirrored chip")
|
||||
if not isinstance(data.get("families"), list) or not data["families"]:
|
||||
problems.append("families must be a non-empty list")
|
||||
return problems
|
||||
|
||||
|
||||
def _validate_names(families: List[Any]) -> List[str]:
|
||||
problems: List[str] = []
|
||||
seen: Dict[str, str] = {}
|
||||
for index, fam in enumerate(families):
|
||||
label = fam.get("label")
|
||||
if not isinstance(label, str) or not label:
|
||||
problems.append(f"family #{index + 1}: label must be a non-empty string")
|
||||
continue
|
||||
aliases = fam.get("aliases", [])
|
||||
if not isinstance(aliases, list) or not all(
|
||||
isinstance(a, str) for a in aliases
|
||||
):
|
||||
problems.append(f"family {label!r}: aliases must be a list of strings")
|
||||
aliases = []
|
||||
for name in [label, *aliases]:
|
||||
if name in seen:
|
||||
problems.append(
|
||||
f"family {label!r}: duplicate label or alias {name!r} "
|
||||
f"(already used by {seen[name]!r})"
|
||||
)
|
||||
else:
|
||||
seen[name] = label
|
||||
return problems
|
||||
|
||||
|
||||
def _validate_scalars(label: str, fam: Dict[str, Any]) -> List[str]:
|
||||
problems: List[str] = []
|
||||
if not isinstance(fam.get("models"), str):
|
||||
problems.append(f"family {label!r}: models must be a string")
|
||||
if fam.get("group") not in GROUPS:
|
||||
problems.append(f"family {label!r}: group must be one of {list(GROUPS)}")
|
||||
if fam.get("vaeRatio") not in VAE_RATIOS:
|
||||
problems.append(f"family {label!r}: vaeRatio must be one of {list(VAE_RATIOS)}")
|
||||
if not _is_step(fam.get("step")):
|
||||
problems.append(f"family {label!r}: step must be a positive integer")
|
||||
elif int(fam["step"]) % LATENT_STEP:
|
||||
problems.append(
|
||||
f"family {label!r}: step must be a multiple of {LATENT_STEP} "
|
||||
"so sizes land on the latent grid"
|
||||
)
|
||||
mp = fam.get("megapixels")
|
||||
if (
|
||||
not isinstance(mp, list)
|
||||
or not mp
|
||||
or not all(
|
||||
_is_number(m) and _is_finite(m) and 0 < m <= MAX_MEGAPIXELS for m in mp
|
||||
)
|
||||
):
|
||||
problems.append(f"family {label!r}: megapixels must be a non-empty number list")
|
||||
return problems
|
||||
|
||||
|
||||
def _validate_chip(
|
||||
label: str, chip: str, entries: Any, fam: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
where = f"family {label!r}, chip {chip}"
|
||||
if not isinstance(entries, list) or not all(_is_size(s) for s in entries):
|
||||
return [f"{where}: sizes must be a list of [width, height] integer pairs"]
|
||||
problems: List[str] = []
|
||||
step = fam.get("step")
|
||||
step_ok = _is_step(step)
|
||||
target = _chip_ratio(chip)
|
||||
for w, h in entries:
|
||||
if step_ok and (w % step or h % step):
|
||||
problems.append(f"{where}: {w}x{h} is not a multiple of step {int(step)}")
|
||||
if abs(w / h - target) / target > CHIP_TOLERANCE:
|
||||
problems.append(f"{where}: {w}x{h} is more than 7% off the {chip} ratio")
|
||||
areas = [w * h for w, h in entries]
|
||||
if areas != sorted(areas):
|
||||
problems.append(f"{where}: sizes must be in ascending pixel order")
|
||||
return problems
|
||||
|
||||
|
||||
def _validate_sizes(label: str, fam: Dict[str, Any]) -> List[str]:
|
||||
sizes = fam.get("sizes")
|
||||
if not isinstance(sizes, dict):
|
||||
return [f"family {label!r}: sizes must be an object keyed by chip"]
|
||||
problems: List[str] = []
|
||||
for chip in CHIPS:
|
||||
if chip not in sizes:
|
||||
problems.append(f"family {label!r}: sizes is missing chip {chip}")
|
||||
else:
|
||||
problems.extend(_validate_chip(label, chip, sizes[chip], fam))
|
||||
valid = {
|
||||
chip: [tuple(s) for s in sizes[chip]]
|
||||
for chip in CHIPS
|
||||
if chip in sizes
|
||||
and isinstance(sizes[chip], list)
|
||||
and all(_is_size(s) for s in sizes[chip])
|
||||
}
|
||||
problems.extend(_validate_mirrors(label, valid))
|
||||
problems.extend(_validate_default(label, fam, valid))
|
||||
return problems
|
||||
|
||||
|
||||
def _validate_mirrors(label: str, sizes: Dict[str, List[Size]]) -> List[str]:
|
||||
problems: List[str] = []
|
||||
owner: Dict[Size, str] = {}
|
||||
for chip, entries in sizes.items():
|
||||
mirrored = sizes.get(MIRROR[chip])
|
||||
for w, h in entries:
|
||||
if mirrored is not None and (h, w) not in mirrored:
|
||||
problems.append(
|
||||
f"family {label!r}, chip {chip}: {w}x{h} has no mirror "
|
||||
f"{h}x{w} under {MIRROR[chip]}"
|
||||
)
|
||||
if (w, h) in owner:
|
||||
problems.append(
|
||||
f"family {label!r}, chip {chip}: {w}x{h} is listed under more "
|
||||
f"than one chip (also {owner[(w, h)]})"
|
||||
)
|
||||
else:
|
||||
owner[(w, h)] = chip
|
||||
return problems
|
||||
|
||||
|
||||
def _validate_default(
|
||||
label: str, fam: Dict[str, Any], sizes: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
default = fam.get("default")
|
||||
if not _is_size(default):
|
||||
return [f"family {label!r}: default must be a [width, height] pair"]
|
||||
if not any(tuple(default) in entries for entries in sizes.values()):
|
||||
return [f"family {label!r}: default {list(default)} is not in its sizes"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_default_label(default: Any, entries: List[Dict[str, Any]]) -> List[str]:
|
||||
names = set()
|
||||
for fam in entries:
|
||||
if isinstance(fam.get("label"), str):
|
||||
names.add(fam["label"])
|
||||
if isinstance(fam.get("aliases"), list):
|
||||
names.update(a for a in fam["aliases"] if isinstance(a, str))
|
||||
if isinstance(default, str) and default in names:
|
||||
return []
|
||||
return [f"default {default!r} is not a family label"]
|
||||
|
||||
|
||||
def validate_table(data: Any) -> List[str]:
|
||||
"""Return every problem found in ``data``; an empty list means it is valid.
|
||||
|
||||
Bounds: step a multiple of 8 and <= 4096, megapixels in (0, 64], each side
|
||||
in 64..8192.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return ["table must be a JSON object"]
|
||||
problems = _validate_top_level(data)
|
||||
families = data.get("families")
|
||||
if not isinstance(families, list):
|
||||
return problems
|
||||
entries = [f for f in families if isinstance(f, dict)]
|
||||
for index, fam in enumerate(families):
|
||||
if not isinstance(fam, dict):
|
||||
problems.append(f"family #{index + 1}: must be an object")
|
||||
problems.extend(_validate_names(entries))
|
||||
for fam in entries:
|
||||
label = fam.get("label")
|
||||
if not isinstance(label, str) or not label:
|
||||
continue
|
||||
problems.extend(_validate_scalars(label, fam))
|
||||
problems.extend(_validate_sizes(label, fam))
|
||||
problems.extend(_validate_default_label(data.get("default"), entries))
|
||||
return problems
|
||||
|
||||
|
||||
def build_families(data: Dict[str, Any]) -> Tuple[Family, ...]:
|
||||
"""Build ``Family`` records from a table that passed ``validate_table``."""
|
||||
return tuple(
|
||||
Family(
|
||||
label=fam["label"],
|
||||
aliases=tuple(fam.get("aliases", ())),
|
||||
models=fam["models"],
|
||||
group=fam["group"],
|
||||
vae_ratio=fam["vaeRatio"],
|
||||
step=int(fam["step"]),
|
||||
megapixels=tuple(fam["megapixels"]),
|
||||
default=(fam["default"][0], fam["default"][1]),
|
||||
sizes={
|
||||
chip: tuple((w, h) for w, h in fam["sizes"].get(chip, ()))
|
||||
for chip in CHIPS
|
||||
},
|
||||
)
|
||||
for fam in data["families"]
|
||||
)
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.1.2"
|
||||
version = "1.3.0"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
@@ -37,6 +37,9 @@ Repository = "https://github.com/ComfyAssets/ComfyUI-KikoTools"
|
||||
include = ["kikotools*"]
|
||||
exclude = ["tests*", "web*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
kikotools = ["**/*.json"]
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "kiko9"
|
||||
DisplayName = "ComfyUI-KikoTools"
|
||||
|
||||
@@ -71,7 +71,7 @@ class HostNode {
|
||||
if (key === 'preset') entry.options.values = ['custom', '832×1216 - portrait', '1216×832 - landscape'];
|
||||
}
|
||||
}
|
||||
addDOMWidget(name, type, element) { this.element.append(element); const result = { name, element, onRemove: () => element.remove() }; this.widgets.push(result); return result; }
|
||||
addDOMWidget(name, type, element, options = {}) { this.element.append(element); const result = { name, element, options, onRemove: () => element.remove() }; this.widgets.push(result); return result; }
|
||||
addWidget(type, name, value, callback, options = {}) {
|
||||
if (type !== 'button') { const entry = widget(this, name, value); entry.callback = callback; return entry; }
|
||||
const button = el('button', { textContent: name, onclick: () => callback() });
|
||||
@@ -85,7 +85,12 @@ class HostNode {
|
||||
computeSize() { return this.size; }
|
||||
onResize(size) { this.size = size; }
|
||||
setDirtyCanvas() {}
|
||||
configure(info) { this.properties = info.properties || {}; }
|
||||
configure(info) {
|
||||
this.properties = info.properties || {};
|
||||
const editable = this.widgets.filter(entry => entry.inputEl);
|
||||
(info.widgets_values || []).forEach((value, index) => { if (editable[index]) editable[index].value = value; });
|
||||
this.onConfigure?.(info);
|
||||
}
|
||||
onNodeCreated() { this.created = true; }
|
||||
onExecuted() { this.executions = (this.executions || 0) + 1; }
|
||||
onRemoved() { this.removed = true; }
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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' };
|
||||
|
||||
export const fixture = 'http://127.0.0.1:8771';
|
||||
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
|
||||
|
||||
export function installHost(test, expect) {
|
||||
test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
|
||||
const info = await (await request.get(`${baseURL}system_stats`)).json();
|
||||
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.startsWith('/kikotools/autocomplete/') || path.startsWith('/kikotools/model_resolution/') || 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/'));
|
||||
return route.fulfill({ json: extensions });
|
||||
}
|
||||
if (path.startsWith('/extensions/kikotools/')) {
|
||||
const source = resolve(web, decodeURIComponent(path.slice('/extensions/kikotools/'.length)));
|
||||
if (!source.startsWith(web + sep)) return route.abort();
|
||||
if (!/\.(js|css)$/.test(source)) return route.abort();
|
||||
return route.fulfill({ body: await readFile(source), contentType: source.endsWith('.css') ? 'text/css' : 'text/javascript' });
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,51 +1,89 @@
|
||||
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { installHost, fixture } from './host-setup.mjs';
|
||||
|
||||
const fixture = 'http://127.0.0.1:8771';
|
||||
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
|
||||
installHost(test, expect);
|
||||
|
||||
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');
|
||||
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') {
|
||||
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 === '/extensions') {
|
||||
const response = await route.fetch();
|
||||
const extensions = (await response.json()).filter(path => path.startsWith('/extensions/kikotools/'));
|
||||
return route.fulfill({ json: extensions });
|
||||
}
|
||||
if (path.startsWith('/extensions/kikotools/')) {
|
||||
const source = resolve(web, decodeURIComponent(path.slice('/extensions/kikotools/'.length)));
|
||||
if (!source.startsWith(web + sep)) return route.abort();
|
||||
if (!/\.(js|css)$/.test(source)) return route.abort();
|
||||
return route.fulfill({ body: await readFile(source), contentType: source.endsWith('.css') ? 'text/css' : 'text/javascript' });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async () => {
|
||||
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');
|
||||
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.KikoLocalImageLoader;
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
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');
|
||||
app.graph.clear();
|
||||
});
|
||||
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 () => {
|
||||
@@ -315,3 +353,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,123 @@
|
||||
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
|
||||
import { installHost } from './host-setup.mjs';
|
||||
|
||||
installHost(test, expect);
|
||||
|
||||
test('resolution picker survives renderers, swap, themes and workflow reload', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('ModelResolutionPicker');
|
||||
app.graph.add(node);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const panel = page.locator('.ca-resolution-picker');
|
||||
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await panel.getByRole('button', { name: '16:9', exact: true }).click();
|
||||
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 expect(panel).toHaveCount(1);
|
||||
await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click();
|
||||
}
|
||||
// three swaps from 16:9
|
||||
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
// model change in Nodes 2.0 lands on the new family's default for the derived chip
|
||||
const payload = await page.evaluate(async () => (await fetch('/kikotools/model_resolution/families')).json());
|
||||
const hunyuan = payload.families.find(family => family.label === 'HunyuanImage 2.1');
|
||||
const target = hunyuan.default[0] * hunyuan.default[1];
|
||||
const [expectedWidth, expectedHeight] = [...hunyuan.sizes['9:16']].sort(
|
||||
(a, b) => Math.abs(a[0] * a[1] - target) - Math.abs(b[0] * b[1] - target),
|
||||
)[0];
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', true);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(true);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(item => item.type === 'ModelResolutionPicker');
|
||||
const widget = node.widgets.find(item => item.name === 'model');
|
||||
widget.value = 'HunyuanImage 2.1';
|
||||
widget.callback(widget.value, app.canvas, node, [0, 0], null);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(item => item.type === 'ModelResolutionPicker');
|
||||
return ['width', 'height'].map(name => node.widgets.find(widget => widget.name === name).value);
|
||||
})).toEqual([expectedWidth, expectedHeight]);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', false);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(false);
|
||||
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
// wired-in state disables the chips and Swap, and clears when the link goes away
|
||||
const swap = page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true });
|
||||
await expect(swap).toBeEnabled();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(item => item.type === 'ModelResolutionPicker');
|
||||
window.wiredEntry = { name: 'width', widget: { name: 'width' }, link: 7 };
|
||||
node.inputs.push(window.wiredEntry);
|
||||
node.onConnectionsChange();
|
||||
});
|
||||
await expect(panel.locator('.ca-resolution-header')).toHaveText('Dimensions are wired in');
|
||||
await expect(swap).toBeDisabled();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(item => item.type === 'ModelResolutionPicker');
|
||||
node.inputs.splice(node.inputs.indexOf(window.wiredEntry), 1);
|
||||
node.onConnectionsChange();
|
||||
});
|
||||
await expect(swap).toBeEnabled();
|
||||
await expect(panel.locator('.ca-resolution-header')).not.toHaveText('Dimensions are wired in');
|
||||
// the node height is fitted to the panel content: no empty space under the list, and it follows the list
|
||||
const heights = () => page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(item => item.type === 'ModelResolutionPicker');
|
||||
return { size: node.size[1], computed: node.computeSize()[1] };
|
||||
});
|
||||
await expect.poll(async () => { const h = await heights(); return Math.abs(h.size - h.computed) < 1 && h.size < 600; }).toBe(true);
|
||||
const fitted = (await heights()).size;
|
||||
await panel.getByRole('button', { name: 'Custom ratio', exact: true }).click();
|
||||
await expect.poll(async () => (await heights()).size).toBeGreaterThan(fitted);
|
||||
await panel.getByRole('button', { name: '9:16', exact: true }).click();
|
||||
await expect.poll(async () => (await heights()).size).toBe(fitted);
|
||||
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(`ModelResolutionPicker-${theme}.png`) });
|
||||
}
|
||||
const restored = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
const saved = workflow.nodes.find(node => node.type === 'ModelResolutionPicker');
|
||||
if (saved.widgets_values.slice(5).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values');
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker');
|
||||
return {
|
||||
saved: saved.widgets_values.slice(0, 5),
|
||||
restored: node.widgets.filter(widget => ['model', 'resolution', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value),
|
||||
size: Array.from(node.size),
|
||||
savedSize: Array.from(saved.size),
|
||||
};
|
||||
});
|
||||
expect(restored.restored).toEqual(restored.saved);
|
||||
expect(restored.restored[1]).toMatch(/^\d+×\d+ - 9:16 \(\d\.\dMP\)$/);
|
||||
expect(restored.size).toEqual(restored.savedSize);
|
||||
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker');
|
||||
window.removedPicker = node;
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedPicker.resolutionScope.signal.aborted)).toBe(true);
|
||||
});
|
||||
@@ -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,71 @@
|
||||
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]);
|
||||
});
|
||||
|
||||
test('nodes without a preset widget swap width and height directly', () => {
|
||||
const calls = [];
|
||||
const node = { widgets: [
|
||||
{ name: 'width', value: 1344, callback(value) { calls.push(['width', value]); } },
|
||||
{ name: 'height', value: 768, callback(value) { calls.push(['height', value]); } },
|
||||
], graph: { setDirtyCanvas() { calls.push(['dirty']); } } };
|
||||
swapDimensions(node, app);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.value), [768, 1344]);
|
||||
assert.deepEqual(calls, [['width', 768], ['height', 1344], ['dirty']]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
defaultEntry,
|
||||
dropdownOptions,
|
||||
deriveSelection,
|
||||
effectiveSize,
|
||||
megapixels,
|
||||
modelOptions,
|
||||
parseRatio,
|
||||
parseSizeLabel,
|
||||
sizeLabel,
|
||||
sizesForRatio,
|
||||
sizeOnModelChange,
|
||||
} from '../../web/ui/resolution-picker.js';
|
||||
|
||||
const chips = ['1:1', '4:5', '5:4', '3:4', '4:3', '2:3', '3:2', '9:16', '16:9', '21:9', '9:21'];
|
||||
const mirror = { '1:1': '1:1', '4:5': '5:4', '5:4': '4:5', '3:4': '4:3', '4:3': '3:4', '2:3': '3:2', '3:2': '2:3', '9:16': '16:9', '16:9': '9:16', '21:9': '9:21', '9:21': '21:9' };
|
||||
|
||||
function sizesFor(listed) {
|
||||
return Object.fromEntries(chips.map((chip) => [chip, listed[chip] || []]));
|
||||
}
|
||||
|
||||
const payload = {
|
||||
chips,
|
||||
mirror,
|
||||
default: 'SDXL',
|
||||
families: [
|
||||
{
|
||||
label: 'SDXL', aliases: ['SDXL 1.0'], models: [], group: 'image', vaeRatio: 8, step: 64, megapixels: [1.0], default: [1024, 1024],
|
||||
sizes: sizesFor({ '1:1': [[1024, 1024]], '3:2': [[1216, 832]], '2:3': [[832, 1216]], '16:9': [[1344, 768]], '9:16': [[768, 1344]] }),
|
||||
},
|
||||
{
|
||||
label: 'HunyuanImage 2.1', aliases: [], models: [], group: 'image', vaeRatio: 32, step: 32, megapixels: [4.0], default: [2048, 2048],
|
||||
sizes: sizesFor({ '1:1': [[2048, 2048]], '16:9': [[2560, 1536]], '9:16': [[1536, 2560]] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('parseRatio accepts the documented forms and rejects malformed input', () => {
|
||||
for (const text of ['16:9', ' 16 / 9 ', '16x9', '16×9', '16X9']) assert.deepEqual(parseRatio(text), [16, 9]);
|
||||
for (const text of ['0:5', '16:', 'a:b', '99999999:1', '', null]) assert.equal(parseRatio(text), null);
|
||||
});
|
||||
test('deriveSelection prefers authored membership, then the 7% band, then custom', () => {
|
||||
assert.deepEqual(deriveSelection(payload, 'HunyuanImage 2.1', 2560, 1536), { family: payload.families[1], chip: '16:9', entryIndex: 0, custom: false, ratioText: '5:3' });
|
||||
assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).chip, '3:2'); // 1.5 exactly, not listed
|
||||
assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).entryIndex, -1);
|
||||
const custom = deriveSelection(payload, 'SDXL', 1600, 800); // 2:1 is >7% from every chip
|
||||
assert.deepEqual([custom.chip, custom.custom, custom.ratioText], [null, true, '2:1']);
|
||||
assert.equal(deriveSelection(payload, 'SDXL 1.0', 1024, 1024).chip, '1:1'); // alias
|
||||
assert.equal(deriveSelection(payload, 'nope', 1024, 1024).family, null);
|
||||
});
|
||||
test('sizesForRatio follows the core formula per tier and effectiveSize rounds to the step', () => {
|
||||
assert.deepEqual(sizesForRatio({ step: 16, megapixels: [1, 2] }, [16, 9]), [[1360, 768], [1936, 1088]]);
|
||||
assert.deepEqual(sizesForRatio({ step: 16, megapixels: [1, 4] }, [20, 1]), [[4576, 224]]); // 4 MP tier would exceed 8192
|
||||
assert.deepEqual(effectiveSize(payload.families[1], 70, 1000), [64, 992]);
|
||||
assert.deepEqual(effectiveSize(null, 1001, 1001), [1000, 1000]);
|
||||
assert.equal(megapixels(1216, 832), '1.0');
|
||||
});
|
||||
test('model change keeps listed sizes, else the same chip default, else the family default', () => {
|
||||
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 2560, 1536, '16:9'), [2560, 1536]);
|
||||
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1344, 768, '16:9'), [2560, 1536]);
|
||||
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1216, 832, '3:2'), [2048, 2048]);
|
||||
assert.deepEqual(defaultEntry(payload.families[0], '21:9'), null);
|
||||
});
|
||||
test('size labels round-trip and mirror the Python format', () => {
|
||||
assert.equal(sizeLabel(1216, 832, '3:2'), '1216×832 - 3:2 (1.0MP)');
|
||||
assert.equal(sizeLabel(2560, 1536, '16:9'), '2560×1536 - 16:9 (3.8MP)');
|
||||
assert.equal(sizeLabel(512, 512, '1:1'), '512×512 - 1:1 (0.3MP)');
|
||||
assert.deepEqual(parseSizeLabel(sizeLabel(768, 1344, '9:16')), [768, 1344]);
|
||||
for (const text of ['custom', 'garbage', '', null, undefined, '0×832 - 3:2 (1.0MP)']) assert.equal(parseSizeLabel(text), null);
|
||||
});
|
||||
test('dropdownOptions lists the active chip sizes, or every family size for custom', () => {
|
||||
assert.deepEqual(dropdownOptions(payload, 'SDXL', '3:2'), ['custom', '1216×832 - 3:2 (1.0MP)']);
|
||||
assert.deepEqual(dropdownOptions(payload, 'SDXL', null), [
|
||||
'custom', '1024×1024 - 1:1 (1.0MP)', '832×1216 - 2:3 (1.0MP)', '1216×832 - 3:2 (1.0MP)',
|
||||
'768×1344 - 9:16 (1.0MP)', '1344×768 - 16:9 (1.0MP)',
|
||||
]);
|
||||
assert.deepEqual(dropdownOptions(payload, 'SDXL', 'custom'), dropdownOptions(payload, 'SDXL', null));
|
||||
assert.deepEqual(dropdownOptions(payload, 'SDXL', '21:9'), ['custom']);
|
||||
assert.deepEqual(dropdownOptions(payload, 'nope', '3:2'), ['custom']);
|
||||
assert.deepEqual(dropdownOptions(null, 'SDXL', null), ['custom']);
|
||||
});
|
||||
|
||||
test('modelOptions lists payload labels in order and keeps an absent current value', () => {
|
||||
const payload = { families: [{ label: 'A' }, { label: 'B' }] };
|
||||
assert.deepEqual(modelOptions(payload, 'B'), ['A', 'B']);
|
||||
assert.deepEqual(modelOptions(payload, 'Gone'), ['A', 'B', 'Gone']);
|
||||
assert.deepEqual(modelOptions(payload, undefined), ['A', 'B']);
|
||||
assert.deepEqual(modelOptions(null, 'Gone'), ['Gone']);
|
||||
});
|
||||
|
||||
test('parseSizeLabel rejects sizes outside the widget limits like the Python parser', () => {
|
||||
assert.equal(parseSizeLabel('16384×16384 - 1:1 (256.0MP)'), null);
|
||||
assert.equal(parseSizeLabel('32×32 - 1:1 (0.0MP)'), null);
|
||||
assert.deepEqual(parseSizeLabel('64×8192 - 1:128 (0.5MP)'), [64, 8192]);
|
||||
assert.equal(parseSizeLabel('8193×64 - 128:1 (0.5MP)'), null);
|
||||
assert.equal(parseSizeLabel(`${'9'.repeat(5000)}×${'9'.repeat(5000)} - 1:1 (1.0MP)`), null);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
from kikotools.tools.model_resolution_picker.families import (
|
||||
CHIPS,
|
||||
DEFAULT_FAMILY,
|
||||
FAMILIES,
|
||||
MIRROR,
|
||||
families_payload,
|
||||
family_labels,
|
||||
resolve_family,
|
||||
)
|
||||
|
||||
CHIP_RATIO = {c: int(c.split(":")[0]) / int(c.split(":")[1]) for c in CHIPS}
|
||||
|
||||
|
||||
def test_chip_order_and_mirrors():
|
||||
assert CHIPS == (
|
||||
"1:1",
|
||||
"4:5",
|
||||
"5:4",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
"9:21",
|
||||
)
|
||||
assert all(MIRROR[MIRROR[c]] == c for c in CHIPS)
|
||||
|
||||
|
||||
def test_every_size_is_on_step_and_within_seven_percent_of_its_chip():
|
||||
for family in FAMILIES:
|
||||
for chip, entries in family.sizes.items():
|
||||
for w, h in entries:
|
||||
assert w % family.step == 0 and h % family.step == 0, (
|
||||
family.label,
|
||||
w,
|
||||
h,
|
||||
)
|
||||
assert abs(w / h - CHIP_RATIO[chip]) / CHIP_RATIO[chip] <= 0.07, (
|
||||
family.label,
|
||||
chip,
|
||||
w,
|
||||
h,
|
||||
)
|
||||
|
||||
|
||||
def test_every_entry_has_its_mirror_and_lists_are_sorted():
|
||||
for family in FAMILIES:
|
||||
for chip, entries in family.sizes.items():
|
||||
assert list(entries) == sorted(entries, key=lambda s: s[0] * s[1])
|
||||
for w, h in entries:
|
||||
assert (h, w) in family.sizes.get(MIRROR[chip], ()), (
|
||||
family.label,
|
||||
chip,
|
||||
w,
|
||||
h,
|
||||
)
|
||||
|
||||
|
||||
def test_defaults_labels_and_must_includes():
|
||||
labels = family_labels()
|
||||
assert labels[0] == "SD 1.5" and "SDXL" in labels and DEFAULT_FAMILY == "SDXL"
|
||||
assert len(set(labels)) == len(labels)
|
||||
for family in FAMILIES:
|
||||
assert any(
|
||||
family.default in entries for entries in family.sizes.values()
|
||||
), family.label
|
||||
flux2 = [(1248, 832), (832, 1248), (2048, 2048), (1920, 1088), (1088, 1920)]
|
||||
must = {
|
||||
"SDXL": [(1344, 768)],
|
||||
"Flux.1": [(1920, 1088), (1088, 1920)],
|
||||
"Flux.2 / Klein / MOSS": flux2,
|
||||
"Z-Image": [(1680, 720), (1280, 1280)],
|
||||
"Qwen-Image": [(1664, 928)],
|
||||
"Qwen-Image 2.1": [(2048, 2048), (2400, 1792), (2528, 1696), (2752, 1536)],
|
||||
"HunyuanImage 2.1": [(2560, 1536), (2304, 1792)],
|
||||
"Illustrious XL": [(1248, 1824), (1536, 1536), (1152, 2048)],
|
||||
"Wan 2.2 (keyframes)": [(1280, 704), (640, 640), (960, 960)],
|
||||
"LTX-2 (keyframes)": [(960, 544), (768, 512)],
|
||||
"HunyuanVideo 1.5 (keyframes)": [(848, 480)],
|
||||
"MiniMax H3 (keyframes)": [(864, 480)],
|
||||
"SD 1.5": [(512, 512), (768, 768), (512, 768), (768, 512)],
|
||||
}
|
||||
for label, required in must.items():
|
||||
listed = {
|
||||
size for entries in resolve_family(label).sizes.values() for size in entries
|
||||
}
|
||||
for size in required:
|
||||
assert size in listed, (label, size)
|
||||
|
||||
|
||||
def test_no_size_is_listed_under_two_chips():
|
||||
for family in FAMILIES:
|
||||
seen = {}
|
||||
for chip, entries in family.sizes.items():
|
||||
for size in entries:
|
||||
assert size not in seen, (family.label, size, seen[size], chip)
|
||||
seen[size] = chip
|
||||
|
||||
|
||||
def test_resolve_family_by_alias_and_unknown():
|
||||
assert resolve_family("SDXL").step == 64
|
||||
assert resolve_family("nope") is None
|
||||
|
||||
|
||||
def test_payload_is_json_shaped():
|
||||
payload = families_payload()
|
||||
assert payload["default"] == "SDXL" and payload["chips"] == list(CHIPS)
|
||||
sdxl = next(f for f in payload["families"] if f["label"] == "SDXL")
|
||||
assert (
|
||||
sdxl["sizes"]["3:2"] == [[1216, 832]]
|
||||
and sdxl["vaeRatio"] == 8
|
||||
and sdxl["group"] == "image"
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
from kikotools.tools.model_resolution_picker.logic import (
|
||||
MEGAPIXEL,
|
||||
generate_size,
|
||||
parse_size_label,
|
||||
ratio_label,
|
||||
round_to_step,
|
||||
size_label,
|
||||
)
|
||||
|
||||
|
||||
def test_round_to_step_rounds_nearest_and_never_below_minimum():
|
||||
assert round_to_step(1000, 32) == 992
|
||||
assert round_to_step(1232, 16) == 1232
|
||||
assert round_to_step(70, 32) == 64
|
||||
assert round_to_step(10, 64) == 64
|
||||
assert round_to_step(1016, 64) == 1024
|
||||
|
||||
|
||||
def test_generate_size_matches_core_resolution_selector():
|
||||
assert generate_size(1, 1, 1.0, 8) == (1024, 1024)
|
||||
assert generate_size(16, 9, 2.0, 16) == (1936, 1088)
|
||||
assert generate_size(3, 2, 4.0, 32) == (2496, 1664)
|
||||
assert generate_size(21, 9, 0.25, 64) == (768, 320)
|
||||
|
||||
|
||||
def test_ratio_label_reduces_and_aliases():
|
||||
assert ratio_label(1024, 1024) == "1:1"
|
||||
assert ratio_label(1216, 832) == "19:13"
|
||||
assert ratio_label(1280, 800) == "16:10"
|
||||
assert ratio_label(1344, 576) == "21:9"
|
||||
assert MEGAPIXEL == 1024 * 1024
|
||||
|
||||
|
||||
def test_size_label_formats_size_ratio_and_megapixels():
|
||||
assert size_label(1216, 832, "3:2") == "1216×832 - 3:2 (1.0MP)"
|
||||
assert size_label(2560, 1536, "16:9") == "2560×1536 - 16:9 (3.8MP)"
|
||||
|
||||
|
||||
def test_size_label_uses_binary_megapixels_and_rounds_ties_up():
|
||||
assert size_label(512, 512, "1:1") == "512×512 - 1:1 (0.3MP)"
|
||||
assert size_label(1000, 1000, "1:1") == "1000×1000 - 1:1 (1.0MP)"
|
||||
|
||||
|
||||
def test_parse_size_label_round_trips_and_rejects_garbage():
|
||||
assert parse_size_label("1216×832 - 3:2 (1.0MP)") == (1216, 832)
|
||||
assert parse_size_label(size_label(768, 1344, "9:16")) == (768, 1344)
|
||||
assert parse_size_label("custom") is None
|
||||
assert parse_size_label("garbage") is None
|
||||
assert parse_size_label("0×832 - 3:2 (1.0MP)") is None
|
||||
assert parse_size_label(None) is None
|
||||
|
||||
|
||||
def test_parse_size_label_rejects_sizes_outside_the_widget_limits():
|
||||
assert parse_size_label("16384×16384 - 1:1 (256.0MP)") is None
|
||||
assert parse_size_label("32×32 - 1:1 (0.0MP)") is None
|
||||
assert parse_size_label("64×8192 - 1:128 (0.5MP)") == (64, 8192)
|
||||
assert parse_size_label("8193×64 - 128:1 (0.5MP)") is None
|
||||
huge = "9" * 5000 + "×" + "9" * 5000 + " - 1:1 (1.0MP)"
|
||||
assert parse_size_label(huge) is None
|
||||
@@ -0,0 +1,119 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from unittest import mock
|
||||
|
||||
from kikotools.tools.model_resolution_picker import families
|
||||
from kikotools.tools.model_resolution_picker.node import (
|
||||
ModelResolutionPickerNode,
|
||||
get_families,
|
||||
)
|
||||
|
||||
|
||||
def test_inputs_and_outputs():
|
||||
inputs = ModelResolutionPickerNode.INPUT_TYPES()["required"]
|
||||
assert inputs["model"][1]["default"] == "SDXL"
|
||||
assert "HunyuanImage 2.1" in inputs["model"][0]
|
||||
width = inputs["width"][1]
|
||||
assert (width["default"], width["min"], width["max"], width["step"]) == (
|
||||
1024,
|
||||
64,
|
||||
8192,
|
||||
8,
|
||||
)
|
||||
assert ModelResolutionPickerNode.RETURN_TYPES == ("LATENT", "INT", "INT")
|
||||
assert ModelResolutionPickerNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
|
||||
def test_resolution_options_come_from_shipped_families():
|
||||
options, config = ModelResolutionPickerNode.INPUT_TYPES()["required"]["resolution"]
|
||||
assert options[0] == "custom"
|
||||
assert config["default"] == "custom"
|
||||
assert "1216×832 - 3:2 (1.0MP)" in options
|
||||
assert options[1:] == sorted(set(options[1:]))
|
||||
|
||||
|
||||
def test_resolution_input_sits_between_model_and_width():
|
||||
assert list(ModelResolutionPickerNode.INPUT_TYPES()["required"])[:3] == [
|
||||
"model",
|
||||
"resolution",
|
||||
"width",
|
||||
]
|
||||
|
||||
|
||||
def test_resolution_label_overrides_width_height():
|
||||
_, w, h = ModelResolutionPickerNode().create(
|
||||
"SDXL", "1216×832 - 3:2 (1.0MP)", 1024, 1024, 1
|
||||
)
|
||||
assert (w, h) == (1216, 832)
|
||||
|
||||
|
||||
def test_custom_uses_width_height():
|
||||
_, w, h = ModelResolutionPickerNode().create("SDXL", "custom", 1024, 768, 1)
|
||||
assert (w, h) == (1024, 768)
|
||||
|
||||
|
||||
def test_unparseable_resolution_behaves_like_custom():
|
||||
_, w, h = ModelResolutionPickerNode().create("SDXL", "stale text", 1024, 768, 1)
|
||||
assert (w, h) == (1024, 768)
|
||||
|
||||
|
||||
def test_validate_inputs_accepts_unlisted_resolution():
|
||||
assert ModelResolutionPickerNode.VALIDATE_INPUTS("SDXL", "9999×1 - 1:1 (0.0MP)")
|
||||
|
||||
|
||||
def test_execution_rounds_to_family_step_and_marks_latent():
|
||||
latent, w, h = ModelResolutionPickerNode().create(
|
||||
"HunyuanImage 2.1", "custom", 70, 1000, 2
|
||||
)
|
||||
assert (w, h) == (64, 992)
|
||||
assert latent["samples"].shape == (2, 4, 124, 8)
|
||||
assert latent["downscale_ratio_spacial"] == 8
|
||||
|
||||
|
||||
def test_exact_sizes_pass_through():
|
||||
_, w, h = ModelResolutionPickerNode().create(
|
||||
"Flux.2 / Klein / MOSS", "custom", 1248, 832, 1
|
||||
)
|
||||
assert (w, h) == (1248, 832)
|
||||
|
||||
|
||||
def test_unknown_label_keeps_size_and_warns(caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_, w, h = ModelResolutionPickerNode().create(
|
||||
"Renamed Family", "custom", 1003, 1003, 1
|
||||
)
|
||||
assert (w, h) == (1000, 1000) # fallback step 8
|
||||
assert "Renamed Family" in caplog.text
|
||||
|
||||
|
||||
def test_validate_inputs_accepts_any_label():
|
||||
assert ModelResolutionPickerNode.VALIDATE_INPUTS("Renamed Family", "stale") is True
|
||||
|
||||
|
||||
def test_alias_label_resolves_to_its_family():
|
||||
sdxl = families.resolve_family("SDXL")
|
||||
patched = sdxl._replace(aliases=("SDXL 1.0",))
|
||||
others = tuple(f for f in families.FAMILIES if f.label != "SDXL")
|
||||
with mock.patch.object(families, "FAMILIES", (patched,) + others):
|
||||
assert families.resolve_family("SDXL 1.0").label == "SDXL"
|
||||
latent, w, h = ModelResolutionPickerNode().create(
|
||||
"SDXL 1.0", "custom", 1216, 832, 1
|
||||
)
|
||||
assert (w, h) == (1216, 832)
|
||||
assert latent["samples"].shape[-2:] == (104, 152)
|
||||
|
||||
|
||||
def test_families_route_returns_payload():
|
||||
response = asyncio.run(get_families(None))
|
||||
assert response.status == 200
|
||||
assert json.loads(response.text)["default"] == "SDXL"
|
||||
|
||||
|
||||
def test_registered_in_kikotools():
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
assert NODE_CLASS_MAPPINGS["ModelResolutionPicker"] is ModelResolutionPickerNode
|
||||
assert (
|
||||
NODE_DISPLAY_NAME_MAPPINGS["ModelResolutionPicker"] == "Model Resolution Picker"
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.model_resolution_picker import families, override
|
||||
from kikotools.tools.model_resolution_picker.node import (
|
||||
ModelResolutionPickerNode,
|
||||
get_families,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_user_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"folder_paths",
|
||||
types.SimpleNamespace(get_user_directory=lambda: str(tmp_path)),
|
||||
)
|
||||
override._reset_cache()
|
||||
yield tmp_path
|
||||
override._reset_cache()
|
||||
|
||||
|
||||
def write_override(tmp_path: Path, data) -> Path:
|
||||
path = tmp_path / "kikotools" / "model_resolutions.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(data if isinstance(data, str) else json.dumps(data))
|
||||
return path
|
||||
|
||||
|
||||
def sdxl_entry(sizes):
|
||||
payload = families.families_payload()["families"]
|
||||
base = next(f for f in payload if f["label"] == "SDXL")
|
||||
return {**base, "sizes": {**base["sizes"], **sizes}}
|
||||
|
||||
|
||||
def test_override_path_points_into_user_directory(tmp_path):
|
||||
assert override.override_path() == tmp_path / "kikotools" / "model_resolutions.json"
|
||||
|
||||
|
||||
def test_override_path_none_without_folder_paths(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", None)
|
||||
assert override.override_path() is None
|
||||
assert override.current_table()[1] == []
|
||||
|
||||
|
||||
def test_override_path_none_without_get_user_directory(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", types.SimpleNamespace())
|
||||
assert override.override_path() is None
|
||||
|
||||
|
||||
def test_no_file_returns_shipped_data():
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_override_replaces_family_sizes(tmp_path):
|
||||
entry = sdxl_entry({"1:1": [[1024, 1024], [1280, 1280]]})
|
||||
write_override(tmp_path, {"families": [entry]})
|
||||
assert families.resolve_family("SDXL").sizes["1:1"] == ((1024, 1024), (1280, 1280))
|
||||
shipped = next(f for f in families.FAMILIES if f.label == "SDXL")
|
||||
assert shipped.sizes["1:1"] == ((1024, 1024),)
|
||||
|
||||
|
||||
def test_new_family_is_appended_and_resolvable(tmp_path):
|
||||
entry = {**sdxl_entry({"1:1": [[1024, 1024]]}), "label": "My Model"}
|
||||
write_override(tmp_path, {"families": [entry]})
|
||||
labels = [f["label"] for f in families.families_payload()["families"]]
|
||||
assert labels[-1] == "My Model"
|
||||
assert labels[:-1] == [f.label for f in families.FAMILIES]
|
||||
assert families.resolve_family("My Model").label == "My Model"
|
||||
|
||||
|
||||
def test_remove_hides_family(tmp_path):
|
||||
write_override(tmp_path, {"remove": ["SD 1.5"]})
|
||||
labels = [f["label"] for f in families.families_payload()["families"]]
|
||||
assert "SD 1.5" not in labels
|
||||
assert families.resolve_family("SD 1.5") is None
|
||||
|
||||
|
||||
def test_default_is_replaced(tmp_path):
|
||||
write_override(tmp_path, {"default": "Illustrious XL"})
|
||||
assert families.families_payload()["default"] == "Illustrious XL"
|
||||
|
||||
|
||||
def test_top_level_chips_and_version_are_ignored(tmp_path):
|
||||
write_override(tmp_path, {"version": 99, "chips": ["1:1"], "mirror": {}})
|
||||
data, warnings = override.current_table()
|
||||
assert warnings == []
|
||||
assert data["chips"] == families.SHIPPED_DATA["chips"]
|
||||
assert data["version"] == families.SHIPPED_DATA["version"]
|
||||
|
||||
|
||||
def test_merge_never_mutates_inputs():
|
||||
shipped = json.loads(json.dumps(families.SHIPPED_DATA))
|
||||
snapshot = json.loads(json.dumps(shipped))
|
||||
ov = {"families": [sdxl_entry({"1:1": [[1024, 1024]]})], "remove": ["SD 1.5"]}
|
||||
ov_snapshot = json.loads(json.dumps(ov))
|
||||
merged = override.merge_override(shipped, ov)
|
||||
merged["families"][0]["label"] = "x"
|
||||
assert shipped == snapshot
|
||||
assert ov == ov_snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
{"families": "nope"},
|
||||
{"families": [1]},
|
||||
{"families": [{"label": 3}]},
|
||||
{"remove": "SD 1.5"},
|
||||
{"remove": [1]},
|
||||
{"default": 5},
|
||||
],
|
||||
)
|
||||
def test_merge_rejects_malformed_override(bad):
|
||||
with pytest.raises(ValueError):
|
||||
override.merge_override(families.SHIPPED_DATA, bad)
|
||||
|
||||
|
||||
def test_invalid_json_is_ignored_with_one_warning(tmp_path, caplog):
|
||||
path = write_override(tmp_path, "{not json")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert len(warnings) == 1
|
||||
assert warnings[0].startswith("Override ignored: ")
|
||||
assert str(path) in warnings[0]
|
||||
assert warnings[0] in caplog.text
|
||||
|
||||
|
||||
def test_non_object_json_is_ignored(tmp_path):
|
||||
write_override(tmp_path, "[1, 2]")
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert warnings[0].startswith("Override ignored: ")
|
||||
|
||||
|
||||
def test_invariant_break_ignores_whole_override(tmp_path):
|
||||
entry = sdxl_entry({"1:1": [[1000, 1000]]})
|
||||
write_override(tmp_path, {"families": [entry], "remove": ["SD 1.5"]})
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert len(warnings) == 1 and warnings[0].startswith("Override ignored: ")
|
||||
assert "SDXL" in warnings[0]
|
||||
assert families.resolve_family("SD 1.5") is not None
|
||||
assert families.resolve_family("SDXL").sizes["1:1"] != ((1000, 1000),)
|
||||
|
||||
|
||||
def test_edit_with_newer_mtime_is_picked_up(tmp_path):
|
||||
path = write_override(tmp_path, {"remove": ["SD 1.5"]})
|
||||
assert families.resolve_family("SD 1.5") is None
|
||||
path.write_text(json.dumps({"remove": ["Anima"]}))
|
||||
stat = path.stat()
|
||||
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000))
|
||||
assert families.resolve_family("SD 1.5") is not None
|
||||
assert families.resolve_family("Anima") is None
|
||||
|
||||
|
||||
def test_result_is_cached_until_mtime_changes(tmp_path):
|
||||
write_override(tmp_path, {"remove": ["SD 1.5"]})
|
||||
first, _ = override.current_table()
|
||||
second, _ = override.current_table()
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_path_change_invalidates_cache(tmp_path, monkeypatch):
|
||||
write_override(tmp_path, {"remove": ["SD 1.5"]})
|
||||
assert override.current_table()[0] is not families.SHIPPED_DATA
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"folder_paths",
|
||||
types.SimpleNamespace(get_user_directory=lambda: str(other)),
|
||||
)
|
||||
assert override.current_table()[0] is families.SHIPPED_DATA
|
||||
|
||||
|
||||
def test_route_payload_includes_warnings(tmp_path):
|
||||
response = asyncio.run(get_families(None))
|
||||
assert json.loads(response.body)["warnings"] == []
|
||||
write_override(tmp_path, "{bad")
|
||||
response = asyncio.run(get_families(None))
|
||||
warnings = json.loads(response.body)["warnings"]
|
||||
assert len(warnings) == 1 and warnings[0].startswith("Override ignored:")
|
||||
|
||||
|
||||
def test_deeply_nested_json_is_ignored_without_raising(tmp_path):
|
||||
write_override(tmp_path, "[" * 100000 + "]" * 100000)
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert len(warnings) == 1 and warnings[0].startswith("Override ignored: ")
|
||||
assert families.families_payload()["warnings"] == warnings
|
||||
|
||||
|
||||
def test_override_step_off_the_latent_grid_is_ignored_whole(tmp_path):
|
||||
# A step that is not a multiple of 8 would make the empty latent allocation
|
||||
# raise at execution; the override is dropped and the shipped table stays.
|
||||
entry = {**sdxl_entry({"1:1": [[1023, 1023]]}), "step": 3, "default": [1023, 1023]}
|
||||
write_override(tmp_path, {"families": [entry]})
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert len(warnings) == 1 and "multiple of 8" in warnings[0]
|
||||
assert families.resolve_family("SDXL").step == 64
|
||||
|
||||
|
||||
def test_remove_wins_over_families_with_the_same_label(tmp_path):
|
||||
entry = sdxl_entry({"1:1": [[1024, 1024], [1280, 1280]]})
|
||||
entry = {**entry, "label": "Illustrious XL"}
|
||||
write_override(tmp_path, {"families": [entry], "remove": ["Illustrious XL"]})
|
||||
labels = [f["label"] for f in families.families_payload()["families"]]
|
||||
assert "Illustrious XL" not in labels
|
||||
assert override.current_table()[1] == []
|
||||
|
||||
|
||||
def test_default_naming_a_removed_family_ignores_override(tmp_path):
|
||||
write_override(tmp_path, {"remove": ["Anima"], "default": "Anima"})
|
||||
data, warnings = override.current_table()
|
||||
assert data is families.SHIPPED_DATA
|
||||
assert len(warnings) == 1 and "Anima" in warnings[0]
|
||||
|
||||
|
||||
def docs_example(tmp_path):
|
||||
entry = {**sdxl_entry({"1:1": [[1024, 1024]]}), "label": "My SDXL"}
|
||||
return write_override(
|
||||
tmp_path, {"families": [entry], "remove": ["Anima"], "default": "My SDXL"}
|
||||
)
|
||||
|
||||
|
||||
def test_combo_reflects_override_families_and_default(tmp_path):
|
||||
docs_example(tmp_path)
|
||||
options, config = ModelResolutionPickerNode.INPUT_TYPES()["required"]["model"]
|
||||
assert "My SDXL" in options
|
||||
assert "Anima" not in options
|
||||
assert config["default"] == "My SDXL"
|
||||
assert families.DEFAULT_FAMILY != "My SDXL"
|
||||
assert any(f.label == "Anima" for f in families.FAMILIES)
|
||||
|
||||
|
||||
def test_family_labels_match_payload_order(tmp_path):
|
||||
docs_example(tmp_path)
|
||||
payload = families.families_payload()
|
||||
assert families.family_labels() == [f["label"] for f in payload["families"]]
|
||||
|
||||
|
||||
def test_alias_default_is_normalised_to_its_label(tmp_path):
|
||||
entry = {
|
||||
**sdxl_entry({"1:1": [[1024, 1024]]}),
|
||||
"label": "My SDXL",
|
||||
"aliases": ["my-sdxl"],
|
||||
}
|
||||
write_override(tmp_path, {"families": [entry], "default": "my-sdxl"})
|
||||
assert families.families_payload()["default"] == "My SDXL"
|
||||
config = ModelResolutionPickerNode.INPUT_TYPES()["required"]["model"][1]
|
||||
assert config["default"] == "My SDXL"
|
||||
|
||||
|
||||
def test_same_mtime_replacement_with_different_size_is_reread(tmp_path):
|
||||
path = write_override(tmp_path, {"remove": ["SD 1.5"]})
|
||||
old = path.stat()
|
||||
assert families.resolve_family("SD 1.5") is None
|
||||
path.write_text(json.dumps({"remove": ["Anima"], "default": "SDXL"}))
|
||||
os.utime(path, ns=(old.st_atime_ns, old.st_mtime_ns))
|
||||
assert families.resolve_family("SD 1.5") is not None
|
||||
assert families.resolve_family("Anima") is None
|
||||
@@ -0,0 +1,255 @@
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.model_resolution_picker import families as families_module
|
||||
from kikotools.tools.model_resolution_picker.table import (
|
||||
build_families,
|
||||
load_table,
|
||||
validate_table,
|
||||
)
|
||||
|
||||
SHIPPED = Path(families_module.__file__).with_name("families.json")
|
||||
|
||||
|
||||
def shipped_data() -> Dict[str, Any]:
|
||||
return copy.deepcopy(json.loads(SHIPPED.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def family(data: Dict[str, Any], label: str) -> Dict[str, Any]:
|
||||
return next(f for f in data["families"] if f["label"] == label)
|
||||
|
||||
|
||||
def off_step(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["sizes"]["1:1"][1] = [700, 700]
|
||||
|
||||
|
||||
def off_ratio(data: Dict[str, Any]) -> None:
|
||||
sizes = family(data, "SD 1.5")["sizes"]["1:1"]
|
||||
sizes[1:1] = [[576, 704], [704, 576]]
|
||||
|
||||
|
||||
def missing_mirror(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["sizes"]["4:5"].pop()
|
||||
|
||||
|
||||
def unsorted(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["sizes"]["1:1"].reverse()
|
||||
|
||||
|
||||
def duplicate_size(data: Dict[str, Any]) -> None:
|
||||
sizes = family(data, "SD 1.5")["sizes"]
|
||||
for chip, size in (("3:4", [640, 832]), ("4:3", [832, 640])):
|
||||
sizes[chip].append(size)
|
||||
sizes[chip].sort(key=lambda s: s[0] * s[1])
|
||||
|
||||
|
||||
def duplicate_label(data: Dict[str, Any]) -> None:
|
||||
data["families"][2]["label"] = data["families"][0]["label"]
|
||||
|
||||
|
||||
def missing_chip(data: Dict[str, Any]) -> None:
|
||||
del family(data, "SD 1.5")["sizes"]["21:9"]
|
||||
|
||||
|
||||
def default_not_listed(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["default"] = [520, 520]
|
||||
|
||||
|
||||
def bad_vae_ratio(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["vaeRatio"] = 7
|
||||
|
||||
|
||||
def bad_group(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["group"] = "audio"
|
||||
|
||||
|
||||
def bad_step(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["step"] = 0
|
||||
|
||||
|
||||
def off_latent_grid_step(data: Dict[str, Any]) -> None:
|
||||
# 4 divides every SD 1.5 size, so only the latent-grid rule fires.
|
||||
family(data, "SD 1.5")["step"] = 4
|
||||
|
||||
|
||||
def empty_megapixels(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["megapixels"] = []
|
||||
|
||||
|
||||
def duplicate_alias(data: Dict[str, Any]) -> None:
|
||||
family(data, "SD 1.5")["aliases"] = ["SDXL"]
|
||||
|
||||
|
||||
def test_validate_table_accepts_the_shipped_file():
|
||||
assert validate_table(shipped_data()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate, needle, chip, count",
|
||||
[
|
||||
(off_step, "step", "1:1", 1),
|
||||
(off_ratio, "7%", "1:1", 2),
|
||||
(missing_mirror, "mirror", "5:4", 1),
|
||||
(unsorted, "ascending", "1:1", 1),
|
||||
(duplicate_size, "more than one chip", ("3:4", "4:3"), 2),
|
||||
(missing_chip, "missing", "21:9", 1),
|
||||
(default_not_listed, "default", None, 1),
|
||||
(bad_vae_ratio, "vaeRatio", None, 1),
|
||||
(bad_group, "group", None, 1),
|
||||
(bad_step, "step", None, 1),
|
||||
(off_latent_grid_step, "multiple of 8", None, 1),
|
||||
(empty_megapixels, "megapixels", None, 1),
|
||||
],
|
||||
)
|
||||
def test_validate_table_reports_each_invariant(
|
||||
mutate: Callable[[Dict[str, Any]], None],
|
||||
needle: str,
|
||||
chip: Any,
|
||||
count: int,
|
||||
):
|
||||
data = shipped_data()
|
||||
mutate(data)
|
||||
|
||||
problems = validate_table(data)
|
||||
|
||||
assert len(problems) == count, problems
|
||||
for problem in problems:
|
||||
assert "SD 1.5" in problem and needle in problem
|
||||
if chip:
|
||||
assert any(
|
||||
c in problem for c in ([chip] if isinstance(chip, str) else chip)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate, needle",
|
||||
[(duplicate_label, "SD 1.5"), (duplicate_alias, "SDXL")],
|
||||
)
|
||||
def test_validate_table_reports_duplicate_names(
|
||||
mutate: Callable[[Dict[str, Any]], None], needle: str
|
||||
):
|
||||
data = shipped_data()
|
||||
mutate(data)
|
||||
|
||||
problems = validate_table(data)
|
||||
|
||||
assert len(problems) == 1 and "duplicate" in problems[0] and needle in problems[0]
|
||||
|
||||
|
||||
def test_validate_table_checks_top_level_keys():
|
||||
data = shipped_data()
|
||||
data["version"] = 2
|
||||
data["chips"] = data["chips"][:-1]
|
||||
data["default"] = "Nope"
|
||||
|
||||
problems = validate_table(data)
|
||||
|
||||
assert any("version" in p for p in problems)
|
||||
assert any("chips" in p for p in problems)
|
||||
assert any("default" in p for p in problems)
|
||||
|
||||
|
||||
def test_validate_table_never_raises_on_garbage():
|
||||
assert validate_table([]) != [] # type: ignore[arg-type]
|
||||
assert validate_table({}) != []
|
||||
data = shipped_data()
|
||||
data["families"][0] = "oops"
|
||||
data["families"][1]["sizes"] = {"1:1": [[1, "x"]]}
|
||||
assert validate_table(data) != []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate",
|
||||
[
|
||||
lambda d: d.update(default=["SDXL"]),
|
||||
lambda d: d["families"][0].update(label=["SD 1.5"]),
|
||||
lambda d: d["families"][0].update(aliases=[["x"]]),
|
||||
lambda d: d["families"][0].update(step=float("inf")),
|
||||
lambda d: d["families"][0].update(step=float("nan")),
|
||||
lambda d: d["families"][0].update(megapixels=[float("inf")]),
|
||||
lambda d: d["families"][0].update(step=10**400),
|
||||
lambda d: d["families"][0].update(megapixels=[10**400]),
|
||||
lambda d: d["families"][0].update(default=[10**400, 1]),
|
||||
lambda d: d["families"][0]["sizes"]["1:1"].append([10**400, 1]),
|
||||
],
|
||||
)
|
||||
def test_validate_table_survives_unhashable_and_non_finite_values(
|
||||
mutate: Callable[[Dict[str, Any]], None],
|
||||
):
|
||||
data = shipped_data()
|
||||
mutate(data)
|
||||
|
||||
assert validate_table(data) != []
|
||||
|
||||
|
||||
def test_validate_table_ignores_unknown_keys():
|
||||
data = shipped_data()
|
||||
data["extra"] = 1
|
||||
family(data, "SD 1.5")["notes"] = "kept for humans"
|
||||
family(data, "SD 1.5")["whatever"] = {"a": 1}
|
||||
|
||||
assert validate_table(data) == []
|
||||
assert build_families(data)[0].label == "SD 1.5"
|
||||
|
||||
|
||||
def test_load_table_bad_json_names_the_path(tmp_path: Path):
|
||||
bad = tmp_path / "broken.json"
|
||||
bad.write_text("{not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError) as error:
|
||||
load_table(bad)
|
||||
|
||||
assert str(bad) in str(error.value)
|
||||
|
||||
|
||||
def test_load_table_missing_file_names_the_path(tmp_path: Path):
|
||||
missing = tmp_path / "absent.json"
|
||||
|
||||
with pytest.raises(ValueError) as error:
|
||||
load_table(missing)
|
||||
|
||||
assert str(missing) in str(error.value)
|
||||
|
||||
|
||||
def test_families_module_is_built_from_json():
|
||||
data = shipped_data()
|
||||
|
||||
assert [f.label for f in families_module.FAMILIES] == [
|
||||
f["label"] for f in data["families"]
|
||||
]
|
||||
assert families_module.DEFAULT_FAMILY == data["default"]
|
||||
assert families_module.families_payload()["families"][0]["sizes"] == {
|
||||
chip: sizes for chip, sizes in data["families"][0]["sizes"].items()
|
||||
}
|
||||
|
||||
|
||||
def test_families_module_raises_runtime_error_on_invalid_shipped_file(
|
||||
tmp_path: Path,
|
||||
):
|
||||
data = shipped_data()
|
||||
bad_vae_ratio(data)
|
||||
path = tmp_path / "families.json"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError) as error:
|
||||
families_module.load_shipped_families(path)
|
||||
|
||||
assert "SD 1.5" in str(error.value) and "vaeRatio" in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", [63, 8200, 65536])
|
||||
def test_validate_table_rejects_sizes_outside_the_widget_limits(side: int):
|
||||
data = shipped_data()
|
||||
family(data, "SD 1.5")["sizes"]["1:1"].append([side, side])
|
||||
|
||||
assert any("sizes must be a list" in p for p in validate_table(data))
|
||||
|
||||
|
||||
def test_validate_table_accepts_sizes_at_the_widget_limits():
|
||||
from kikotools.tools.model_resolution_picker.table import MAX_SIDE, MIN_SIDE
|
||||
|
||||
assert (MIN_SIDE, MAX_SIDE) == (64, 8192)
|
||||
@@ -24,6 +24,12 @@ class TestEmptyLatentBatchLogic:
|
||||
assert samples.shape == (1, 4, 64, 64) # 512/8 = 64
|
||||
assert torch.all(samples == 0) # Should be all zeros
|
||||
|
||||
def test_marks_spatial_ratio_for_other_vaes(self):
|
||||
"""The 8x mark lets ComfyUI resize the empty latent for 16x VAEs (Flux.2, Qwen-Image 2.1)."""
|
||||
result = create_empty_latent_batch(1024, 1024, 1)
|
||||
|
||||
assert result["downscale_ratio_spacial"] == 8
|
||||
|
||||
def test_create_empty_latent_batch_with_batch_size(self):
|
||||
"""Test empty latent creation with larger batch size."""
|
||||
batch_size = 4
|
||||
|
||||
@@ -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,16 @@
|
||||
"statements": 98
|
||||
},
|
||||
"kikotools_extensions.js": {
|
||||
"lines": 12,
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 0,
|
||||
"statements": 12
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"model_resolution_picker.js": {
|
||||
"lines": 99,
|
||||
"branches": 80,
|
||||
"functions": 95,
|
||||
"statements": 99
|
||||
},
|
||||
"seed_history_ui.js": {
|
||||
"lines": 95,
|
||||
@@ -71,6 +107,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 +131,88 @@
|
||||
"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/resolution-picker.js": {
|
||||
"lines": 100,
|
||||
"branches": 86,
|
||||
"functions": 100,
|
||||
"statements": 100
|
||||
},
|
||||
"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/resolution-picker.js": {
|
||||
"lines": 100,
|
||||
"branches": 91,
|
||||
"functions": 100,
|
||||
"statements": 100
|
||||
},
|
||||
"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;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { api } from '../../scripts/api.js';
|
||||
import { createScope, mountTheme } from './ui/core.js';
|
||||
import { swapDimensions } from './ui/dimensions.js';
|
||||
import { mountDimensionSwap } from './ui/dimension-swap.js';
|
||||
import { defaultEntry, deriveSelection, dropdownOptions, effectiveSize, megapixels, modelOptions, parseRatio, parseSizeLabel, sizeLabel, sizeOnModelChange, sizesForRatio } from './ui/resolution-picker.js';
|
||||
import { createResolutionPickerView } from './views/resolution-picker.js';
|
||||
|
||||
const FAMILIES_ROUTE = '/kikotools/model_resolution/families';
|
||||
const CUSTOM_CHIP = 'custom';
|
||||
const CHIP_IDS = ['1:1', '4:5', '5:4', '3:4', '4:3', '2:3', '3:2', '9:16', '16:9', '21:9', '9:21'];
|
||||
const UNAVAILABLE_STATUS = 'Sizes unavailable; type width and height';
|
||||
const RATIO_ERROR = 'Enter a ratio like 16:9';
|
||||
const FALLBACK_STEP = 8;
|
||||
const NODE_WIDTH = 340;
|
||||
const FALLBACK_PANEL_HEIGHT = 320;
|
||||
const FIT_RETRY_FRAMES = 60;
|
||||
const FIT_SLACK = 1;
|
||||
const LEGACY_VALUE_COUNT = 4;
|
||||
const LEGACY_WIDGET_ORDER = ['model', 'width', 'height', 'batch_size'];
|
||||
const WRAPPED_WIDGETS = ['model', 'resolution', 'width', 'height'];
|
||||
|
||||
let familiesPromise;
|
||||
|
||||
function loadFamilies(client) {
|
||||
if (!familiesPromise) {
|
||||
familiesPromise = client.fetchApi(FAMILIES_ROUTE)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`Families request failed: ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.catch(error => {
|
||||
familiesPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return familiesPromise;
|
||||
}
|
||||
|
||||
function isWiredIn(node, name) {
|
||||
return Boolean(node.inputs?.some(input => input.widget?.name === name && input.link != null));
|
||||
}
|
||||
|
||||
function widgetOf(node, name) {
|
||||
return node.widgets?.find(widget => widget.name === name);
|
||||
}
|
||||
|
||||
function mountPicker(node) {
|
||||
const scope = createScope();
|
||||
node.resolutionScope = scope;
|
||||
node.resolutionCustom = { open: false, text: '' };
|
||||
let payload = null;
|
||||
let failed = false;
|
||||
let writing = false;
|
||||
let lastChip = null;
|
||||
let modelLabel = widgetOf(node, 'model')?.value;
|
||||
let pendingModel = null;
|
||||
let view;
|
||||
let optionsKey = '';
|
||||
let panelHeight = FALLBACK_PANEL_HEIGHT;
|
||||
let fitRetryPending = false;
|
||||
|
||||
const readSize = () => ['width', 'height'].map(name => Number(widgetOf(node, name)?.value));
|
||||
|
||||
function buildState() {
|
||||
const [width, height] = readSize();
|
||||
const wired = isWiredIn(node, 'width') || isWiredIn(node, 'height');
|
||||
const model = widgetOf(node, 'model')?.value;
|
||||
const staticChips = () => [...CHIP_IDS, CUSTOM_CHIP].map(id => ({
|
||||
id, label: id === CUSTOM_CHIP ? 'Custom ratio' : id, active: false, disabled: true,
|
||||
}));
|
||||
const empty = { disabled: true, chips: staticChips(), customOpen: false, ratioText: '', entries: [] };
|
||||
if (wired) return { ...empty, wired, header: 'Dimensions are wired in' };
|
||||
if (failed || (payload && !Number.isFinite(width + height))) {
|
||||
const [w, h] = effectiveSize(null, width, height);
|
||||
return { ...empty, header: `${w} × ${h}` };
|
||||
}
|
||||
if (!payload) return { ...empty, header: 'Loading sizes…' };
|
||||
const selection = deriveSelection(payload, model, width, height);
|
||||
const { family, chip } = selection;
|
||||
lastChip = chip;
|
||||
const [w, h] = effectiveSize(family, width, height);
|
||||
const custom = node.resolutionCustom;
|
||||
const ratio = custom.open ? parseRatio(custom.text) : null;
|
||||
let list = [];
|
||||
if (family && custom.open) list = ratio ? sizesForRatio(family, ratio) : [];
|
||||
else if (family && chip) list = family.sizes[chip] || [];
|
||||
const showCustom = custom.open || selection.custom;
|
||||
const chips = CHIP_IDS.map(id => ({
|
||||
id, label: id, active: !showCustom && chip === id, disabled: !family?.sizes[id]?.length,
|
||||
}));
|
||||
chips.push({ id: CUSTOM_CHIP, label: 'Custom ratio', active: showCustom, disabled: !family });
|
||||
return {
|
||||
wired: false,
|
||||
disabled: false,
|
||||
header: `${w} × ${h} · ${chip || selection.ratioText} · ${megapixels(w, h)} MP`,
|
||||
chips,
|
||||
customOpen: custom.open,
|
||||
ratioText: custom.text,
|
||||
entries: list.map(([ew, eh]) => ({ w: ew, h: eh, active: ew === width && eh === height })),
|
||||
selection,
|
||||
};
|
||||
}
|
||||
|
||||
function syncResolution(state) {
|
||||
const widget = widgetOf(node, 'resolution');
|
||||
if (!widget) return;
|
||||
const selection = state.selection;
|
||||
if (!selection) {
|
||||
// Direct assignment fires no callback; a stale label must not override width/height.
|
||||
widget.value = 'custom';
|
||||
return;
|
||||
}
|
||||
const chip = state.customOpen || selection.custom ? null : selection.chip;
|
||||
const key = `${selection.family?.label}|${chip}`;
|
||||
if (key !== optionsKey) {
|
||||
optionsKey = key;
|
||||
widget.options.values = dropdownOptions(payload, selection.family?.label, chip);
|
||||
}
|
||||
const [width, height] = readSize();
|
||||
const label = selection.entryIndex >= 0 ? sizeLabel(width, height, selection.chip) : 'custom';
|
||||
widget.value = widget.options.values.includes(label) ? label : 'custom';
|
||||
}
|
||||
|
||||
function syncModelOptions() {
|
||||
const widget = widgetOf(node, 'model');
|
||||
if (!widget || !payload) return;
|
||||
const values = modelOptions(payload, widget.value);
|
||||
const current = widget.options.values;
|
||||
if (!Array.isArray(current) || current.length !== values.length || current.some((item, index) => item !== values[index])) {
|
||||
widget.options.values = values;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (scope.signal.aborted) return;
|
||||
syncModelOptions();
|
||||
const state = buildState();
|
||||
view.render(state);
|
||||
syncResolution(state);
|
||||
const swap = node.swapButtonWidget?.element?.querySelector('button');
|
||||
if (swap) swap.disabled = state.wired;
|
||||
fitNodeHeight();
|
||||
}
|
||||
|
||||
// Natural height of the panel: padding plus the visible children and the gaps between them.
|
||||
// The element itself may be stretched by the host, so its own height is not the content height.
|
||||
// Returns 0 when the element is detached or not laid out (hidden by the host while off-screen).
|
||||
function measurePanel() {
|
||||
const element = view.element;
|
||||
if (!element.isConnected || element.offsetParent === null) return 0;
|
||||
const style = getComputedStyle(element);
|
||||
const visible = Array.from(element.children).filter(child => getComputedStyle(child).display !== 'none');
|
||||
if (!visible.length || visible.every(child => child.offsetHeight === 0)) return 0;
|
||||
const gap = parseFloat(style.rowGap) || 0;
|
||||
const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
|
||||
return Math.ceil(padding + visible.reduce((sum, child) => sum + child.offsetHeight, 0) + gap * (visible.length - 1)) + FIT_SLACK;
|
||||
}
|
||||
|
||||
// The panel reports its content height as both minimum and maximum widget height, so the node
|
||||
// never shows empty space under the list; the node height then follows the host's computed size.
|
||||
function fitNodeHeight(attempt = 0) {
|
||||
if (scope.signal.aborted || (!payload && !failed)) return;
|
||||
const measured = measurePanel();
|
||||
if (!measured) {
|
||||
// Not attached to the document yet (fresh mount, workflow load): retry on the next frames.
|
||||
if (attempt < FIT_RETRY_FRAMES && !fitRetryPending && typeof requestAnimationFrame === 'function') {
|
||||
fitRetryPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
fitRetryPending = false;
|
||||
fitNodeHeight(attempt + 1);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
panelHeight = measured;
|
||||
const target = node.computeSize?.()?.[1];
|
||||
if (!Number.isFinite(target) || Math.abs(target - node.size[1]) < 1) return;
|
||||
node.setSize([node.size[0], target]);
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function writeSize(w, h) {
|
||||
const width = widgetOf(node, 'width');
|
||||
const height = widgetOf(node, 'height');
|
||||
if (!width || !height || writing) return;
|
||||
writing = true;
|
||||
try {
|
||||
width.value = w;
|
||||
height.value = h;
|
||||
for (const widget of [width, height]) widget.callback?.(widget.value, app.canvas, node, [0, 0], null);
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
const currentSelection = () => {
|
||||
const [width, height] = readSize();
|
||||
return deriveSelection(payload, widgetOf(node, 'model')?.value, width, height);
|
||||
};
|
||||
|
||||
function applyModelChange(label) {
|
||||
const [width, height] = readSize();
|
||||
const wired = isWiredIn(node, 'width') || isWiredIn(node, 'height');
|
||||
if (!payload || wired || !Number.isFinite(width + height)) return render();
|
||||
const [w, h] = sizeOnModelChange(payload, label, width, height, lastChip);
|
||||
if (w !== width || h !== height) writeSize(w, h);
|
||||
else render();
|
||||
}
|
||||
|
||||
function applyPendingModelChange() {
|
||||
const pending = pendingModel;
|
||||
pendingModel = null;
|
||||
if (!pending || scope.signal.aborted || widgetOf(node, 'model')?.value !== pending.to) return;
|
||||
const [width, height] = readSize();
|
||||
const { chip } = deriveSelection(payload, pending.from, width, height);
|
||||
lastChip = chip;
|
||||
applyModelChange(pending.to);
|
||||
}
|
||||
|
||||
const actions = {
|
||||
selectChip(chip) {
|
||||
if (!payload) return;
|
||||
view.setStatus('');
|
||||
node.resolutionCustom.open = false;
|
||||
const [width, height] = readSize();
|
||||
const { family } = currentSelection();
|
||||
const entry = family && defaultEntry(family, chip);
|
||||
const listed = family?.sizes[chip]?.some(([w, h]) => w === width && h === height);
|
||||
if (!entry || listed) return render();
|
||||
writeSize(entry[0], entry[1]);
|
||||
},
|
||||
openCustom() {
|
||||
if (!payload) return;
|
||||
node.resolutionCustom = { open: true, text: currentSelection().ratioText };
|
||||
render();
|
||||
},
|
||||
commitRatio(text) {
|
||||
const custom = node.resolutionCustom;
|
||||
if (!parseRatio(text)) {
|
||||
view.setStatus(RATIO_ERROR, true);
|
||||
return;
|
||||
}
|
||||
view.setStatus('');
|
||||
if (text.trim() === custom.text) return;
|
||||
custom.text = text.trim();
|
||||
render();
|
||||
},
|
||||
selectSize: writeSize,
|
||||
};
|
||||
|
||||
view = createResolutionPickerView(scope, actions);
|
||||
node.addDOMWidget('resolution_picker', 'div', view.element, {
|
||||
serialize: false, hideOnZoom: false, getMinHeight: () => panelHeight, getMaxHeight: () => panelHeight,
|
||||
});
|
||||
node.setSize([NODE_WIDTH, node.computeSize?.()?.[1] ?? node.size[1]]);
|
||||
// The stylesheet, fonts or a wrapped status line can change the content height after a render
|
||||
// (the first fit may even see an unstyled panel): refit whenever a child's box changes. The
|
||||
// children are created once by the view, so observing them at mount covers every render.
|
||||
// The refit runs on the next frame, never inside the observer delivery: measuring there forces a
|
||||
// layout while the stylesheet may be applying, and WebKit then reports an undelivered-notifications
|
||||
// loop error for the observed children.
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
let refitScheduled = false;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scope.signal.aborted || refitScheduled) return;
|
||||
refitScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
refitScheduled = false;
|
||||
if (scope.signal.aborted) return;
|
||||
const measured = measurePanel();
|
||||
if (measured && measured !== panelHeight) fitNodeHeight();
|
||||
});
|
||||
});
|
||||
for (const child of view.element.children) observer.observe(child);
|
||||
scope.cleanup(() => observer.disconnect());
|
||||
}
|
||||
mountTheme(view.element, app.ui.settings, scope);
|
||||
scope.cleanup(() => view.element.remove());
|
||||
|
||||
for (const name of WRAPPED_WIDGETS) {
|
||||
const widget = widgetOf(node, name);
|
||||
if (!widget) continue;
|
||||
const callback = widget.callback;
|
||||
widget.callback = function () {
|
||||
const result = callback?.apply(this, arguments);
|
||||
if (writing) return result;
|
||||
if (name === 'resolution') {
|
||||
const size = isWiredIn(node, 'width') || isWiredIn(node, 'height')
|
||||
? null : parseSizeLabel(arguments[0] ?? this.value);
|
||||
if (size) writeSize(size[0], size[1]);
|
||||
else render();
|
||||
} else if (name === 'model') {
|
||||
const label = arguments[0] ?? this.value;
|
||||
if (!payload && !failed) {
|
||||
pendingModel = { from: pendingModel ? pendingModel.from : modelLabel, to: label };
|
||||
modelLabel = label;
|
||||
render();
|
||||
} else {
|
||||
modelLabel = label;
|
||||
applyModelChange(label);
|
||||
}
|
||||
}
|
||||
else render();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
node.swapDimensions = () => {
|
||||
swapDimensions(node, app);
|
||||
render();
|
||||
};
|
||||
node.createSwapButton = () => mountDimensionSwap(node, app);
|
||||
node.createSwapButton();
|
||||
|
||||
const onRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
scope.dispose();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Workflows saved before the resolution widget carry four values
|
||||
// (model, width, height, batch_size) and load shifted by one slot.
|
||||
const onConfigure = node.onConfigure;
|
||||
node.onConfigure = function (info) {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
const values = info?.widgets_values;
|
||||
if (Array.isArray(values) && values.length === LEGACY_VALUE_COUNT) {
|
||||
LEGACY_WIDGET_ORDER.forEach((name, index) => {
|
||||
const widget = widgetOf(node, name);
|
||||
if (widget) widget.value = values[index];
|
||||
});
|
||||
const resolution = widgetOf(node, 'resolution');
|
||||
if (resolution) resolution.value = 'custom';
|
||||
modelLabel = widgetOf(node, 'model')?.value;
|
||||
render();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConnectionsChange = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function () {
|
||||
const result = onConnectionsChange?.apply(this, arguments);
|
||||
render();
|
||||
return result;
|
||||
};
|
||||
|
||||
render();
|
||||
loadFamilies(api).then(
|
||||
loaded => {
|
||||
payload = loaded;
|
||||
view.setWarning(loaded.warnings?.[0] || '');
|
||||
if (pendingModel) applyPendingModelChange();
|
||||
else render();
|
||||
},
|
||||
() => {
|
||||
if (scope.signal.aborted) return;
|
||||
failed = true;
|
||||
view.setStatus(UNAVAILABLE_STATUS, true);
|
||||
render();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'comfyassets.ModelResolutionPicker',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== 'ModelResolutionPicker') return;
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
mountPicker(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -86,3 +86,147 @@ 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.
|
||||
|
||||
## Model Resolution Picker
|
||||
|
||||
The picker's chips, size list and header are derived from the native `model`,
|
||||
`width` and `height` widgets on every change; no panel state is saved in the
|
||||
workflow. The only extra state is an in-memory custom-ratio flag, lost on
|
||||
reload. The family table comes from `GET /kikotools/model_resolution/families`,
|
||||
and a chip whose family lists no sizes for it is disabled. Swap reuses
|
||||
`dimension-swap.js` and `dimensions.js`, and node removal disposes the scope.
|
||||
|
||||
The native `resolution` combo is another projection of the same state. Its
|
||||
options are rebuilt whenever the model or the active chip changes: `custom` plus
|
||||
the active chip's sizes (all family sizes for Custom ratio or no chip). Picking
|
||||
a label writes `width`/`height` through their callbacks and the matching chip
|
||||
activates; a chip click, swap, typed size or model change sets the label to the
|
||||
matching size, and a size outside the list, wired inputs or a failed families
|
||||
load show `custom`. Each ratio chip also carries an `aria-hidden` outline of its
|
||||
shape.
|
||||
|
||||
The panel reports its measured content height (padding, visible children and
|
||||
gaps) as both the minimum and the maximum DOM-widget height, and after every
|
||||
render the extension sets the node height to the host's `computeSize()` while
|
||||
keeping the width. The measurement is retried for up to 60 frames when the
|
||||
element is not attached yet (fresh mount, workflow load), skipped while the
|
||||
family table is still loading, and ignored when the panel is hidden by the
|
||||
host. A `ResizeObserver` on the panel's children schedules a refit on the next
|
||||
frame when their boxes change later, for example when the stylesheet arrives
|
||||
after the first render; measuring inside the observer delivery would make WebKit
|
||||
report an undelivered-notifications loop error. The panel children must carry no
|
||||
vertical margins (the stylesheet zeroes them).
|
||||
|
||||
## 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,81 @@
|
||||
.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; }
|
||||
|
||||
.ca-resolution-picker { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; display: flex; flex-direction: column; gap: 8px; padding: 8px; overflow-x: hidden; }
|
||||
.ca-resolution-picker [hidden] { display: none; }
|
||||
.ca-resolution-header { margin: 0; font-size: 12px; color: var(--ca-muted); overflow-wrap: anywhere; }
|
||||
.ca-resolution-chips { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.ca-resolution-chips button { min-inline-size: 0; padding-inline: 4px; white-space: normal; overflow-wrap: anywhere; }
|
||||
.ca-resolution-shape { display: inline-block; border: 1px solid currentColor; border-radius: 2px; margin-inline-end: 6px; vertical-align: middle; }
|
||||
.ca-resolution-sizes { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ca-resolution-sizes button { inline-size: 100%; }
|
||||
.ca-resolution-picker :where(.ca-resolution-chips, .ca-resolution-sizes) button[aria-pressed="true"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
|
||||
.ca-resolution-ratio { inline-size: 100%; }
|
||||
.ca-resolution-status { margin: 0; min-block-size: 17px; font-size: 11px; color: var(--ca-muted); overflow-wrap: anywhere; }
|
||||
.ca-resolution-status[data-error="true"] { color: var(--ca-error-text); }
|
||||
.ca-resolution-picker :where(.ca-resolution-chips, .ca-resolution-sizes) button[aria-pressed="true"]:hover:not(:disabled) { background: #096ba6; border-color: #096ba6; color: #fff; }
|
||||
|
||||
@@ -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 (!width || !height || swapping.has(node)) return;
|
||||
const custom = !preset || 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,143 @@
|
||||
const PIXELS_PER_MEGAPIXEL = 1048576;
|
||||
const MIN_SIDE = 64;
|
||||
const DEFAULT_STEP = 8;
|
||||
const NEAREST_CHIP_TOLERANCE = 0.07;
|
||||
const MAX_RATIO_TERM = 10000;
|
||||
const MAX_SIDE = 8192;
|
||||
const RATIO_ALIASES = { '8:5': '16:10', '5:8': '10:16', '7:3': '21:9', '3:7': '9:21' };
|
||||
|
||||
export function parseRatio(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const match = /^\s*(\d+)\s*[:/x×]\s*(\d+)\s*$/i.exec(text);
|
||||
if (!match) return null;
|
||||
const [w, h] = [Number(match[1]), Number(match[2])];
|
||||
if (w < 1 || h < 1 || w > MAX_RATIO_TERM || h > MAX_RATIO_TERM) return null;
|
||||
return [w, h];
|
||||
}
|
||||
|
||||
function gcd(a, b) {
|
||||
return b === 0 ? a : gcd(b, a % b);
|
||||
}
|
||||
|
||||
export function reducedRatio(width, height) {
|
||||
const divisor = gcd(width, height);
|
||||
const label = `${width / divisor}:${height / divisor}`;
|
||||
return RATIO_ALIASES[label] || label;
|
||||
}
|
||||
|
||||
export function findFamily(payload, label) {
|
||||
const families = (payload && payload.families) || [];
|
||||
return families.find((family) => family.label === label || (family.aliases || []).includes(label)) || null;
|
||||
}
|
||||
|
||||
function chipRatio(chip) {
|
||||
const [w, h] = chip.split(':').map(Number);
|
||||
return w / h;
|
||||
}
|
||||
|
||||
function listedIndex(list, width, height) {
|
||||
return list.findIndex(([w, h]) => w === width && h === height);
|
||||
}
|
||||
|
||||
function nearestChip(chips, width, height) {
|
||||
const actual = width / height;
|
||||
let best = null;
|
||||
let bestError = Infinity;
|
||||
for (const chip of chips) {
|
||||
const target = chipRatio(chip);
|
||||
const error = Math.abs(actual - target) / target;
|
||||
if (error < bestError) {
|
||||
best = chip;
|
||||
bestError = error;
|
||||
}
|
||||
}
|
||||
return bestError <= NEAREST_CHIP_TOLERANCE ? best : null;
|
||||
}
|
||||
|
||||
export function deriveSelection(payload, label, width, height) {
|
||||
const family = findFamily(payload, label);
|
||||
const chips = payload.chips || [];
|
||||
const ratioText = reducedRatio(width, height);
|
||||
if (family) {
|
||||
for (const chip of chips) {
|
||||
const entryIndex = listedIndex(family.sizes[chip] || [], width, height);
|
||||
if (entryIndex >= 0) return { family, chip, entryIndex, custom: false, ratioText };
|
||||
}
|
||||
}
|
||||
const chip = nearestChip(chips, width, height);
|
||||
return { family, chip, entryIndex: -1, custom: chip === null, ratioText };
|
||||
}
|
||||
|
||||
function roundToStep(side, step) {
|
||||
return Math.max(Math.ceil(MIN_SIDE / step) * step, Math.round(side / step) * step);
|
||||
}
|
||||
|
||||
export function sizesForRatio(family, [rw, rh]) {
|
||||
const seen = new Set();
|
||||
const sizes = [];
|
||||
for (const mp of family.megapixels) {
|
||||
const height = Math.sqrt((mp * PIXELS_PER_MEGAPIXEL * rh) / rw);
|
||||
const width = (height * rw) / rh;
|
||||
const size = [roundToStep(width, family.step), roundToStep(height, family.step)];
|
||||
if (size[0] > MAX_SIDE || size[1] > MAX_SIDE) continue;
|
||||
const key = size.join('x');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
sizes.push(size);
|
||||
}
|
||||
return sizes.sort((a, b) => a[0] * a[1] - b[0] * b[1]);
|
||||
}
|
||||
|
||||
export function defaultEntry(family, chip) {
|
||||
const list = (family && family.sizes[chip]) || [];
|
||||
const target = family.default[0] * family.default[1];
|
||||
let best = null;
|
||||
for (const entry of list) {
|
||||
if (best === null || Math.abs(entry[0] * entry[1] - target) < Math.abs(best[0] * best[1] - target)) best = entry;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function effectiveSize(family, width, height) {
|
||||
const step = family ? family.step : DEFAULT_STEP;
|
||||
return [roundToStep(width, step), roundToStep(height, step)];
|
||||
}
|
||||
|
||||
export function megapixels(width, height) {
|
||||
return ((width * height) / PIXELS_PER_MEGAPIXEL).toFixed(1);
|
||||
}
|
||||
|
||||
export function sizeOnModelChange(payload, newLabel, width, height, chip) {
|
||||
const family = findFamily(payload, newLabel);
|
||||
if (!family) return [width, height];
|
||||
const listed = Object.values(family.sizes).some((list) => listedIndex(list, width, height) >= 0);
|
||||
if (listed) return [width, height];
|
||||
return defaultEntry(family, chip) || family.default;
|
||||
}
|
||||
|
||||
const SIZE_LABEL = /^(\d{1,5})×(\d{1,5}) - \S+ \(\d+\.\dMP\)$/;
|
||||
|
||||
export function sizeLabel(width, height, ratioText) {
|
||||
return `${width}×${height} - ${ratioText} (${megapixels(width, height)}MP)`;
|
||||
}
|
||||
|
||||
export function parseSizeLabel(text) {
|
||||
const match = typeof text === 'string' ? SIZE_LABEL.exec(text) : null;
|
||||
if (!match) return null;
|
||||
const [width, height] = [Number(match[1]), Number(match[2])];
|
||||
const inRange = (side) => side >= MIN_SIDE && side <= MAX_SIDE;
|
||||
return inRange(width) && inRange(height) ? [width, height] : null;
|
||||
}
|
||||
|
||||
export function dropdownOptions(payload, label, chip) {
|
||||
const family = findFamily(payload, label);
|
||||
if (!family) return ['custom'];
|
||||
const chips = chip && chip !== 'custom' ? [chip] : payload.chips || [];
|
||||
const labels = chips.flatMap((id) => (family.sizes[id] || []).map(([w, h]) => sizeLabel(w, h, id)));
|
||||
return ['custom', ...new Set(labels)];
|
||||
}
|
||||
|
||||
export function modelOptions(payload, current) {
|
||||
const labels = ((payload && payload.families) || []).map((family) => family.label);
|
||||
return typeof current === 'string' && current && !labels.includes(current) ? [...labels, current] : labels;
|
||||
}
|
||||
@@ -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,108 @@
|
||||
import { createButton } from '../ui/core.js';
|
||||
|
||||
const CUSTOM_CHIP = 'custom';
|
||||
|
||||
function createToggle(text, pressed, disabled) {
|
||||
const button = createButton(text, '');
|
||||
button.setAttribute('aria-pressed', String(pressed));
|
||||
button.disabled = disabled;
|
||||
return button;
|
||||
}
|
||||
|
||||
const SHAPE_LONG_SIDE = 18;
|
||||
const SHAPE_MIN_SIDE = 6;
|
||||
|
||||
function createShape(chipId) {
|
||||
const [w, h] = chipId.split(':').map(Number);
|
||||
const short = Math.max(SHAPE_MIN_SIDE, Math.round((SHAPE_LONG_SIDE * Math.min(w, h)) / Math.max(w, h)));
|
||||
const shape = document.createElement('span');
|
||||
shape.className = 'ca-resolution-shape';
|
||||
shape.setAttribute('aria-hidden', 'true');
|
||||
shape.style.width = `${w >= h ? SHAPE_LONG_SIDE : short}px`;
|
||||
shape.style.height = `${h >= w ? SHAPE_LONG_SIDE : short}px`;
|
||||
return shape;
|
||||
}
|
||||
|
||||
export function createResolutionPickerView(scope, actions) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'ca-tools ca-resolution-picker';
|
||||
const header = document.createElement('p');
|
||||
header.className = 'ca-resolution-header';
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'ca-resolution-chips';
|
||||
chips.setAttribute('role', 'group');
|
||||
chips.setAttribute('aria-label', 'Aspect ratio');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'ca-resolution-ratio';
|
||||
input.placeholder = '16:9';
|
||||
input.setAttribute('aria-label', 'Custom ratio');
|
||||
input.hidden = true;
|
||||
const sizes = document.createElement('div');
|
||||
sizes.className = 'ca-resolution-sizes';
|
||||
sizes.setAttribute('role', 'group');
|
||||
sizes.setAttribute('aria-label', 'Resolution');
|
||||
const status = document.createElement('p');
|
||||
status.className = 'ca-resolution-status';
|
||||
status.setAttribute('role', 'status');
|
||||
element.append(header, chips, input, sizes, status);
|
||||
|
||||
scope.listen(chips, 'click', event => {
|
||||
const button = event.target.closest('button[data-chip]');
|
||||
if (!button || button.disabled) return;
|
||||
if (button.dataset.chip === CUSTOM_CHIP) actions.openCustom();
|
||||
else actions.selectChip(button.dataset.chip);
|
||||
});
|
||||
scope.listen(sizes, 'click', event => {
|
||||
const button = event.target.closest('button[data-width]');
|
||||
if (button && !button.disabled) actions.selectSize(Number(button.dataset.width), Number(button.dataset.height));
|
||||
});
|
||||
scope.listen(input, 'keydown', event => {
|
||||
if (event.key === 'Enter') actions.commitRatio(input.value);
|
||||
});
|
||||
scope.listen(input, 'change', () => actions.commitRatio(input.value));
|
||||
|
||||
let message = '';
|
||||
let isMessageError = false;
|
||||
let warning = '';
|
||||
const showStatus = () => {
|
||||
status.textContent = message || warning;
|
||||
status.dataset.error = String(Boolean(message) && isMessageError);
|
||||
};
|
||||
|
||||
return {
|
||||
element, input,
|
||||
setStatus(text, isError = false) {
|
||||
message = text;
|
||||
isMessageError = Boolean(isError);
|
||||
showStatus();
|
||||
},
|
||||
setWarning(text) {
|
||||
warning = text;
|
||||
showStatus();
|
||||
},
|
||||
render(state) {
|
||||
const focused = document.activeElement;
|
||||
const focusSelector = chips.contains(focused) ? `button[data-chip="${focused.dataset.chip}"]`
|
||||
: sizes.contains(focused) ? `button[data-width="${focused.dataset.width}"][data-height="${focused.dataset.height}"]` : null;
|
||||
header.textContent = state.header;
|
||||
chips.replaceChildren(...state.chips.map(chip => {
|
||||
const button = createToggle(chip.label, chip.active, state.disabled || chip.disabled);
|
||||
button.dataset.chip = chip.id;
|
||||
if (chip.id !== CUSTOM_CHIP) button.prepend(createShape(chip.id));
|
||||
return button;
|
||||
}));
|
||||
input.hidden = !state.customOpen;
|
||||
input.disabled = state.disabled;
|
||||
if (state.customOpen && document.activeElement !== input && input.value !== state.ratioText) input.value = state.ratioText;
|
||||
sizes.replaceChildren(...state.entries.map(entry => {
|
||||
const button = createToggle(`${entry.w} × ${entry.h}`, entry.active, state.disabled);
|
||||
button.dataset.width = entry.w;
|
||||
button.dataset.height = entry.h;
|
||||
return button;
|
||||
}));
|
||||
if (focusSelector) element.querySelector(focusSelector)?.focus();
|
||||
if (state.error !== undefined) this.setStatus(state.error || '', Boolean(state.error));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||