chore: gitignore local-only docs and remove from tracking
ProjectStructure.md, README.md, Roadmap.md, AI-CONTEXT.md, and docs/ are local development references — not needed in the repo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2bded1cf6c
commit
6d1fcdb87f
+6
-1
@@ -5,4 +5,9 @@
|
||||
.claude_data/
|
||||
.claude/
|
||||
/.claude_data/
|
||||
/.claude/
|
||||
/.claude/
|
||||
AI-CONTEXT.md
|
||||
ProjectStructure.md
|
||||
README.md
|
||||
Roadmap.md
|
||||
docs/
|
||||
@@ -1,722 +0,0 @@
|
||||
### **ComfyUI Registry Security Requirements**
|
||||
|
||||
This node is published to the ComfyUI Registry. Every version is scanned by an automated
|
||||
security scanner. Versions that fail the scan receive `NodeVersionStatusFlagged` status and
|
||||
are hidden from ComfyUI Manager. **All contributors must follow these rules:**
|
||||
|
||||
1. **No `import requests`** — Use `urllib.request` (stdlib) instead. The scanner flags
|
||||
outbound HTTP libraries as potential data exfiltration vectors.
|
||||
2. **No `subprocess`, `os.system`, `eval()`, `exec()`** — These are blocked as arbitrary
|
||||
code execution risks.
|
||||
3. **No runtime `pip install`** — Package installation must go through `requirements.txt`
|
||||
only, never called at runtime.
|
||||
4. **No code obfuscation** — No encoded strings, multi-statement tricks, or undefined
|
||||
variable patterns.
|
||||
5. **No custom file-serving endpoints** — Use ComfyUI's built-in `WEB_DIRECTORY` for JS and
|
||||
the built-in `/view` endpoint for images. Never use `web.FileResponse` or create custom
|
||||
routes that serve files from disk. For external images, create symlinks into the output
|
||||
directory and use `/view?filename=X&type=output&subfolder=Y`.
|
||||
6. **Path containment on all filesystem operations** — Every endpoint that touches the
|
||||
filesystem must validate that resolved paths stay within the expected base directory
|
||||
(e.g., `output/benchmarks/`). Use `os.path.realpath()` + `startswith()` checks.
|
||||
7. **Sanitize all user-supplied path components** — Session names, filenames, etc. must be
|
||||
sanitized with `re.sub(r'[^\w\-]', '', name)` or `os.path.basename()` before use.
|
||||
|
||||
Reference: https://blog.comfy.org/p/comfyui-2025-jan-security-update
|
||||
|
||||
---
|
||||
|
||||
### **Updated Project File Structure**
|
||||
|
||||
```text
|
||||
ComfyUI-Ultimate-Sampler-Grid/
|
||||
├── __init__.py # Server Routes & Node Mappings
|
||||
├── sampler_node.py # Main "Sampler Grid" Node
|
||||
├── dashboard_node.py # "Dashboard Viewer" Node
|
||||
├── config_builder_node.py # "Config Builder" Node
|
||||
├── json_text_node.py # "Smart JSON" Node
|
||||
│
|
||||
├── generation_orchestrator.py # The "Conductor" (Loop Logic, ~1700 lines)
|
||||
├── image_generation.py # KSampler & VAE Wrappers
|
||||
├── model_loader.py # Checkpoint/LoRA/VAE/GGUF Loading Logic
|
||||
├── model_cache.py # 3-Tier Model Caching System
|
||||
├── remote_vae.py # Remote VAE Offloading
|
||||
│
|
||||
├── batch_encoding.py # Batch CLIP Encoding with Combinator Support
|
||||
├── conditioning_cache.py # Disk-based Conditioning Cache
|
||||
├── lora_utils.py # LoRA Searching & Validation
|
||||
├── trigger_words.py # CivitAI Trigger Word Fetching & Prompt Assembly
|
||||
├── config_utils.py # Config Parsing, Expansion & Cartesian Products
|
||||
├── directory_scanner.py # External Directory Session Scanner
|
||||
├── metadata_packer.py # PNG/WebP Metadata Embedding for Exports
|
||||
├── manifest_utils.py # Manifest Read/Write/Merge Helpers
|
||||
├── html_generator.py # Reads /resources/ to build the Dashboard HTML
|
||||
│
|
||||
├── distribution_manager.py # Distributed Job Queue Coordinator (Master)
|
||||
├── distribution_worker.py # Worker Thread for Remote Processing
|
||||
├── distribution_routes.py # Distribution REST API Endpoints
|
||||
│
|
||||
├── web/ # [ComfyUI Integration Layer]
|
||||
│ ├── dashboard.js # Registers Dashboard Node & Message Handling
|
||||
│ ├── smart_json_text.js # Registers JSON Node & Syntax Highlighting
|
||||
│ └── conf_builder/ # [Config Builder UI Modules]
|
||||
│ ├── conf-builder-main.js # Main entry, widget registration
|
||||
│ ├── conf-builder-ui-components.js # UI rendering (dropdowns, sliders, cards)
|
||||
│ ├── conf-builder-config-management.js # Save/Load/Duplicate config logic
|
||||
│ ├── conf-builder-utilities.js # State-to-JSON conversion, iteration counting
|
||||
│ └── conf-builder-distribution.js # Distribution settings UI (workers, toggles)
|
||||
│
|
||||
└── resources/ # [The Dashboard SPA Core]
|
||||
├── template.html # HTML Skeleton for the generated report
|
||||
├── report.css # Styling (Infinite Canvas, Cards, Modals)
|
||||
├── logic_init.js # Bootstrapper (Load JSON, Init State)
|
||||
├── logic_state.js # State Store (Redux-style: filters, favorites)
|
||||
├── logic_pipeline.js # Data Processing (Filter -> Sort -> Layout)
|
||||
├── logic_virtual.js # Virtual DOM / Infinite Scroller Engine
|
||||
├── logic_ui.js # UI Renderer (DOM creation, Modal handling)
|
||||
├── logic_events.js # Interaction Handler (Hotkeys, API calls)
|
||||
└── logic_utils.js # Helpers (Debounce, Formatters)
|
||||
|
||||
```
|
||||
|
||||
**Output Directory Structure (Generated at Runtime):**
|
||||
```text
|
||||
ComfyUI/output/
|
||||
├── ultimate-configs/ # Saved config presets (NOT under benchmarks/)
|
||||
│ └── {name}.json
|
||||
└── benchmarks/
|
||||
├── loras_tags.json # LoRA trigger word cache (shared)
|
||||
├── model_hashes.json # SHA256 hash cache for CivitAI lookups
|
||||
├── model-data/ # CivitAI metadata cache per model
|
||||
│ └── {model_name}/metadata.json
|
||||
└── {session_name}/
|
||||
├── manifest.json # Generation metadata + user annotations
|
||||
├── images/
|
||||
│ └── img_{n}.webp
|
||||
└── favorites/ # Created by export_favorites endpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Project Architecture Overview**
|
||||
|
||||
**ComfyUI-Ultimate-Sampler-Grid** is a hybrid application.
|
||||
|
||||
1. **The Backend (Python):** Runs inside ComfyUI. It orchestrates the grid generation, manages resources (caching/loading), and serves the API.
|
||||
2. **The Frontend Bridge (JS):** Runs in the ComfyUI browser tab. It registers the custom nodes and widgets.
|
||||
3. **The Dashboard SPA (HTML/JS/CSS):** A standalone, virtualized "IDE" for viewing results. It is generated by the backend but runs entirely in the client's browser, communicating back to the server only when saving changes.
|
||||
|
||||
---
|
||||
|
||||
### **Node Class Mappings**
|
||||
|
||||
| Internal Name | Python Class | Purpose |
|
||||
|---|---|---|
|
||||
| `UltimateSamplerGrid` | `SamplerGridTester` | Main generation node — receives configs, runs Cartesian grid |
|
||||
| `UltimateGridDashboard` | `SamplerConfigDashboardViewer` | Dashboard viewer — renders iframe with results |
|
||||
| `UltimateConfigBuilder` | `UltimateConfigBuilder` | Visual config builder — outputs `configs_json` string |
|
||||
| `SmartJSONText` | `SmartJSONTextNode` | JSON text widget with syntax highlighting |
|
||||
|
||||
---
|
||||
|
||||
### **API Routes Reference**
|
||||
|
||||
All routes registered in `__init__.py` via `PromptServer.instance.routes`.
|
||||
|
||||
**Config Builder APIs** (`/configbuilder/`):
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/configbuilder/list_configs` | List saved config preset filenames |
|
||||
| POST | `/configbuilder/save_config` | Save config preset `{name, data}` |
|
||||
| POST | `/configbuilder/load_config` | Load config preset `{name}` |
|
||||
| POST | `/configbuilder/lookup_triggers` | Bulk LoRA trigger word lookup `{loras: [...]}` |
|
||||
| POST | `/configbuilder/lookup_lora_metadata` | Full CivitAI metadata for LoRA `{lora_name}` |
|
||||
| POST | `/configbuilder/lookup_model_metadata` | Full CivitAI metadata for model `{model_name, model_type}` |
|
||||
| GET | `/configbuilder/model_lists` | All model/sampler/scheduler/VAE lists |
|
||||
| POST | `/configbuilder/refresh_models` | Signal frontend cache clear |
|
||||
|
||||
**Dashboard/Tester APIs** (`/config_tester/`):
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/config_tester/delete_session` | Delete session folder `{session_name}` |
|
||||
| POST | `/config_tester/save_changes` | Delta save — only changed items `{session_name, changed_items}` |
|
||||
| POST | `/config_tester/save_manifest` | Full merge save `{session_name, manifest}` |
|
||||
| POST | `/config_tester/get_session_html` | Generate dashboard HTML on-demand `{session_name, node_id}` |
|
||||
| POST | `/config_tester/export_favorites` | Export favorited images `{session_name, pack_metadata, organize_by_prompt, organize_by_lora}` |
|
||||
| POST | `/config_tester/scan_directory` | Scan external dir, create symlinks, use built-in `/view` `{directory_path, session_name}` |
|
||||
|
||||
**Distribution APIs** (`/distribution/`, registered in `distribution_routes.py`):
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/distribution/claim_job` | Worker claims next pending job (200 + job JSON, 204 = no jobs, 503 = inactive) |
|
||||
| POST | `/distribution/submit_result` | Worker uploads completed image (multipart: {metadata, image}) |
|
||||
| GET | `/distribution/status` | Get distribution status, worker stats, job counts |
|
||||
| POST | `/distribution/register_worker` | Worker registration with master |
|
||||
| GET | `/distribution/download_model` | Download model file for model sync `{category, filename}` |
|
||||
| POST | `/distribution/heartbeat` | Worker keepalive signal |
|
||||
|
||||
---
|
||||
|
||||
### **1. The Backend: Core Logic & Orchestration**
|
||||
|
||||
*Located in the root directory.*
|
||||
|
||||
* **`sampler_node.py`** (The Interface)
|
||||
* **Role:** The primary ComfyUI node (`SamplerGridTester`). Validates inputs and passes control to the orchestrator.
|
||||
* **Key Functions:** `run_tests()` (entry point → delegates to `generation_orchestrator.run_generation_loop()`), `find_existing_match()`, `get_latent_channels()`
|
||||
* **Key Logic:** Disables caching features if "Optional" inputs are connected (since external changes can't be tracked). `IS_CHANGED()` returns `NaN` when optionals connected (forces re-execution), deterministic hash otherwise.
|
||||
* **Required Inputs:** `ckpt_name`, `positive_text`, `negative_text`, `seed`, `denoise`, `configs_json`, `resolutions_json`, `session_name`, `overwrite_existing`, `flush_batch_every`, `add_random_seeds_to_gens`, `lora_triggerwords_mode`, `remote_vae_endpoint`, `save_conditioning_cache_to_file`, `enable_model_cache`
|
||||
* **Optional Inputs:** `optional_model` (MODEL), `optional_clip` (CLIP), `optional_vae` (VAE), `optional_positive` (CONDITIONING), `optional_negative` (CONDITIONING), `optional_latent` (LATENT)
|
||||
|
||||
|
||||
* **`generation_orchestrator.py`** (The Conductor — largest file, ~1700 lines)
|
||||
* **Role:** Manages the generation loop, distributed processing, and master text encoding.
|
||||
* **Key Functions:** `run_generation_loop()`, `check_if_job_completed()`, `setup_session_directories()`, `load_model_by_type()`, `get_model_cache_key()`, `calculate_clip_hash()`, `_conditioning_to_serializable()`, `_serializable_to_conditioning()`, `_preencode_all_conditionings()`, `_run_distributed_generation()`, `_send_distribution_status()`
|
||||
* **Key Logic:**
|
||||
* **Smart Skip:** `check_if_job_completed()` matches against manifest items by seed/resolution/sampler/scheduler/steps/cfg/denoise. Skips model/lora/prompt matching when optional inputs connected.
|
||||
* **Lookahead:** Triggers `model_cache.preload_lora_model()` / `preload_base_model()` for the *next* job while the current one runs.
|
||||
* **Interrupts:** Catches `InterruptProcessingException` during text encoding AND image generation. Flushes pending VAE batch and saves manifest before stopping.
|
||||
* **ETA Tracking:** Sends `ultimate_grid.progress` events to dashboard via `PromptServer.instance.send_sync()`.
|
||||
* **Node Change Detection:** Detects when connected upstream nodes have changed for smarter job resuming.
|
||||
* **Pre-encoding:** Single model → batch-encode all unique prompts upfront. Multi-model → re-encode per model switch.
|
||||
* **VAE Switching:** Flushes pending batch before switching VAE. Tracks `default_model_vae` for reverting to "Default".
|
||||
* **Distribution:** `_run_distributed_generation()` manages the distributed pipeline — populates job queue, optionally pre-encodes conditionings, notifies workers, and processes master's share of jobs.
|
||||
* **Master Text Encoding:** `_preencode_all_conditionings()` groups configs by (model, LoRA), loads each combination, encodes all unique prompts, and serializes via `_conditioning_to_serializable()`. Handles multi-entry conditionings (AND combinator).
|
||||
* **Per-Worker Logging:** Final summary prints each worker's completed/failed job counts from `manager.get_status()["workers"]`.
|
||||
|
||||
|
||||
|
||||
|
||||
* **`image_generation.py`** (The Worker)
|
||||
* **Role:** Wraps KSampler and VAE Decode.
|
||||
* **Key Logic:** Implements `flush_batch_with_vae` to save images and update the manifest in chunks (preventing VRAM overflow).
|
||||
|
||||
|
||||
* **`config_builder_node.py`** (The Builder UI Backend)
|
||||
* **Role:** `UltimateConfigBuilder` node. All state stored in single `lora_config` widget as JSON string.
|
||||
* **Key Functions:** `generate_config()`, `process_lora_array()`, `get_available_sessions()`, `expand_lora_folders()`, `lookup_lora_triggers()`
|
||||
* **Key Logic:** Python reads ONLY `lora_config` widget — all other INPUT_TYPES widgets (samplers, schedulers, steps, cfg) are vestigial and ignored. Also defines API routes for trigger/metadata lookup and model lists.
|
||||
* **Distribution Output:** When distribution is enabled, wraps configs in `{"configs": [...], "_distribution": {...}}` with keys: `enabled`, `worker_urls`, `claim_timeout` (default 600), `use_master_encoding`, `sync_models_to_workers`. When disabled, still wraps in `{"configs": [...]}` — `sampler_node.py` must unwrap in both cases.
|
||||
|
||||
|
||||
* **`html_generator.py`** (The Builder)
|
||||
* **Role:** Compiles the SPA into a single inlined HTML string.
|
||||
* **Key Functions:** `get_html_template(title, manifest_data, node_id)`
|
||||
* **Key Logic:** Reads all files from `/resources/` in dependency order, injects them into `template.html`, embeds `manifest.json` as `fullManifest` JS variable. Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__`. No separate HTTP requests — everything inlined.
|
||||
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ui.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_events.js` → `logic_init.js`
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### **2. The Backend: Resource Management**
|
||||
|
||||
*Located in the root directory.*
|
||||
|
||||
* **`model_cache.py`** (The 3-Tier Cache)
|
||||
* **Role:** Thread-safe resource manager. Single `ModelCache` class.
|
||||
* **Tiers:** 1. LoRA File Cache (raw state_dicts in RAM), 2. Incremental States (partial LoRA stacks via `check_incremental_cache()`), 3. Final Patched Models (`get_lora_model()` / `put_lora_model()`).
|
||||
* **Key Methods:** `get_base_model()`, `put_base_model()`, `preload_lora_model()`, `preload_base_model()`, `register_schedule()`, `set_current_step()`, `clear()`, `print_stats()`
|
||||
* **Async Preloading:** `preload_*` methods run loaders in background threads via `concurrent.futures`, results retrieved later.
|
||||
|
||||
|
||||
* **`conditioning_cache.py`** (The Prompt Cache)
|
||||
* **Role:** Caches CLIP text encodings to disk.
|
||||
* **Key Logic:** Hashes `(Prompt + CLIP State + LoRA Config)` to ensure validity. Auto-disabled when optional inputs connected.
|
||||
|
||||
|
||||
* **`batch_encoding.py`** (The Accelerator)
|
||||
* **Role:** Bulk prompt encoder with combinator support.
|
||||
* **Key Functions:** `encode_prompt_with_combinators(clip, text, clip_skip)`, `batch_encode_prompts(clip, positives, negatives, cond_cache, clip_skip)`
|
||||
* **Combinators:** `AND` (multi-cond with optional weights like `:1.5`), `CAT` (tensor concatenation), `AVG(weight)` (weighted average, default 0.5), `BREAK` (native tokenizer segment break at 77-token chunks).
|
||||
|
||||
|
||||
* **`model_loader.py`** (The Loader)
|
||||
* **Role:** Checkpoint, LoRA, VAE, GGUF, and diffusion model loading.
|
||||
* **Key Functions:** `load_checkpoint()`, `load_loras()`, `load_vae_by_name()`, `load_diffusion_model_and_clip()`, `load_loras_for_preencoding()`, `cleanup_model_references()`, `print_incompatible_loras_summary()`
|
||||
|
||||
|
||||
* **`trigger_words.py`** (Trigger Word Engine)
|
||||
* **Role:** CivitAI trigger word fetching and prompt assembly.
|
||||
* **Key Functions:** `collect_unique_prompts_with_triggers(expanded_configs, mode)`, `build_prompt_with_triggers(config, mode)`, `get_filtered_lora_triggers(lora_string, omit_list)`
|
||||
* **Modes:** `"None"`, `"Append To End"`, `"Append To Start"`, `"Read From Config"`. Per-LoRA placement via `lora_triggerwords_append_settings`. Also applies `model_prompt_prefix` / `model_prompt_suffix`.
|
||||
|
||||
|
||||
* **`config_utils.py`** (Expansion Engine)
|
||||
* **Role:** Config parsing, Cartesian product expansion, folder expansion.
|
||||
* **Key Functions:** `expand_configs()`, `parse_prompt_input_nested()`, `prepare_input_jobs()`, `parse_lora_definition()`, `get_files_from_folder()`, `sanitize_session_name()`
|
||||
* **Key Logic:** `parse_prompt_input_nested()` supports arbitrarily deep nested arrays for Cartesian prompting. `"folder/"` = expand individually, `"folder/*"` = stack all together.
|
||||
|
||||
|
||||
* **`manifest_utils.py`** (Manifest Persistence)
|
||||
* **Key Functions:** `load_existing_manifest()`, `save_manifest()`, `merge_manifest_user_changes()`
|
||||
* **Key Logic:** `save_manifest()` calls `merge_manifest_user_changes()` first — reloads from disk to preserve user favorites/rejected/notes that may have changed concurrently.
|
||||
|
||||
|
||||
* **`directory_scanner.py`** (External Image Scanner)
|
||||
* **Key Functions:** `scan_directory_for_images()`, `parse_a1111_parameters()`
|
||||
* **Key Logic:** Parses A1111-style PNG metadata into dashboard-compatible manifest items. Preserves user tags on re-scan by merging with existing manifest.
|
||||
|
||||
|
||||
* **`metadata_packer.py`** (Export Metadata)
|
||||
* **Key Functions:** `pack_metadata_into_image()`, `extract_metadata_from_image()`, `calculate_file_hash()`
|
||||
* **Key Logic:** Packs ComfyUI-compatible metadata into PNG for CivitAI uploads. Maintains SHA256 hash cache at `benchmarks/model_hashes.json`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### **2b. The Backend: Distribution System**
|
||||
|
||||
*Located in the root directory. Enables multi-machine parallel processing.*
|
||||
|
||||
* **`distribution_manager.py`** (The Job Coordinator)
|
||||
* **Role:** Thread-safe distributed job queue running on the master. Manages job lifecycle: PENDING → CLAIMED → COMPLETED/FAILED.
|
||||
* **Classes:** `JobState` (constants), `DistributedJob` (individual job), `DistributionManager` (coordinator)
|
||||
* **Key Methods:** `populate_jobs()`, `claim_job(worker_id)`, `complete_job(job_id)`, `get_stats()`, `set_encoded_conditionings()`, `get_encoded_conditionings_for_job()`
|
||||
* **Key Logic:**
|
||||
* Thread-safe with `RLock` for concurrent worker access
|
||||
* Job timeout handling (default 600s) — re-queues timed-out jobs as PENDING
|
||||
* `MAX_FAIL_COUNT = 3` — jobs that fail 3 times are abandoned
|
||||
* `_job_to_dict()` attaches pre-encoded conditionings to job claim response if available
|
||||
* Tracks per-worker stats (completed, failed, last_seen)
|
||||
|
||||
* **`distribution_worker.py`** (The Remote Worker)
|
||||
* **Role:** Background thread running on remote ComfyUI instances. Polls master for jobs, processes locally, uploads results.
|
||||
* **Class:** `WorkerThread(threading.Thread)` — daemon thread
|
||||
* **Key Methods:** `run()` (main loop), `_process_job()`, `_download_model_from_master()`, `_prefetch_models()`, `stop()`
|
||||
* **Key Logic:**
|
||||
* Polls `/distribution/claim_job` every 2 seconds
|
||||
* Caches model/CLIP/VAE between jobs (reuses when same model)
|
||||
* Checks for `encoded_positive`/`encoded_negative` in job data — uses master conditionings if available, falls back to local CLIP encoding otherwise
|
||||
* Model sync: downloads models from master via `/distribution/download_model` if enabled
|
||||
* Exits after 30 consecutive empty polls or 503 response (master finished)
|
||||
|
||||
* **`distribution_routes.py`** (The API Layer)
|
||||
* **Role:** Registers `/distribution/*` API endpoints on the PromptServer.
|
||||
* **Global State:** `_distribution_manager` (active manager, master mode), `_worker_thread` (active worker, worker mode) — both thread-safe via getters/setters
|
||||
* **Key Endpoints:** `claim_job` (GET), `submit_result` (POST multipart), `status` (GET), `register_worker` (POST), `download_model` (GET), `heartbeat` (POST)
|
||||
|
||||
---
|
||||
|
||||
### **Key Data Shapes**
|
||||
|
||||
**Config Array State** (`node.state.config_arrays[n]` in JS, parsed from `lora_config` widget in Python):
|
||||
```json
|
||||
{
|
||||
"name": "Config 1",
|
||||
"samplers": ["euler", "dpmpp_2m"],
|
||||
"schedulers": ["normal", "karras"],
|
||||
"steps": "20, 30",
|
||||
"cfg": "7.0",
|
||||
"models": [{"path": "model.safetensors", "type": "checkpoint"}],
|
||||
"vaes": ["None"],
|
||||
"loras": ["lora.safetensors:1.00:1.00"],
|
||||
"lora_bypass_states": {"lora_name": true},
|
||||
"model_bypass_states": {"model_path": true},
|
||||
"lora_omit_triggers": [],
|
||||
"lora_triggerwords_append_settings": {},
|
||||
"lora_strength_lock": {},
|
||||
"combine": false,
|
||||
"positive_prompt_groups": [],
|
||||
"negative_prompt": "",
|
||||
"use_custom_prompts": false,
|
||||
"model_prompt_prefix": "",
|
||||
"model_prompt_suffix": "",
|
||||
"attention_modes": ["default"],
|
||||
"resolutions": [],
|
||||
"seed_behavior": "fixed",
|
||||
"text_encoders": [],
|
||||
"clip_type": "stable_diffusion",
|
||||
"gguf_options": {},
|
||||
"model_sampling_override": "None",
|
||||
"model_sampling_shift": 3.0,
|
||||
"use_advanced_sampling": false,
|
||||
"advanced_guider": "Basic",
|
||||
"use_flux_guidance": false,
|
||||
"flux_guidance_value": 3.5
|
||||
}
|
||||
```
|
||||
|
||||
**Distribution Config** (embedded in `config_builder_node.py` output as `_distribution` key):
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"worker_urls": ["http://192.168.1.100:8188", "http://192.168.1.101:8188"],
|
||||
"claim_timeout": 600,
|
||||
"use_master_encoding": false,
|
||||
"sync_models_to_workers": false
|
||||
}
|
||||
```
|
||||
|
||||
**Top-level state fields** (in `conf-builder-main.js` default state, outside `config_arrays`):
|
||||
```json
|
||||
{
|
||||
"session_name": "my_session",
|
||||
"config_name": "my_config",
|
||||
"auto_save": false,
|
||||
"include_none": true,
|
||||
"label_mode": "full",
|
||||
"global_positive_groups": [],
|
||||
"global_negative": "",
|
||||
"distribution_enabled": false,
|
||||
"worker_urls": [],
|
||||
"claim_timeout": 600,
|
||||
"use_master_encoding": false,
|
||||
"sync_models_to_workers": false
|
||||
}
|
||||
```
|
||||
|
||||
**Manifest Item** (`manifest.json` `items[n]`):
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"seed": 12345, "cfg": 7.0, "steps": 20,
|
||||
"sampler": "euler", "scheduler": "normal",
|
||||
"model": "model.safetensors", "lora": "lora:1.0:1.0",
|
||||
"vae": "vae.safetensors", "attention_mode": "default",
|
||||
"positive": "prompt text", "negative": "negative text",
|
||||
"denoise": 1.0, "width": 1024, "height": 1024,
|
||||
"batch_idx": 0, "generation_time": 45.2,
|
||||
"file": "/view?filename=img_0.webp&type=output&subfolder=benchmarks/session/images",
|
||||
"favorite": false, "rejected": false, "notes": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Communication Patterns**
|
||||
|
||||
**Widget Bridge (Config Builder → Python):**
|
||||
```
|
||||
JS node.state → JSON.stringify() → lora_config widget.value → Python json.loads(lora_config)
|
||||
```
|
||||
All other widget params in INPUT_TYPES (samplers, schedulers, steps, cfg) are ignored by Python.
|
||||
|
||||
**Live Dashboard Updates (Server → Dashboard iframe):**
|
||||
```
|
||||
generation_orchestrator → PromptServer.send_sync("ultimate_grid.update")
|
||||
→ dashboard.js api.addEventListener → postMessage({type: 'update_data'})
|
||||
→ iframe logic_events.js message handler
|
||||
```
|
||||
|
||||
**Progress Updates (Server → Dashboard iframe):**
|
||||
```
|
||||
generation_orchestrator → PromptServer.send_sync("ultimate_grid.progress")
|
||||
→ dashboard.js → postMessage({type: 'progress_update'})
|
||||
→ iframe #eta-bar update
|
||||
```
|
||||
|
||||
**Dashboard → Parent Frame:**
|
||||
```
|
||||
iframe postMessage({type: 'toggle_fullscreen', node_id})
|
||||
→ dashboard.js listener → toggle .dashboard-fullscreen class
|
||||
```
|
||||
|
||||
**Distribution Status Updates (Server → Dashboard iframe):**
|
||||
```
|
||||
generation_orchestrator → PromptServer.send_sync("ultimate_grid.distribution_status")
|
||||
→ dashboard.js api.addEventListener → postMessage({type: 'distribution_status'})
|
||||
→ iframe displays worker activity
|
||||
```
|
||||
|
||||
**Distribution Job Flow (Master ↔ Worker):**
|
||||
```
|
||||
Master:
|
||||
1. generation_orchestrator → DistributionManager.populate_jobs()
|
||||
2. [Optional] _preencode_all_conditionings() → manager.set_encoded_conditionings()
|
||||
3. POST /distribution/register_worker on each worker
|
||||
4. Workers poll GET /distribution/claim_job
|
||||
5. manager._job_to_dict() attaches encoded_positive/encoded_negative if available
|
||||
6. Worker processes job → POST /distribution/submit_result (multipart: metadata + image)
|
||||
7. Master saves image + updates manifest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **3. The Frontend: ComfyUI Integration**
|
||||
|
||||
*Located in `/web/`.*
|
||||
|
||||
* **`dashboard.js`**
|
||||
* **Role:** Registers the Dashboard node (`UltimateGridDashboard`). Listens for `ultimate_grid.update`, `ultimate_grid.progress`, and `ultimate_grid.distribution_status` server events.
|
||||
* **Key Logic:** Forwards events to matching iframe via `postMessage()`. Auto-loads session when update received for unloaded session. Handles fullscreen toggle via `.dashboard-fullscreen` CSS class. **Auto-loads on node creation** with 500ms delay via `forceLoadSession()` so the dashboard shows content immediately (landing page or session grid) instead of a blank black box.
|
||||
* **Widgets:** "RELOAD / SHOW SESSION" button → `forceLoadSession()`, "DELETE SESSION" button → POST `/config_tester/delete_session`
|
||||
* **Event Listeners (Singletons):** Each uses `window.__ultimateGrid*ListenerInstalled` guard to prevent duplicate registration:
|
||||
* `ultimate_grid.update` — Live data updates to iframe
|
||||
* `ultimate_grid.progress` — ETA/progress bar updates
|
||||
* `ultimate_grid.distribution_status` — Distribution worker status
|
||||
* `window "message"` — Fullscreen toggle from iframe
|
||||
|
||||
|
||||
* **`config_builder.js`** (entry point, loads `conf_builder/` modules)
|
||||
* **Role:** Registers the Builder node (`UltimateConfigBuilder`). Entry point for the modular Config Builder UI.
|
||||
* **Key Logic:** `onNodeCreated` **MUST be synchronous** (async init runs in fire-and-forget IIFE). Uses `Date.now()` cache-busting on module `import()` calls. Hooks `app.refreshComboInNodes()` to detect model/LoRA changes.
|
||||
* **Node Methods:** `node.saveState()`, `node.renderUI()`, `node.loadSession(sessionName)`, `node.saveConfigToBackend()`, `node.loadConfigFromBackend(filename)`, `node.triggerAutoSave()` (2s debounce)
|
||||
|
||||
|
||||
* **`smart_json_text.js`**
|
||||
* **Role:** Registers the JSON node (`SmartJSONText`). Adds syntax highlighting and validation.
|
||||
|
||||
|
||||
* **`conf_builder/conf-builder-utilities.js`** (The Data Layer)
|
||||
* **Key Exports:** `convertStateToConfigs()`, `getIterationCount()`, `parseLoraString()`, `buildLoraString()`, `refreshAllConfigBuilders()`, `clearAllCaches()`, `normalizePath()`, `getAvailableLoras()`, `getModelLists()`
|
||||
* **Global Caches:** `availableLoras`, `availableModels`, `availableVAEs`, `availableSamplers`, `availableSchedulers`, `availableSessions`, `availableConfigs`
|
||||
|
||||
|
||||
* **`conf_builder/conf-builder-config-management.js`** (The UI Builder)
|
||||
* **Key Exports:** `renderUI()`, `updatePreview()`, `renderConfigSection()`, `renderSessionSection()`, `createConfigArrayElement()`, `createModelElement()`
|
||||
|
||||
|
||||
* **`conf_builder/conf-builder-ui-components.js`** (The Widget Library)
|
||||
* **Key Exports:** `createSearchableSelect()`, `createSlider()`, `createInputGroup()`, `createTopBar()`, `createSidebar()`, `createSectionHeader()`, `getStyles()`
|
||||
|
||||
|
||||
* **`conf_builder/conf-builder-distribution.js`** (Distribution Settings UI)
|
||||
* **Role:** Renders the distribution settings section in the Config Builder.
|
||||
* **Key Exports:** `renderDistributionSection()`, `renderDistributionSettingsSection()`
|
||||
* **Features:** Worker URL management (add/remove), connection testing via `/distribution/test_worker`, status indicators (green/yellow/red dots), claim timeout slider (30-900s, default 600), toggles for "Use Master Text Encoding" and "Sync Models to Workers".
|
||||
|
||||
|
||||
---
|
||||
|
||||
### **4. The Frontend: The Dashboard SPA**
|
||||
|
||||
*Located in `/resources/`. This is the "App" users interact with.*
|
||||
|
||||
* **`logic_init.js`** (The Bootstrapper)
|
||||
* **Role:** Main Entry Point.
|
||||
* **Key Logic:**
|
||||
* **`initDashboard()`:** The first function called.
|
||||
* **Data Injection:** detects if `fullManifest` exists (injected by Python). If not, it attempts to fetch it (View Mode).
|
||||
* **Lifecycle:** Calls `initLogicState()` -> `applyPipeline()` -> `initVirtualScroller()` -> `initGlobalEvents()`.
|
||||
|
||||
|
||||
|
||||
|
||||
* **`logic_state.js`** (The Brain)
|
||||
* **Role:** Central State Store (Redux-style).
|
||||
* **Key Logic:**
|
||||
* **`State` Object:** Holds `items` (all images), `filteredItems` (visible), `selection` (active card), and `favorites`.
|
||||
* **Triple JSON Lists:** Manages the logic for the "Accepted", "Favorites", and "Rejected" JSON bars at the bottom of the UI.
|
||||
* **Persistence:** Saves user preferences (Sort Order, Column Count) to `localStorage` so they persist across reloads.
|
||||
|
||||
|
||||
|
||||
|
||||
* **`logic_events.js`** (The Handler)
|
||||
* **Role:** Interaction & API Bridge.
|
||||
* **Key Logic:**
|
||||
* **Keyboard Shortcuts:** Maps `Arrow Keys` (Pan), `Space` (Scroll), `+/-` (Zoom), `F` (Fit), `Escape` (close modals with priority chain).
|
||||
* **API Calls:** Uses `fetch` to hit endpoints like `/config_tester/save_manifest` (when you star/reject an image) and `/config_tester/delete_session`.
|
||||
* **Live Updates:** Listens for the `executed` event from ComfyUI to trigger a soft-refresh of the image list.
|
||||
|
||||
|
||||
|
||||
|
||||
* **`logic_virtual.js`** (The Engine)
|
||||
* **Role:** Virtual Scroller.
|
||||
* **Key Logic:** Calculates viewport geometry to render only the ~20 visible cards out of thousands, enabling high performance.
|
||||
|
||||
|
||||
* **`logic_pipeline.js`** (The Processor)
|
||||
* **Role:** Filtering & Sorting.
|
||||
* **Key Logic:** Transforms the raw item list based on active filters (Model, Sampler, etc.) before passing it to the virtual scroller.
|
||||
|
||||
|
||||
* **`logic_ui.js`** (The Renderer)
|
||||
* **Role:** DOM Manipulation.
|
||||
* **Key Logic:** Generates the HTML for individual cards, the "Revise" modal, and the unified Settings panel (gear icon). Manages modal open/close with overlay and body overflow lock.
|
||||
|
||||
|
||||
* **`logic_utils.js`** (The Helpers)
|
||||
* **Role:** Utilities and server communication.
|
||||
* **Key Functions:** `loadSession()`, `saveState()`, `exportFavorites()`, `scanDirectory()`, `triggerGen()`, `toggleFullscreen()`, `rejectItem()`
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### **Key Functions Quick Reference**
|
||||
|
||||
**Python (by file):**
|
||||
|
||||
| File | Key Functions |
|
||||
|---|---|
|
||||
| `sampler_node.py` | `run_tests()`, `find_existing_match()`, `get_latent_channels()`, `IS_CHANGED()` |
|
||||
| `config_builder_node.py` | `generate_config()`, `process_lora_array()`, `get_available_sessions()`, `expand_lora_folders()` |
|
||||
| `generation_orchestrator.py` | `run_generation_loop()`, `check_if_job_completed()`, `setup_session_directories()`, `load_model_by_type()`, `_preencode_all_conditionings()`, `_conditioning_to_serializable()`, `_serializable_to_conditioning()`, `_run_distributed_generation()` |
|
||||
| `batch_encoding.py` | `encode_prompt_with_combinators()`, `batch_encode_prompts()` |
|
||||
| `config_utils.py` | `expand_configs()`, `parse_prompt_input_nested()`, `prepare_input_jobs()`, `parse_lora_definition()` |
|
||||
| `model_loader.py` | `load_checkpoint()`, `load_loras()`, `load_vae_by_name()`, `load_diffusion_model_and_clip()` |
|
||||
| `model_cache.py` | `ModelCache.get_base_model()`, `.put_base_model()`, `.preload_lora_model()`, `.register_schedule()` |
|
||||
| `trigger_words.py` | `collect_unique_prompts_with_triggers()`, `build_prompt_with_triggers()`, `get_filtered_lora_triggers()` |
|
||||
| `manifest_utils.py` | `load_existing_manifest()`, `save_manifest()`, `merge_manifest_user_changes()` |
|
||||
| `directory_scanner.py` | `scan_directory_for_images()`, `parse_a1111_parameters()` |
|
||||
| `metadata_packer.py` | `pack_metadata_into_image()`, `extract_metadata_from_image()`, `calculate_file_hash()` |
|
||||
| `distribution_manager.py` | `DistributionManager.populate_jobs()`, `.claim_job()`, `.complete_job()`, `.get_stats()`, `.set_encoded_conditionings()`, `.get_encoded_conditionings_for_job()` |
|
||||
| `distribution_worker.py` | `WorkerThread.run()`, `._process_job()`, `._download_model_from_master()`, `._prefetch_models()`, `.stop()` |
|
||||
| `distribution_routes.py` | `get_distribution_manager()`, `set_distribution_manager()`, endpoint handlers for `/distribution/*` |
|
||||
|
||||
**JavaScript (by file):**
|
||||
|
||||
| File | Key Functions |
|
||||
|---|---|
|
||||
| `conf-builder-utilities.js` | `convertStateToConfigs()`, `getIterationCount()`, `parseLoraString()`, `buildLoraString()`, `refreshAllConfigBuilders()` |
|
||||
| `conf-builder-config-management.js` | `renderUI()`, `updatePreview()`, `createConfigArrayElement()`, `createModelElement()` |
|
||||
| `conf-builder-ui-components.js` | `createSearchableSelect()`, `createSlider()`, `getStyles()` |
|
||||
| `conf-builder-main.js` | `ensureModulesLoaded()`, `node.saveState()`, `node.renderUI()`, `node.loadSession()`, `node.triggerAutoSave()` |
|
||||
| `conf-builder-distribution.js` | `renderDistributionSection()`, `renderDistributionSettingsSection()` |
|
||||
| `logic_ui.js` | `toggleCogMenu()`, `toggleFiltersPopup()`, `initFilters()` |
|
||||
| `logic_utils.js` | `loadSession()`, `exportFavorites()`, `scanDirectory()`, `triggerGen()`, `toggleFullscreen()` |
|
||||
| `logic_virtual.js` | `renderDOM()`, `updateVisibleItems()`, `calculateVisibleRange()`, `autoFitZoom()`, `goToImage()` |
|
||||
| `logic_pipeline.js` | `updateDataPipeline()`, `executePipeline()`, `processNewData()`, `incrementalFilter()` |
|
||||
| `logic_events.js` | Message handler for `progress_update` and `update_data` types |
|
||||
|
||||
---
|
||||
|
||||
### **Summary of Data Flow**
|
||||
|
||||
1. **User Config:** You set parameters in the **Config Builder** (visual UI) or **Sampler Node** (JSON text).
|
||||
2. **Config Output:** `config_builder_node.py` wraps configs in `{"configs": [...], "_distribution": {...}}`. The `sampler_node.py` unwraps the `"configs"` key and extracts distribution settings.
|
||||
3. **Expansion:** `generation_orchestrator.py` expands configs into a full list of jobs (Cartesian product).
|
||||
4. **Distribution (optional):** If enabled, `_run_distributed_generation()` populates the job queue, optionally pre-encodes conditionings, and notifies workers. Workers poll for jobs, process locally, and upload results.
|
||||
5. **Generation:** The backend loops through its share of jobs, using **ModelCache** and **ConditioningCache** for speed.
|
||||
6. **Storage:** Images are saved to disk; metadata is appended to `manifest.json`.
|
||||
7. **Compilation:** `html_generator.py` reads the `manifest.json` and all `.js` files in `/resources/`, baking them into a single `dashboard_html` string.
|
||||
8. **Display:** The **Dashboard Node** (JS) renders this HTML in an iframe. Auto-loads on node creation.
|
||||
9. **Boot:** `logic_init.js` calls `init()` which calls `showSessionLandingIfEmpty()`. If no data, the session landing page is shown. If data exists, `logic_virtual.js` renders the grid.
|
||||
10. **Interaction:** You click "Favorite" in the UI -> `logic_events.js` sends an API request to `__init__.py` -> Python updates `manifest.json` on disk.
|
||||
|
||||
---
|
||||
|
||||
### **Critical Development Gotchas**
|
||||
|
||||
> ⚠️ **GOLDEN RULE: DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.**
|
||||
|
||||
1. **Dual Data Path** — Frontend `convertStateToConfigs()` (in `conf-builder-utilities.js`) and Python `generate_config()` (in `config_builder_node.py`) are **independent implementations** that both transform `node.state` into config JSON. Both must stay in sync when adding new config fields or the UI preview will mismatch the actual output.
|
||||
|
||||
2. **Widget Bridge** — The `lora_config` widget (STRING type) holds the **entire** node state as serialized JSON. Python `generate_config()` ignores all other widget params and reads only `lora_config`. The `samplers`, `schedulers`, `steps`, `cfg` widgets in `INPUT_TYPES` are vestigial (kept for ComfyUI schema compliance only).
|
||||
|
||||
3. **onNodeCreated Must Be Synchronous** — In `conf-builder-main.js`, `onNodeCreated` MUST remain synchronous. Async initialization runs inside a fire-and-forget `(async () => { ... })()` IIFE. Making `onNodeCreated` itself async breaks widget registration.
|
||||
|
||||
4. **Resources Are Inlined** — `html_generator.py` reads all `/resources/` files and bakes them into one HTML string. Zero external HTTP requests. Changes to dashboard JS/CSS require re-generating the HTML (re-run the sampler node or call `/config_tester/get_session_html`).
|
||||
|
||||
5. **Manifest Merge on Save** — `manifest_utils.py` `save_manifest()` calls `merge_manifest_user_changes()` which reloads from disk before saving. This preserves user favorites/rejected/notes that may have been changed concurrently while generation was running.
|
||||
|
||||
6. **Optional Inputs Disable Caches** — `sampler_node.py` disables conditioning cache when `optional_model`/`optional_clip`/`optional_positive`/`optional_negative` are connected. `IS_CHANGED()` returns `NaN` (forces re-execution). `check_if_job_completed()` skips model/lora/prompt matching.
|
||||
|
||||
7. **Folder Expansion Syntax** — `"folder/"` = expand to individual entries (one combination each). `"folder/*"` = stack ALL files in folder together as a single combined entry. Weight array syntax `"lora:[0.5, 0.8]:1.0"` creates grid search over strengths. No trailing slash = single file match.
|
||||
|
||||
8. **LoRA String Format** — `"name:model_str:clip_str"`, stacked with `" + "` separator. `parseLoraString()` in JS, `parse_lora_definition()` in Python. Default strengths: 1.0 for both if colon-separated parts are missing.
|
||||
|
||||
9. **Cache Busting** — `conf-builder-main.js` uses `Date.now()` timestamp on `import()` calls for module loading. Also intercepts `window.fetch` to detect `/object_info` calls and trigger cache refresh. Static JS served via ComfyUI's `/extensions/` path.
|
||||
|
||||
10. **Config Presets Path** — Saved to `output/ultimate-configs/` (defined in `__init__.py`), NOT `output/benchmarks/configs/`. Session data goes under `output/benchmarks/{session_name}/`.
|
||||
|
||||
11. **Field Name Inconsistency: `favorite` vs `favorited`** — `manifest_utils.py` `merge_manifest_user_changes()` uses the key `"favorite"`. But `__init__.py` routes like `save_manifest` and `export_favorites` check `"favorited"`. When touching favorite/unfavorite logic, check which key name the specific code path uses.
|
||||
|
||||
12. **Prompt Expansion is Recursive** — `parse_prompt_input_nested()` in `config_utils.py` supports arbitrarily deep nested arrays. Flat list = OR (options). List containing sub-lists = AND (Cartesian product). This is used by both the node text inputs and per-config prompt groups from the builder UI.
|
||||
|
||||
13. **Config Builder Output is ALWAYS Wrapped** — `config_builder_node.py` always outputs `{"configs": [...]}` (with optional `"_distribution"` key). The `sampler_node.py` MUST unwrap the `"configs"` key before passing to `expand_configs()`. Both the `_distribution` present AND absent cases must be handled (see lines 269-286 of `sampler_node.py`).
|
||||
|
||||
14. **Dashboard Auto-Load on Creation** — `web/dashboard.js` calls `forceLoadSession()` with 500ms delay when a dashboard node is created. The `get_session_html` endpoint in `__init__.py` returns a full HTML template with empty manifest for non-existent sessions (NOT a 404). This ensures the landing page shows immediately.
|
||||
|
||||
15. **`logic_pipeline.js` Legacy Init Guard** — Lines 489-505 have a `DOMContentLoaded` handler that calls `loadSession()` ONLY when `fullManifest.items.length > 0`. Without this guard, `loadSession()` triggers an alert popup for non-existent sessions. The proper initialization path is `logic_init.js` → `init()` → `showSessionLandingIfEmpty()`.
|
||||
|
||||
16. **Distribution Default Values Must Stay in Sync** — `claim_timeout` default (600) appears in: `distribution_manager.py` (constructor), `config_builder_node.py` (fallback), `conf-builder-distribution.js` (slider default), `conf-builder-main.js` (default state). All four must match. Same for `use_master_encoding` default (false).
|
||||
|
||||
17. **Conditioning Serialization Uses np.copy()** — `_serializable_to_conditioning()` in `generation_orchestrator.py` must call `np.frombuffer(...).copy()` before `torch.from_numpy()` because `np.frombuffer()` returns read-only arrays that are incompatible with GPU tensor operations.
|
||||
|
||||
18. **Multi-Entry Conditioning (AND combinator)** — The AND combinator creates multiple `[tensor, {pooled_output}]` entries in a conditioning list. Serialization in `_conditioning_to_serializable()` must iterate ALL entries, not just `[0]`. Deserialization in `_serializable_to_conditioning()` must reconstruct the full list.
|
||||
|
||||
19. **State Migration for New Fields** — When adding new fields to the Config Builder default state in `conf-builder-main.js` (around line 89), also add a migration check (around line 363) to backfill the default for existing saved workflows. Without migration, old workflows will have `undefined` for the new field.
|
||||
|
||||
20. **ComfyUI Registry Security** — No `import requests`, no `subprocess`/`os.system`/`eval()`/`exec()`, no runtime `pip install`, no custom file-serving endpoints, no code obfuscation. Use `urllib.request` for HTTP. Use ComfyUI's `/view` endpoint for serving files. Sanitize all user-supplied paths. See top of this file for full rules.
|
||||
|
||||
---
|
||||
|
||||
### **Notes for AI Development**
|
||||
|
||||
This section provides critical context for AI assistants working on this codebase.
|
||||
|
||||
**Architecture Mental Model:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ComfyUI Browser Tab │
|
||||
│ ┌──────────────────┐ ┌──────────────────────────────────┐│
|
||||
│ │ Config Builder │ │ Dashboard Node ││
|
||||
│ │ (conf-builder-*) │ │ (dashboard.js) ││
|
||||
│ │ Direct DOM widget │ │ ┌──────────────────────────────┐││
|
||||
│ │ │ │ │ iframe (srcdoc) │││
|
||||
│ │ │ │ │ Dashboard SPA │││
|
||||
│ │ │ │ │ (resources/logic_*.js) │││
|
||||
│ │ │ │ │ postMessage() ↕ │││
|
||||
│ │ │ │ └──────────────────────────────┘││
|
||||
│ └──────────────────┘ └──────────────────────────────────┘│
|
||||
│ ↕ widget value ↕ server events │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ComfyUI Server (Python) │
|
||||
│ ┌──────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
||||
│ │config_builder│ │sampler_node.py │ │__init__.py │ │
|
||||
│ │_node.py │→ │ │ │(API endpoints) │ │
|
||||
│ └──────────────┘ │ ↓ │ └────────────────┘ │
|
||||
│ │generation_ │ │
|
||||
│ │orchestrator.py │ │
|
||||
│ │ ↓ │ │
|
||||
│ ┌─────────┐ │model_loader + │ ┌─────────────────┐ │
|
||||
│ │ModelCache│←────│image_generation│ │DistributionMgr │ │
|
||||
│ └─────────┘ │ ↓ │ │(master jobs) │ │
|
||||
│ │html_generator │ └─────────────────┘ │
|
||||
│ └────────────────┘ ↕ HTTP │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │WorkerThread │ │
|
||||
│ │(remote workers) │ │
|
||||
│ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**File Dependency Graph (Python):**
|
||||
```
|
||||
sampler_node.py
|
||||
→ generation_orchestrator.py (main entry)
|
||||
→ config_utils.py (expand_configs, prepare_input_jobs)
|
||||
→ model_loader.py (load_checkpoint, load_loras)
|
||||
→ image_generation.py (generate_image, flush_batch)
|
||||
→ batch_encoding.py (encode_prompt_with_combinators)
|
||||
→ trigger_words.py (build_prompt_with_triggers)
|
||||
→ manifest_utils.py (save_manifest, load_existing_manifest)
|
||||
→ model_cache.py (ModelCache)
|
||||
→ conditioning_cache.py (ConditioningCache)
|
||||
→ remote_vae.py (RemoteVAEDecodeWorker)
|
||||
→ html_generator.py (get_html_template)
|
||||
→ distribution_manager.py (DistributionManager)
|
||||
→ distribution_worker.py (WorkerThread)
|
||||
|
||||
config_builder_node.py
|
||||
→ lora_utils.py (trigger word lookup, CivitAI API)
|
||||
|
||||
__init__.py
|
||||
→ all node classes (sampler, dashboard, config_builder, json_text)
|
||||
→ distribution_routes.py (distribution API endpoints)
|
||||
→ html_generator.py (on-demand HTML generation)
|
||||
→ metadata_packer.py (export favorites)
|
||||
→ directory_scanner.py (scan external directories)
|
||||
→ manifest_utils.py (save/load manifests)
|
||||
```
|
||||
|
||||
**Server Event Types:**
|
||||
|
||||
| Event Name | Sender | Data Fields | Purpose |
|
||||
|---|---|---|---|
|
||||
| `ultimate_grid.update` | `generation_orchestrator.py` | `session_name, node, manifest, meta, new_items` | New images generated |
|
||||
| `ultimate_grid.progress` | `generation_orchestrator.py` | `session_name, progress_pct, eta_str, current_job, total_jobs, avg_duration, total_generated, total_elapsed, finish_time` | ETA/progress bar |
|
||||
| `ultimate_grid.distribution_status` | `generation_orchestrator.py` | `session_name, workers, stats` | Distribution worker status |
|
||||
|
||||
**Testing Changes:**
|
||||
- **Python backend files:** Restart ComfyUI
|
||||
- **`web/` JS files:** Refresh browser (Ctrl+F5)
|
||||
- **`resources/` JS/CSS/HTML files:** Dashboard HTML must be regenerated — re-run the sampler node or call `/config_tester/get_session_html`
|
||||
- **Config Builder state fields:** Also update migration checks in `conf-builder-main.js`
|
||||
-292
@@ -1,292 +0,0 @@
|
||||
|
||||
|
||||
99% of this project's near 10k lines of code was written by either Gemini or Claude. Sorry if that upsets you, I've been a programmer for 15 years and it's simply a much faster means of developing.
|
||||
|
||||
With that said, here is a guide on:
|
||||
|
||||
Easyily Get AI To Add Features To This Project:
|
||||
|
||||
Gemini Pro & Claude Sonnet 4.5 both work great but they sometimes make mistakes. The key is to really get a very clear prompt built out.
|
||||
|
||||
Step 1.
|
||||
Send the AI the ProjectStructure.md and README.md files and your task and ask it to tell you which files need to be edited to get your task completed.
|
||||
|
||||
Step 2.
|
||||
Start a new chat and attach the files it mentioned, along with the following prompt or something similar with your task request prompt in the middle:
|
||||
|
||||
Prompt Fill-in:
|
||||
|
||||
Help me update my ComfyUI Custom Node. When updating files, DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
PUT YOUR TASK HERE
|
||||
|
||||
Check ProjectStructure.md to get an idea of file structure and contents, check README.md to get an idea of the project as a whole.
|
||||
|
||||
step 3
|
||||
?????
|
||||
|
||||
step 4
|
||||
Profit!!!
|
||||
|
||||
But seriously, after updating your code see if it works. If it didn't send the error message if there isnt one or your symptoms. If its still not working try a different AI or try breaking down your task in smaller pieces.
|
||||
|
||||
|
||||
---
|
||||
|
||||
!!!!
|
||||
Check Roadmap.md for some tasks and do them. If you think you can do the ones marked as New to-do items, needs more info/explaining/numbering those are good.
|
||||
|
||||
There is info on the project in README.md and info on the file structure and notes on each files functions in the ProjectStructure.md
|
||||
!!!!
|
||||
ProjectStructure.md has notes to assist AI in developing this project
|
||||
!!!!
|
||||
|
||||
### **ComfyUI Ultimate Sampler Grid – Development Roadmap**
|
||||
|
||||
## Builder UI Improve Navigation, Layout & Styles - Always on top, sticky, Top Bar & Sidebar:
|
||||
|
||||
Top bar should have Session Name as a text input first, far left. Then let's add a Settings button with a dropdown with Load Session, Load Config, Save Config, Enable Distributed Processing
|
||||
|
||||
Side Bar, Icons For Each Major Section, click to scroll to it.
|
||||
|
||||
Layout & Styles fix: Many section headers are too small to find and read easily, and some are different sizes, lets make them uniform.
|
||||
|
||||
Color issues, comfyui is dark mode, many buttons and headers and texts are too dark or darkly colored to be viewed easily for everyone, lets fix that.
|
||||
|
||||
|
||||
|
||||
## Jobs don't generate prompts in the order they're written in the array, it's mildly inconvenient. Lets fix that and make it run in order from top to bottom of the array.
|
||||
|
||||
## Custom Job Resume / Skipping - Start At Job # Option
|
||||
|
||||
## Batch Encoding could use with implementing the smart, look ahead, caching system we built for lora swapping.
|
||||
|
||||
## Add HF-RemoteVae list to BuilderUI Remote Vae (when Remote URL is selected, add a Presets: selection to choose which one from the list)
|
||||
|
||||
|
||||
## bug fix, the Builder UI is very slow to update when first starting, adding models, adding loras, a few other things, we need to check it and optimize it to load and respond faster.
|
||||
|
||||
|
||||
## Add on off switches in builder ui to text encoder selection and vae selection (nearly complete but vae on off switches don't work, also I'd prefer for simple omition from node output confg_json instead of adding uncessary on-off switches to the output)
|
||||
|
||||
## In the Dashboard UI, I'd love to see a toggle option for view prompts without lora trigger-words. Currently it shows them all with them added and it causes a bit of a duplicate issue if we're just trying to see which prompts work best on variety of various loras.
|
||||
|
||||
|
||||
## Models from UI aren't getting hashed properly for some reason, maybe diffusion models, maybe builder UI path / \ normalization? console logs: Using fallback hash for model: 559ad07aae
|
||||
|
||||
## Add on off switches in builder ui to text encoder selection and vae selection that works exactly the same as the model on off switches
|
||||
|
||||
## Add more info to the lora and model civitai lookups in the builderUI, option to show full API response, show full JSON response, save full JSON resposne, if lookup data is cached, show a big warning on the very top that says READ FROM DISK CACHE, LAST LOOKED UP ON {lookup date}
|
||||
|
||||
## When you click a sampler or scheduler in the dropdown it should add it to the list right away.
|
||||
|
||||
## In the builder ui civit-ai lookup modal, when it alerts the user the data is cached and read from disk there needs to be a button for re-check/lookup now so they can get updated data when they want.
|
||||
|
||||
## Feature: Adding permanent/saving/editing custom lora trigger words to a lora, read from and edit the lora_triggers.json file, with nice ui to make it easy and convenient to edit, remove and add triggerwords.
|
||||
|
||||
|
||||
|
||||
#### **12. Arrays in LoRA Weights** Integrated but needs adding to the Builder UI
|
||||
|
||||
Builder UI could have a + button right next to model strength text input (on its right side) to make it easy to add a compare different lora strengths. If activated it should add an additonal slider, or two sliders if 🔒 Lock Model & CLIP Strength Together is deactivated, for each time they click +. If a lora has a multiple strength array, the preview json and py node json_output should duplicate the full config for that run for each entry with the only difference being each lora strength. If multiple loras have multiple arrays it needs to output a cartesian of an each for each of all lora strenth combos.
|
||||
|
||||
|
||||
|
||||
|
||||
## Save/Load/Import (Merge) Prompts. (With settable unique naming option) Load should offer a searchable dropdown menu for all past saved prompts. Let's store this data in a outputs/benchmarks/PromptsData folder. Each prompt/save should be its own file.
|
||||
|
||||
|
||||
## ~~Easy Feature: Add Esc key close to Revise modal and add X to close in top right of modal in Dashboard and the Lookup LoRA Metadata from CivitAI, and omit lora triggerwords modals in the Builder UI.~~ COMPLETED Also
|
||||
Replace revise button in dashboard with edit emoji
|
||||
|
||||
|
||||
|
||||
## Bug Fix: Batch Encoding Runs Before Job Skip/Continue/Resume check and will encode everything again even if it's already been completed. Also Continue/Resume needs optional inputs to be tracked. We need to track connected node changes from each of the optional inputs, we could also use this step to save the workflow to the benchmark/session folder and compare the last run workflow to the current to track node changes and determine changes and also integrate currenly missing from optional inputs such as model, loras, prompts, etc.
|
||||
|
||||
## ~~Fix: Manifest doesn't need lora omit triggers list in every item~~ (DONE - already stripped in create_image_metadata via .pop())
|
||||
|
||||
# Needs Testing: Batch encoding doesnt seem to be working for optional inputs possibly, or maybe its very large models, maybe ggufs, I get loading messages after every single encoding instead of once per batch and it takes a long time (low priority)
|
||||
Symptom: each encoding fills the GPU more and more and eventually it becomes 0 usable, 0 loaded all offloaded.
|
||||
loaded partially; 5585.34 MB usable, 5543.55 MB loaded, 628.32 MB offloaded, 41.79 MB buffer reserved, lowvram patches: 0
|
||||
loaded partially; 5569.51 MB usable, 5527.72 MB loaded, 644.90 MB offloaded, 41.79 MB buffer reserved, lowvram patches: 0
|
||||
loaded partially; 5553.58 MB usable, 5511.79 MB loaded, 660.34 MB offloaded, 41.79 MB buffer reserved, lowvram patches: 0
|
||||
loaded partially; 5537.65 MB usable, 5495.86 MB loaded, 676.40 MB offloaded, 41.79 MB buffer reserved, lowvram patches: 0
|
||||
maybe we need to force encodings to offload to ram?
|
||||
|
||||
## ~~Add attention options, xformers, sdpa, sage, flash, etc, option for test all, test all should clear ram & vram between each test.~~ COMPLETED
|
||||
* **Status:** Added `attention_mode` config field supporting: default, xformers, pytorch, flash, sage, sage3, sub_quad, split. Use `"*"` to test all modes. Runtime switching via `transformer_options["optimized_attention_override"]` in ComfyUI's attention registry. Config Builder UI has dropdown+chips selector. Per-image metadata tracks which attention mode was used for dashboard filtering. Integrated into config expansion (Cartesian product with other params), skip/resume matching, and generation pipeline.
|
||||
|
||||
# Deeper explained items to-do list
|
||||
|
||||
#### **1. Skip Logic for Optional Inputs** COMPLETED
|
||||
|
||||
* **Problem:** The `SamplerGridTester` node cannot reliably detect changes in optional inputs (`optional_model`, `optional_vae`, etc.) because standard `IS_CHANGED` logic relies on input hash comparisons, which don't update for passed objects in optional slots.
|
||||
* **Status:** Implemented `IS_CHANGED` classmethod in `sampler_node.py` that returns `float("NaN")` when any optional input is connected (forces re-execution), or a deterministic hash when no optional inputs are used (allows caching). Updated `check_if_job_completed` in `generation_orchestrator.py` to skip model/lora/prompt matching when optional inputs are connected, since those values come from upstream nodes whose changes can't be tracked. Added warning when resume mode is used with optional inputs.
|
||||
* **Target Files:** `sampler_node.py`, `generation_orchestrator.py`
|
||||
|
||||
|
||||
#### **7. CivitAI Download Integration** (low priority)
|
||||
A button in the builder UI to pack short sha256 into config with an explanation that it can be used to share or move an Ultimate Sampler Config Tester workflow and allow for downloading all models and loras in the workflow from civitAI with a few simple easy clicks. lora_utils has calculate civit model has function in it. dropdown configurable options for where to store each file type.
|
||||
|
||||
|
||||
* **Target Files:** `__init__.py`, `lora_utils.py`, `web/config_builder.js`
|
||||
|
||||
|
||||
#### **9. Tag/Token-Based Omit Logic** DONE
|
||||
|
||||
|
||||
#### **10. Validation Warning (Omit vs Lookup) - Warn user if omits are added but lookup is off** (low priority) DONE
|
||||
|
||||
|
||||
#### **11. Model-Specific Prompts** COMPLETED
|
||||
|
||||
* **Problem:** Different models need different trigger words (e.g., "score_9, score_8" for Pony vs "masterpiece" for SD1.5).
|
||||
* **Status:** Implemented as per-config `model_prompt_prefix` and `model_prompt_suffix` fields. These quality tags are prepended/appended to ALL prompts for a given config, wrapping around the entire prompt+triggers assembly. Added `_apply_model_prompt_affixes()` helper in `trigger_words.py`, integrated into both `build_prompt_with_triggers()` and `collect_unique_prompts_with_triggers()` for correct pre-encoding. Config Builder UI shows prefix/suffix text inputs in the Prompts section of each config. Fields persist through save/load and are passed through `expand_configs()` in `config_utils.py`.
|
||||
* **Target Files:** `trigger_words.py`, `config_utils.py`, `conf-builder-config-management.js`, `conf-builder-utilities.js`, `conf-builder-main.js`
|
||||
|
||||
#### **13. Real-Time ETA** COMPLETED
|
||||
|
||||
* **Problem:** ETA only prints to server console.
|
||||
* **Status:** Implemented real-time progress bar in Dashboard. Backend sends `ultimate_grid.progress` events via WebSocket after each job. Frontend displays an ETA bar below the header with job count, percentage, ETA, finish time, and avg duration. Shows completion summary and auto-hides after 30s.
|
||||
* **Target Files:** `generation_orchestrator.py`, `web/dashboard.js`, `resources/logic_events.js`, `resources/template.html`
|
||||
|
||||
#### **14. Cache Trigger Word Placement** (low priority)
|
||||
|
||||
* **Problem:** `trigger_words.py` logic runs every loop iteration.
|
||||
* **Instruction:**
|
||||
1. **Modify `trigger_words.py`:**
|
||||
2. **Apply:** Decorate `get_filtered_lora_triggers`
|
||||
|
||||
* **Target Files:** `trigger_words.py`
|
||||
|
||||
|
||||
|
||||
|
||||
#### **17. Menu Refactor (Cog Wheel)** COMPLETED
|
||||
|
||||
* **Problem:** Header is cluttered.
|
||||
* **Instruction:**
|
||||
1. **Modify `resources/template.html`:** Replace the "Session" button with a `div` class "menu-container" containing an SVG gear icon.
|
||||
2. **Dropdown:** Create a hidden `div` "menu-dropdown". Move "Cols", "Go To", and "Save/Load" inputs inside it.
|
||||
3. **Modify `resources/logic_ui.js`:** Add logic to toggle visibility of "menu-dropdown" when the gear is clicked.
|
||||
|
||||
Move COLS input into session popup modal, change session to cogwheel, change filters to filter icon,
|
||||
|
||||
* **Target Files:** `resources/template.html`, `resources/logic_ui.js`
|
||||
* **Status:** Moved Go To #, Cols, and Reset Zoom into a cog wheel (gear icon) dropdown menu. Added SVG gear icon button, CSS for dropdown menu with items, click-outside-to-close, and Esc key handling.
|
||||
* **Target Files:** `resources/template.html`, `resources/logic_ui.js`, `resources/report.css`
|
||||
|
||||
#### **18. Optionally Pack workflow into images ** (low priority)
|
||||
|
||||
* **Problem:** Pack workflow into images optionally
|
||||
* **Instruction:**
|
||||
1. **Modify `image_generation.py`:** In `save_image_to_disk`.
|
||||
2. **Implementation:** Use `PIL.PngImagePlugin.PngInfo`. Create a `PngInfo` object. Add `info.add_text("prompt", json.dumps(prompt))`. Pass this `pnginfo` to `image.save()`.
|
||||
3. **Data Source:** Ensure the raw prompt/workflow is passed down from `sampler_node.py`.
|
||||
|
||||
|
||||
* **Target Files:** `image_generation.py`, `generation_orchestrator.py`
|
||||
|
||||
|
||||
#### **20. Hotkeys Reference List** COMPLETED
|
||||
|
||||
* **Problem:** Users don't know shortcuts.
|
||||
* **Status:** Already implemented in `resources/template.html` as a KEYBOARD SHORTCUTS section in the Filters & Info popup, with a table showing Space, Shift+Space, Arrow Keys, +/-, 0, F, and Shift+Click shortcuts. `0 Key Resets Zoom & Pan` Etc
|
||||
* **Target Files:** `resources/template.html`
|
||||
|
||||
Put the reference list at the bottom of the session popup modal
|
||||
|
||||
#### **21. Virtual DOM Pan/Zoom (Canvas Builder)** (low priority)
|
||||
|
||||
* **Problem:** The Config Builder UI (graph node) needs infinite canvas capabilities for large node graphs.
|
||||
* **Instruction:**
|
||||
1. **Scope Check:** If this refers to the *Dashboard*, it's done (`logic_virtual.js`). If this refers to the *Config Builder Node UI* (`web/config_builder.js`), it needs HTML5 Canvas implementation.
|
||||
2. **Implementation:** In `web/config_builder.js`, wrap the main container in a parent `div` with `overflow: hidden`. Implement `mousedown` (start pan), `mousemove` (update transform translate), `wheel` (update transform scale) event listeners on the container.
|
||||
|
||||
|
||||
* **Target Files:** `web/config_builder.js`
|
||||
|
||||
#### **22. Import Configs (Merge)** (low priority)
|
||||
|
||||
* **Problem:** Can only load full sessions, not merge snippets.
|
||||
* **Instruction:**
|
||||
1. **Backend:** `config_utils.py` handles parsing.
|
||||
2. **Frontend (`web/config_builder.js`):** Add "Import JSON" button.
|
||||
3. **Logic:** On click, open file picker. Read JSON. Iterate through arrays in JSON. Push them into `this.state.config_arrays`. Call `this.renderUI()`.
|
||||
|
||||
|
||||
* **Target Files:** `web/config_builder.js`
|
||||
|
||||
#### **23. Pseudo-JSON Nodes (Recursion)** (low priority)
|
||||
|
||||
* **Problem:** Advanced. Running a raw JSON workflow as a sub-node.
|
||||
* **Instruction:**
|
||||
1. **Modify `json_text_node.py`:** This needs to interface with `comfy.nodes.GraphExecutor`.
|
||||
2. **Logic:** Treat the input JSON as a "Group Node". Map the inputs of the `SamplerGridTester` to the inputs defined in the JSON. Execute the subgraph. Return the latent.
|
||||
|
||||
More info needed on how this could work, would like to see a visual interface for it in the builder UI eventaully. (big job, very low priority)
|
||||
|
||||
* **Target Files:** `json_text_node.py`, `sampler_node.py`
|
||||
|
||||
#### **24. Combinatorial Randomization** - More Randomization tools - generate x configs from y possibilities and z prompts - (very low priority)
|
||||
|
||||
* **Problem:** Feature. Combinatorial generation logic. Combine random prompts with random loras, fun!
|
||||
* **Instruction:**
|
||||
1. **Modify `config_builder_node.py`:** In `generate_config`.
|
||||
2. **Logic:** Add a "Random Sample" mode. Instead of `itertools.product` (all combos), use `random.sample(all_combos, k=N)`.
|
||||
|
||||
|
||||
* **Target Files:** `config_builder_node.py`
|
||||
|
||||
#### **25. Double Click Filter (Isolate)** - (very low priority)
|
||||
|
||||
* **Problem:** Tedious to uncheck all other filters.
|
||||
* **Instruction:**
|
||||
1. **Modify `resources/logic_ui.js`:** When generating filter tag buttons.
|
||||
2. **Event:** Add `ondblclick`.
|
||||
3. **Logic:** On double click, clear all other filters in that category and select only the clicked one. Call `logic_pipeline.update()`.
|
||||
|
||||
|
||||
* **Target Files:** `resources/logic_ui.js`
|
||||
|
||||
#### **26. Path Validation in Builder** (DONE)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#### COMPLETED
|
||||
|
||||
#### **27. Refresh Model List** COMPLETED
|
||||
|
||||
#### **Show Rejected ASDFeature** COMPLETED
|
||||
* **Problem:** Rejected images disappear; hard to undo.
|
||||
3. **UI:** Added a toggle button for rejected in the Filters menu.
|
||||
|
||||
#### **2. Session Load: Disable Auto-Save & Safe Filename** COMPLETED
|
||||
|
||||
#### **6. Copy Favorites To Favorites Subfolders Based On LoRA Sets ** COMPLETED
|
||||
|
||||
#### **4. Fix Lora Trigger Append Position** COMPLETED
|
||||
|
||||
#### **15/16. Lookahead Caching Switch & Debug** COMPLETED
|
||||
|
||||
|
||||
Add "Don't Append" option to Append Lora Triggerwords To: section. Adds all triggerwords to omit lora triggerwords list. COMPLETED
|
||||
|
||||
|
||||
## Feature: LoRA Lookup From Builder UI. Get metadata, images, url, tags, & more to view quickly from builder in comfyui
|
||||
|
||||
## Fix: Manifest doesn't need lora omit triggers list in every item. Save civitai lookup hashes when calculating lora short 256 and looking up lora trigger words. For use later with civit lookup lora / model info. Save all meta-data from lookup in a folder in output/benchmarks/model-data/{modelName} COMPLETED
|
||||
|
||||
|
||||
Prompts manager section in config builder, browse & combine past prompts, analyze favorited tags, auto generate tag tests, COMPLETED
|
||||
|
||||
|
||||
#### **3. Lora/Model Quick Toggle (Bypass)** COMPLETED
|
||||
|
||||
|
||||
#### **8. Visualize Omitted Triggers** COMPLETED
|
||||
|
||||
|
||||
## ~~Fix: Manifest doesn't need lora omit triggers list in every item~~ (DONE - already stripped in create_image_metadata via .pop())
|
||||
@@ -1,224 +0,0 @@
|
||||
# Builder UI Improvements — Design Document
|
||||
|
||||
**Date:** 2026-02-28
|
||||
**Scope:** 9 items — performance, bug fixes, and feature enhancements
|
||||
|
||||
## Critical Constraint
|
||||
|
||||
DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
## 1. Performance Optimization (Builder UI Slow Everywhere)
|
||||
|
||||
### Problem
|
||||
`renderUI()` does a full DOM rebuild on every state change. There are 30+ call sites across `conf-builder-config-management.js` that trigger complete re-renders after single-item interactions (toggle, add, remove, dropdown change). This makes the UI sluggish on initial load AND on every interaction.
|
||||
|
||||
### Solution: Eliminate unnecessary full re-renders
|
||||
- **On/off toggles**: Already update DOM inline (opacity/filter). Remove any `node.renderUI()` calls following toggle actions — the visual update is already done.
|
||||
- **Add model/lora/VAE**: After creating the new item in state, create its DOM element directly and `appendChild()` it to the container. Skip `renderUI()`.
|
||||
- **Remove model/lora/VAE**: Remove the specific DOM element from the parent container. Skip `renderUI()`.
|
||||
- **Dropdown changes** (model type, model selection, LoRA selection): Update only the affected card's content. Skip `renderUI()`.
|
||||
- **Collapse/expand**: Already handled inline — verify no re-render follows.
|
||||
- **Chip list builders** (samplers, schedulers): Already DON'T call `renderUI()` — no change needed.
|
||||
- **Bulk operations** (load session, add config, delete config, duplicate config): Keep `renderUI()` — these legitimately change entire UI structure.
|
||||
|
||||
### Approach for add/remove operations
|
||||
Each `renderUI()` call after an add operation follows this pattern:
|
||||
```javascript
|
||||
// Current: add to state then rebuild everything
|
||||
node.state.config_arrays[arrayIdx].models.push(newModel);
|
||||
node.saveState();
|
||||
node.renderUI();
|
||||
|
||||
// New: add to state then append just the new element
|
||||
node.state.config_arrays[arrayIdx].models.push(newModel);
|
||||
node.saveState();
|
||||
const newElement = createModelElement(node, newModel, arrayIdx, newIdx, modelLists);
|
||||
modelsContainer.appendChild(newElement);
|
||||
```
|
||||
|
||||
For remove operations:
|
||||
```javascript
|
||||
// Current: remove from state then rebuild everything
|
||||
node.state.config_arrays[arrayIdx].models.splice(idx, 1);
|
||||
node.saveState();
|
||||
node.renderUI();
|
||||
|
||||
// New: remove from state then remove DOM element
|
||||
node.state.config_arrays[arrayIdx].models.splice(idx, 1);
|
||||
node.saveState();
|
||||
element.parentNode.removeChild(element);
|
||||
```
|
||||
|
||||
### Key files
|
||||
- `web/conf_builder/conf-builder-config-management.js` — all 30+ `renderUI()` call sites
|
||||
|
||||
---
|
||||
|
||||
## 2. VAE & Text Encoder On/Off Switches (Bug Fix + Cleanup)
|
||||
|
||||
### Problem
|
||||
VAE on/off switches exist in the UI but user reports they "don't work." Additionally, `vae_bypass_states` and `te_bypass_states` are being output as separate fields in the `config_json` output (lines 586-594 in `conf-builder-utilities.js`), which is unnecessary — they should only control filtering.
|
||||
|
||||
### Root Cause
|
||||
The bypass states are internal UI state that controls which items are included. The filtering in `convertStateToConfigs()` at line 500 already works:
|
||||
```javascript
|
||||
const vaes = (configArray.vaes || []).filter(v => v && v !== "None" && !configArray.vae_bypass_states?.[v]);
|
||||
```
|
||||
But the bypass state dicts are ALSO being added to the output config (lines 586-594), polluting the JSON output. Additionally, the bypass states may not be persisted properly across sessions.
|
||||
|
||||
### Fix
|
||||
1. **Remove** lines 586-594 in `conf-builder-utilities.js` that output `vae_bypass_states` and `te_bypass_states` to config JSON — these are UI-internal, not config parameters
|
||||
2. **Verify** the bypass toggle onclick handler properly persists via `node.saveState()`
|
||||
3. **Verify** bypass states survive load/save session cycles (check migration in `loadSession`)
|
||||
4. Ensure `convertConfigsToConfigArrays()` preserves bypass states when loading
|
||||
|
||||
### Key files
|
||||
- `web/conf_builder/conf-builder-utilities.js` lines 586-594 (remove output), line 500 (VAE filter), line 545 (TE filter)
|
||||
- `web/conf_builder/conf-builder-config-management.js` — VAE element (line 2277+), TE section (line 1664+)
|
||||
|
||||
---
|
||||
|
||||
## 3. Model Hashing Bug Fix
|
||||
|
||||
### Problem
|
||||
Console shows `"Using fallback hash for model: 559ad07aae"` — models from the builder UI aren't getting proper SHA256 hashes.
|
||||
|
||||
### Root Cause
|
||||
In `metadata_packer.py` line 145-175, `find_model_file()` only searches `checkpoints` and `loras` folder paths. It **never checks `diffusion_models` or `unet` folders**. When a diffusion model is used, the file isn't found, and a filename-based hash is generated instead (line 268-272).
|
||||
|
||||
### Fix
|
||||
Add `diffusion_models` and `unet` to the search paths in `find_model_file()`:
|
||||
```python
|
||||
search_paths = [
|
||||
folder_paths.get_folder_paths("checkpoints")[0] if folder_paths.get_folder_paths("checkpoints") else None,
|
||||
folder_paths.get_folder_paths("loras")[0] if folder_paths.get_folder_paths("loras") else None,
|
||||
folder_paths.get_folder_paths("diffusion_models")[0] if folder_paths.get_folder_paths("diffusion_models") else None,
|
||||
folder_paths.get_folder_paths("unet")[0] if folder_paths.get_folder_paths("unet") else None,
|
||||
]
|
||||
```
|
||||
|
||||
Also consider using `folder_paths.get_full_path()` which handles all model types, as a secondary fallback before the filename hash.
|
||||
|
||||
### Key files
|
||||
- `metadata_packer.py` lines 145-175 (`find_model_file()`), lines 260-272 (fallback hash)
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboard Prompt Toggle (Hide Trigger Words)
|
||||
|
||||
### Problem
|
||||
Dashboard always shows prompts WITH lora trigger words appended. When comparing prompts across different loras, the trigger words create visual noise and apparent duplicates.
|
||||
|
||||
### Solution
|
||||
Store the raw config prompt (without triggers) alongside the final prompt in the manifest. Add a toggle in the dashboard to switch between views.
|
||||
|
||||
### Implementation
|
||||
1. **In `image_generation.py` `create_image_metadata()`**: Before overwriting `positive` with `actual_positive_prompt`, preserve the raw config prompt:
|
||||
```python
|
||||
meta["config_positive"] = config.get("positive", "")
|
||||
meta["config_negative"] = config.get("negative", "")
|
||||
meta.update(update_dict) # This overwrites "positive" with actual_positive_prompt
|
||||
```
|
||||
|
||||
2. **In `resources/template.html`**: Add a toggle checkbox near the prompt filter area:
|
||||
```html
|
||||
<label><input type="checkbox" id="toggle-triggers" checked> Show Trigger Words</label>
|
||||
```
|
||||
|
||||
3. **In `resources/logic_ui.js`** line 836-837: Check toggle state when displaying prompts:
|
||||
```javascript
|
||||
const showTriggers = document.getElementById('toggle-triggers')?.checked !== false;
|
||||
if (posEl) posEl.value = showTriggers ? (d.positive || meta.positive || "") : (d.config_positive || d.positive || meta.positive || "");
|
||||
```
|
||||
|
||||
4. **In filter buttons**: When toggle is off, filter by `config_positive` instead of `positive`
|
||||
|
||||
### Key files
|
||||
- `image_generation.py` line 356-367 (`create_image_metadata`)
|
||||
- `resources/template.html` line 287-294 (filter area)
|
||||
- `resources/logic_ui.js` line 831-837 (prompt display)
|
||||
|
||||
---
|
||||
|
||||
## 5. CivitAI Lookup Enhancements
|
||||
|
||||
### Current State
|
||||
- Cache warning banner exists (lines 1186-1195) with orange styling
|
||||
- Full JSON toggle exists (lines 1287-1300) for models and (lines 1517-1529) for loras
|
||||
- Cache date shown from `data.cache_date`
|
||||
|
||||
### Enhancements Needed
|
||||
1. **Bigger cache warning**: Move to very top of modal, increase font size, add a red/orange gradient border
|
||||
2. **Re-check button**: Add "Re-fetch from CivitAI" button inside the cache banner. Calls the same endpoint with `force_refresh=true` parameter
|
||||
3. **Save JSON button**: Add "Save JSON Response" button next to the JSON toggle. Downloads the JSON as a `.json` file using `Blob` + `URL.createObjectURL()`
|
||||
4. **Backend `force_refresh` support**: In the CivitAI lookup endpoint in `config_builder_node.py`, accept `force_refresh` parameter that skips disk cache and re-fetches from API
|
||||
|
||||
### Key files
|
||||
- `web/conf_builder/conf-builder-config-management.js` — model modal (line 1112+), lora modal (line 1375+)
|
||||
- `config_builder_node.py` — CivitAI lookup endpoints
|
||||
|
||||
---
|
||||
|
||||
## 6. Sampler/Scheduler Instant Add (Verification)
|
||||
|
||||
### Current State
|
||||
The `onchange` handler in `createChipListBuilder()` (line 239) already adds items immediately when a dropdown option is selected. The Add button is redundant.
|
||||
|
||||
### Action Needed
|
||||
Verify that no `renderUI()` call follows the chip addition that would cause perceived lag. The `createChipListBuilder` function correctly uses local `renderChips()` + `populateSelect()` without triggering a full re-render. If there's still lag, it's from the performance issue (Item 1) affecting other interactions.
|
||||
|
||||
### Key files
|
||||
- `web/conf_builder/conf-builder-config-management.js` lines 159-283
|
||||
|
||||
---
|
||||
|
||||
## 7. Custom Lora Trigger Word Editor
|
||||
|
||||
### Design
|
||||
A dedicated modal accessible from each lora card via an "Edit Triggers" button.
|
||||
|
||||
### UI
|
||||
- Button: "✏️ Edit Triggers" on each lora card, next to the existing "🔍 Lookup LoRA Metadata from CivitAI" button
|
||||
- Modal contains:
|
||||
- Header: "Edit Trigger Words — {lora_name}"
|
||||
- Current triggers displayed as editable chips (each with × remove button)
|
||||
- Text input field + Enter/Add button to add new trigger words
|
||||
- "Save" button (saves to `loras_tags.json`) and "Cancel" button
|
||||
- "Reset to CivitAI" button that re-fetches from API and replaces custom edits
|
||||
|
||||
### Backend
|
||||
- New endpoint: `POST /configbuilder/save_lora_triggers`
|
||||
- Request body: `{ "lora_name": "...", "triggers": ["word1", "word2"] }`
|
||||
- Reads `loras_tags.json`, updates the entry, writes back
|
||||
- Clears the trigger word LRU cache after save
|
||||
- New endpoint: `GET /configbuilder/get_lora_triggers?lora_name=...`
|
||||
- Returns current triggers from `loras_tags.json`
|
||||
|
||||
### Data flow
|
||||
- `loras_tags.json` format: `{ "lora_name.safetensors": ["trigger1", "trigger2"] }`
|
||||
- Editor reads from and writes to this file
|
||||
- Trigger word cache (`@lru_cache`) cleared after save so changes take effect immediately
|
||||
|
||||
### Key files
|
||||
- `web/conf_builder/conf-builder-config-management.js` — new modal + button on lora card
|
||||
- `config_builder_node.py` — new endpoints
|
||||
- `lora_utils.py` — `loras_tags.json` read/write
|
||||
- `trigger_words.py` — cache clearing
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Recommended order (dependencies + quick wins first):
|
||||
|
||||
1. **Model hashing bug** — smallest change, standalone fix
|
||||
2. **VAE & TE on/off switches** — small fix, verifiable immediately
|
||||
3. **Performance optimization** — foundational, makes everything else feel better
|
||||
4. **Sampler/scheduler instant add** — verification only after perf fix
|
||||
5. **CivitAI lookup enhancements** — moderate scope, self-contained
|
||||
6. **Dashboard prompt toggle** — touches backend + frontend, needs manifest change
|
||||
7. **Custom lora trigger word editor** — largest new feature, self-contained
|
||||
|
||||
Total estimated scope: ~15-20 implementation tasks across 8-10 files.
|
||||
@@ -1,780 +0,0 @@
|
||||
# Builder UI Improvements Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix bugs (model hashing, VAE on/off switches), optimize builder UI performance, enhance CivitAI lookup modals, add dashboard prompt toggle, and build a lora trigger word editor.
|
||||
|
||||
**Architecture:** Incremental changes across the existing JS/Python codebase. Performance fix eliminates unnecessary full DOM rebuilds. Bug fixes are targeted. New features follow existing modal/endpoint patterns.
|
||||
|
||||
**Tech Stack:** Vanilla JavaScript (ComfyUI widget), Python aiohttp routes, JSON file storage.
|
||||
|
||||
**Critical Constraint:** DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix Model Hashing Bug (metadata_packer.py)
|
||||
|
||||
**Files:**
|
||||
- Modify: `metadata_packer.py:145-175` (find_model_file function)
|
||||
|
||||
**Step 1: Add diffusion_models and unet to search paths**
|
||||
|
||||
In `find_model_file()` (line 156-163), the `search_paths` default only includes `checkpoints` and `loras`. Add `diffusion_models` and `unet`:
|
||||
|
||||
```python
|
||||
# Current (lines 156-163):
|
||||
if search_paths is None:
|
||||
import folder_paths
|
||||
search_paths = [
|
||||
folder_paths.get_folder_paths("checkpoints")[0] if folder_paths.get_folder_paths("checkpoints") else None,
|
||||
folder_paths.get_folder_paths("loras")[0] if folder_paths.get_folder_paths("loras") else None,
|
||||
]
|
||||
search_paths = [p for p in search_paths if p]
|
||||
|
||||
# New:
|
||||
if search_paths is None:
|
||||
import folder_paths
|
||||
search_paths = []
|
||||
for folder_type in ["checkpoints", "loras", "diffusion_models", "unet"]:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_type)
|
||||
if paths:
|
||||
search_paths.extend(paths)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
Note: Use `extend(paths)` not just `[0]` — some folder types have multiple search directories.
|
||||
|
||||
**Step 2: Add folder_paths.get_full_path fallback before filename hash**
|
||||
|
||||
In lines 262-272, before the filename-based fallback hash, try `folder_paths.get_full_path()` for multiple model types:
|
||||
|
||||
```python
|
||||
# After line 263 (model_file = find_model_file(model, checkpoint_paths)):
|
||||
# Add this BEFORE the existing fallback block (lines 268-272):
|
||||
if not model_file:
|
||||
# Try folder_paths.get_full_path for various model types
|
||||
import folder_paths as fp
|
||||
for model_type in ["checkpoints", "diffusion_models", "unet"]:
|
||||
try:
|
||||
resolved = fp.get_full_path(model_type, model)
|
||||
if resolved and os.path.isfile(resolved):
|
||||
model_file = resolved
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python -c "import py_compile; py_compile.compile('metadata_packer.py', doraise=True)"`
|
||||
Expected: No errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add metadata_packer.py
|
||||
git commit -m "Fix model hashing: search diffusion_models and unet folders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fix VAE & Text Encoder On/Off Switches
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-utilities.js:586-594`
|
||||
|
||||
**Step 1: Remove bypass state fields from config JSON output**
|
||||
|
||||
The VAE/TE bypass states are internal UI state — they should filter the arrays (which they already do at lines 500 and 545) but NOT appear in the output config JSON. Remove lines 586-594:
|
||||
|
||||
```javascript
|
||||
// DELETE these lines (586-594) — bypass states are internal, not config output:
|
||||
// Add vae_bypass_states if any are set
|
||||
if (configArray.vae_bypass_states && Object.keys(configArray.vae_bypass_states).length > 0) {
|
||||
config.vae_bypass_states = configArray.vae_bypass_states;
|
||||
}
|
||||
|
||||
// Add te_bypass_states if any are set
|
||||
if (configArray.te_bypass_states && Object.keys(configArray.te_bypass_states).length > 0) {
|
||||
config.te_bypass_states = configArray.te_bypass_states;
|
||||
}
|
||||
```
|
||||
|
||||
Wait — the constraint says DO NOT REMOVE ANY CODE. So instead, comment them out and add an explanation:
|
||||
|
||||
```javascript
|
||||
// vae_bypass_states and te_bypass_states are internal UI state only.
|
||||
// They control filtering (lines 500, 545) but should NOT be in config output.
|
||||
// Bypass state persistence is handled by node.saveState() separately.
|
||||
// if (configArray.vae_bypass_states && Object.keys(configArray.vae_bypass_states).length > 0) {
|
||||
// config.vae_bypass_states = configArray.vae_bypass_states;
|
||||
// }
|
||||
// if (configArray.te_bypass_states && Object.keys(configArray.te_bypass_states).length > 0) {
|
||||
// config.te_bypass_states = configArray.te_bypass_states;
|
||||
// }
|
||||
```
|
||||
|
||||
**Step 2: Verify bypass states persist across sessions**
|
||||
|
||||
Check that `conf-builder-main.js` migration block includes `vae_bypass_states` and `te_bypass_states` in the default state. They should already be there (line ~3486 in config-management.js sets defaults). Also verify `loadSession` and `loadConfigFromBackend` preserve them.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-utilities.js
|
||||
git commit -m "Fix VAE/TE on/off switches: remove bypass states from config output"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Performance — Eliminate Unnecessary renderUI() Calls
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js` (many locations)
|
||||
|
||||
This is the largest task. There are 30+ `node.renderUI()` calls after single-item interactions. Most can be replaced with targeted DOM updates.
|
||||
|
||||
**Category A: Toggle/checkbox changes that DON'T need re-render**
|
||||
|
||||
These already update DOM inline. Remove the `node.renderUI()` call:
|
||||
|
||||
- Line 975: Strength lock toggle — BUT this one actually needs re-render to show/hide the CLIP slider. **KEEP this one.**
|
||||
- Line 3307: Custom prompts toggle — needs re-render to show/hide prompt editors. **KEEP this one.**
|
||||
- Line 3510: Label mode toggle — needs re-render to change display. **KEEP this one.**
|
||||
|
||||
**Category B: Model type / selection changes that trigger re-render**
|
||||
|
||||
These change the element structure (different dropdowns, different options). These are harder to optimize without a significant refactor. **KEEP these for now** but they're candidates for future optimization:
|
||||
|
||||
- Line 591: Model type changed (checkpoint/diffusion/gguf) — changes available options
|
||||
- Line 611: Model selection via searchable select
|
||||
- Line 861: LoRA type changed
|
||||
- Line 880: LoRA selection via searchable select
|
||||
|
||||
**Category C: Add/Delete operations that CAN be optimized**
|
||||
|
||||
Replace `renderUI()` with targeted DOM manipulation:
|
||||
|
||||
**Step 1: Add Model button (line 384-389)**
|
||||
|
||||
Replace the onclick handler. Instead of rebuilding everything, create and append the new model element:
|
||||
|
||||
```javascript
|
||||
// Line 384-389 — replace the onclick handler:
|
||||
addModelBtn.onclick = () => {
|
||||
if (!node.state.config_arrays[arrayIdx].models) node.state.config_arrays[arrayIdx].models = [];
|
||||
node.state.config_arrays[arrayIdx].models.push("None");
|
||||
node.saveState();
|
||||
// Instead of node.renderUI(), append new element directly
|
||||
const newIdx = node.state.config_arrays[arrayIdx].models.length - 1;
|
||||
const modelsContainer = document.querySelector(`#config-array-${arrayIdx} .cb-models-container`);
|
||||
if (modelsContainer) {
|
||||
const newElement = createModelElement(node, "None", arrayIdx, newIdx, modelLists);
|
||||
modelsContainer.appendChild(newElement);
|
||||
} else {
|
||||
node.renderUI(); // Fallback if container not found
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**IMPORTANT:** For this to work, we need to add an id or class to the models container in `renderModelsSection()`. Find where models are rendered and add `modelsContainer.className = "cb-models-container";` or similar. We also need `modelLists` to be accessible.
|
||||
|
||||
**Given the complexity of making all add/delete operations work without renderUI, and the constraint "ONLY CHANGE WHAT IS NECESSARY", a better approach is debouncing:**
|
||||
|
||||
**Step 2: Add debounced renderUI wrapper**
|
||||
|
||||
At the top of conf-builder-config-management.js, add a debounced version:
|
||||
|
||||
```javascript
|
||||
// Debounced renderUI to batch rapid state changes
|
||||
let _renderUITimer = null;
|
||||
function debouncedRenderUI(node) {
|
||||
if (_renderUITimer) clearTimeout(_renderUITimer);
|
||||
_renderUITimer = setTimeout(() => {
|
||||
_renderUITimer = null;
|
||||
node.renderUI();
|
||||
}, 150);
|
||||
}
|
||||
```
|
||||
|
||||
Then replace `node.renderUI()` with `debouncedRenderUI(node)` at all 30+ call sites EXCEPT:
|
||||
- Initial load / loadSession / loadConfig (these should render immediately)
|
||||
- Add config / delete config / duplicate config (structural changes, render immediately)
|
||||
|
||||
This batches rapid changes (e.g., strength slider dragging) without the risk of breaking targeted DOM updates.
|
||||
|
||||
**Step 3: Optimize the main renderUI function for speed**
|
||||
|
||||
In the `renderUI` function (conf-builder-config-management.js line ~3440), the scroll position save/restore is already there. Add one optimization: skip re-fetching model lists if they haven't changed.
|
||||
|
||||
In `conf-builder-main.js` line 213, the `renderUI` function does `await utilities.getModelLists()` every time. This fetches from the backend. Cache it:
|
||||
|
||||
```javascript
|
||||
// In conf-builder-main.js, add caching for model lists:
|
||||
let _cachedModelLists = null;
|
||||
let _modelListsCacheTime = 0;
|
||||
|
||||
this.renderUI = async function () {
|
||||
if (!utilities || !configManagement) return;
|
||||
|
||||
// Cache model lists for 5 seconds to avoid redundant fetches during rapid re-renders
|
||||
const now = Date.now();
|
||||
if (!_cachedModelLists || (now - _modelListsCacheTime) > 5000) {
|
||||
const availableLoras = await utilities.getAvailableLoras();
|
||||
const loraFolders = await utilities.getLoraFolders();
|
||||
await utilities.getModelLists();
|
||||
_cachedModelLists = {
|
||||
// ... build modelLists object ...
|
||||
};
|
||||
_modelListsCacheTime = now;
|
||||
}
|
||||
// ... rest of renderUI using _cachedModelLists ...
|
||||
};
|
||||
```
|
||||
|
||||
Actually, the simplest high-impact change: the `renderUI` function in `conf-builder-main.js` makes multiple `await` calls to get model lists. These can be parallelized:
|
||||
|
||||
```javascript
|
||||
// Current: sequential awaits (slow)
|
||||
const availableLoras = await utilities.getAvailableLoras();
|
||||
const loraFolders = await utilities.getLoraFolders();
|
||||
const availableSessions = await utilities.getAvailableSessions();
|
||||
const availableConfigs = await utilities.getAvailableConfigs();
|
||||
await utilities.getModelLists();
|
||||
|
||||
// New: parallel fetches (fast)
|
||||
const [availableLoras, loraFolders, availableSessions, availableConfigs] = await Promise.all([
|
||||
utilities.getAvailableLoras(),
|
||||
utilities.getLoraFolders(),
|
||||
utilities.getAvailableSessions(),
|
||||
utilities.getAvailableConfigs(),
|
||||
utilities.getModelLists()
|
||||
]);
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js web/conf_builder/conf-builder-main.js
|
||||
git commit -m "Optimize builder UI: debounce renderUI, parallelize model list fetches"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CivitAI Lookup Enhancements
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js` (both modals)
|
||||
- Modify: `config_builder_node.py` (lookup endpoints)
|
||||
|
||||
**Step 1: Add force_refresh support to backend endpoints**
|
||||
|
||||
In `config_builder_node.py`, modify both lookup endpoints to accept `force_refresh`:
|
||||
|
||||
For the model endpoint (line 816+), after line 820 (`data = await request.json()`):
|
||||
```python
|
||||
force_refresh = data.get("force_refresh", False)
|
||||
```
|
||||
|
||||
Then wrap the cache check (lines 836-851) with:
|
||||
```python
|
||||
if os.path.exists(metadata_file) and not force_refresh:
|
||||
```
|
||||
|
||||
Same pattern for the LoRA endpoint (line 703+).
|
||||
|
||||
**Step 2: Add Re-fetch button to cache banner (both modals)**
|
||||
|
||||
In the LoRA modal (line 1186-1195), enhance the cache banner:
|
||||
|
||||
```javascript
|
||||
if (data.cached) {
|
||||
const cacheBanner = document.createElement("div");
|
||||
cacheBanner.style.cssText = "background: #553300; border: 2px solid #ffaa00; border-radius: 6px; padding: 10px 14px; margin-bottom: 15px; text-align: center;";
|
||||
cacheBanner.innerHTML = `
|
||||
<div style="font-size: 16px; font-weight: bold; color: #ffaa00;">⚠️ READ FROM DISK CACHE</div>
|
||||
<div style="font-size: 12px; color: #ccaa66; margin-top: 4px;">Last looked up on: <strong>${data.cache_date || 'Unknown'}</strong></div>
|
||||
`;
|
||||
// Re-fetch button
|
||||
const refetchBtn = document.createElement("button");
|
||||
refetchBtn.className = "cb-button";
|
||||
refetchBtn.style.cssText = "margin-top: 8px; background: #664400; border: 1px solid #ffaa00; color: #ffcc44; font-size: 12px; padding: 6px 16px;";
|
||||
refetchBtn.textContent = "🔄 Re-fetch from CivitAI Now";
|
||||
refetchBtn.onclick = async () => {
|
||||
refetchBtn.disabled = true;
|
||||
refetchBtn.textContent = "🔄 Fetching...";
|
||||
closeModal();
|
||||
await showLoraMetadataModal(node, arrayIdx, loraName, true); // pass force_refresh
|
||||
};
|
||||
cacheBanner.appendChild(refetchBtn);
|
||||
content.appendChild(cacheBanner);
|
||||
}
|
||||
```
|
||||
|
||||
Update `showLoraMetadataModal` signature to accept `forceRefresh` parameter:
|
||||
```javascript
|
||||
async function showLoraMetadataModal(node, arrayIdx, loraName, forceRefresh = false) {
|
||||
```
|
||||
|
||||
And update the fetch call (line 1163-1166) to include it:
|
||||
```javascript
|
||||
body: JSON.stringify({ lora_name: loraName, force_refresh: forceRefresh })
|
||||
```
|
||||
|
||||
Apply the SAME pattern to `showModelMetadataModal` (lines 1340+, 1416-1425).
|
||||
|
||||
**Step 3: Add Save JSON button**
|
||||
|
||||
Near the existing JSON toggle (line 1287-1300 for models, 1517-1529 for loras), add a save button:
|
||||
|
||||
```javascript
|
||||
// Save JSON button
|
||||
const saveJsonBtn = document.createElement("button");
|
||||
saveJsonBtn.className = "cb-button";
|
||||
saveJsonBtn.style.cssText = "margin-bottom: 8px; margin-left: 8px; background: #333; border-left: 3px solid #44aa44;";
|
||||
saveJsonBtn.textContent = "💾 Save JSON Response";
|
||||
saveJsonBtn.onclick = () => {
|
||||
const blob = new Blob([JSON.stringify(metadata, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${loraName.replace(/[/\\]/g, "_")}_civitai_metadata.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
jsonSection.appendChild(saveJsonBtn);
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js config_builder_node.py
|
||||
git commit -m "Enhance CivitAI modals: re-fetch button, save JSON, force_refresh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Dashboard Prompt Toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `image_generation.py:356-367` (create_image_metadata)
|
||||
- Modify: `resources/template.html:286-294` (add toggle)
|
||||
- Modify: `resources/logic_ui.js:831-837` (prompt display)
|
||||
- Modify: `resources/logic_pipeline.js:291-307` (filter logic)
|
||||
|
||||
**Step 1: Store raw config prompt in metadata**
|
||||
|
||||
In `image_generation.py` `create_image_metadata()`, preserve the raw config prompts before they get overwritten. Add before line 356 (the `update_dict`):
|
||||
|
||||
```python
|
||||
# Preserve raw config prompts (without trigger words) for dashboard toggle
|
||||
meta["config_positive"] = config.get("positive", "")
|
||||
meta["config_negative"] = config.get("negative", "")
|
||||
```
|
||||
|
||||
These get added to the meta dict BEFORE `meta.update(update_dict)` overwrites `positive` and `negative` with the trigger-appended versions.
|
||||
|
||||
**Step 2: Add toggle to dashboard template**
|
||||
|
||||
In `resources/template.html`, after line 279 (the FILTERS section title), add a toggle:
|
||||
|
||||
```html
|
||||
<div style="margin-bottom: 8px; padding: 4px 0;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 11px; color: #aaa;">
|
||||
<input type="checkbox" id="toggle-triggers" checked
|
||||
style="cursor: pointer;"
|
||||
onchange="if(typeof toggleTriggerWords==='function') toggleTriggerWords(this.checked)">
|
||||
Show Lora Trigger Words in Prompts
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 3: Update prompt display in logic_ui.js**
|
||||
|
||||
In `resources/logic_ui.js`, modify lines 836-837:
|
||||
|
||||
```javascript
|
||||
// Check trigger word toggle
|
||||
const showTriggers = document.getElementById('toggle-triggers')?.checked !== false;
|
||||
if (posEl) posEl.value = showTriggers
|
||||
? (d.positive || meta.positive || "")
|
||||
: (d.config_positive || d.positive || meta.positive || "");
|
||||
if (negEl) negEl.value = showTriggers
|
||||
? (d.negative || meta.negative || "")
|
||||
: (d.config_negative || d.negative || meta.negative || "");
|
||||
```
|
||||
|
||||
**Step 4: Update filter logic in logic_pipeline.js**
|
||||
|
||||
In `resources/logic_pipeline.js`, modify lines 304-305 to respect the toggle:
|
||||
|
||||
```javascript
|
||||
const showTriggers = document.getElementById('toggle-triggers')?.checked !== false;
|
||||
if (filters.positive.size > 0) {
|
||||
const posValue = showTriggers
|
||||
? (d.positive || meta.positive || "")
|
||||
: (d.config_positive || d.positive || meta.positive || "");
|
||||
if (!filters.positive.has(posValue)) return false;
|
||||
}
|
||||
if (filters.negative.size > 0) {
|
||||
const negValue = showTriggers
|
||||
? (d.negative || meta.negative || "")
|
||||
: (d.config_negative || d.negative || meta.negative || "");
|
||||
if (!filters.negative.has(negValue)) return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Update filter button generation in logic_ui.js**
|
||||
|
||||
In `resources/logic_ui.js` `initFilters()` (lines 145-152), update the positive/negative value extraction:
|
||||
|
||||
```javascript
|
||||
const showTriggers = document.getElementById('toggle-triggers')?.checked !== false;
|
||||
// ... inside the forEach ...
|
||||
if (key === 'positive') return showTriggers
|
||||
? (d.positive || meta.positive || "")
|
||||
: (d.config_positive || d.positive || meta.positive || "");
|
||||
if (key === 'negative') return showTriggers
|
||||
? (d.negative || meta.negative || "")
|
||||
: (d.config_negative || d.negative || meta.negative || "");
|
||||
```
|
||||
|
||||
**Step 6: Add toggleTriggerWords function in logic_ui.js**
|
||||
|
||||
```javascript
|
||||
function toggleTriggerWords(showTriggers) {
|
||||
// Rebuild filters with new prompt mode
|
||||
initFilters();
|
||||
updateDataPipeline();
|
||||
// If modal is open, refresh prompt display
|
||||
if (window.currentModalId) {
|
||||
openM(window.currentModalId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add image_generation.py resources/template.html resources/logic_ui.js resources/logic_pipeline.js
|
||||
git commit -m "Add dashboard toggle to show/hide trigger words in prompts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Custom Lora Trigger Word Editor
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js` (add button + modal)
|
||||
- Modify: `config_builder_node.py` (new endpoints)
|
||||
|
||||
**Step 1: Add backend endpoints**
|
||||
|
||||
In `config_builder_node.py`, after the existing route handlers (after line ~940), add:
|
||||
|
||||
```python
|
||||
@server.PromptServer.instance.routes.get("/configbuilder/get_lora_triggers")
|
||||
async def get_lora_triggers_endpoint(request):
|
||||
"""Get trigger words for a specific LoRA from loras_tags.json"""
|
||||
try:
|
||||
lora_name = request.query.get("lora_name", "")
|
||||
if not lora_name:
|
||||
return web.json_response({"error": "Missing lora_name"}, status=400)
|
||||
|
||||
json_tags_path = os.path.join(folder_paths.get_output_directory(), "benchmarks/loras_tags.json")
|
||||
triggers = []
|
||||
if os.path.exists(json_tags_path):
|
||||
from .lora_utils import load_json_from_file
|
||||
lora_tags = load_json_from_file(json_tags_path) or {}
|
||||
# Try exact match, normalized, and backslash variants
|
||||
normalized = lora_name.replace("\\", "/")
|
||||
backslash = lora_name.replace("/", "\\")
|
||||
triggers = lora_tags.get(lora_name, lora_tags.get(normalized, lora_tags.get(backslash, [])))
|
||||
|
||||
return web.json_response({"lora_name": lora_name, "triggers": triggers})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/configbuilder/save_lora_triggers")
|
||||
async def save_lora_triggers_endpoint(request):
|
||||
"""Save edited trigger words for a LoRA to loras_tags.json"""
|
||||
try:
|
||||
data = await request.json()
|
||||
lora_name = data.get("lora_name", "")
|
||||
triggers = data.get("triggers", [])
|
||||
|
||||
if not lora_name:
|
||||
return web.json_response({"error": "Missing lora_name"}, status=400)
|
||||
|
||||
json_tags_path = os.path.join(folder_paths.get_output_directory(), "benchmarks/loras_tags.json")
|
||||
from .lora_utils import load_json_from_file, save_dict_to_json
|
||||
lora_tags = {}
|
||||
if os.path.exists(json_tags_path):
|
||||
lora_tags = load_json_from_file(json_tags_path) or {}
|
||||
|
||||
# Normalize the name for consistent storage
|
||||
normalized = lora_name.replace("\\", "/")
|
||||
lora_tags[normalized] = triggers
|
||||
|
||||
save_dict_to_json(lora_tags, json_tags_path)
|
||||
|
||||
# Clear the trigger word LRU cache so changes take effect immediately
|
||||
from .trigger_words import clear_trigger_caches
|
||||
clear_trigger_caches()
|
||||
|
||||
print(f"[ConfigBuilder] ✏️ Saved {len(triggers)} trigger words for: {normalized}")
|
||||
return web.json_response({"status": "saved", "lora_name": normalized, "triggers": triggers})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
```
|
||||
|
||||
**Step 2: Add "Edit Triggers" button to lora card**
|
||||
|
||||
In `conf-builder-config-management.js`, after line 1056 (the existing LoRA metadata button), add:
|
||||
|
||||
```javascript
|
||||
// 4. Edit Trigger Words Button
|
||||
const editTriggersBtn = document.createElement("button");
|
||||
editTriggersBtn.className = "cb-button";
|
||||
editTriggersBtn.style.cssText = `width: 100%; background: linear-gradient(135deg, #336633, #446644); border-left: 4px solid #66cc66; margin-top: 4px;`;
|
||||
editTriggersBtn.textContent = "✏️ Edit Trigger Words";
|
||||
editTriggersBtn.onclick = async () => await showEditTriggersModal(node, arrayIdx, parsed.name);
|
||||
moreOptionsContent.appendChild(editTriggersBtn);
|
||||
```
|
||||
|
||||
**Step 3: Create the trigger word editor modal**
|
||||
|
||||
Add this function near the other modal functions (after `showLoraMetadataModal` or `showModelMetadataModal`):
|
||||
|
||||
```javascript
|
||||
async function showEditTriggersModal(node, arrayIdx, loraName) {
|
||||
// Build modal overlay (same pattern as metadata modals)
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = "position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.85); z-index: 10000; display: flex; align-items: center; justify-content: center;";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.style.cssText = "background: #1a1a1a; border: 2px solid #66cc66; border-radius: 12px; padding: 25px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; position: relative;";
|
||||
|
||||
const closeModal = () => { document.body.removeChild(overlay); };
|
||||
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
|
||||
document.addEventListener("keydown", function escHandler(e) {
|
||||
if (e.key === "Escape") { closeModal(); document.removeEventListener("keydown", escHandler); }
|
||||
});
|
||||
|
||||
// Close X button
|
||||
const closeX = document.createElement("button");
|
||||
closeX.textContent = "✕";
|
||||
closeX.style.cssText = "position: absolute; top: 10px; right: 15px; background: none; border: none; color: #ff4444; font-size: 20px; cursor: pointer;";
|
||||
closeX.onclick = closeModal;
|
||||
modal.appendChild(closeX);
|
||||
|
||||
// Title
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "✏️ Edit Trigger Words";
|
||||
title.style.cssText = "margin: 0 0 5px 0; color: #66cc66;";
|
||||
modal.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement("div");
|
||||
subtitle.style.cssText = "font-size: 12px; color: #888; margin-bottom: 15px;";
|
||||
subtitle.textContent = loraName.split('/').pop();
|
||||
modal.appendChild(subtitle);
|
||||
|
||||
const status = document.createElement("div");
|
||||
status.textContent = "🔄 Loading trigger words...";
|
||||
status.style.cssText = "margin-bottom: 15px; color: #aaa; font-size: 12px;";
|
||||
modal.appendChild(status);
|
||||
|
||||
const chipsContainer = document.createElement("div");
|
||||
chipsContainer.style.cssText = "display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 15px; min-height: 30px; padding: 8px; background: #111; border: 1px solid #333; border-radius: 6px;";
|
||||
modal.appendChild(chipsContainer);
|
||||
|
||||
// Input row
|
||||
const inputRow = document.createElement("div");
|
||||
inputRow.style.cssText = "display: flex; gap: 6px; margin-bottom: 15px;";
|
||||
const input = document.createElement("input");
|
||||
input.className = "cb-input";
|
||||
input.type = "text";
|
||||
input.placeholder = "Add new trigger word...";
|
||||
input.style.cssText = "flex: 1; padding: 6px 10px; font-size: 12px;";
|
||||
const addWordBtn = document.createElement("button");
|
||||
addWordBtn.className = "cb-button primary";
|
||||
addWordBtn.textContent = "+ Add";
|
||||
addWordBtn.style.cssText = "padding: 6px 12px; font-size: 12px;";
|
||||
inputRow.appendChild(input);
|
||||
inputRow.appendChild(addWordBtn);
|
||||
modal.appendChild(inputRow);
|
||||
|
||||
// Button row
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.style.cssText = "display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px;";
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "cb-button";
|
||||
saveBtn.style.cssText = "background: #336633; border: 1px solid #66cc66; color: #88ff88; padding: 8px 20px;";
|
||||
saveBtn.textContent = "💾 Save";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "cb-button";
|
||||
cancelBtn.style.cssText = "background: #333; color: #aaa; padding: 8px 20px;";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.onclick = closeModal;
|
||||
|
||||
btnRow.appendChild(cancelBtn);
|
||||
btnRow.appendChild(saveBtn);
|
||||
modal.appendChild(btnRow);
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// State
|
||||
let currentTriggers = [];
|
||||
|
||||
function renderTriggerChips() {
|
||||
chipsContainer.innerHTML = "";
|
||||
if (currentTriggers.length === 0) {
|
||||
const empty = document.createElement("span");
|
||||
empty.style.cssText = "color: #666; font-size: 11px; font-style: italic;";
|
||||
empty.textContent = "No trigger words";
|
||||
chipsContainer.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
currentTriggers.forEach((trigger, idx) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.style.cssText = "background: #224422; border: 1px solid #448844; color: #aaffaa; padding: 3px 8px; border-radius: 12px; font-size: 11px; display: flex; align-items: center; gap: 4px;";
|
||||
chip.textContent = trigger;
|
||||
const removeBtn = document.createElement("span");
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.style.cssText = "cursor: pointer; color: #ff6666; font-weight: bold; margin-left: 2px;";
|
||||
removeBtn.onclick = () => {
|
||||
currentTriggers.splice(idx, 1);
|
||||
renderTriggerChips();
|
||||
};
|
||||
chip.appendChild(removeBtn);
|
||||
chipsContainer.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function addTriggerWord() {
|
||||
const word = input.value.trim();
|
||||
if (word && !currentTriggers.includes(word)) {
|
||||
currentTriggers.push(word);
|
||||
renderTriggerChips();
|
||||
input.value = "";
|
||||
}
|
||||
input.focus();
|
||||
}
|
||||
|
||||
addWordBtn.onclick = addTriggerWord;
|
||||
input.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); addTriggerWord(); } };
|
||||
|
||||
saveBtn.onclick = async () => {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "💾 Saving...";
|
||||
try {
|
||||
const resp = await fetch("/configbuilder/save_lora_triggers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lora_name: loraName, triggers: currentTriggers })
|
||||
});
|
||||
if (resp.ok) {
|
||||
status.textContent = "✅ Trigger words saved!";
|
||||
status.style.color = "#66ff66";
|
||||
setTimeout(closeModal, 800);
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
status.textContent = "❌ Error: " + (err.error || "Save failed");
|
||||
status.style.color = "#ff6666";
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "💾 Save";
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "❌ Error: " + e.message;
|
||||
status.style.color = "#ff6666";
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "💾 Save";
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch current triggers
|
||||
try {
|
||||
const resp = await fetch(`/configbuilder/get_lora_triggers?lora_name=${encodeURIComponent(loraName)}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentTriggers = data.triggers || [];
|
||||
status.textContent = `Loaded ${currentTriggers.length} trigger word(s)`;
|
||||
status.style.color = "#88ff88";
|
||||
} else {
|
||||
status.textContent = "⚠️ No triggers found (you can add new ones)";
|
||||
status.style.color = "#ffaa00";
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "⚠️ Could not load triggers: " + e.message;
|
||||
status.style.color = "#ffaa00";
|
||||
}
|
||||
renderTriggerChips();
|
||||
input.focus();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Verify syntax**
|
||||
|
||||
Run: `python -c "import py_compile; py_compile.compile('config_builder_node.py', doraise=True)"`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js config_builder_node.py
|
||||
git commit -m "Add custom lora trigger word editor with modal UI and backend endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Sampler/Scheduler Instant Add (Verification)
|
||||
|
||||
**Files:**
|
||||
- Verify: `web/conf_builder/conf-builder-config-management.js:239-246`
|
||||
|
||||
**Step 1: Verify behavior**
|
||||
|
||||
The `createChipListBuilder` function already adds items on dropdown `onchange` (line 239). It does NOT call `renderUI()`. Verify this still works after the debouncing changes from Task 3. No code changes expected — this is a verification task.
|
||||
|
||||
**Step 2: Commit (only if changes needed)**
|
||||
|
||||
No commit expected unless issues found.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Sync to Main Working Directory
|
||||
|
||||
**Step 1: Verify all changes compile**
|
||||
|
||||
```bash
|
||||
python -c "import py_compile; py_compile.compile('metadata_packer.py', doraise=True)"
|
||||
python -c "import py_compile; py_compile.compile('config_builder_node.py', doraise=True)"
|
||||
python -c "import py_compile; py_compile.compile('image_generation.py', doraise=True)"
|
||||
```
|
||||
|
||||
**Step 2: Fast-forward merge to main**
|
||||
|
||||
```bash
|
||||
cd Z:\comfy_v0.12.3\ComfyUI\custom_nodes\ComfyUI-Ultimate-Auto-Sampler-Config-Grid-Testing-Suite
|
||||
git merge claude/upbeat-jackson --ff-only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Scope | Files | Estimated Steps |
|
||||
|------|-------|-------|----------------|
|
||||
| 1. Model hashing bug | Small | metadata_packer.py | 4 |
|
||||
| 2. VAE/TE on/off fix | Small | conf-builder-utilities.js | 3 |
|
||||
| 3. Performance | Medium | conf-builder-config-management.js, conf-builder-main.js | 4 |
|
||||
| 4. CivitAI enhancements | Medium | conf-builder-config-management.js, config_builder_node.py | 4 |
|
||||
| 5. Dashboard prompt toggle | Medium | image_generation.py, template.html, logic_ui.js, logic_pipeline.js | 7 |
|
||||
| 6. Trigger word editor | Large | conf-builder-config-management.js, config_builder_node.py | 5 |
|
||||
| 7. Sampler/scheduler verify | Tiny | (verification only) | 1 |
|
||||
| 8. Sync to main | Tiny | git operations | 2 |
|
||||
@@ -1,113 +0,0 @@
|
||||
# Builder UI Fixes & Enhancements Design
|
||||
|
||||
**Date:** 2026-03-01
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Five targeted improvements to the Builder UI: fix VAE/TE bypass toggle JSON
|
||||
output, add force_refresh tag comparison prompts, add Remote VAE presets
|
||||
dropdown, add per-config sidebar navigation, and reorganize sections with
|
||||
improved header contrast.
|
||||
|
||||
## Item 1: VAE/TE On/Off Switch Fix
|
||||
|
||||
**Problem:** Bypassed VAEs and TEs still appear in JSON Preview despite being
|
||||
toggled off. The bypass toggle onclick handler updates visual state and calls
|
||||
`node.saveState()` but does NOT call `updatePreview(node)`, so the JSON preview
|
||||
shows stale data.
|
||||
|
||||
**Root cause:** In `conf-builder-config-management.js`, the bypass toggle in
|
||||
`createVAEElement()` (line ~2713-2721) and similar handlers for TE and model
|
||||
bypass toggles do not trigger `updatePreview(node)`.
|
||||
|
||||
**Fix:**
|
||||
- Add `updatePreview(node)` call after `node.saveState()` in every bypass
|
||||
toggle onclick handler (VAE, TE, model)
|
||||
- Add `normalizePath()` safety to bypass state filter key lookups in
|
||||
`convertStateToConfigs()` to guard against path separator mismatches
|
||||
|
||||
**Files:** `conf-builder-config-management.js`, `conf-builder-utilities.js`
|
||||
|
||||
## Item 2: force_refresh Lora Tags Update Prompt
|
||||
|
||||
**Problem:** When force_refresh re-fetches CivitAI metadata, the system
|
||||
overwrites without checking if tags changed. User wants to see what changed
|
||||
and choose whether to update.
|
||||
|
||||
**Backend changes:**
|
||||
- In `config_builder_node.py` `lookup_lora_metadata_endpoint`: when
|
||||
`force_refresh=True`, compare fresh `trainedWords` with stored tags from
|
||||
`loras_tags.json`
|
||||
- Return additional fields: `tags_changed: bool`, `old_tags: list`,
|
||||
`new_tags: list`
|
||||
|
||||
**Frontend changes:**
|
||||
- In the CivitAI metadata modal re-fetch handler: check response for
|
||||
`tags_changed`
|
||||
- Show inline diff banner with old/new tags and Update/Keep buttons
|
||||
- Update button writes new tags to `loras_tags.json` via existing save endpoint
|
||||
|
||||
**Files:** `config_builder_node.py`, `lora_utils.py`,
|
||||
`conf-builder-config-management.js`
|
||||
|
||||
## Item 3: HF Remote VAE Presets Dropdown
|
||||
|
||||
**Problem:** When "Remote URL" VAE type is selected, user must manually paste
|
||||
HuggingFace URLs. The URLs are already defined in Python (`remote_vae.py`
|
||||
`HF_ENDPOINTS`) but not exposed to the frontend.
|
||||
|
||||
**Fix:**
|
||||
- Define `REMOTE_VAE_PRESETS` constant in JS matching Python's `HF_ENDPOINTS`:
|
||||
`{ "HF-SD": "url", "HF-SDXL": "url", "HF-Flux": "url", "HF-HunyuanVideo": "url" }`
|
||||
- In `createVAEElement()`, when type selector changes to "Remote URL", show a
|
||||
presets dropdown below the type selector
|
||||
- Selecting a preset auto-fills the URL input
|
||||
- "Custom" option leaves URL input empty for manual entry
|
||||
|
||||
**Files:** `conf-builder-config-management.js`
|
||||
|
||||
## Item 4: Per-Config Sidebar Navigation
|
||||
|
||||
**Problem:** Sidebar only has one "Config Arrays" icon. With multiple config
|
||||
arrays, there's no way to quickly jump to a specific config's sub-section.
|
||||
|
||||
**Fix:**
|
||||
- Below the main ⚙️ Config Arrays icon, show numbered cog icons (one per
|
||||
config array), color-coded using `CONFIG_COLORS`
|
||||
- Hovering a config cog icon expands the sidebar vertically to show sub-section
|
||||
icons: Models, Text Encoders, VAE, LoRAs
|
||||
- Clicking a sub-icon scrolls to that specific sub-section
|
||||
- Add `id` attributes to sub-section containers:
|
||||
`cb-config-{idx}-models`, `cb-config-{idx}-te`, `cb-config-{idx}-vae`,
|
||||
`cb-config-{idx}-loras`
|
||||
- Scroll spy highlights the active config cog
|
||||
|
||||
**Files:** `conf-builder-ui-components.js`, `conf-builder-config-management.js`
|
||||
|
||||
## Item 5: Section Organization & Header Fixes
|
||||
|
||||
**Problem:** Section order and header contrast need improvement for dark mode.
|
||||
|
||||
**Changes:**
|
||||
- **Section order:** Distribution Settings (top) → Global Prompts → Config
|
||||
Arrays → JSON Preview
|
||||
- **Extra Model & Sampling Options:** Move inside Models section as a
|
||||
sub-section, rendered just below the models list
|
||||
- **Text Encoders header:** Brighter to match Models section header style
|
||||
- **VAE header:** Lighter background (`#444` instead of `#3a3a3a`), brighter
|
||||
text color (`#cc66ff`)
|
||||
- **LoRAs header:** Brighter text (`#4499ff`)
|
||||
|
||||
**Files:** `conf-builder-config-management.js`, `conf-builder-ui-components.js`
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `conf-builder-config-management.js` | Bypass toggle fix (Item 1), tag diff UI (Item 2), Remote VAE presets (Item 3), sub-section IDs (Item 4), section reorder + header colors (Item 5) |
|
||||
| `conf-builder-utilities.js` | Path normalization in bypass filter (Item 1) |
|
||||
| `conf-builder-ui-components.js` | Per-config sidebar nav (Item 4), header style updates (Item 5) |
|
||||
| `config_builder_node.py` | Tag comparison in force_refresh (Item 2) |
|
||||
| `lora_utils.py` | Tag comparison helper (Item 2) |
|
||||
| `conf-builder-distribution.js` | No changes (section already has standalone renderer) |
|
||||
@@ -1,850 +0,0 @@
|
||||
# Builder UI Fixes & Enhancements Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix bypass toggle JSON output, add force_refresh tag diff prompts, add Remote VAE presets dropdown, add per-config sidebar navigation, and reorganize sections with improved headers.
|
||||
|
||||
**Architecture:** Five independent UI/backend improvements to the Config Builder node. Items 1, 3, and 5 are frontend-only. Item 2 spans backend (Python) and frontend (JS). Item 4 modifies sidebar and section rendering. All changes are additive — existing code must not be removed.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ComfyUI widget), Python (aiohttp server routes), JSON file I/O
|
||||
|
||||
**Critical Constraint:** DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix Bypass Toggle JSON Preview Update
|
||||
|
||||
All four bypass toggles (model, TE, VAE, LoRA) update visual state and call `node.saveState()` but never call `updatePreview(node)`, so the JSON Preview shows stale data when items are toggled off.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:696-704` (model bypass)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:975-982` (LoRA bypass)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2131-2139` (TE bypass)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2713-2721` (VAE bypass)
|
||||
|
||||
**Step 1: Add `updatePreview(node)` to model bypass toggle**
|
||||
|
||||
In `conf-builder-config-management.js`, find the model bypass onclick handler at line ~696:
|
||||
|
||||
```javascript
|
||||
bypassCheck.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const newBypassState = !bypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].model_bypass_states[modelPath] = newBypassState;
|
||||
node.saveState();
|
||||
// Visual feedback
|
||||
div.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
div.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
Add `updatePreview(node);` after `node.saveState();`:
|
||||
|
||||
```javascript
|
||||
bypassCheck.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const newBypassState = !bypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].model_bypass_states[modelPath] = newBypassState;
|
||||
node.saveState();
|
||||
updatePreview(node);
|
||||
// Visual feedback
|
||||
div.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
div.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Add `updatePreview(node)` to LoRA bypass toggle**
|
||||
|
||||
Find the LoRA bypass onclick at line ~975:
|
||||
|
||||
```javascript
|
||||
bypassCheck.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const newBypassState = !bypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].lora_bypass_states[parsed.name] = newBypassState;
|
||||
node.saveState();
|
||||
// Visual feedback
|
||||
div.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
div.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
Add `updatePreview(node);` after `node.saveState();`:
|
||||
|
||||
```javascript
|
||||
bypassCheck.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const newBypassState = !bypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].lora_bypass_states[parsed.name] = newBypassState;
|
||||
node.saveState();
|
||||
updatePreview(node);
|
||||
// Visual feedback
|
||||
div.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
div.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
**Step 3: Add `updatePreview(node)` to TE bypass toggle**
|
||||
|
||||
Find the TE bypass onclick at line ~2131:
|
||||
|
||||
```javascript
|
||||
teBypassCheck.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const newBypassState = !teBypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].te_bypass_states[tePath] = newBypassState;
|
||||
node.saveState();
|
||||
// Visual feedback
|
||||
teRow.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
teRow.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
Add `updatePreview(node);` after `node.saveState();`:
|
||||
|
||||
```javascript
|
||||
teBypassCheck.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const newBypassState = !teBypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].te_bypass_states[tePath] = newBypassState;
|
||||
node.saveState();
|
||||
updatePreview(node);
|
||||
// Visual feedback
|
||||
teRow.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
teRow.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
**Step 4: Add `updatePreview(node)` to VAE bypass toggle**
|
||||
|
||||
Find the VAE bypass onclick at line ~2713:
|
||||
|
||||
```javascript
|
||||
bypassCheck.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const newBypassState = !bypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].vae_bypass_states[vaeName] = newBypassState;
|
||||
node.saveState();
|
||||
// Visual feedback
|
||||
div.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
div.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
Add `updatePreview(node);` after `node.saveState();`:
|
||||
|
||||
```javascript
|
||||
bypassCheck.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent header collapse
|
||||
const newBypassState = !bypassCheck.checked;
|
||||
node.state.config_arrays[arrayIdx].vae_bypass_states[vaeName] = newBypassState;
|
||||
node.saveState();
|
||||
updatePreview(node);
|
||||
// Visual feedback
|
||||
div.style.opacity = newBypassState ? "0.5" : "1.0";
|
||||
div.style.filter = newBypassState ? "grayscale(0.7)" : "none";
|
||||
};
|
||||
```
|
||||
|
||||
**Step 5: Verify and commit**
|
||||
|
||||
Verify: Open the Builder UI, add a VAE, toggle it off via the "On" checkbox, and check the JSON Preview — the bypassed VAE should NOT appear. Repeat for models, TEs, and LoRAs.
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js
|
||||
git commit -m "Fix bypass toggles not updating JSON Preview
|
||||
|
||||
Add updatePreview(node) call after node.saveState() in all four
|
||||
bypass toggle handlers (model, LoRA, TE, VAE) so the JSON Preview
|
||||
immediately reflects bypass state changes."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: force_refresh Tag Comparison & Update Prompt
|
||||
|
||||
When force_refresh re-fetches CivitAI metadata, compare fresh `trainedWords` with stored tags and prompt the user if they differ.
|
||||
|
||||
**Files:**
|
||||
- Modify: `config_builder_node.py:764-805` (metadata endpoint response)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:1384-1413` (metadata modal response handler)
|
||||
|
||||
**Step 1: Add tag comparison to backend metadata endpoint**
|
||||
|
||||
In `config_builder_node.py`, inside `lookup_lora_metadata_endpoint`, after the metadata dict is built (line ~779) and before saving (line ~798), add tag comparison logic. Find this code block:
|
||||
|
||||
```python
|
||||
# Save metadata to file
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
model_data_dir = os.path.join(output_dir, "benchmarks", "model-data", lora_name.replace("/", "_").replace("\\", "_").replace(".safetensors", ""))
|
||||
os.makedirs(model_data_dir, exist_ok=True)
|
||||
|
||||
metadata_file = os.path.join(model_data_dir, "metadata.json")
|
||||
save_dict_to_json(metadata, metadata_file)
|
||||
|
||||
print(f"[ConfigBuilder] ✅ Metadata saved to: {metadata_file}")
|
||||
|
||||
return web.json_response({
|
||||
"metadata": metadata,
|
||||
"saved_to": metadata_file
|
||||
})
|
||||
```
|
||||
|
||||
Replace the return statement only (keep everything above intact):
|
||||
|
||||
```python
|
||||
# Save metadata to file
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
model_data_dir = os.path.join(output_dir, "benchmarks", "model-data", lora_name.replace("/", "_").replace("\\", "_").replace(".safetensors", ""))
|
||||
os.makedirs(model_data_dir, exist_ok=True)
|
||||
|
||||
metadata_file = os.path.join(model_data_dir, "metadata.json")
|
||||
save_dict_to_json(metadata, metadata_file)
|
||||
|
||||
print(f"[ConfigBuilder] ✅ Metadata saved to: {metadata_file}")
|
||||
|
||||
# Compare fresh tags with stored loras_tags.json (only on force_refresh)
|
||||
tags_changed = False
|
||||
old_tags = []
|
||||
new_tags = metadata.get("trained_words", [])
|
||||
if force_refresh and new_tags:
|
||||
json_tags_path = os.path.join(output_dir, "benchmarks/loras_tags.json")
|
||||
if os.path.exists(json_tags_path):
|
||||
lora_tags = load_json_from_file(json_tags_path) or {}
|
||||
normalized = lora_name.replace("\\", "/")
|
||||
backslash = lora_name.replace("/", "\\")
|
||||
old_tags = lora_tags.get(lora_name, lora_tags.get(normalized, lora_tags.get(backslash, [])))
|
||||
if old_tags is None:
|
||||
old_tags = []
|
||||
# Compare sorted lists to detect any difference
|
||||
if sorted(old_tags) != sorted(new_tags):
|
||||
tags_changed = True
|
||||
print(f"[ConfigBuilder] ⚠️ Tags changed for {lora_name}: {old_tags} -> {new_tags}")
|
||||
|
||||
return web.json_response({
|
||||
"metadata": metadata,
|
||||
"saved_to": metadata_file,
|
||||
"tags_changed": tags_changed,
|
||||
"old_tags": old_tags,
|
||||
"new_tags": new_tags
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Add tag diff banner to frontend metadata modal**
|
||||
|
||||
In `conf-builder-config-management.js`, find the metadata modal response handling at line ~1384:
|
||||
|
||||
```javascript
|
||||
const data = await resp.json();
|
||||
const metadata = data.metadata;
|
||||
|
||||
status.textContent = "✅ Metadata loaded successfully!";
|
||||
status.style.color = "#66ff66";
|
||||
|
||||
// Display metadata
|
||||
content.innerHTML = "";
|
||||
```
|
||||
|
||||
Add a tag diff banner right after `content.innerHTML = "";` and before the cache warning banner block (line ~1393). Insert this code at line ~1392 (after `content.innerHTML = "";`):
|
||||
|
||||
```javascript
|
||||
// Tag diff banner (shown when force_refresh detects different tags)
|
||||
if (data.tags_changed) {
|
||||
const tagDiffBanner = document.createElement("div");
|
||||
tagDiffBanner.style.cssText = "background: #1a3300; border: 2px solid #44cc00; border-radius: 6px; padding: 10px 14px; margin-bottom: 15px;";
|
||||
|
||||
const tagDiffTitle = document.createElement("div");
|
||||
tagDiffTitle.style.cssText = "font-size: 14px; font-weight: bold; color: #44cc00; margin-bottom: 8px;";
|
||||
tagDiffTitle.textContent = "🔄 Trigger Words Changed";
|
||||
tagDiffBanner.appendChild(tagDiffTitle);
|
||||
|
||||
const oldTagsDiv = document.createElement("div");
|
||||
oldTagsDiv.style.cssText = "font-size: 11px; color: #cc6666; margin-bottom: 4px;";
|
||||
oldTagsDiv.textContent = "Old: " + (data.old_tags.length > 0 ? data.old_tags.join(", ") : "(none)");
|
||||
tagDiffBanner.appendChild(oldTagsDiv);
|
||||
|
||||
const newTagsDiv = document.createElement("div");
|
||||
newTagsDiv.style.cssText = "font-size: 11px; color: #66cc66; margin-bottom: 8px;";
|
||||
newTagsDiv.textContent = "New: " + data.new_tags.join(", ");
|
||||
tagDiffBanner.appendChild(newTagsDiv);
|
||||
|
||||
const tagBtnRow = document.createElement("div");
|
||||
tagBtnRow.style.cssText = "display: flex; gap: 8px;";
|
||||
|
||||
const updateTagsBtn = document.createElement("button");
|
||||
updateTagsBtn.className = "cb-button primary";
|
||||
updateTagsBtn.style.cssText = "flex: 1; background: #336600; border: 1px solid #44cc00; color: #88ff44;";
|
||||
updateTagsBtn.textContent = "✅ Update Tags";
|
||||
updateTagsBtn.onclick = async () => {
|
||||
try {
|
||||
const saveResp = await fetch("/configbuilder/save_lora_triggers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lora_name: loraName, triggers: data.new_tags })
|
||||
});
|
||||
if (saveResp.ok) {
|
||||
updateTagsBtn.textContent = "✅ Updated!";
|
||||
updateTagsBtn.disabled = true;
|
||||
keepTagsBtn.disabled = true;
|
||||
}
|
||||
} catch (e) {
|
||||
updateTagsBtn.textContent = "❌ Error";
|
||||
}
|
||||
};
|
||||
|
||||
const keepTagsBtn = document.createElement("button");
|
||||
keepTagsBtn.className = "cb-button";
|
||||
keepTagsBtn.style.cssText = "flex: 1;";
|
||||
keepTagsBtn.textContent = "Keep Current";
|
||||
keepTagsBtn.onclick = () => {
|
||||
tagDiffBanner.style.display = "none";
|
||||
};
|
||||
|
||||
tagBtnRow.appendChild(updateTagsBtn);
|
||||
tagBtnRow.appendChild(keepTagsBtn);
|
||||
tagDiffBanner.appendChild(tagBtnRow);
|
||||
content.appendChild(tagDiffBanner);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add config_builder_node.py web/conf_builder/conf-builder-config-management.js
|
||||
git commit -m "Add tag diff prompt on force_refresh metadata re-fetch
|
||||
|
||||
Backend compares fresh CivitAI trainedWords with stored loras_tags.json
|
||||
and returns tags_changed/old_tags/new_tags. Frontend shows diff banner
|
||||
with Update/Keep buttons in the metadata modal."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: HF Remote VAE Presets Dropdown
|
||||
|
||||
When "Remote URL" is selected as VAE type, show a presets dropdown for known HuggingFace endpoints.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2820-2841` (remote VAE URL section in `createVAEElement()`)
|
||||
|
||||
**Step 1: Add REMOTE_VAE_PRESETS constant**
|
||||
|
||||
At the top of `conf-builder-config-management.js`, near the other constants (e.g., `CONFIG_COLORS`), add:
|
||||
|
||||
```javascript
|
||||
// HF Remote VAE endpoint presets (mirrors remote_vae.py HF_ENDPOINTS)
|
||||
const REMOTE_VAE_PRESETS = {
|
||||
"Custom": "",
|
||||
"HF-SD": "https://q1bj3bpq6kzilnsu.us-east-1.aws.endpoints.huggingface.cloud/",
|
||||
"HF-SDXL": "https://x2dmsqunjd6k9prw.us-east-1.aws.endpoints.huggingface.cloud/",
|
||||
"HF-Flux": "https://whhx50ex1aryqvw6.us-east-1.aws.endpoints.huggingface.cloud/",
|
||||
"HF-HunyuanVideo": "https://o7ywnmrahorts457.us-east-1.aws.endpoints.huggingface.cloud/"
|
||||
};
|
||||
```
|
||||
|
||||
Find the exact location: search for `const CONFIG_COLORS` and add the `REMOTE_VAE_PRESETS` constant right after the CONFIG_COLORS array declaration.
|
||||
|
||||
**Step 2: Add presets dropdown in the Remote URL branch**
|
||||
|
||||
In `createVAEElement()`, find the `if (isRemote)` block at line ~2820:
|
||||
|
||||
```javascript
|
||||
if (isRemote) {
|
||||
// Text input for remote VAE endpoint URL
|
||||
const urlInput = document.createElement("input");
|
||||
```
|
||||
|
||||
Replace the entire `if (isRemote) { ... }` block (lines 2820-2841, up to but NOT including the `} else {`) with:
|
||||
|
||||
```javascript
|
||||
if (isRemote) {
|
||||
// Presets dropdown for known HF Remote VAE endpoints
|
||||
const presetSelect = document.createElement("select");
|
||||
presetSelect.className = "cb-select";
|
||||
presetSelect.style.marginBottom = "4px";
|
||||
const currentUrl = vaeName.replace(/^remote:/, "");
|
||||
Object.entries(REMOTE_VAE_PRESETS).forEach(([label, url]) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = url;
|
||||
opt.textContent = label;
|
||||
// Select matching preset, or "Custom" if URL doesn't match any preset
|
||||
if (url && currentUrl === url) opt.selected = true;
|
||||
else if (!url && !Object.values(REMOTE_VAE_PRESETS).includes(currentUrl)) opt.selected = true;
|
||||
presetSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Text input for remote VAE endpoint URL
|
||||
const urlInput = document.createElement("input");
|
||||
urlInput.className = "cb-input";
|
||||
urlInput.type = "text";
|
||||
urlInput.placeholder = "http://192.168.1.100:8080/decode";
|
||||
urlInput.value = currentUrl;
|
||||
urlInput.style.fontFamily = "monospace";
|
||||
urlInput.style.fontSize = "12px";
|
||||
urlInput.onchange = () => {
|
||||
const url = urlInput.value.trim();
|
||||
node.state.config_arrays[arrayIdx].vaes[vaeIdx] = url ? `remote:${url}` : "remote:";
|
||||
node.saveState();
|
||||
// Update preset dropdown to match
|
||||
const matchingPreset = Object.entries(REMOTE_VAE_PRESETS).find(([, u]) => u && u === url);
|
||||
presetSelect.value = matchingPreset ? matchingPreset[1] : "";
|
||||
};
|
||||
urlInput.onblur = urlInput.onchange;
|
||||
|
||||
presetSelect.onchange = () => {
|
||||
const selectedUrl = presetSelect.value;
|
||||
if (selectedUrl) {
|
||||
urlInput.value = selectedUrl;
|
||||
node.state.config_arrays[arrayIdx].vaes[vaeIdx] = `remote:${selectedUrl}`;
|
||||
node.saveState();
|
||||
} else {
|
||||
// "Custom" selected - clear URL for manual entry
|
||||
urlInput.value = "";
|
||||
urlInput.focus();
|
||||
}
|
||||
};
|
||||
|
||||
contentDiv.appendChild(presetSelect);
|
||||
contentDiv.appendChild(urlInput);
|
||||
|
||||
// Helper text
|
||||
const helperText = document.createElement("div");
|
||||
helperText.style.cssText = "font-size: 9px; color: #666; padding: 2px 4px;";
|
||||
helperText.textContent = "Select a preset or enter a custom endpoint URL for your remote VAE decode server";
|
||||
contentDiv.appendChild(helperText);
|
||||
} else {
|
||||
```
|
||||
|
||||
Note: The `} else {` line that follows must remain unchanged — only the content inside `if (isRemote) { ... }` is replaced.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js
|
||||
git commit -m "Add Remote VAE presets dropdown for HF endpoints
|
||||
|
||||
When Remote URL type is selected in VAE element, show a presets
|
||||
dropdown with known HuggingFace VAE endpoints (SD, SDXL, Flux,
|
||||
HunyuanVideo). Selecting a preset auto-fills the URL input."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Per-Config Sidebar Navigation
|
||||
|
||||
Add per-config-array cog icons in the sidebar that expand on hover to show sub-section navigation (Models, TE, VAE, LoRAs).
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js:723-731` (sidebar nav icons section)
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js:801` (scroll spy section IDs)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:1964` (models section — add ID)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2064-2066` (TE section — add ID)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2263-2272` (VAE section — add ID)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2888-2896` (LoRAs section — add ID)
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js:184-424` (getStyles — add sidebar expansion CSS)
|
||||
|
||||
**Step 1: Add sub-section IDs to config sections**
|
||||
|
||||
Each sub-section renderer needs a unique ID for sidebar scroll-to targeting.
|
||||
|
||||
In `renderModelsSection()` at line ~1969, after `const modelGrid = document.createElement("div");` (line ~1969-1970), add:
|
||||
|
||||
```javascript
|
||||
modelGrid.id = `cb-config-${arrayIdx}-models`;
|
||||
```
|
||||
|
||||
In `renderTextEncodersSection()` at line ~2065, after `const section = document.createElement("div");`, add:
|
||||
|
||||
```javascript
|
||||
section.id = `cb-config-${arrayIdx}-te`;
|
||||
```
|
||||
|
||||
In `renderVAEsSection()` at line ~2271, after `const vaeGrid = document.createElement("div");` (line ~2271-2272), add:
|
||||
|
||||
```javascript
|
||||
vaeGrid.id = `cb-config-${arrayIdx}-vae`;
|
||||
```
|
||||
|
||||
In `renderLorasSection()` at line ~2893, after `const loraGrid = document.createElement("div");` (line ~2893-2894), add:
|
||||
|
||||
```javascript
|
||||
loraGrid.id = `cb-config-${arrayIdx}-loras`;
|
||||
```
|
||||
|
||||
**Step 2: Add CSS for sidebar config expansion**
|
||||
|
||||
In `getStyles()` in `conf-builder-ui-components.js`, find the closing `</style>` tag at line ~423. Just BEFORE the closing `</style>`, add:
|
||||
|
||||
```css
|
||||
/* Per-config sidebar navigation */
|
||||
.cb-sidebar-config-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.cb-sidebar-config-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.cb-sidebar-config-icon:hover {
|
||||
background: #333;
|
||||
}
|
||||
.cb-sidebar-config-icon.active {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
.cb-sidebar-sub-icons {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.cb-sidebar-config-group:hover .cb-sidebar-sub-icons {
|
||||
display: flex;
|
||||
}
|
||||
.cb-sidebar-sub-icon {
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.cb-sidebar-sub-icon:hover {
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add per-config icons to sidebar**
|
||||
|
||||
In `createSidebar()` in `conf-builder-ui-components.js`, find the line that adds the ⚙️ Config Arrays nav icon at line ~724:
|
||||
|
||||
```javascript
|
||||
addNavIcon("⚙️", "Config Arrays", "cb-sec-configs");
|
||||
```
|
||||
|
||||
After this line, add the per-config cog icons. Import `CONFIG_COLORS` at the top of the file if needed (check if it's already imported — it is defined in `conf-builder-config-management.js`). Since `CONFIG_COLORS` is in a different module, we'll define a local copy or pass it via the node state. The simplest approach: read from `node.state.config_arrays` directly since the sidebar already receives `node`.
|
||||
|
||||
Add after `addNavIcon("⚙️", "Config Arrays", "cb-sec-configs");`:
|
||||
|
||||
```javascript
|
||||
// Per-config array navigation icons
|
||||
const configColors = ["#0088ff", "#ff6600", "#00cc66", "#cc44cc", "#ffaa00", "#44cccc", "#ff4466", "#88aa00"];
|
||||
if (node.state.config_arrays && node.state.config_arrays.length > 0) {
|
||||
node.state.config_arrays.forEach((ca, idx) => {
|
||||
const color = configColors[idx % configColors.length];
|
||||
const group = document.createElement("div");
|
||||
group.className = "cb-sidebar-config-group";
|
||||
|
||||
const cogBtn = document.createElement("button");
|
||||
cogBtn.className = "cb-sidebar-config-icon";
|
||||
cogBtn.style.color = color;
|
||||
cogBtn.style.borderLeft = `2px solid ${color}`;
|
||||
cogBtn.textContent = `${idx + 1}`;
|
||||
cogBtn.title = ca.name || `Config ${idx + 1}`;
|
||||
cogBtn.dataset.targetId = `cb-config-${idx}`;
|
||||
cogBtn.onclick = () => {
|
||||
const target = mainContent.querySelector(`#cb-config-${idx}`);
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
icons.push(cogBtn);
|
||||
group.appendChild(cogBtn);
|
||||
|
||||
// Sub-icons container (shown on hover)
|
||||
const subIcons = document.createElement("div");
|
||||
subIcons.className = "cb-sidebar-sub-icons";
|
||||
|
||||
const subSections = [
|
||||
{ emoji: "🧊", label: "Models", id: `cb-config-${idx}-models` },
|
||||
{ emoji: "📎", label: "Text Enc", id: `cb-config-${idx}-te` },
|
||||
{ emoji: "🎨", label: "VAE", id: `cb-config-${idx}-vae` },
|
||||
{ emoji: "🔗", label: "LoRAs", id: `cb-config-${idx}-loras` }
|
||||
];
|
||||
|
||||
subSections.forEach(sub => {
|
||||
const subBtn = document.createElement("button");
|
||||
subBtn.className = "cb-sidebar-sub-icon";
|
||||
subBtn.textContent = sub.emoji;
|
||||
subBtn.title = sub.label;
|
||||
subBtn.onclick = () => {
|
||||
const target = mainContent.querySelector(`#${sub.id}`);
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
subIcons.appendChild(subBtn);
|
||||
});
|
||||
|
||||
group.appendChild(subIcons);
|
||||
sidebar.appendChild(group);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Update scroll spy to include config IDs**
|
||||
|
||||
In `createSidebar()`, find the scroll spy `sectionIds` array at line ~801:
|
||||
|
||||
```javascript
|
||||
const sectionIds = ["cb-sec-prompts", "cb-sec-configs", "cb-sec-distribution", "cb-sec-preview"];
|
||||
```
|
||||
|
||||
Add per-config IDs dynamically. Replace this line with:
|
||||
|
||||
```javascript
|
||||
const sectionIds = ["cb-sec-prompts", "cb-sec-configs"];
|
||||
// Add per-config section IDs for scroll spy
|
||||
if (node.state.config_arrays) {
|
||||
node.state.config_arrays.forEach((_, idx) => {
|
||||
sectionIds.push(`cb-config-${idx}`);
|
||||
});
|
||||
}
|
||||
sectionIds.push("cb-sec-distribution", "cb-sec-preview");
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-ui-components.js web/conf_builder/conf-builder-config-management.js
|
||||
git commit -m "Add per-config sidebar navigation with hover sub-sections
|
||||
|
||||
Each config array gets a numbered cog icon in the sidebar, color-coded
|
||||
with CONFIG_COLORS. Hovering expands sub-icons for Models, TE, VAE,
|
||||
and LoRAs sub-section navigation. Adds section IDs for scroll targets."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Section Organization & Header Fixes
|
||||
|
||||
Reorder sections, move Extra Model Sampling inside Models, and improve header contrast.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:3885-3991` (renderUI section order)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:3977-3981` (per-config sub-section order)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2064-2070` (TE header style)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2274-2276` (VAE header style)
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:2898-2900` (LoRAs header style)
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js:723-731` (sidebar icon order)
|
||||
|
||||
**Step 1: Reorder main content sections in renderUI()**
|
||||
|
||||
In `renderUI()`, find the section rendering order at lines ~3884-3991. Change the order to: Distribution → Global Prompts → Config Arrays → Preview.
|
||||
|
||||
Find this block:
|
||||
|
||||
```javascript
|
||||
// === MAIN CONTENT SECTIONS ===
|
||||
|
||||
// Global Prompts Section
|
||||
renderGlobalPromptsSection(node, mainContent);
|
||||
|
||||
// Config Arrays Section
|
||||
const configSection = document.createElement("div");
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```javascript
|
||||
// === MAIN CONTENT SECTIONS ===
|
||||
|
||||
// Distribution Settings Section (when enabled, appears first)
|
||||
if (node.state.distribution_enabled) {
|
||||
renderDistributionSettingsSection(node, mainContent);
|
||||
}
|
||||
|
||||
// Global Prompts Section
|
||||
renderGlobalPromptsSection(node, mainContent);
|
||||
|
||||
// Config Arrays Section
|
||||
const configSection = document.createElement("div");
|
||||
```
|
||||
|
||||
And REMOVE the old distribution block at lines ~3988-3991 (after `mainContent.appendChild(configSection);`):
|
||||
|
||||
```javascript
|
||||
// Distribution Settings Section (only when enabled)
|
||||
if (node.state.distribution_enabled) {
|
||||
renderDistributionSettingsSection(node, mainContent);
|
||||
}
|
||||
```
|
||||
|
||||
Delete those 4 lines (the comment and the if block). The distribution section is now rendered at the top instead.
|
||||
|
||||
**Step 2: Move Extra Model Sampling inside Models section**
|
||||
|
||||
In `renderUI()`, find the per-config sub-section rendering at lines ~3977-3981:
|
||||
|
||||
```javascript
|
||||
renderConfigPromptsSection(node, arrayElement, configArray, arrayIdx);
|
||||
renderModelsSection(node, arrayElement, configArray, arrayIdx, modelLists);
|
||||
renderExtraModelSamplingSection(node, arrayElement, configArray, arrayIdx);
|
||||
renderVAEsSection(node, arrayElement, configArray, arrayIdx, modelLists);
|
||||
renderLorasSection(node, arrayElement, configArray, arrayIdx, availableLoras, loraFolders);
|
||||
```
|
||||
|
||||
The `renderExtraModelSamplingSection` call needs to move inside `renderModelsSection()` instead. Remove line 3979:
|
||||
|
||||
```javascript
|
||||
renderExtraModelSamplingSection(node, arrayElement, configArray, arrayIdx);
|
||||
```
|
||||
|
||||
So it becomes:
|
||||
|
||||
```javascript
|
||||
renderConfigPromptsSection(node, arrayElement, configArray, arrayIdx);
|
||||
renderModelsSection(node, arrayElement, configArray, arrayIdx, modelLists);
|
||||
renderVAEsSection(node, arrayElement, configArray, arrayIdx, modelLists);
|
||||
renderLorasSection(node, arrayElement, configArray, arrayIdx, availableLoras, loraFolders);
|
||||
```
|
||||
|
||||
Now add the call inside `renderModelsSection()`. Find the end of `renderModelsSection()` — look for where the function appends its final elements and returns. Find the line that appends `modelGrid` to `div` (around line ~2060):
|
||||
|
||||
```javascript
|
||||
div.appendChild(modelGrid);
|
||||
}
|
||||
```
|
||||
|
||||
Just before the closing `}` of `renderModelsSection`, add:
|
||||
|
||||
```javascript
|
||||
// Extra Model & Sampling Options (sub-section of Models)
|
||||
renderExtraModelSamplingSection(node, div, configArray, arrayIdx);
|
||||
```
|
||||
|
||||
So it becomes:
|
||||
|
||||
```javascript
|
||||
div.appendChild(modelGrid);
|
||||
|
||||
// Extra Model & Sampling Options (sub-section of Models)
|
||||
renderExtraModelSamplingSection(node, div, configArray, arrayIdx);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Fix Text Encoders header to be bigger and match other sections**
|
||||
|
||||
In `renderTextEncodersSection()` at line ~2068-2070, find:
|
||||
|
||||
```javascript
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = "font-size: 11px; font-weight: bold; color: #44aaff; margin-bottom: 6px;";
|
||||
header.textContent = "TEXT ENCODERS (CLIP)";
|
||||
```
|
||||
|
||||
Change the style to match the other section toggle headers (bigger font, same background pattern):
|
||||
|
||||
```javascript
|
||||
const header = document.createElement("div");
|
||||
header.className = "cb-section-toggle";
|
||||
header.style.cssText = "padding: 8px; background: #3a3a3a; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #66bbff;";
|
||||
header.textContent = "Text Encoders (CLIP)";
|
||||
```
|
||||
|
||||
**Step 4: Fix VAE header — lighter background, brighter text**
|
||||
|
||||
In `renderVAEsSection()` at line ~2274-2276, find:
|
||||
|
||||
```javascript
|
||||
const vaeHeader = document.createElement("div");
|
||||
vaeHeader.className = "cb-section-toggle";
|
||||
vaeHeader.style.cssText = "padding: 8px; background: #3a3a3a; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #9900cc;";
|
||||
```
|
||||
|
||||
Change to lighter background and brighter text:
|
||||
|
||||
```javascript
|
||||
const vaeHeader = document.createElement("div");
|
||||
vaeHeader.className = "cb-section-toggle";
|
||||
vaeHeader.style.cssText = "padding: 8px; background: #444; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #cc66ff;";
|
||||
```
|
||||
|
||||
**Step 5: Fix LoRAs header — brighter text**
|
||||
|
||||
In `renderLorasSection()` at line ~2898-2900, find:
|
||||
|
||||
```javascript
|
||||
const loraHeader = document.createElement("div");
|
||||
loraHeader.className = "cb-section-toggle";
|
||||
loraHeader.style.cssText = "padding: 8px; background: #3a3a3a; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #0066cc;";
|
||||
```
|
||||
|
||||
Change to brighter text:
|
||||
|
||||
```javascript
|
||||
const loraHeader = document.createElement("div");
|
||||
loraHeader.className = "cb-section-toggle";
|
||||
loraHeader.style.cssText = "padding: 8px; background: #3a3a3a; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #4499ff;";
|
||||
```
|
||||
|
||||
**Step 6: Update sidebar icon order**
|
||||
|
||||
In `createSidebar()` in `conf-builder-ui-components.js`, find the nav icon order at lines ~723-731:
|
||||
|
||||
```javascript
|
||||
addNavIcon("📝", "Global Prompts", "cb-sec-prompts");
|
||||
addNavIcon("⚙️", "Config Arrays", "cb-sec-configs");
|
||||
|
||||
// Distribution icon (only when enabled)
|
||||
if (node.state.distribution_enabled) {
|
||||
addNavIcon("🌐", "Distribution", "cb-sec-distribution");
|
||||
}
|
||||
|
||||
addNavIcon("📄", "JSON Preview", "cb-sec-preview");
|
||||
```
|
||||
|
||||
Change to put Distribution first:
|
||||
|
||||
```javascript
|
||||
// Distribution icon (only when enabled, appears first)
|
||||
if (node.state.distribution_enabled) {
|
||||
addNavIcon("🌐", "Distribution", "cb-sec-distribution");
|
||||
}
|
||||
|
||||
addNavIcon("📝", "Global Prompts", "cb-sec-prompts");
|
||||
addNavIcon("⚙️", "Config Arrays", "cb-sec-configs");
|
||||
|
||||
addNavIcon("📄", "JSON Preview", "cb-sec-preview");
|
||||
```
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js web/conf_builder/conf-builder-ui-components.js
|
||||
git commit -m "Reorganize sections and improve header contrast
|
||||
|
||||
Move Distribution Settings to top of layout. Move Extra Model &
|
||||
Sampling Options inside Models section as sub-section. Make TE header
|
||||
bigger with matching toggle style. Lighten VAE header background and
|
||||
brighten text. Brighten LoRAs header text color."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all tasks are complete, verify each item:
|
||||
|
||||
1. **Bypass toggles:** Toggle off a VAE/TE/model/LoRA and confirm it vanishes from JSON Preview immediately (no page reload needed)
|
||||
2. **Tag diff:** Open a LoRA metadata modal, click re-fetch, verify the diff banner appears if tags changed, and the Update button writes to `loras_tags.json`
|
||||
3. **Remote VAE presets:** Set a VAE to Remote URL type, verify the presets dropdown appears with HF-SD/SDXL/Flux/HunyuanVideo options, selecting one fills the URL input
|
||||
4. **Sidebar nav:** With 2+ config arrays, verify numbered cog icons appear in sidebar, hovering shows sub-icons, clicking scrolls to the right section
|
||||
5. **Section order:** Distribution is at top, Extra Model Sampling is inside Models, headers have improved contrast
|
||||
@@ -1,134 +0,0 @@
|
||||
# Builder UI Navigation, Layout & Styles Design
|
||||
|
||||
**Date:** 2026-03-01
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Overhaul the Builder UI with a sticky top bar (session name + settings dropdown),
|
||||
left sidebar with navigation icons and quick actions, uniform section headers with
|
||||
accent color stripes, and improved dark mode colors for better readability.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **Sticky Top Bar:** Session name text input (far left), Settings button with
|
||||
dropdown (far right). Settings contains: Load Session, Load Config, Save Config
|
||||
(inline-expandable), Auto-Save toggle, Enable Distribution toggle, Refresh
|
||||
Models action, Label Mode toggle, Include None toggle, Include Default toggle.
|
||||
|
||||
2. **Left Sidebar:** ~40px wide icon column, sticky alongside top bar. Navigation
|
||||
icons scroll to sections (Global Prompts, Config Arrays, Distribution Settings,
|
||||
JSON Preview). Quick action icons (Add Config, Refresh Models) below divider.
|
||||
Active section highlighted via scroll spy.
|
||||
|
||||
3. **Remove Top Row:** The 3-section top row (Session Management, Config Management,
|
||||
Distribution) is eliminated. Controls move to top bar, settings dropdown, and
|
||||
distribution section.
|
||||
|
||||
4. **Distribution Section:** When enabled via Settings dropdown, appears as its own
|
||||
full-width section below Config Arrays (above Preview). Sidebar gets nav icon.
|
||||
|
||||
5. **Uniform Section Headers:** White/light text (16px bold) on subtle dark gradient
|
||||
bar with left accent color stripe. Consistent across all sections.
|
||||
|
||||
6. **Color/Contrast Fixes:** Brighter text colors, better button contrast, improved
|
||||
header visibility for dark mode.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
+------------------------------------------------------+
|
||||
| TOP BAR (sticky, z-index: 100) |
|
||||
| [Session Name input________] [Settings gear] |
|
||||
+----+-------------------------------------------------+
|
||||
| S | MAIN CONTENT (scrollable) |
|
||||
| I | |
|
||||
| D | [Global Prompts section] |
|
||||
| E | [Config Arrays section] |
|
||||
| B | [Distribution Settings] (when enabled) |
|
||||
| A | [JSON Preview section] |
|
||||
| R | |
|
||||
+----+-------------------------------------------------+
|
||||
```
|
||||
|
||||
## Top Bar
|
||||
|
||||
- Height: ~40px, background: `#1a1a1a`, border-bottom: `1px solid #3a3a3a`
|
||||
- Session name: text input, `flex: 1`, transparent bg, border on focus
|
||||
- Settings button: `gear` icon, opens dropdown below on click
|
||||
|
||||
### Settings Dropdown
|
||||
|
||||
Dropdown opens below settings button, closes on click-outside. Items:
|
||||
|
||||
| Item | Type | Behavior |
|
||||
|------|------|----------|
|
||||
| Load Session | Expandable | Click expands inline searchable select |
|
||||
| Load Config | Expandable | Click expands inline searchable select |
|
||||
| Save Config | Expandable | Click expands config name input + save button |
|
||||
| --- | Divider | |
|
||||
| Auto-Save (2s) | Toggle | Checkbox, immediate effect |
|
||||
| --- | Divider | |
|
||||
| Enable Distribution | Toggle | Checkbox, shows/hides distribution section |
|
||||
| --- | Divider | |
|
||||
| Refresh Models | Action | Click triggers refresh, shows feedback |
|
||||
| Label Mode | Toggle | Checkbox |
|
||||
| Include None | Toggle | Checkbox |
|
||||
| Include Default | Toggle | Checkbox |
|
||||
|
||||
## Sidebar
|
||||
|
||||
- Width: ~40px, background: `#1e1e1e`, border-right: `1px solid #333`
|
||||
- Position: sticky left column, full height below top bar
|
||||
- Icons: emoji in ~36px square buttons, tooltip on hover
|
||||
|
||||
### Icons (top to bottom)
|
||||
|
||||
| Icon | Label | Type | Action |
|
||||
|------|-------|------|--------|
|
||||
| 📝 | Global Prompts | Nav | Scroll to section |
|
||||
| ⚙️ | Config Arrays | Nav | Scroll to section |
|
||||
| 🌐 | Distribution | Nav | Scroll to section (only when enabled) |
|
||||
| 📄 | JSON Preview | Nav | Scroll to section |
|
||||
| --- | Divider | | |
|
||||
| + | Add Config | Action | Adds new config array |
|
||||
| 🔄 | Refresh | Action | Refresh models/LoRAs |
|
||||
|
||||
Active section highlighted via scroll spy (brighter background).
|
||||
|
||||
## Section Headers
|
||||
|
||||
Uniform style replacing varied `.cb-section-title`:
|
||||
|
||||
- Font: 16px bold, color: `#e0e0e0` (bright white-gray)
|
||||
- Background: subtle gradient from accent color at 15% opacity to transparent
|
||||
- Left border: 3px solid accent color
|
||||
- Padding: 10px 12px
|
||||
- Each section has a unique accent color
|
||||
|
||||
| Section | Accent Color | Emoji |
|
||||
|---------|-------------|-------|
|
||||
| Global Prompts | `#00cc55` (green) | 📝 |
|
||||
| Config Arrays | `#0088ff` (blue) | ⚙️ |
|
||||
| Distribution | `#ff8800` (orange) | 🌐 |
|
||||
| JSON Preview | `#00cccc` (teal) | 📄 |
|
||||
|
||||
## Color/Contrast Improvements
|
||||
|
||||
| Element | Old | New |
|
||||
|---------|-----|-----|
|
||||
| `.cb-label` | `#aaa` | `#ccc` |
|
||||
| `.cb-section-title` | `#0066cc` (14px) | `#e0e0e0` (16px) with accent stripe |
|
||||
| `.cb-button` | `#4a4a4a` bg | `#4a4a4a` bg, `#e0e0e0` text |
|
||||
| `.cb-button.primary` | `#0066cc` | `#0077dd` (slightly brighter) |
|
||||
| `.cb-controls-bar` | `#252525` | `#2a2a2a` |
|
||||
| `.cb-input, .cb-select` | `#1a1a1a` bg, `#4a4a4a` border | `#1a1a1a` bg, `#555` border |
|
||||
| Info text | `#888` | `#999` |
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `conf-builder-ui-components.js` | New CSS classes, `createTopBar()`, `createSidebar()`, `createSettingsDropdown()`, `createSectionHeader()`, color updates |
|
||||
| `conf-builder-config-management.js` | `renderUI()` restructure, section ID attributes, remove top row rendering |
|
||||
| `conf-builder-distribution.js` | New `renderDistributionSettingsSection()` for standalone full-width section |
|
||||
@@ -1,784 +0,0 @@
|
||||
# Builder UI Navigation, Layout & Styles Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Overhaul the Builder UI with sticky top bar, left sidebar navigation, uniform section headers, and improved dark mode colors.
|
||||
|
||||
**Architecture:** Replace the 3-section top row with a compact top bar (session name + settings dropdown). Add a left sidebar with navigation icons and quick actions. Restructure the main container as a flex layout: top bar + (sidebar | scrollable content). All section headers get uniform styling with accent color stripes.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ComfyUI widget), embedded CSS via getStyles()
|
||||
|
||||
**Critical constraint:** DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add New CSS Classes to getStyles()
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js:184-394` (getStyles function)
|
||||
|
||||
**Step 1: Add top bar CSS**
|
||||
|
||||
After the `.cb-container` rule (line 187), add:
|
||||
|
||||
```css
|
||||
.cb-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
min-height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cb-top-bar-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
min-width: 100px;
|
||||
}
|
||||
.cb-top-bar-input:hover { border-color: #444; }
|
||||
.cb-top-bar-input:focus { outline: none; border-color: #0077dd; background: #1a1a1a; }
|
||||
.cb-settings-btn {
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
color: #ccc;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cb-settings-btn:hover { background: #444; color: #fff; }
|
||||
```
|
||||
|
||||
**Step 2: Add settings dropdown CSS**
|
||||
|
||||
```css
|
||||
.cb-settings-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 280px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
.cb-settings-dropdown.open { display: block; }
|
||||
.cb-settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.cb-settings-item:hover { background: #2a2a2a; color: #fff; }
|
||||
.cb-settings-divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.cb-settings-expand {
|
||||
padding: 8px 14px;
|
||||
background: #252525;
|
||||
border-top: 1px solid #333;
|
||||
display: none;
|
||||
}
|
||||
.cb-settings-expand.open { display: block; }
|
||||
```
|
||||
|
||||
**Step 3: Add sidebar CSS**
|
||||
|
||||
```css
|
||||
.cb-layout-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cb-sidebar {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.cb-sidebar-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
position: relative;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.cb-sidebar-icon:hover { background: #333; color: #fff; }
|
||||
.cb-sidebar-icon.active { background: #0077dd33; color: #4aa8ff; }
|
||||
.cb-sidebar-icon[title]:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #222;
|
||||
color: #eee;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
z-index: 300;
|
||||
border: 1px solid #444;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cb-sidebar-divider {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: #444;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.cb-main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add uniform section header CSS**
|
||||
|
||||
```css
|
||||
.cb-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin: -12px -12px 12px -12px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
user-select: none;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Update existing colors for better contrast**
|
||||
|
||||
Update these existing rules:
|
||||
- `.cb-label`: change `color: #aaa` to `color: #ccc`
|
||||
- `.cb-button`: ensure text is `color: #e0e0e0`
|
||||
- `.cb-button.primary`: change `background: #0066cc` to `background: #0077dd`
|
||||
- `.cb-button.primary:hover`: change `background: #0077ee` to `background: #0088ee`
|
||||
- `.cb-input, .cb-select`: change `border: 1px solid #4a4a4a` to `border: 1px solid #555`
|
||||
- `.cb-controls-bar`: change `background: #252525` to `background: #2a2a2a`
|
||||
|
||||
**Step 6: Update `.cb-container` for new layout**
|
||||
|
||||
Change `.cb-container` from:
|
||||
```css
|
||||
.cb-container { padding: 12px; height: 100%; overflow-y: auto; box-sizing: border-box; }
|
||||
```
|
||||
to:
|
||||
```css
|
||||
.cb-container { display: flex; flex-direction: column; height: 100%; overflow: hidden; box-sizing: border-box; }
|
||||
```
|
||||
|
||||
The scrolling now happens in `.cb-main-content` instead.
|
||||
|
||||
**Step 7: Commit**
|
||||
```
|
||||
feat: add CSS classes for top bar, sidebar, settings dropdown, section headers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Top Bar and Settings Dropdown Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js` (add new exports after getStyles)
|
||||
|
||||
**Step 1: Add `createTopBar()` function**
|
||||
|
||||
Add after `getStyles()` function (after line ~395):
|
||||
|
||||
```javascript
|
||||
// --- TOP BAR COMPONENT ---
|
||||
|
||||
export function createTopBar(node, {
|
||||
availableSessions, availableConfigs,
|
||||
refreshAllConfigBuilders,
|
||||
onLoadSession, onLoadConfig, onSaveConfig
|
||||
}) {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "cb-top-bar";
|
||||
|
||||
// Session name input
|
||||
const sessionInput = document.createElement("input");
|
||||
sessionInput.className = "cb-top-bar-input";
|
||||
sessionInput.type = "text";
|
||||
sessionInput.value = node.state.session_name || "my_test_session";
|
||||
sessionInput.placeholder = "Session name...";
|
||||
sessionInput.title = "Session Name";
|
||||
sessionInput.onchange = () => {
|
||||
node.state.session_name = sessionInput.value;
|
||||
node.saveState();
|
||||
};
|
||||
bar.appendChild(sessionInput);
|
||||
|
||||
// Settings button + dropdown container
|
||||
const settingsWrapper = document.createElement("div");
|
||||
settingsWrapper.style.position = "relative";
|
||||
|
||||
const settingsBtn = document.createElement("button");
|
||||
settingsBtn.className = "cb-settings-btn";
|
||||
settingsBtn.innerHTML = "⚙ Settings ▾";
|
||||
|
||||
const dropdown = createSettingsDropdown(node, {
|
||||
availableSessions, availableConfigs,
|
||||
refreshAllConfigBuilders,
|
||||
onLoadSession, onLoadConfig, onSaveConfig
|
||||
});
|
||||
|
||||
settingsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.classList.toggle("open");
|
||||
};
|
||||
|
||||
// Close on click outside
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!settingsWrapper.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
settingsWrapper.appendChild(settingsBtn);
|
||||
settingsWrapper.appendChild(dropdown);
|
||||
bar.appendChild(settingsWrapper);
|
||||
|
||||
return bar;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add `createSettingsDropdown()` function**
|
||||
|
||||
Add after `createTopBar()`:
|
||||
|
||||
```javascript
|
||||
function createSettingsDropdown(node, {
|
||||
availableSessions, availableConfigs,
|
||||
refreshAllConfigBuilders,
|
||||
onLoadSession, onLoadConfig, onSaveConfig
|
||||
}) {
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "cb-settings-dropdown";
|
||||
|
||||
// Helper: create expandable item
|
||||
function addExpandableItem(emoji, label, expandContent) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "cb-settings-item";
|
||||
item.innerHTML = `<span>${emoji}</span><span>${label}</span><span style="margin-left:auto;font-size:10px;color:#666">▶</span>`;
|
||||
|
||||
const expand = document.createElement("div");
|
||||
expand.className = "cb-settings-expand";
|
||||
expand.appendChild(expandContent);
|
||||
|
||||
item.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
// Close other expands
|
||||
dropdown.querySelectorAll(".cb-settings-expand.open").forEach(el => {
|
||||
if (el !== expand) el.classList.remove("open");
|
||||
});
|
||||
expand.classList.toggle("open");
|
||||
const arrow = item.querySelector("span:last-child");
|
||||
arrow.textContent = expand.classList.contains("open") ? "▼" : "▶";
|
||||
};
|
||||
|
||||
dropdown.appendChild(item);
|
||||
dropdown.appendChild(expand);
|
||||
}
|
||||
|
||||
// Helper: create toggle item
|
||||
function addToggleItem(emoji, label, checked, onChange) {
|
||||
const item = document.createElement("label");
|
||||
item.className = "cb-settings-item";
|
||||
item.style.cursor = "pointer";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.checked = checked;
|
||||
cb.onchange = () => onChange(cb.checked);
|
||||
item.innerHTML = `<span>${emoji}</span>`;
|
||||
item.appendChild(cb);
|
||||
const lbl = document.createElement("span");
|
||||
lbl.textContent = label;
|
||||
item.appendChild(lbl);
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
|
||||
// Helper: divider
|
||||
function addDivider() {
|
||||
const d = document.createElement("div");
|
||||
d.className = "cb-settings-divider";
|
||||
dropdown.appendChild(d);
|
||||
}
|
||||
|
||||
// === Load Session (expandable) ===
|
||||
const sessionSelectContainer = document.createElement("div");
|
||||
const sessionSelect = createSearchableSelect(
|
||||
availableSessions || [],
|
||||
"",
|
||||
(value) => {
|
||||
if (value && value !== "None") {
|
||||
onLoadSession(value);
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
},
|
||||
"Search sessions..."
|
||||
);
|
||||
sessionSelectContainer.appendChild(sessionSelect);
|
||||
addExpandableItem("📂", "Load Session", sessionSelectContainer);
|
||||
|
||||
// === Load Config (expandable) ===
|
||||
const configSelectContainer = document.createElement("div");
|
||||
const configSelect = createSearchableSelect(
|
||||
availableConfigs || [],
|
||||
"",
|
||||
(value) => {
|
||||
if (value && value !== "None") {
|
||||
onLoadConfig(value);
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
},
|
||||
"Search configs..."
|
||||
);
|
||||
configSelectContainer.appendChild(configSelect);
|
||||
addExpandableItem("📂", "Load Config", configSelectContainer);
|
||||
|
||||
// === Save Config (expandable) ===
|
||||
const saveContainer = document.createElement("div");
|
||||
saveContainer.style.cssText = "display: flex; gap: 6px; align-items: center;";
|
||||
const saveNameInput = document.createElement("input");
|
||||
saveNameInput.className = "cb-input";
|
||||
saveNameInput.style.cssText = "flex: 1; font-size: 12px; padding: 6px 8px;";
|
||||
saveNameInput.value = node.state.config_name || "";
|
||||
saveNameInput.placeholder = "config_name";
|
||||
saveNameInput.onchange = () => {
|
||||
node.state.config_name = saveNameInput.value;
|
||||
node.saveState();
|
||||
};
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "cb-button primary";
|
||||
saveBtn.textContent = "💾 Save";
|
||||
saveBtn.style.cssText = "font-size: 12px; padding: 6px 12px;";
|
||||
saveBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
node.state.config_name = saveNameInput.value;
|
||||
await onSaveConfig();
|
||||
saveBtn.textContent = "✅ Saved!";
|
||||
setTimeout(() => { saveBtn.textContent = "💾 Save"; }, 2000);
|
||||
};
|
||||
saveContainer.appendChild(saveNameInput);
|
||||
saveContainer.appendChild(saveBtn);
|
||||
addExpandableItem("💾", "Save Config", saveContainer);
|
||||
|
||||
addDivider();
|
||||
|
||||
// === Auto-Save toggle ===
|
||||
addToggleItem("💾", "Auto-Save (2s)", node.state.auto_save || false, (val) => {
|
||||
node.state.auto_save = val;
|
||||
node.saveState();
|
||||
});
|
||||
|
||||
addDivider();
|
||||
|
||||
// === Enable Distribution toggle ===
|
||||
addToggleItem("🌐", "Enable Distribution", node.state.distribution_enabled || false, (val) => {
|
||||
node.state.distribution_enabled = val;
|
||||
node.saveState();
|
||||
node.renderUI();
|
||||
});
|
||||
|
||||
addDivider();
|
||||
|
||||
// === Refresh Models action ===
|
||||
const refreshItem = document.createElement("div");
|
||||
refreshItem.className = "cb-settings-item";
|
||||
refreshItem.innerHTML = "<span>🔄</span><span>Refresh Models & LoRAs</span>";
|
||||
refreshItem.onclick = async () => {
|
||||
refreshItem.querySelector("span:last-child").textContent = "Refreshing...";
|
||||
await refreshAllConfigBuilders();
|
||||
refreshItem.querySelector("span:last-child").textContent = "✅ Refreshed!";
|
||||
setTimeout(() => {
|
||||
refreshItem.querySelector("span:last-child").textContent = "Refresh Models & LoRAs";
|
||||
}, 2000);
|
||||
};
|
||||
dropdown.appendChild(refreshItem);
|
||||
|
||||
// === Label Mode toggle ===
|
||||
addToggleItem("🏷️", "Label Mode", node.state.label_mode || false, (val) => {
|
||||
node.state.label_mode = val;
|
||||
node.saveState();
|
||||
node.renderUI();
|
||||
});
|
||||
|
||||
// === Include None toggle ===
|
||||
addToggleItem("⬜", "Include None", node.state.include_none || false, (val) => {
|
||||
node.state.include_none = val;
|
||||
node.saveState();
|
||||
});
|
||||
|
||||
// === Include Default toggle ===
|
||||
addToggleItem("⬜", "Include Default", node.state.include_default || false, (val) => {
|
||||
node.state.include_default = val;
|
||||
node.saveState();
|
||||
});
|
||||
|
||||
return dropdown;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```
|
||||
feat: add top bar and settings dropdown components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create Sidebar Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js` (add after settings dropdown)
|
||||
|
||||
**Step 1: Add `createSidebar()` function**
|
||||
|
||||
```javascript
|
||||
// --- SIDEBAR COMPONENT ---
|
||||
|
||||
export function createSidebar(node, mainContent) {
|
||||
const sidebar = document.createElement("div");
|
||||
sidebar.className = "cb-sidebar";
|
||||
|
||||
const icons = [];
|
||||
|
||||
// Navigation icons
|
||||
function addNavIcon(emoji, title, targetId) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "cb-sidebar-icon";
|
||||
btn.textContent = emoji;
|
||||
btn.title = title;
|
||||
btn.dataset.targetId = targetId;
|
||||
btn.onclick = () => {
|
||||
const target = mainContent.querySelector(`#${targetId}`);
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
sidebar.appendChild(btn);
|
||||
icons.push(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
addNavIcon("📝", "Global Prompts", "cb-sec-prompts");
|
||||
addNavIcon("⚙️", "Config Arrays", "cb-sec-configs");
|
||||
|
||||
// Distribution icon (only when enabled)
|
||||
if (node.state.distribution_enabled) {
|
||||
addNavIcon("🌐", "Distribution", "cb-sec-distribution");
|
||||
}
|
||||
|
||||
addNavIcon("📄", "JSON Preview", "cb-sec-preview");
|
||||
|
||||
// Divider
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "cb-sidebar-divider";
|
||||
sidebar.appendChild(divider);
|
||||
|
||||
// Quick action: Add Config
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.className = "cb-sidebar-icon";
|
||||
addBtn.textContent = "+";
|
||||
addBtn.title = "Add Config";
|
||||
addBtn.style.cssText = "font-size: 20px; font-weight: bold;";
|
||||
addBtn.onclick = () => {
|
||||
// Dispatch to same handler as the Add Config button
|
||||
node.state.config_arrays.push({
|
||||
name: `Config ${node.state.config_arrays.length + 1}`,
|
||||
samplers: ["euler"],
|
||||
schedulers: ["normal"],
|
||||
steps: "20",
|
||||
cfg: "7.0",
|
||||
seed_behavior: "fixed",
|
||||
models: ["None"],
|
||||
vaes: ["None"],
|
||||
text_encoders: [],
|
||||
clip_type: "stable_diffusion",
|
||||
gguf_options: {},
|
||||
loras: ["None"],
|
||||
lora_omit_triggers: [],
|
||||
lora_triggerwords_append_settings: {},
|
||||
lora_bypass_states: {},
|
||||
lora_strength_lock: {},
|
||||
model_bypass_states: {},
|
||||
vae_bypass_states: {},
|
||||
te_bypass_states: {},
|
||||
combine: false,
|
||||
positive_prompt_groups: [],
|
||||
negative_prompt: "",
|
||||
use_custom_prompts: false,
|
||||
model_prompt_prefix: "",
|
||||
model_prompt_suffix: "",
|
||||
attention_modes: ["default"],
|
||||
resolutions: [],
|
||||
model_sampling_override: "none",
|
||||
model_sampling_shift: "1.73",
|
||||
model_sampling_flux_max_shift: "1.15",
|
||||
model_sampling_flux_base_shift: "0.5",
|
||||
use_advanced_sampling: false,
|
||||
advanced_guider: "cfg_guider",
|
||||
advanced_scheduler: "basic",
|
||||
use_flux_guidance: false,
|
||||
flux_guidance_value: "3.5"
|
||||
});
|
||||
node.saveState();
|
||||
node.renderUI();
|
||||
};
|
||||
sidebar.appendChild(addBtn);
|
||||
|
||||
// Quick action: Refresh Models
|
||||
const refreshBtn = document.createElement("button");
|
||||
refreshBtn.className = "cb-sidebar-icon";
|
||||
refreshBtn.textContent = "🔄";
|
||||
refreshBtn.title = "Refresh Models & LoRAs";
|
||||
sidebar.appendChild(refreshBtn);
|
||||
|
||||
// Scroll spy: highlight active section
|
||||
const sectionIds = ["cb-sec-prompts", "cb-sec-configs", "cb-sec-distribution", "cb-sec-preview"];
|
||||
const updateActiveIcon = () => {
|
||||
const scrollTop = mainContent.scrollTop;
|
||||
let activeId = sectionIds[0];
|
||||
for (const id of sectionIds) {
|
||||
const el = mainContent.querySelector(`#${id}`);
|
||||
if (el && el.offsetTop - 20 <= scrollTop) {
|
||||
activeId = id;
|
||||
}
|
||||
}
|
||||
icons.forEach(btn => {
|
||||
btn.classList.toggle("active", btn.dataset.targetId === activeId);
|
||||
});
|
||||
};
|
||||
mainContent.addEventListener("scroll", updateActiveIcon);
|
||||
// Initial highlight
|
||||
setTimeout(updateActiveIcon, 50);
|
||||
|
||||
return { sidebar, refreshBtn };
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```
|
||||
feat: add sidebar navigation component with scroll spy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create Uniform Section Header Helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js` (add after sidebar)
|
||||
|
||||
**Step 1: Add `createSectionHeader()` function**
|
||||
|
||||
```javascript
|
||||
// --- UNIFORM SECTION HEADER ---
|
||||
|
||||
const SECTION_ACCENTS = {
|
||||
prompts: "#00cc55",
|
||||
configs: "#0088ff",
|
||||
distribution: "#ff8800",
|
||||
preview: "#00cccc"
|
||||
};
|
||||
|
||||
export function createSectionHeader(emoji, title, accentKey, { collapsible = false, collapsed = false, onToggle = null } = {}) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cb-section-header";
|
||||
header.style.cssText = `
|
||||
background: linear-gradient(90deg, ${SECTION_ACCENTS[accentKey] || "#0088ff"}22, transparent);
|
||||
border-left: 3px solid ${SECTION_ACCENTS[accentKey] || "#0088ff"};
|
||||
`;
|
||||
|
||||
const titleSpan = document.createElement("span");
|
||||
titleSpan.textContent = `${emoji} ${title}`;
|
||||
header.appendChild(titleSpan);
|
||||
|
||||
if (collapsible) {
|
||||
header.style.cursor = "pointer";
|
||||
const arrow = document.createElement("span");
|
||||
arrow.textContent = collapsed ? "▶" : "▼";
|
||||
arrow.style.cssText = "margin-left: auto; font-size: 12px; color: #888;";
|
||||
header.appendChild(arrow);
|
||||
|
||||
header.onclick = () => {
|
||||
if (onToggle) onToggle();
|
||||
};
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```
|
||||
feat: add uniform section header component with accent colors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Restructure renderUI() — New Layout with Top Bar + Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:3837-3963` (renderUI function)
|
||||
|
||||
This is the main layout restructure. The renderUI function changes from:
|
||||
1. `cb-container` > topRow (3 sections) > globalPrompts > configArrays > preview
|
||||
|
||||
To:
|
||||
1. `cb-container` > topBar (sticky) > layoutWrapper (sidebar | mainContent)
|
||||
2. In mainContent: globalPrompts > configArrays > distributionSettings > preview
|
||||
|
||||
**Step 1: Update imports**
|
||||
|
||||
At the top of the file (line ~19-24), update the import from `conf-builder-ui-components.js` to include new exports:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
createSearchableSelect,
|
||||
createSlider,
|
||||
createInputGroup,
|
||||
getStyles,
|
||||
createTopBar,
|
||||
createSidebar,
|
||||
createSectionHeader
|
||||
} from './conf-builder-ui-components.js';
|
||||
```
|
||||
|
||||
**Step 2: Replace the renderUI function body**
|
||||
|
||||
The new renderUI function should:
|
||||
|
||||
1. Save scroll from `.cb-main-content` (not `.cb-container`)
|
||||
2. Inject styles + `cb-container` root
|
||||
3. Create top bar with `createTopBar()`
|
||||
4. Create layout wrapper (flex row)
|
||||
5. Create main content area
|
||||
6. Create sidebar with `createSidebar()` — pass mainContent for scroll spy
|
||||
7. In main content: render sections with IDs and uniform headers
|
||||
8. Wire up the sidebar refresh button
|
||||
9. Restore scroll on `.cb-main-content`
|
||||
|
||||
Replace lines 3837-3963 with the new version. Key changes:
|
||||
|
||||
- Remove the `topRow` creation and the 3 `render*Section` calls
|
||||
- Replace `root.appendChild(topRow)` with top bar
|
||||
- Add sidebar + layout wrapper
|
||||
- Add `id="cb-sec-prompts"` to Global Prompts section
|
||||
- Add `id="cb-sec-configs"` to Config Arrays section
|
||||
- Add `id="cb-sec-distribution"` to Distribution section (conditionally)
|
||||
- Add `id="cb-sec-preview"` to Preview section
|
||||
- Use `createSectionHeader()` for each section title instead of inline `.cb-section-title`
|
||||
- Remove the old sticky nav bar (replaced by sidebar)
|
||||
- Keep config nav bar inside config section (it supplements the sidebar for config-specific navigation)
|
||||
|
||||
**Step 3: Update renderGlobalPromptsSection**
|
||||
|
||||
Change the section header from inline-styled `.cb-section-title` to use `createSectionHeader()`:
|
||||
|
||||
Replace the header creation in `renderGlobalPromptsSection` (~line 3571-3593) to:
|
||||
- Add `id="cb-sec-prompts"` to the section element
|
||||
- Use `createSectionHeader("📝", "Global Prompts", "prompts", { collapsible: true, ... })` instead of the manual header
|
||||
|
||||
**Step 4: Update renderPreviewSection**
|
||||
|
||||
Change the section header (~line 3819-3828):
|
||||
- Add `id="cb-sec-preview"` to the section element
|
||||
- Use `createSectionHeader("📄", "JSON Preview", "preview")` instead of inline title
|
||||
|
||||
**Step 5: Commit**
|
||||
```
|
||||
feat: restructure renderUI with top bar, sidebar, and uniform section headers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Refactor Distribution Section as Standalone Full-Width Section
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-distribution.js`
|
||||
|
||||
**Step 1: Add new export for standalone distribution section**
|
||||
|
||||
Add a new function `renderDistributionSettingsSection()` that renders the distribution controls as a full-width section (not inside the top row). This function:
|
||||
|
||||
- Creates a `cb-section full-width` div with `id="cb-sec-distribution"`
|
||||
- Uses `createSectionHeader("🌐", "Distribution Settings", "distribution")`
|
||||
- Contains all the existing distribution details (worker URLs, claim timeout, master encoding) but NOT the enable/disable toggle (that's now in Settings dropdown)
|
||||
- Only rendered when `node.state.distribution_enabled === true`
|
||||
|
||||
Keep the original `renderDistributionSection()` function intact (DO NOT REMOVE) but it will no longer be called from renderUI.
|
||||
|
||||
**Step 2: Import `createSectionHeader` in distribution module**
|
||||
|
||||
```javascript
|
||||
import { createInputGroup, createSlider, createSectionHeader } from './conf-builder-ui-components.js';
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```
|
||||
feat: add standalone distribution settings section for new layout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verify & Commit
|
||||
|
||||
**Step 1: JS brace balance check**
|
||||
|
||||
Verify all modified JS files have balanced braces/parens.
|
||||
|
||||
**Step 2: Visual review**
|
||||
|
||||
Check that all section IDs are consistent between sidebar nav targets and section elements.
|
||||
|
||||
**Step 3: Final commit**
|
||||
|
||||
```
|
||||
feat: builder UI overhaul — top bar, sidebar nav, uniform headers, dark mode contrast
|
||||
```
|
||||
@@ -1,114 +0,0 @@
|
||||
# Per-Config Resolutions in Builder UI
|
||||
|
||||
**Date:** 2026-03-01
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Add per-config resolution support to the Builder UI. Each config array gets its own
|
||||
resolution list displayed as chips (same pattern as samplers/schedulers). Resolutions
|
||||
flow through `configs_json` and override the sampler node's `resolutions_json` input
|
||||
on a per-config basis.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **Per-config scope:** Each config array has its own resolution list, independent of
|
||||
other config arrays.
|
||||
2. **Override behavior:** If a config includes resolutions, they replace the sampler
|
||||
node's `resolutions_json` for that config. If a config has NO resolutions, the
|
||||
sampler's `resolutions_json` is used as fallback.
|
||||
3. **Categorized preset dropdown:** Quick-add dropdown organized by model type
|
||||
(SD 1.5, SDXL, Flux) with sub-categories for orientation (Square, Portrait,
|
||||
Landscape), sizes listed largest to smallest within each sub-category.
|
||||
4. **Custom resolution input:** W x H number inputs for arbitrary resolutions.
|
||||
5. **Chip display:** Selected resolutions shown as removable chips with x button,
|
||||
matching the visual pattern of samplers/schedulers.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Preset Dropdown Structure (using native `<optgroup>`)
|
||||
|
||||
```
|
||||
── SD 1.5 — Square ──
|
||||
512 x 512
|
||||
── SD 1.5 — Portrait ──
|
||||
512 x 896
|
||||
512 x 768
|
||||
── SD 1.5 — Landscape ──
|
||||
896 x 512
|
||||
768 x 512
|
||||
── SDXL — Square ──
|
||||
1024 x 1024
|
||||
── SDXL — Portrait ──
|
||||
768 x 1344
|
||||
832 x 1216
|
||||
896 x 1152
|
||||
── SDXL — Landscape ──
|
||||
1344 x 768
|
||||
1216 x 832
|
||||
1152 x 896
|
||||
── Flux — Square ──
|
||||
1024 x 1024
|
||||
── Flux — Portrait ──
|
||||
768 x 1344
|
||||
832 x 1216
|
||||
── Flux — Landscape ──
|
||||
1344 x 768
|
||||
1216 x 832
|
||||
```
|
||||
|
||||
### Custom Input
|
||||
|
||||
Small inline W x H number inputs with an Add button for arbitrary resolutions.
|
||||
|
||||
### Chip Display
|
||||
|
||||
Amber-accented (#cc8800) rounded chips showing "WxH" with x remove button.
|
||||
Clear button to remove all. Same visual pattern as samplers/schedulers.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### State Storage
|
||||
|
||||
```javascript
|
||||
configArray.resolutions = ["1024x1024", "832x1216", "1344x768"]
|
||||
```
|
||||
|
||||
Array of "WxH" strings, same pattern as `configArray.samplers`.
|
||||
|
||||
### Config JSON Output
|
||||
|
||||
In `convertStateToConfigs()`, resolutions are converted to nested arrays:
|
||||
|
||||
```json
|
||||
{
|
||||
"sampler": ["euler"],
|
||||
"scheduler": ["normal"],
|
||||
"resolutions": [[1024, 1024], [832, 1216], [1344, 768]]
|
||||
}
|
||||
```
|
||||
|
||||
Only included when the config has resolutions specified (omitted = use sampler fallback).
|
||||
|
||||
### Config Expansion
|
||||
|
||||
In `expand_configs()`, resolutions become another axis of the Cartesian product.
|
||||
Each expanded config gets a single `"resolution": [w, h]` field.
|
||||
|
||||
### Generation Loop Override
|
||||
|
||||
In the orchestrator, if an expanded config has a `resolution` field, it provides
|
||||
its own width/height instead of using the global `input_jobs` from `resolutions_json`.
|
||||
|
||||
Configs WITHOUT resolutions continue to iterate over the sampler's `input_jobs`
|
||||
as before.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `conf-builder-config-management.js` | New resolution chip builder with categorized optgroup dropdown + custom input |
|
||||
| `conf-builder-utilities.js` | Add `resolutions` to `convertStateToConfigs()` output |
|
||||
| `config_utils.py` | Expand `resolutions` array in `expand_configs()` Cartesian product |
|
||||
| `generation_orchestrator.py` | Per-config resolution override in generation loop |
|
||||
| `conf-builder-main.js` | State migration: add empty `resolutions: []` to existing config arrays |
|
||||
@@ -1,455 +0,0 @@
|
||||
# Per-Config Resolutions Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add per-config resolution support to the Builder UI with a categorized preset dropdown, flowing through configs_json to override the sampler's resolutions_json on a per-config basis.
|
||||
|
||||
**Architecture:** Each config array gains a `resolutions` array (e.g., `["1024x1024", "832x1216"]`). The UI uses an `<optgroup>`-based dropdown categorized by model type and orientation plus a custom W×H input. Resolutions flow through `configs_json` as `[[w,h],...]`, get expanded into the Cartesian product in `expand_configs()`, and override the sampler's global `resolutions_json` in the generation loop.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ComfyUI widget), Python (config expansion + generation loop)
|
||||
|
||||
**Critical constraint:** DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: State Migration — Add `resolutions` to Config Arrays
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-main.js:392-397` (migration block)
|
||||
|
||||
**Step 1: Add migration for resolutions field**
|
||||
|
||||
In the per-config-array migration block (around line 397, after the `attention_modes` migration), add:
|
||||
|
||||
```javascript
|
||||
if (!arr.resolutions) arr.resolutions = [];
|
||||
```
|
||||
|
||||
This follows the exact pattern of `if (!arr.attention_modes) arr.attention_modes = ["default"];` at line 397.
|
||||
|
||||
**Step 2: Verify by checking the file**
|
||||
|
||||
Confirm the new line sits inside the `this.state.config_arrays.forEach(arr => { ... })` block, after the existing migrations.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```
|
||||
feat: add resolutions field migration for config arrays
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Resolution Chip Builder UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js`
|
||||
- New function `createResolutionBuilder()` (after `createChipListBuilder` ~line 270)
|
||||
- Render call after Attention builder (~line 362)
|
||||
|
||||
**Step 1: Define the preset resolution data**
|
||||
|
||||
Add this constant near the top of the file (after imports, ~line 20):
|
||||
|
||||
```javascript
|
||||
const RESOLUTION_PRESETS = [
|
||||
{ group: "SD 1.5 — Square", items: ["512x512"] },
|
||||
{ group: "SD 1.5 — Portrait", items: ["512x896", "512x768"] },
|
||||
{ group: "SD 1.5 — Landscape", items: ["896x512", "768x512"] },
|
||||
{ group: "SDXL — Square", items: ["1024x1024"] },
|
||||
{ group: "SDXL — Portrait", items: ["768x1344", "832x1216", "896x1152"] },
|
||||
{ group: "SDXL — Landscape", items: ["1344x768", "1216x832", "1152x896"] },
|
||||
{ group: "Flux — Square", items: ["1024x1024"] },
|
||||
{ group: "Flux — Portrait", items: ["768x1344", "832x1216"] },
|
||||
{ group: "Flux — Landscape", items: ["1344x768", "1216x832"] },
|
||||
];
|
||||
```
|
||||
|
||||
**Step 2: Create the `createResolutionBuilder()` function**
|
||||
|
||||
Add after `createChipListBuilder` (~line 270). This function creates:
|
||||
- Chip display area (same pattern as `createChipListBuilder`)
|
||||
- `<select>` with `<optgroup>` categories populated from `RESOLUTION_PRESETS`
|
||||
- Custom W × H number inputs with Add button
|
||||
- Clear button
|
||||
|
||||
```javascript
|
||||
function createResolutionBuilder({ node, arrayIdx, configArray }) {
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "width: 100%; margin-bottom: 2px;";
|
||||
|
||||
// Ensure array exists
|
||||
if (!Array.isArray(configArray.resolutions)) {
|
||||
configArray.resolutions = [];
|
||||
node.state.config_arrays[arrayIdx].resolutions = [];
|
||||
}
|
||||
|
||||
// ===== CHIPS DISPLAY =====
|
||||
const chipsContainer = document.createElement("div");
|
||||
chipsContainer.style.cssText = "display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; min-height: 24px;";
|
||||
|
||||
const renderChips = () => {
|
||||
chipsContainer.innerHTML = "";
|
||||
const items = configArray.resolutions;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
const ph = document.createElement("div");
|
||||
ph.textContent = "Using sampler node resolutions";
|
||||
ph.style.cssText = "color: #666; font-style: italic; padding: 2px 4px; font-size: 11px;";
|
||||
chipsContainer.appendChild(ph);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const chip = document.createElement("div");
|
||||
chip.style.cssText = "display: flex; align-items: center; background: #554400; color: #ffcc44; border-radius: 12px; padding: 2px 8px; font-size: 11px; border: 1px solid #886600;";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = item;
|
||||
chip.appendChild(text);
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.style.cssText = "margin-left: 6px; cursor: pointer; color: #ff8888; font-weight: bold;";
|
||||
closeBtn.onclick = () => {
|
||||
node.state.config_arrays[arrayIdx].resolutions.splice(idx, 1);
|
||||
node.saveState();
|
||||
renderChips();
|
||||
populateSelect();
|
||||
};
|
||||
chip.appendChild(closeBtn);
|
||||
chipsContainer.appendChild(chip);
|
||||
});
|
||||
};
|
||||
renderChips();
|
||||
container.appendChild(chipsContainer);
|
||||
|
||||
// ===== PRESET DROPDOWN =====
|
||||
const inputRow = document.createElement("div");
|
||||
inputRow.style.cssText = "display: flex; gap: 4px; align-items: center;";
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "cb-select";
|
||||
select.style.cssText = "flex: 1; font-size: 11px; padding: 3px 4px;";
|
||||
|
||||
const populateSelect = () => {
|
||||
select.innerHTML = "";
|
||||
const currentItems = configArray.resolutions || [];
|
||||
|
||||
// Placeholder option
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Add resolution...";
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
select.appendChild(placeholder);
|
||||
|
||||
// Optgroups from presets
|
||||
for (const preset of RESOLUTION_PRESETS) {
|
||||
const available = preset.items.filter(r => !currentItems.includes(r));
|
||||
if (available.length === 0) continue;
|
||||
|
||||
const optgroup = document.createElement("optgroup");
|
||||
optgroup.label = preset.group;
|
||||
for (const res of available) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = res;
|
||||
opt.textContent = res.replace("x", " × ");
|
||||
optgroup.appendChild(opt);
|
||||
}
|
||||
select.appendChild(optgroup);
|
||||
}
|
||||
};
|
||||
populateSelect();
|
||||
|
||||
// Add on selection change (instant add)
|
||||
select.onchange = () => {
|
||||
const val = select.value;
|
||||
if (val && !configArray.resolutions.includes(val)) {
|
||||
node.state.config_arrays[arrayIdx].resolutions.push(val);
|
||||
node.saveState();
|
||||
renderChips();
|
||||
populateSelect();
|
||||
}
|
||||
};
|
||||
|
||||
inputRow.appendChild(select);
|
||||
|
||||
// ===== CUSTOM W x H INPUT =====
|
||||
const customW = document.createElement("input");
|
||||
customW.type = "number";
|
||||
customW.className = "cb-input";
|
||||
customW.placeholder = "W";
|
||||
customW.style.cssText = "width: 55px; font-size: 11px; padding: 3px 4px;";
|
||||
customW.min = 64;
|
||||
customW.max = 8192;
|
||||
customW.step = 8;
|
||||
|
||||
const xLabel = document.createElement("span");
|
||||
xLabel.textContent = "×";
|
||||
xLabel.style.cssText = "color: #888; font-size: 11px;";
|
||||
|
||||
const customH = document.createElement("input");
|
||||
customH.type = "number";
|
||||
customH.className = "cb-input";
|
||||
customH.placeholder = "H";
|
||||
customH.style.cssText = "width: 55px; font-size: 11px; padding: 3px 4px;";
|
||||
customH.min = 64;
|
||||
customH.max = 8192;
|
||||
customH.step = 8;
|
||||
|
||||
const addCustomBtn = document.createElement("button");
|
||||
addCustomBtn.className = "cb-button primary";
|
||||
addCustomBtn.textContent = "+";
|
||||
addCustomBtn.title = "Add custom resolution";
|
||||
addCustomBtn.style.cssText = "padding: 3px 8px; font-size: 11px; min-width: 28px;";
|
||||
addCustomBtn.onclick = () => {
|
||||
const w = parseInt(customW.value);
|
||||
const h = parseInt(customH.value);
|
||||
if (w > 0 && h > 0) {
|
||||
const res = `${w}x${h}`;
|
||||
if (!configArray.resolutions.includes(res)) {
|
||||
node.state.config_arrays[arrayIdx].resolutions.push(res);
|
||||
node.saveState();
|
||||
renderChips();
|
||||
populateSelect();
|
||||
}
|
||||
customW.value = "";
|
||||
customH.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all button
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.className = "cb-button";
|
||||
clearBtn.textContent = "Clear";
|
||||
clearBtn.title = "Remove all resolutions";
|
||||
clearBtn.style.cssText = "padding: 3px 8px; font-size: 11px; min-width: 40px; color: #ff8888;";
|
||||
clearBtn.onclick = () => {
|
||||
node.state.config_arrays[arrayIdx].resolutions = [];
|
||||
configArray.resolutions = [];
|
||||
node.saveState();
|
||||
renderChips();
|
||||
populateSelect();
|
||||
};
|
||||
|
||||
inputRow.appendChild(customW);
|
||||
inputRow.appendChild(xLabel);
|
||||
inputRow.appendChild(customH);
|
||||
inputRow.appendChild(addCustomBtn);
|
||||
inputRow.appendChild(clearBtn);
|
||||
container.appendChild(inputRow);
|
||||
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add the resolution builder to the render flow**
|
||||
|
||||
After the Attention builder block (line 362, after `settingsGrid.appendChild(createInputGroup("Attention", attentionBuilder));`), add:
|
||||
|
||||
```javascript
|
||||
// Resolutions - categorized preset dropdown + custom input + chips
|
||||
const resolutionBuilder = createResolutionBuilder({ node, arrayIdx, configArray });
|
||||
settingsGrid.appendChild(createInputGroup("Resolutions", resolutionBuilder));
|
||||
```
|
||||
|
||||
**Step 4: Update iteration count**
|
||||
|
||||
In `conf-builder-utilities.js`, in `getIterationCount()` (~line 470-473), add resolution count and include it in the product:
|
||||
|
||||
After line 471 (`const a_count = ...`), add:
|
||||
```javascript
|
||||
// 7. Resolutions (per-config overrides)
|
||||
const r_count = (configArray.resolutions && configArray.resolutions.length > 0) ? configArray.resolutions.length : 1;
|
||||
```
|
||||
|
||||
Update the return (line 473) to include `* r_count`:
|
||||
```javascript
|
||||
return m_count * l_count * v_count * s_count * sch_count * st_count * c_count * p_count * a_count * r_count;
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: add resolution chip builder UI with categorized presets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Config JSON Output — Add Resolutions to `convertStateToConfigs()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-utilities.js:478-596` (`convertStateToConfigs`)
|
||||
|
||||
**Step 1: Add resolutions to config output**
|
||||
|
||||
In `convertStateToConfigs()`, after the attention_mode block (~line 514), add:
|
||||
|
||||
```javascript
|
||||
// Per-config resolutions (override sampler's resolutions_json)
|
||||
if (configArray.resolutions && configArray.resolutions.length > 0) {
|
||||
config.resolutions = configArray.resolutions.map(r => {
|
||||
const parts = r.split("x").map(Number);
|
||||
return [parts[0], parts[1]];
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This outputs `"resolutions": [[1024, 1024], [832, 1216]]` — same format as `resolutions_json`.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```
|
||||
feat: output per-config resolutions in configs_json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Config Expansion — Add Resolutions to Cartesian Product
|
||||
|
||||
**Files:**
|
||||
- Modify: `config_utils.py:364-615` (`expand_configs`)
|
||||
|
||||
**Step 1: Extract resolutions from config entry**
|
||||
|
||||
After line 496 (attention_modes extraction), add:
|
||||
|
||||
```python
|
||||
# Per-config resolutions (override sampler's resolutions_json when present)
|
||||
raw_resolutions = entry.get("resolutions", None)
|
||||
if raw_resolutions and isinstance(raw_resolutions, list) and len(raw_resolutions) > 0:
|
||||
config_resolutions = [tuple(r) for r in raw_resolutions] # [(w, h), ...]
|
||||
else:
|
||||
config_resolutions = [None] # Single None = no override, use global resolutions
|
||||
```
|
||||
|
||||
**Step 2: Add resolutions to the itertools.product call**
|
||||
|
||||
Modify the `itertools.product` call (line 548-553) to include `config_resolutions`:
|
||||
|
||||
Add `config_resolutions` to the end of the product arguments:
|
||||
```python
|
||||
for combo in itertools.product(samplers, schedulers, steps_l, cfgs, clip_skips, expanded_loras,
|
||||
denoise_values, entry_prompt_pairs, expanded_models, raw_vaes,
|
||||
attention_modes, model_sampling_overrides, model_sampling_shifts,
|
||||
flux_max_shifts, flux_base_shifts,
|
||||
advanced_sampling_values, advanced_guiders, advanced_schedulers,
|
||||
flux_guidance_values, config_resolutions):
|
||||
```
|
||||
|
||||
**Step 3: Add resolution to the combo dict**
|
||||
|
||||
After line 594 (`"flux_guidance_value": combo[18],`), add:
|
||||
|
||||
```python
|
||||
"resolution": combo[19], # (w, h) tuple or None
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: expand per-config resolutions in Cartesian product
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Generation Loop — Per-Config Resolution Override
|
||||
|
||||
**Files:**
|
||||
- Modify: `generation_orchestrator.py` (main generation loop, ~lines 376-382 and 617-621)
|
||||
|
||||
**Step 1: Fix total job count calculation**
|
||||
|
||||
Replace lines 376-382 with resolution-aware counting:
|
||||
|
||||
```python
|
||||
input_jobs = prepare_input_jobs(optional_latent, resolutions)
|
||||
|
||||
# Count total jobs: configs with per-config resolutions provide their own
|
||||
# width/height and don't multiply with the global input_jobs.
|
||||
configs_with_res = sum(1 for c in expanded if c.get("resolution") is not None)
|
||||
configs_without_res = len(expanded) - configs_with_res
|
||||
total_jobs = configs_with_res + (configs_without_res * len(input_jobs))
|
||||
|
||||
print(f"{'='*80}")
|
||||
print(f"[GridTester] 🚀 GENERATION START")
|
||||
if configs_with_res > 0:
|
||||
print(f"[GridTester] 📋 {configs_with_res} configs with per-config resolutions + "
|
||||
f"{configs_without_res} configs × {len(input_jobs)} resolutions = {total_jobs} total jobs")
|
||||
else:
|
||||
print(f"[GridTester] 📋 {len(expanded)} configs × {len(input_jobs)} resolutions = {total_jobs} total jobs")
|
||||
print(f"{'='*80}")
|
||||
```
|
||||
|
||||
**Step 2: Add per-config resolution override in the generation loop**
|
||||
|
||||
At the start of the inner loop body (~line 621, after `for conf_idx, conf in enumerate(expanded):`), add:
|
||||
|
||||
```python
|
||||
# Per-config resolution override: if this config has its own resolution,
|
||||
# use it instead of the global input_job's resolution. Only process once
|
||||
# (at job_idx == 0) to avoid duplicating work across input_jobs.
|
||||
if conf.get("resolution") is not None:
|
||||
if job_idx > 0:
|
||||
continue # Already processed at job_idx=0
|
||||
w, h = conf["resolution"]
|
||||
batch_idx = 0
|
||||
```
|
||||
|
||||
This keeps `w, h` from the outer loop for configs without per-config resolutions, and overrides them for configs that have their own.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```
|
||||
feat: per-config resolution override in generation loop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Distribution Manager — Same Per-Config Resolution Override
|
||||
|
||||
**Files:**
|
||||
- Modify: `distribution_manager.py:97-102` (job population loop)
|
||||
|
||||
**Step 1: Add per-config resolution override**
|
||||
|
||||
In `populate_jobs()`, after line 102 (`for conf_idx, conf in enumerate(expanded_configs):`), add the same skip/override logic:
|
||||
|
||||
```python
|
||||
# Per-config resolution override
|
||||
if conf.get("resolution") is not None:
|
||||
if job_idx > 0:
|
||||
continue
|
||||
w = conf["resolution"][0]
|
||||
h = conf["resolution"][1]
|
||||
batch_idx = 0
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```
|
||||
feat: per-config resolution override in distribution manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verify & Sync
|
||||
|
||||
**Step 1: Python syntax check**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import py_compile; py_compile.compile('config_utils.py', doraise=True); py_compile.compile('generation_orchestrator.py', doraise=True); py_compile.compile('distribution_manager.py', doraise=True); print('OK')"
|
||||
```
|
||||
|
||||
**Step 2: JS brace balance check**
|
||||
|
||||
Verify all JS files have balanced braces/parens.
|
||||
|
||||
**Step 3: Sync worktree to main if using worktree**
|
||||
|
||||
**Step 4: Final commit**
|
||||
|
||||
```
|
||||
feat: per-config resolutions — builder UI, config expansion, generation override
|
||||
```
|
||||
@@ -1,179 +0,0 @@
|
||||
# Sync Models to Workers — Design
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Optional feature that automatically transfers models, LoRAs, text encoders,
|
||||
and VAEs from the master ComfyUI instance to worker instances via HTTP.
|
||||
Workers save files permanently, preserving the master's directory structure.
|
||||
Intelligent pre-fetching downloads upcoming models in the background while
|
||||
current jobs process.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two new components:
|
||||
|
||||
1. **Master File Server** — New endpoints on the master that stream model
|
||||
files from the master's local `ComfyUI/models/` folders. Workers request
|
||||
files by category + full relative path.
|
||||
|
||||
2. **Worker Model Sync** — Logic in the worker thread that checks
|
||||
`os.path.exists()` for each required model before processing a job. If
|
||||
missing, blocks and downloads from master via HTTP. Background pre-fetch
|
||||
thread downloads upcoming models between jobs.
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
Job claimed → Extract model paths from config
|
||||
→ Check each with folder_paths.get_full_path()
|
||||
→ Missing? → Block + download from master → Process job
|
||||
↓ (parallel)
|
||||
Background: pre-fetch upcoming_models list
|
||||
```
|
||||
|
||||
## Master File Server
|
||||
|
||||
### Endpoint: POST /distribution/check_models
|
||||
|
||||
Bulk check which models a worker is missing. Returns missing list with sizes.
|
||||
|
||||
```
|
||||
Request: {
|
||||
"worker_id": "w_abc123",
|
||||
"models": [
|
||||
{"category": "checkpoints", "filename": "SDXL/Illustrious/Styles/model.safetensors"},
|
||||
{"category": "loras", "filename": "XL/detail_enhancer.safetensors"},
|
||||
{"category": "vae", "filename": "sdxl_vae.safetensors"}
|
||||
]
|
||||
}
|
||||
|
||||
Response: {
|
||||
"missing": [
|
||||
{"category": "loras", "filename": "XL/detail_enhancer.safetensors", "size_bytes": 184549376}
|
||||
],
|
||||
"total_size": 184549376
|
||||
}
|
||||
```
|
||||
|
||||
Master resolves each file via `folder_paths.get_full_path(category, filename)`
|
||||
and returns size for files the worker reports as missing.
|
||||
|
||||
### Endpoint: POST /distribution/download_model
|
||||
|
||||
Streams a single model file from master to worker.
|
||||
|
||||
```
|
||||
Request: {
|
||||
"worker_id": "w_abc123",
|
||||
"category": "checkpoints",
|
||||
"filename": "SDXL/Illustrious/Styles/model.safetensors"
|
||||
}
|
||||
|
||||
Response: Streaming file with Content-Length header
|
||||
```
|
||||
|
||||
Security: Validates `worker_id` is registered via `/distribution/register_worker`.
|
||||
|
||||
Supported categories: `checkpoints`, `loras`, `text_encoders`, `clip`,
|
||||
`clip_gguf`, `diffusion_models`, `unet`, `unet_gguf`, `vae`
|
||||
|
||||
### Directory Structure Preservation
|
||||
|
||||
The `filename` field carries the full relative path including subdirectories.
|
||||
Workers recreate the same folder hierarchy using `os.makedirs(parent, exist_ok=True)`.
|
||||
|
||||
Example: `checkpoints/SDXL/Illustrious/Styles/model.safetensors` on master
|
||||
becomes `models/checkpoints/SDXL/Illustrious/Styles/model.safetensors` on worker.
|
||||
|
||||
## Worker Model Sync Logic
|
||||
|
||||
### Pre-job Model Check
|
||||
|
||||
When a worker claims a job, before processing it extracts all required model
|
||||
paths from the job config:
|
||||
|
||||
- `config.model` → category from `config.model_type` (checkpoint/diffusion_model/gguf)
|
||||
- `config.lora` → `loras` category
|
||||
- `config.vae` → `vae` category (skip `remote:` URLs)
|
||||
- `config.text_encoders` → `text_encoders` category
|
||||
|
||||
For each, `folder_paths.get_full_path(category, filename)` is checked. If it
|
||||
returns `None`, the worker downloads from master before proceeding.
|
||||
|
||||
### Download Process
|
||||
|
||||
1. Worker calls `/distribution/check_models` with all needed models
|
||||
2. Master responds with missing list + sizes
|
||||
3. Worker downloads each missing file via `/distribution/download_model`
|
||||
4. File saved to `ComfyUI/models/{category}/{filename}` with full subdirectory
|
||||
structure preserved
|
||||
5. Worker logs progress: `[Worker] Downloading checkpoints/SDXL/.../model.safetensors (6.5 GB)...`
|
||||
|
||||
### Background Pre-fetch
|
||||
|
||||
After claiming a job, the worker also receives an `upcoming_models` list from
|
||||
the master (models needed for the next N pending jobs). A background thread
|
||||
downloads these while the current job processes, prioritized by job order.
|
||||
|
||||
Files stay permanently on the worker's disk. Future runs find them via
|
||||
`folder_paths.get_full_path()` — zero transfer overhead.
|
||||
|
||||
## Job Claim Enhancement
|
||||
|
||||
### Modified Response: GET /distribution/claim_job
|
||||
|
||||
When `sync_models_to_workers` is enabled, the job claim response includes an
|
||||
`upcoming_models` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "...",
|
||||
"config": { ... },
|
||||
"upcoming_models": [
|
||||
{"category": "checkpoints", "filename": "SDXL/Illustrious/Styles/model_B.safetensors"},
|
||||
{"category": "loras", "filename": "XL/detail_enhancer.safetensors"},
|
||||
{"category": "loras", "filename": "XL/style_anime.safetensors"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The master builds this by scanning the next N pending jobs, collecting unique
|
||||
model references. The worker's background pre-fetch thread works through this
|
||||
list in priority order.
|
||||
|
||||
## UI — Distribution Settings
|
||||
|
||||
New checkbox in the Distribution Settings section, below the Master Text
|
||||
Encoding toggle:
|
||||
|
||||
**Label:** "Sync Models to Workers"
|
||||
**Info text:** "Automatically transfer required models, LoRAs, text encoders,
|
||||
and VAEs to workers. Files are saved permanently to each worker's ComfyUI
|
||||
models folder, matching the master's directory structure."
|
||||
|
||||
State: `node.state.sync_models_to_workers` (boolean, default `false`)
|
||||
Config: `distribution_config.sync_models_to_workers` (boolean)
|
||||
|
||||
When disabled, workers behave exactly as today — must have models pre-installed.
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `distribution_routes.py` | New endpoints: `/distribution/check_models`, `/distribution/download_model` |
|
||||
| `distribution_worker.py` | Pre-job model check, blocking download, background pre-fetch thread |
|
||||
| `distribution_manager.py` | `upcoming_models` in `_job_to_dict()`, model extraction from pending jobs |
|
||||
| `conf-builder-distribution.js` | "Sync Models to Workers" checkbox in Distribution Settings |
|
||||
| `generation_orchestrator.py` | Pass `sync_models_to_workers` flag through to distribution manager |
|
||||
|
||||
## What Doesn't Change
|
||||
|
||||
Everything else about distributed processing stays the same:
|
||||
- Pull-based job claiming
|
||||
- Heartbeats and timeout reclaiming
|
||||
- Pre-encoded conditionings (Master Text Encoding)
|
||||
- Result submission
|
||||
- Worker registration
|
||||
@@ -1,650 +0,0 @@
|
||||
# Sync Models to Workers Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add optional HTTP-based model file transfer from master to workers, with intelligent background pre-fetching and permanent local storage.
|
||||
|
||||
**Architecture:** Master exposes two new endpoints for model file checking and streaming. Workers check for missing models before each job, download what's needed, and pre-fetch upcoming models in the background. A new UI checkbox enables the feature. The `sync_models_to_workers` flag flows through `distribution_config` JSON from Config Builder to orchestrator to manager to worker.
|
||||
|
||||
**Tech Stack:** Python (aiohttp server routes, threading, urllib), Vanilla JS (ComfyUI widget), ComfyUI `folder_paths` API
|
||||
|
||||
**Critical Constraint:** DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: UI Checkbox + Config Passthrough
|
||||
|
||||
Add the "Sync Models to Workers" checkbox in Distribution Settings and pass
|
||||
the flag through the config pipeline.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-distribution.js:393-416`
|
||||
- Modify: `config_builder_node.py:662-670`
|
||||
|
||||
**Step 1: Add checkbox to Distribution Settings UI**
|
||||
|
||||
In `web/conf_builder/conf-builder-distribution.js`, find the Master Text
|
||||
Encoding info text block at lines ~393-416. After the `encodingInfo` div
|
||||
(around line 416), add a new checkbox following the exact same pattern:
|
||||
|
||||
```javascript
|
||||
// Sync Models to Workers toggle
|
||||
const syncRow = document.createElement("div");
|
||||
syncRow.style.cssText = "display: flex; align-items: flex-start; gap: 10px; margin-top: 10px;";
|
||||
|
||||
const syncLabel = document.createElement("label");
|
||||
syncLabel.className = "cb-toggle";
|
||||
syncLabel.style.fontSize = "12px";
|
||||
const syncCheckbox = document.createElement("input");
|
||||
syncCheckbox.type = "checkbox";
|
||||
syncCheckbox.checked = node.state.sync_models_to_workers || false;
|
||||
syncCheckbox.onchange = () => {
|
||||
node.state.sync_models_to_workers = syncCheckbox.checked;
|
||||
node.saveState();
|
||||
};
|
||||
syncLabel.appendChild(syncCheckbox);
|
||||
syncLabel.appendChild(document.createTextNode(" ☁️ Sync Models to Workers"));
|
||||
syncRow.appendChild(syncLabel);
|
||||
detailsContainer.appendChild(syncRow);
|
||||
|
||||
const syncInfo = document.createElement("div");
|
||||
syncInfo.style.cssText = "font-size: 10px; color: #666; margin-top: 4px; line-height: 1.3; padding-left: 2px;";
|
||||
syncInfo.textContent = "Automatically transfer required models, LoRAs, text encoders, "
|
||||
+ "and VAEs to workers via HTTP. Files are saved permanently to each worker's "
|
||||
+ "ComfyUI models folder, matching the master's directory structure.";
|
||||
detailsContainer.appendChild(syncInfo);
|
||||
```
|
||||
|
||||
**Step 2: Add flag to distribution_config JSON output**
|
||||
|
||||
In `config_builder_node.py`, find the dist_config JSON assembly at line ~665-670:
|
||||
|
||||
```python
|
||||
dist_config = json.dumps({
|
||||
"enabled": True,
|
||||
"worker_urls": [u for u in state["worker_urls"] if u and u.strip()],
|
||||
"claim_timeout": state.get("claim_timeout", 600),
|
||||
"use_master_encoding": state.get("use_master_encoding", False)
|
||||
})
|
||||
```
|
||||
|
||||
Add the new field:
|
||||
|
||||
```python
|
||||
dist_config = json.dumps({
|
||||
"enabled": True,
|
||||
"worker_urls": [u for u in state["worker_urls"] if u and u.strip()],
|
||||
"claim_timeout": state.get("claim_timeout", 600),
|
||||
"use_master_encoding": state.get("use_master_encoding", False),
|
||||
"sync_models_to_workers": state.get("sync_models_to_workers", False)
|
||||
})
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-distribution.js config_builder_node.py
|
||||
git commit -m "Add Sync Models to Workers checkbox and config passthrough
|
||||
|
||||
New toggle in Distribution Settings section. Flag flows through
|
||||
distribution_config JSON as sync_models_to_workers boolean."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Master File Server Endpoints
|
||||
|
||||
Add two new endpoints on the master for model file checking and streaming.
|
||||
|
||||
**Files:**
|
||||
- Modify: `distribution_routes.py` (add after line ~400, after existing endpoints)
|
||||
|
||||
**Step 1: Add /distribution/check_models endpoint**
|
||||
|
||||
In `distribution_routes.py`, after the last endpoint (around line 400+),
|
||||
add the `check_models` endpoint. This uses `folder_paths` to resolve each
|
||||
file and returns which ones exist with their sizes:
|
||||
|
||||
```python
|
||||
@server.PromptServer.instance.routes.post("/distribution/check_models")
|
||||
async def check_models(request):
|
||||
"""
|
||||
Bulk check which model files exist on this instance.
|
||||
Workers call this to find out what they need to download.
|
||||
|
||||
Request body:
|
||||
worker_id: Registered worker ID
|
||||
models: List of {category, filename} dicts
|
||||
|
||||
Returns:
|
||||
missing: List of {category, filename, size_bytes} for files that exist here
|
||||
total_size: Total bytes of all files in the list
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
worker_id = data.get("worker_id", "")
|
||||
models = data.get("models", [])
|
||||
|
||||
import folder_paths
|
||||
|
||||
available = []
|
||||
total_size = 0
|
||||
for m in models:
|
||||
category = m.get("category", "")
|
||||
filename = m.get("filename", "")
|
||||
if not category or not filename:
|
||||
continue
|
||||
|
||||
full_path = folder_paths.get_full_path(category, filename)
|
||||
if full_path and os.path.exists(full_path):
|
||||
size = os.path.getsize(full_path)
|
||||
available.append({
|
||||
"category": category,
|
||||
"filename": filename,
|
||||
"size_bytes": size
|
||||
})
|
||||
total_size += size
|
||||
|
||||
return web.json_response({
|
||||
"available": available,
|
||||
"total_size": total_size
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
```
|
||||
|
||||
**Step 2: Add /distribution/download_model endpoint**
|
||||
|
||||
Add the streaming file download endpoint right after `check_models`:
|
||||
|
||||
```python
|
||||
@server.PromptServer.instance.routes.post("/distribution/download_model")
|
||||
async def download_model(request):
|
||||
"""
|
||||
Stream a model file to a worker.
|
||||
|
||||
Request body:
|
||||
worker_id: Registered worker ID
|
||||
category: Model category (checkpoints, loras, vae, etc.)
|
||||
filename: Relative path including subdirectories
|
||||
|
||||
Returns:
|
||||
Streaming file response with Content-Length header
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
worker_id = data.get("worker_id", "")
|
||||
category = data.get("category", "")
|
||||
filename = data.get("filename", "")
|
||||
|
||||
if not category or not filename:
|
||||
return web.Response(status=400, text="Missing category or filename")
|
||||
|
||||
import folder_paths
|
||||
full_path = folder_paths.get_full_path(category, filename)
|
||||
|
||||
if not full_path or not os.path.exists(full_path):
|
||||
return web.Response(status=404, text=f"File not found: {category}/{filename}")
|
||||
|
||||
file_size = os.path.getsize(full_path)
|
||||
print(f"[Distribution] 📤 Serving {category}/{filename} ({file_size / (1024**3):.2f} GB) to worker {worker_id}")
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.content_type = "application/octet-stream"
|
||||
response.content_length = file_size
|
||||
response.headers["X-Filename"] = filename
|
||||
response.headers["X-Category"] = category
|
||||
await response.prepare(request)
|
||||
|
||||
chunk_size = 8 * 1024 * 1024 # 8 MB chunks
|
||||
with open(full_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
await response.write(chunk)
|
||||
|
||||
await response.write_eof()
|
||||
print(f"[Distribution] ✅ Finished serving {category}/{filename} to worker {worker_id}")
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"[Distribution] ❌ Error serving model: {e}")
|
||||
return web.Response(status=500, text=str(e))
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add distribution_routes.py
|
||||
git commit -m "Add master file server endpoints for model sync
|
||||
|
||||
POST /distribution/check_models - bulk check which models exist
|
||||
POST /distribution/download_model - stream model file to worker
|
||||
Both endpoints use folder_paths for path resolution."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Worker Model Sync — Download Helper
|
||||
|
||||
Add a helper method to the WorkerThread that downloads a single model file
|
||||
from the master, preserving directory structure.
|
||||
|
||||
**Files:**
|
||||
- Modify: `distribution_worker.py:40-60` (add to WorkerThread class)
|
||||
|
||||
**Step 1: Add sync_models flag and download helper**
|
||||
|
||||
In `distribution_worker.py`, in the `WorkerThread.__init__` method (around
|
||||
line 40-60), add the sync flag after `self._incompatible_loras`:
|
||||
|
||||
```python
|
||||
self._sync_models = False # Set by master when sync_models_to_workers enabled
|
||||
self._prefetch_thread = None
|
||||
self._prefetch_queue = [] # List of {category, filename} to pre-fetch
|
||||
self._prefetch_lock = threading.Lock()
|
||||
```
|
||||
|
||||
Then add the download helper methods to the WorkerThread class. Find a good
|
||||
location (after `__init__` but before `run()`). Add:
|
||||
|
||||
```python
|
||||
def _download_model_from_master(self, category, filename):
|
||||
"""
|
||||
Download a single model file from the master instance.
|
||||
Preserves full subdirectory structure.
|
||||
|
||||
Args:
|
||||
category: Model category (checkpoints, loras, vae, etc.)
|
||||
filename: Relative path including subdirs (e.g. SDXL/Illustrious/model.safetensors)
|
||||
|
||||
Returns:
|
||||
str: Local path where file was saved, or None on failure
|
||||
"""
|
||||
import folder_paths
|
||||
import json
|
||||
|
||||
# Determine target directory
|
||||
# folder_paths stores base directories per category
|
||||
base_dirs = folder_paths.get_folder_paths(category)
|
||||
if not base_dirs:
|
||||
print(f"[Worker {self.worker_id}] ❌ Unknown model category: {category}")
|
||||
return None
|
||||
target_dir = base_dirs[0] # Use first (primary) directory
|
||||
target_path = os.path.join(target_dir, filename.replace("/", os.sep))
|
||||
|
||||
# Skip if already exists
|
||||
if os.path.exists(target_path):
|
||||
return target_path
|
||||
|
||||
# Create subdirectories
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
|
||||
# Download from master
|
||||
url = f"{self.master_url}/distribution/download_model"
|
||||
payload = json.dumps({
|
||||
"worker_id": self.worker_id,
|
||||
"category": category,
|
||||
"filename": filename
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url, data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
print(f"[Worker {self.worker_id}] ⬇️ Downloading {category}/{filename}...")
|
||||
with urllib.request.urlopen(req, timeout=3600) as resp:
|
||||
file_size = int(resp.headers.get("Content-Length", 0))
|
||||
if file_size > 0:
|
||||
size_str = f"{file_size / (1024**3):.2f} GB" if file_size > 1024**3 else f"{file_size / (1024**2):.0f} MB"
|
||||
print(f"[Worker {self.worker_id}] ⬇️ Size: {size_str}")
|
||||
|
||||
# Stream to temp file, then rename (atomic-ish)
|
||||
temp_path = target_path + ".downloading"
|
||||
downloaded = 0
|
||||
last_progress = 0
|
||||
with open(temp_path, "wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(8 * 1024 * 1024) # 8 MB chunks
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if file_size > 0:
|
||||
progress = int(downloaded / file_size * 100)
|
||||
if progress >= last_progress + 10:
|
||||
print(f"[Worker {self.worker_id}] ⬇️ {category}/{filename}: {progress}%")
|
||||
last_progress = progress
|
||||
|
||||
os.replace(temp_path, target_path)
|
||||
print(f"[Worker {self.worker_id}] ✅ Downloaded {category}/{filename}")
|
||||
return target_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Worker {self.worker_id}] ❌ Failed to download {category}/{filename}: {e}")
|
||||
# Clean up partial download
|
||||
temp_path = target_path + ".downloading"
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return None
|
||||
|
||||
def _extract_required_models(self, config):
|
||||
"""
|
||||
Extract all model file references from a job config.
|
||||
|
||||
Returns:
|
||||
List of {category, filename} dicts
|
||||
"""
|
||||
models = []
|
||||
|
||||
# Main model/checkpoint
|
||||
model_name = config.get("model", "None")
|
||||
if model_name and model_name != "None":
|
||||
model_type = config.get("model_type", "checkpoint")
|
||||
if model_type == "checkpoint":
|
||||
category = "checkpoints"
|
||||
elif model_type == "gguf":
|
||||
category = "unet_gguf"
|
||||
elif model_type == "diffusion_model":
|
||||
category = "diffusion_models"
|
||||
else:
|
||||
category = "checkpoints"
|
||||
models.append({"category": category, "filename": model_name})
|
||||
|
||||
# LoRA
|
||||
lora = config.get("lora_expanded", config.get("lora", "None"))
|
||||
if lora and lora != "None":
|
||||
models.append({"category": "loras", "filename": lora})
|
||||
|
||||
# VAE (skip remote URLs and "Default")
|
||||
vae = config.get("vae", "Default")
|
||||
if vae and vae != "Default" and not vae.startswith("remote:"):
|
||||
models.append({"category": "vae", "filename": vae})
|
||||
|
||||
# Text encoders
|
||||
text_encoders = config.get("text_encoders", [])
|
||||
for te in text_encoders:
|
||||
if te and te != "None":
|
||||
models.append({"category": "text_encoders", "filename": te})
|
||||
|
||||
return models
|
||||
|
||||
def _ensure_models_available(self, config):
|
||||
"""
|
||||
Check all required models exist locally, download missing ones from master.
|
||||
Blocks until all models are available.
|
||||
"""
|
||||
import folder_paths
|
||||
|
||||
required = self._extract_required_models(config)
|
||||
if not required:
|
||||
return
|
||||
|
||||
missing = []
|
||||
for m in required:
|
||||
path = folder_paths.get_full_path(m["category"], m["filename"])
|
||||
if path is None:
|
||||
missing.append(m)
|
||||
|
||||
if not missing:
|
||||
return
|
||||
|
||||
print(f"[Worker {self.worker_id}] 📦 Missing {len(missing)} model(s), downloading from master...")
|
||||
for m in missing:
|
||||
result = self._download_model_from_master(m["category"], m["filename"])
|
||||
if result is None:
|
||||
raise RuntimeError(
|
||||
f"Failed to download required model: {m['category']}/{m['filename']}. "
|
||||
f"Check that the file exists on the master instance."
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add distribution_worker.py
|
||||
git commit -m "Add worker model download helpers
|
||||
|
||||
_download_model_from_master streams files from master via HTTP.
|
||||
_extract_required_models parses job config for all model references.
|
||||
_ensure_models_available checks local paths and downloads missing."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire Sync Into Worker Job Processing
|
||||
|
||||
Call `_ensure_models_available()` before `_process_job()` when sync is enabled.
|
||||
Also add background pre-fetch thread for upcoming models.
|
||||
|
||||
**Files:**
|
||||
- Modify: `distribution_worker.py` (run loop and _process_job)
|
||||
|
||||
**Step 1: Set sync flag from master's start_worker call**
|
||||
|
||||
In `distribution_routes.py`, find the `/distribution/start_worker` handler
|
||||
(line ~305). The request body is sent by the master when starting workers.
|
||||
Find where `WorkerThread` is instantiated. After creating the thread, set
|
||||
the sync flag. The master passes `sync_models_to_workers` in the start request.
|
||||
|
||||
Find the start_worker handler and read its implementation to see how the
|
||||
WorkerThread is created. Then add:
|
||||
|
||||
```python
|
||||
# Pass sync models flag to worker
|
||||
worker_thread._sync_models = data.get("sync_models_to_workers", False)
|
||||
```
|
||||
|
||||
**Step 2: Add sync check before _process_job in worker run loop**
|
||||
|
||||
In `distribution_worker.py`, find the main run loop where `_process_job(job)`
|
||||
is called. It's inside the `run()` method. Find the call to `_process_job`
|
||||
and add the sync check just before it:
|
||||
|
||||
```python
|
||||
# Sync missing models from master if enabled
|
||||
if self._sync_models:
|
||||
self._ensure_models_available(job["config"])
|
||||
```
|
||||
|
||||
**Step 3: Add background pre-fetch logic**
|
||||
|
||||
In `distribution_worker.py`, add a pre-fetch method and start it after
|
||||
claiming a job:
|
||||
|
||||
```python
|
||||
def _start_prefetch(self, upcoming_models):
|
||||
"""Start background thread to pre-fetch upcoming models."""
|
||||
if not upcoming_models:
|
||||
return
|
||||
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_queue = list(upcoming_models)
|
||||
|
||||
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
||||
return # Already running
|
||||
|
||||
def _prefetch_worker():
|
||||
while True:
|
||||
with self._prefetch_lock:
|
||||
if not self._prefetch_queue:
|
||||
break
|
||||
model = self._prefetch_queue.pop(0)
|
||||
try:
|
||||
self._download_model_from_master(model["category"], model["filename"])
|
||||
except Exception as e:
|
||||
print(f"[Worker {self.worker_id}] ⚠️ Pre-fetch failed: {e}")
|
||||
|
||||
self._prefetch_thread = threading.Thread(target=_prefetch_worker, daemon=True)
|
||||
self._prefetch_thread.start()
|
||||
```
|
||||
|
||||
In the run loop, after claiming a job and before processing, start pre-fetch:
|
||||
|
||||
```python
|
||||
# Start background pre-fetch for upcoming models
|
||||
if self._sync_models and job.get("upcoming_models"):
|
||||
self._start_prefetch(job["upcoming_models"])
|
||||
```
|
||||
|
||||
**Step 4: Pass sync flag when master starts workers**
|
||||
|
||||
In `generation_orchestrator.py`, find where workers are started (search for
|
||||
`/distribution/start_worker`). Add `sync_models_to_workers` to the request
|
||||
payload sent to each worker.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add distribution_worker.py distribution_routes.py generation_orchestrator.py
|
||||
git commit -m "Wire model sync into worker job processing
|
||||
|
||||
Workers check for missing models before processing when sync enabled.
|
||||
Background pre-fetch thread downloads upcoming models during job execution.
|
||||
Master passes sync flag to workers via start_worker request."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Upcoming Models in Job Claim Response
|
||||
|
||||
Modify the distribution manager to include upcoming model references in
|
||||
job claim responses when sync is enabled.
|
||||
|
||||
**Files:**
|
||||
- Modify: `distribution_manager.py:162-184` (claim_job)
|
||||
- Modify: `distribution_manager.py:518-549` (_job_to_dict)
|
||||
|
||||
**Step 1: Add sync flag to distribution manager**
|
||||
|
||||
In `distribution_manager.py`, find the `__init__` method and add:
|
||||
|
||||
```python
|
||||
self.sync_models_to_workers = False
|
||||
```
|
||||
|
||||
**Step 2: Add upcoming models extraction helper**
|
||||
|
||||
Add a method to the DistributionManager class:
|
||||
|
||||
```python
|
||||
def _get_upcoming_models(self, exclude_job_id=None, limit=10):
|
||||
"""
|
||||
Scan pending jobs and collect unique model references for pre-fetching.
|
||||
Returns list of {category, filename} dicts.
|
||||
"""
|
||||
seen = set()
|
||||
upcoming = []
|
||||
|
||||
for job_id in self._pending_queue:
|
||||
if job_id == exclude_job_id:
|
||||
continue
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
continue
|
||||
|
||||
config = job.config
|
||||
# Extract models from config (same logic as worker)
|
||||
model_name = config.get("model", "None")
|
||||
if model_name and model_name != "None":
|
||||
model_type = config.get("model_type", "checkpoint")
|
||||
if model_type == "checkpoint":
|
||||
cat = "checkpoints"
|
||||
elif model_type == "gguf":
|
||||
cat = "unet_gguf"
|
||||
elif model_type == "diffusion_model":
|
||||
cat = "diffusion_models"
|
||||
else:
|
||||
cat = "checkpoints"
|
||||
key = f"{cat}:{model_name}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
upcoming.append({"category": cat, "filename": model_name})
|
||||
|
||||
lora = config.get("lora_expanded", config.get("lora", "None"))
|
||||
if lora and lora != "None":
|
||||
key = f"loras:{lora}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
upcoming.append({"category": "loras", "filename": lora})
|
||||
|
||||
vae = config.get("vae", "Default")
|
||||
if vae and vae != "Default" and not vae.startswith("remote:"):
|
||||
key = f"vae:{vae}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
upcoming.append({"category": "vae", "filename": vae})
|
||||
|
||||
text_encoders = config.get("text_encoders", [])
|
||||
for te in text_encoders:
|
||||
if te and te != "None":
|
||||
key = f"text_encoders:{te}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
upcoming.append({"category": "text_encoders", "filename": te})
|
||||
|
||||
if len(upcoming) >= limit:
|
||||
break
|
||||
|
||||
return upcoming
|
||||
```
|
||||
|
||||
**Step 3: Add upcoming_models to _job_to_dict**
|
||||
|
||||
In `_job_to_dict()` (line ~518-549), find the return at the end. Before
|
||||
`return result`, add:
|
||||
|
||||
```python
|
||||
# Attach upcoming models list if sync is enabled (for pre-fetching)
|
||||
if self.sync_models_to_workers:
|
||||
result["upcoming_models"] = self._get_upcoming_models(
|
||||
exclude_job_id=job.job_id, limit=10
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Set flag from orchestrator**
|
||||
|
||||
In `generation_orchestrator.py`, find where the distribution manager is
|
||||
created or configured (search for `sync_models` or where `use_master_encoding`
|
||||
is read at line ~1532). After the `use_master_encoding` line, add:
|
||||
|
||||
```python
|
||||
sync_models = distribution_config.get("sync_models_to_workers", False)
|
||||
if sync_models:
|
||||
manager.sync_models_to_workers = True
|
||||
print(f"[Distribution] ☁️ Model sync enabled — workers will download missing models from master")
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add distribution_manager.py generation_orchestrator.py
|
||||
git commit -m "Add upcoming models to job claim response
|
||||
|
||||
Distribution manager scans pending queue for unique model references.
|
||||
When sync_models_to_workers is enabled, job claim responses include
|
||||
upcoming_models list for worker background pre-fetching."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all tasks are complete, verify each piece:
|
||||
|
||||
1. **UI checkbox:** Open Builder UI → Distribution Settings → verify "Sync
|
||||
Models to Workers" toggle appears below Master Text Encoding
|
||||
2. **Config passthrough:** Enable the toggle, check JSON Preview — verify
|
||||
`sync_models_to_workers: true` appears in distribution_config
|
||||
3. **Master endpoints:** Start ComfyUI, test endpoints with curl:
|
||||
- `curl -X POST http://localhost:8188/distribution/check_models -H "Content-Type: application/json" -d '{"worker_id":"test","models":[{"category":"checkpoints","filename":"v1-5-pruned-emaonly.safetensors"}]}'`
|
||||
- Verify it returns file size for models that exist
|
||||
4. **Worker sync:** With two ComfyUI instances, enable sync, run a grid test
|
||||
with a model the worker doesn't have — verify it downloads and processes
|
||||
5. **Pre-fetch:** Verify worker logs show background downloads for upcoming
|
||||
models while current job processes
|
||||
6. **Directory structure:** Verify subdirectories are preserved on the worker
|
||||
(e.g., `models/checkpoints/SDXL/Illustrious/model.safetensors`)
|
||||
@@ -1,67 +0,0 @@
|
||||
# Builder UI & Dashboard Improvements Design
|
||||
|
||||
## Item 1: Double Prompt JSON Editor Size
|
||||
|
||||
**Change:** In `conf-builder-ui-components.js`, update `.cb-prompt-raw-editor` CSS class from `min-height: 80px` to `min-height: 160px`. The `resize: vertical` property is already set so users can still manually adjust.
|
||||
|
||||
## Item 2: Sidebar Always-Open Config Sub-Icons + Icon Changes
|
||||
|
||||
**Current:** Per-config sidebar groups show sub-icons (Models, TE, VAE, LoRAs) only on hover via CSS `.cb-sidebar-config-group:hover .cb-sidebar-sub-icons { display: flex; }`.
|
||||
|
||||
**Changes:**
|
||||
1. Change `.cb-sidebar-sub-icons` from `display: none` to `display: flex` (always visible)
|
||||
2. Remove the `:hover` rule (no longer needed)
|
||||
3. Change config icon labels from numbered circles ("1", "2") to "⚙️1", "⚙️2" etc.
|
||||
4. Change Text Encoders icon from "📎" to "🔣"
|
||||
5. Change LoRAs icon from "🔗" to "🔮"
|
||||
6. If sidebar overflows vertically, make it scrollable with `overflow-y: auto`
|
||||
|
||||
## Item 3: Dashboard Shift+N Column Hotkeys
|
||||
|
||||
**Change:** Add keyboard listener in `resources/logic_virtual.js` `setupKeyboardShortcuts()` for `Shift+1` through `Shift+9` to set column count. Update the `#col-count` input value, save to localStorage, and call `recalcColumns()`. Skip if focus is in an input/textarea. Shift+0 resets to auto mode.
|
||||
|
||||
## Item 4: Dashboard Session Landing & Reload
|
||||
|
||||
### 4a: Rename Load Session Button
|
||||
Change topbar button from folder icon + "Load Session" title to a refresh/reload icon + "Reload Session" title. The button calls the existing `loadSession()` function.
|
||||
|
||||
### 4b: Session Landing Page
|
||||
On page load with no session data (empty manifest), show a landing page with session cards instead of an empty grid. Each card shows:
|
||||
- Session name
|
||||
- First image as thumbnail (loaded from `/view?filename=...&type=output&subfolder=benchmarks/{name}/`)
|
||||
- Modification date
|
||||
- Item count from manifest
|
||||
|
||||
**Implementation:**
|
||||
- Add a new backend endpoint `GET /config_tester/list_sessions` that returns `[{name, item_count, first_image, mtime}]` sorted by most recent
|
||||
- In `logic_init.js`, after init(), check if `fullManifest.items` is empty. If so, call the list endpoint and render session cards
|
||||
- Clicking a card calls `loadSession()` with that session name
|
||||
- Add the same dropdown to the Settings cog panel SESSION section
|
||||
|
||||
### 4c: Settings Cog Session Dropdown
|
||||
Add a `<select>` dropdown in the SESSION section of the cog menu populated from the list_sessions endpoint. Selecting a session loads it.
|
||||
|
||||
## Item 5: Distribution Settings Refactor
|
||||
|
||||
### 5a: Always-Visible Toggle
|
||||
Distribution Settings section currently only renders when `node.state.distribution_enabled` is true. Change to:
|
||||
1. Always render the section at the top
|
||||
2. Show a simple On/Off toggle at the top
|
||||
3. When Off, collapse/hide all settings below the toggle
|
||||
4. When On, show full settings as before
|
||||
|
||||
### 5b: Remove Separate distribution_config Output/Input (Clean Break)
|
||||
**Config Builder (`config_builder_node.py`):**
|
||||
- Change `RETURN_TYPES` from `("STRING", "STRING", "STRING")` to `("STRING", "STRING")`
|
||||
- Change `RETURN_NAMES` from `("configs_json", "session_name", "distribution_config")` to `("configs_json", "session_name")`
|
||||
- Embed distribution_config data inside the configs_json output as a top-level `_distribution` key
|
||||
- Return format: `{"configs": [...], "_distribution": {...}}` instead of just `[...]`
|
||||
|
||||
**Sampler Node (`sampler_node.py`):**
|
||||
- Remove `distribution_config` from optional INPUT_TYPES
|
||||
- Remove `distribution_config` parameter from `run_tests()`
|
||||
- Parse distribution config from within `configs_json` (look for `_distribution` key)
|
||||
|
||||
**Generation Orchestrator (`generation_orchestrator.py`):**
|
||||
- Update `run_generation_loop()` signature to remove `distribution_config` parameter
|
||||
- Extract `_distribution` from the parsed configs_json data instead
|
||||
@@ -1,35 +0,0 @@
|
||||
# Builder UI & Dashboard Improvements Implementation Plan
|
||||
|
||||
**Critical Constraint:** DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Double Prompt JSON Editor Size (Easy)
|
||||
**File:** `web/conf_builder/conf-builder-ui-components.js`
|
||||
- Change `.cb-prompt-raw-editor` `min-height: 80px` → `min-height: 160px`
|
||||
|
||||
## Task 2: Sidebar Always-Open + Icon Changes
|
||||
**File:** `web/conf_builder/conf-builder-ui-components.js`
|
||||
- CSS: Change `.cb-sidebar-sub-icons` `display: none` → `display: flex`
|
||||
- CSS: Remove `.cb-sidebar-config-group:hover .cb-sidebar-sub-icons` rule
|
||||
- CSS: Add `overflow-y: auto` to sidebar container
|
||||
- JS: Change config icon content from `idx+1` to `⚙️${idx+1}`
|
||||
- JS: Change TE icon from "📎" to "🔣"
|
||||
- JS: Change LoRA icon from "🔗" to "🔮"
|
||||
|
||||
## Task 3: Dashboard Shift+N Column Hotkeys
|
||||
**File:** `resources/logic_virtual.js`
|
||||
- Add cases in `setupKeyboardShortcuts()` for digits 0-9 with shiftKey
|
||||
|
||||
## Task 4: Dashboard Session Landing & Reload
|
||||
**Files:** `resources/template.html`, `resources/logic_utils.js`, `resources/logic_init.js`, `resources/report.css`, `__init__.py`
|
||||
- Rename topbar load button
|
||||
- Add list_sessions endpoint
|
||||
- Add landing page rendering
|
||||
- Add session dropdown in cog menu
|
||||
|
||||
## Task 5: Distribution Settings Refactor
|
||||
**Files:** `web/conf_builder/conf-builder-distribution.js`, `web/conf_builder/conf-builder-config-management.js`, `config_builder_node.py`, `sampler_node.py`, `generation_orchestrator.py`
|
||||
- Always show distribution toggle
|
||||
- Embed distribution_config in configs_json
|
||||
- Remove separate output/input
|
||||
@@ -1,955 +0,0 @@
|
||||
# Seed Settings, Canvas Flicker Fix, Upscaling & GPU Cooldown Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add 4 features: expanded seed behavior options per-config, fix canvas flicker during simultaneous pan+zoom, add 3 upscaling modes as session-level settings, and add GPU cooldown breaks between generations.
|
||||
|
||||
**Architecture:** Seed settings extend existing per-config `seed_behavior` dropdown with new options (random/increment/decrement before/after full run). Canvas flicker fix batches pan+zoom transforms via requestAnimationFrame. Upscaling and cooldown are session-level settings stored in `node.state` and output alongside `configs_json`, consumed by `generation_orchestrator.py` and `distribution_worker.py`.
|
||||
|
||||
**Tech Stack:** Python (ComfyUI custom nodes), JavaScript (Builder UI widgets), ComfyUI model management APIs, requestAnimationFrame
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL RULE
|
||||
|
||||
**DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.**
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Expand Seed Behavior Options (Builder UI)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js:1018-1029` (seed select dropdown)
|
||||
- Modify: `web/conf_builder/conf-builder-utilities.js:637-639` (convertStateToConfigs seed output)
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js:867` (default state)
|
||||
|
||||
**Step 1: Update the seed behavior dropdown**
|
||||
|
||||
In `web/conf_builder/conf-builder-config-management.js`, find the seed select at line 1018-1029. Replace the `<select>` innerHTML with expanded options. Change the label from "Seed Behavior" to "Seed Behavior (Per Gen)":
|
||||
|
||||
```javascript
|
||||
// Seed Behavior Select — Per-Generation seed behavior
|
||||
const seedSelect = document.createElement("select");
|
||||
seedSelect.className = "cb-select";
|
||||
seedSelect.innerHTML = `
|
||||
<option value="fixed" ${(configArray.seed_behavior || "fixed") === "fixed" ? 'selected' : ''}>Fixed (use node seed)</option>
|
||||
<option value="randomize" ${configArray.seed_behavior === "randomize" ? 'selected' : ''}>Randomize every gen</option>
|
||||
`;
|
||||
seedSelect.onchange = () => {
|
||||
node.state.config_arrays[arrayIdx].seed_behavior = seedSelect.value;
|
||||
node.saveState();
|
||||
};
|
||||
settingsGrid.appendChild(createInputGroup("Seed Behavior (Per Gen)", seedSelect));
|
||||
```
|
||||
|
||||
**Step 2: Add Full Run Seed Behavior dropdown**
|
||||
|
||||
Immediately after the per-gen seed select block (after the `settingsGrid.appendChild` for "Seed Behavior (Per Gen)"), add:
|
||||
|
||||
```javascript
|
||||
// Full Run Seed Behavior — applied before/after the entire grid test session
|
||||
const fullRunSeedSelect = document.createElement("select");
|
||||
fullRunSeedSelect.className = "cb-select";
|
||||
fullRunSeedSelect.innerHTML = `
|
||||
<option value="fixed" ${(configArray.full_run_seed_behavior || "fixed") === "fixed" ? 'selected' : ''}>Fixed</option>
|
||||
<option value="random_before" ${configArray.full_run_seed_behavior === "random_before" ? 'selected' : ''}>Random Before Entire Run</option>
|
||||
<option value="random_after" ${configArray.full_run_seed_behavior === "random_after" ? 'selected' : ''}>Random After Entire Run</option>
|
||||
<option value="increment_after" ${configArray.full_run_seed_behavior === "increment_after" ? 'selected' : ''}>Increment After Entire Run</option>
|
||||
<option value="decrement_after" ${configArray.full_run_seed_behavior === "decrement_after" ? 'selected' : ''}>Decrement After Entire Run</option>
|
||||
`;
|
||||
fullRunSeedSelect.onchange = () => {
|
||||
node.state.config_arrays[arrayIdx].full_run_seed_behavior = fullRunSeedSelect.value;
|
||||
node.saveState();
|
||||
};
|
||||
settingsGrid.appendChild(createInputGroup("Full Run Seed Behavior", fullRunSeedSelect));
|
||||
```
|
||||
|
||||
**Step 3: Update convertStateToConfigs to output full_run_seed_behavior**
|
||||
|
||||
In `web/conf_builder/conf-builder-utilities.js`, find the seed_behavior output block at line 637-639. After it, add:
|
||||
|
||||
```javascript
|
||||
// Add full_run_seed_behavior if not fixed
|
||||
if (configArray.full_run_seed_behavior && configArray.full_run_seed_behavior !== "fixed") {
|
||||
config.full_run_seed_behavior = configArray.full_run_seed_behavior;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Update default state objects**
|
||||
|
||||
In `conf-builder-utilities.js`, find the default config objects (around lines 680 and 883) that have `seed_behavior: "fixed"`. After each `seed_behavior: "fixed"` line, add:
|
||||
|
||||
```javascript
|
||||
full_run_seed_behavior: "fixed",
|
||||
```
|
||||
|
||||
Do the same in `conf-builder-ui-components.js` at line 867.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js web/conf_builder/conf-builder-utilities.js web/conf_builder/conf-builder-ui-components.js
|
||||
git commit -m "feat: add full run seed behavior options to Builder UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Seed Behavior Python Backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `config_builder_node.py:465-524` (generate_config — add full_run_seed_behavior output)
|
||||
- Modify: `config_utils.py:603-604` (expand_configs — pass through full_run_seed_behavior)
|
||||
- Modify: `generation_orchestrator.py:627-695` (main loop — apply full run seed logic)
|
||||
- Modify: `distribution_worker.py:478-480` (worker seed handling)
|
||||
|
||||
**Step 1: Add full_run_seed_behavior to Python config output**
|
||||
|
||||
In `config_builder_node.py`, inside `generate_config()`, find where `seed_behavior` is output (search for "seed_behavior" in the config-building loop). After that line, add:
|
||||
|
||||
```python
|
||||
# Full run seed behavior (applied before/after entire grid test session)
|
||||
full_run_seed_behavior = config_array.get("full_run_seed_behavior", "fixed")
|
||||
if full_run_seed_behavior and full_run_seed_behavior != "fixed":
|
||||
config["full_run_seed_behavior"] = full_run_seed_behavior
|
||||
```
|
||||
|
||||
**Step 2: Pass through in expand_configs**
|
||||
|
||||
In `config_utils.py`, find line 604 where `seed_behavior` is set in the expanded config dict. After it, add:
|
||||
|
||||
```python
|
||||
"full_run_seed_behavior": entry.get("full_run_seed_behavior", "fixed"),
|
||||
```
|
||||
|
||||
**Step 3: Apply full run seed behavior in generation_orchestrator.py**
|
||||
|
||||
In `generation_orchestrator.py`, find the `# ==== MAIN GENERATION LOOP ====` section at line 627. **Before** the `for job_idx, job in enumerate(input_jobs):` loop (line 630), add:
|
||||
|
||||
```python
|
||||
# ==== FULL RUN SEED BEHAVIOR (PRE-RUN) ====
|
||||
# Apply "random_before" full run seed behavior: randomize seed before the entire session
|
||||
for conf_idx, conf in enumerate(expanded):
|
||||
if conf.get("full_run_seed_behavior") == "random_before":
|
||||
import random
|
||||
conf["seed"] = random.randint(0, 2**63 - 1)
|
||||
print(f"[GridTester] 🎲 Full run random_before: config {conf_idx} seed → {conf['seed']}")
|
||||
```
|
||||
|
||||
Then, **after** the main generation loop ends (after all jobs complete, before the final manifest save), add:
|
||||
|
||||
```python
|
||||
# ==== FULL RUN SEED BEHAVIOR (POST-RUN) ====
|
||||
# These modify the seed for the NEXT queue/run, not the current one.
|
||||
# We update the node's seed widget value so the next execution uses the new seed.
|
||||
for conf_idx, conf in enumerate(expanded):
|
||||
frb = conf.get("full_run_seed_behavior", "fixed")
|
||||
if frb == "random_after":
|
||||
import random
|
||||
new_seed = random.randint(0, 2**63 - 1)
|
||||
print(f"[GridTester] 🎲 Full run random_after: config {conf_idx} next seed → {new_seed}")
|
||||
conf["seed"] = new_seed
|
||||
elif frb == "increment_after":
|
||||
conf["seed"] = conf["seed"] + 1
|
||||
print(f"[GridTester] ➕ Full run increment_after: config {conf_idx} next seed → {conf['seed']}")
|
||||
elif frb == "decrement_after":
|
||||
conf["seed"] = conf["seed"] - 1
|
||||
print(f"[GridTester] ➖ Full run decrement_after: config {conf_idx} next seed → {conf['seed']}")
|
||||
```
|
||||
|
||||
**Step 4: Update distribution_worker.py**
|
||||
|
||||
In `distribution_worker.py`, find the seed handling at line 478-480. After it, add:
|
||||
|
||||
```python
|
||||
# Full run seed behavior is handled by the orchestrator/master before dispatching jobs.
|
||||
# Workers just use whatever seed is in the config they receive.
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add config_builder_node.py config_utils.py generation_orchestrator.py distribution_worker.py
|
||||
git commit -m "feat: implement full run seed behavior in backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fix Canvas Flicker Bug (requestAnimationFrame Batching)
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/logic_virtual.js:11-20` (pan/zoom state vars)
|
||||
- Modify: `resources/logic_virtual.js:363-369` (updateTransform)
|
||||
- Modify: `resources/logic_virtual.js:379-404` (updateZoom)
|
||||
- Modify: `resources/logic_virtual.js:530-544` (mousemove + wheel handlers)
|
||||
|
||||
**Step 1: Add rAF batching state variables**
|
||||
|
||||
In `resources/logic_virtual.js`, after the pan/zoom state variables (after line 20 `let panOffsetY = 0;`), add:
|
||||
|
||||
```javascript
|
||||
// --- RAF BATCHING STATE (prevents flicker from simultaneous pan+zoom) ---
|
||||
let rafPending = false;
|
||||
let pendingPanDeltaX = 0;
|
||||
let pendingPanDeltaY = 0;
|
||||
let pendingZoomDelta = 0;
|
||||
let pendingZoomMouseX = 0;
|
||||
let pendingZoomMouseY = 0;
|
||||
let hasPendingZoom = false;
|
||||
```
|
||||
|
||||
**Step 2: Add the batched transform apply function**
|
||||
|
||||
After the `updateTransform()` function (after line 369), add:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Apply all pending pan+zoom transforms in a single animation frame.
|
||||
* This prevents flicker when zooming and panning simultaneously,
|
||||
* because both transforms are applied atomically before the next paint.
|
||||
*/
|
||||
function applyPendingTransforms() {
|
||||
rafPending = false;
|
||||
|
||||
// Apply zoom first (adjusts panOffset to keep cursor stable)
|
||||
if (hasPendingZoom) {
|
||||
updateZoom(pendingZoomDelta, pendingZoomMouseX, pendingZoomMouseY);
|
||||
hasPendingZoom = false;
|
||||
pendingZoomDelta = 0;
|
||||
}
|
||||
|
||||
// Apply pan delta
|
||||
if (pendingPanDeltaX !== 0 || pendingPanDeltaY !== 0) {
|
||||
panOffsetX += pendingPanDeltaX;
|
||||
panOffsetY += pendingPanDeltaY;
|
||||
pendingPanDeltaX = 0;
|
||||
pendingPanDeltaY = 0;
|
||||
updateTransform();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRAF() {
|
||||
if (!rafPending) {
|
||||
rafPending = true;
|
||||
requestAnimationFrame(applyPendingTransforms);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update mousemove handler to batch when zooming simultaneously**
|
||||
|
||||
Replace the mousemove handler at lines 530-539 with:
|
||||
|
||||
```javascript
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
lastMouseX = e.clientX;
|
||||
lastMouseY = e.clientY;
|
||||
if (!isPanning && !isMiddleMousePanning) return;
|
||||
e.preventDefault();
|
||||
|
||||
// If a zoom is also pending, batch the pan via rAF to prevent flicker
|
||||
if (hasPendingZoom) {
|
||||
pendingPanDeltaX = e.clientX - panStartX - panOffsetX;
|
||||
pendingPanDeltaY = e.clientY - panStartY - panOffsetY;
|
||||
scheduleRAF();
|
||||
} else {
|
||||
// No pending zoom — apply pan immediately for responsiveness
|
||||
panOffsetX = e.clientX - panStartX;
|
||||
panOffsetY = e.clientY - panStartY;
|
||||
updateTransform();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Step 4: Update wheel handler to batch via rAF**
|
||||
|
||||
Replace the wheel handler at lines 541-544 with:
|
||||
|
||||
```javascript
|
||||
viewport.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// Batch zoom via rAF to prevent flicker with simultaneous pan
|
||||
if (isPanning || isMiddleMousePanning) {
|
||||
pendingZoomDelta += (e.deltaY > 0 ? -1 : 1);
|
||||
pendingZoomMouseX = e.clientX;
|
||||
pendingZoomMouseY = e.clientY;
|
||||
hasPendingZoom = true;
|
||||
scheduleRAF();
|
||||
} else {
|
||||
// Not panning — apply zoom immediately for responsiveness
|
||||
updateZoom(e.deltaY > 0 ? -1 : 1, e.clientX, e.clientY);
|
||||
}
|
||||
}, { passive: false });
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/logic_virtual.js
|
||||
git commit -m "fix: prevent canvas flicker during simultaneous pan+zoom via rAF batching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Upscaling Settings (Builder UI)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js` (add upscaling UI section)
|
||||
- Modify: `web/conf_builder/conf-builder-utilities.js` (output upscaling in convertStateToConfigs)
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js` (default state)
|
||||
|
||||
**Step 1: Add upscaling UI section**
|
||||
|
||||
In `web/conf_builder/conf-builder-config-management.js`, find the main Builder UI panel rendering. Look for where session-level settings are rendered (near the top of the config panel, before individual config arrays). Add a new "Upscaling" collapsible section:
|
||||
|
||||
```javascript
|
||||
// ============================================================================
|
||||
// UPSCALING SETTINGS (Session-level, applies to all configs)
|
||||
// ============================================================================
|
||||
function createUpscalingSection(node) {
|
||||
const state = node.state;
|
||||
if (!state.upscaling) {
|
||||
state.upscaling = {
|
||||
enabled: false,
|
||||
mode: "hires_only",
|
||||
upscale_ratio: 1.5,
|
||||
hires_denoise: 0.5,
|
||||
hires_steps: 0,
|
||||
tiled_vae: false,
|
||||
tile_size: 512,
|
||||
upscale_model: "",
|
||||
upscale_size: 2.0
|
||||
};
|
||||
}
|
||||
const ups = state.upscaling;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "cb-section";
|
||||
|
||||
// Header with enable toggle
|
||||
const header = document.createElement("div");
|
||||
header.className = "cb-section-header";
|
||||
header.style.cssText = "display: flex; align-items: center; gap: 8px; cursor: pointer;";
|
||||
|
||||
const enableCb = document.createElement("input");
|
||||
enableCb.type = "checkbox";
|
||||
enableCb.checked = ups.enabled;
|
||||
enableCb.onclick = (e) => e.stopPropagation();
|
||||
enableCb.onchange = () => {
|
||||
ups.enabled = enableCb.checked;
|
||||
body.style.display = enableCb.checked ? "block" : "none";
|
||||
node.saveState();
|
||||
};
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.textContent = "🔍 Upscaling Settings";
|
||||
title.style.cssText = "font-weight: bold; color: #cc99ff;";
|
||||
|
||||
header.appendChild(enableCb);
|
||||
header.appendChild(title);
|
||||
container.appendChild(header);
|
||||
|
||||
// Body (hidden when disabled)
|
||||
const body = document.createElement("div");
|
||||
body.style.display = ups.enabled ? "block" : "none";
|
||||
body.style.cssText += " padding: 8px; display: " + (ups.enabled ? "block" : "none") + ";";
|
||||
|
||||
// Mode select
|
||||
const modeSelect = document.createElement("select");
|
||||
modeSelect.className = "cb-select";
|
||||
modeSelect.innerHTML = `
|
||||
<option value="hires_only" ${ups.mode === "hires_only" ? 'selected' : ''}>HiRes Fix Only</option>
|
||||
<option value="model_only" ${ups.mode === "model_only" ? 'selected' : ''}>Model Upscale Only</option>
|
||||
<option value="model_then_hires" ${ups.mode === "model_then_hires" ? 'selected' : ''}>Model Upscale → HiRes Fix</option>
|
||||
`;
|
||||
modeSelect.onchange = () => {
|
||||
ups.mode = modeSelect.value;
|
||||
updateVisibility();
|
||||
node.saveState();
|
||||
};
|
||||
body.appendChild(createInputGroup("Upscale Mode", modeSelect));
|
||||
|
||||
// HiRes Fix settings
|
||||
const hiresGroup = document.createElement("div");
|
||||
hiresGroup.id = "upscale-hires-group";
|
||||
|
||||
const ratioInput = createNumberInput(ups.upscale_ratio, 1.1, 4.0, 0.1, (v) => { ups.upscale_ratio = v; node.saveState(); });
|
||||
hiresGroup.appendChild(createInputGroup("Upscale Ratio", ratioInput));
|
||||
|
||||
const denoiseInput = createNumberInput(ups.hires_denoise, 0.0, 1.0, 0.05, (v) => { ups.hires_denoise = v; node.saveState(); });
|
||||
hiresGroup.appendChild(createInputGroup("HiRes Denoise", denoiseInput));
|
||||
|
||||
const stepsInput = createNumberInput(ups.hires_steps, 0, 150, 1, (v) => { ups.hires_steps = v; node.saveState(); });
|
||||
hiresGroup.appendChild(createInputGroup("HiRes Steps (0=same)", stepsInput));
|
||||
|
||||
const tiledCb = document.createElement("input");
|
||||
tiledCb.type = "checkbox";
|
||||
tiledCb.checked = ups.tiled_vae;
|
||||
tiledCb.onchange = () => {
|
||||
ups.tiled_vae = tiledCb.checked;
|
||||
tileSizeGroup.style.display = tiledCb.checked ? "block" : "none";
|
||||
node.saveState();
|
||||
};
|
||||
hiresGroup.appendChild(createInputGroup("Tiled VAE", tiledCb));
|
||||
|
||||
const tileSizeInput = createNumberInput(ups.tile_size, 128, 1024, 64, (v) => { ups.tile_size = v; node.saveState(); });
|
||||
const tileSizeGroup = createInputGroup("Tile Size", tileSizeInput);
|
||||
tileSizeGroup.style.display = ups.tiled_vae ? "block" : "none";
|
||||
hiresGroup.appendChild(tileSizeGroup);
|
||||
|
||||
body.appendChild(hiresGroup);
|
||||
|
||||
// Model Upscale settings
|
||||
const modelGroup = document.createElement("div");
|
||||
modelGroup.id = "upscale-model-group";
|
||||
|
||||
const modelInput = document.createElement("input");
|
||||
modelInput.type = "text";
|
||||
modelInput.className = "cb-input";
|
||||
modelInput.value = ups.upscale_model || "";
|
||||
modelInput.placeholder = "e.g. 4x-UltraSharp";
|
||||
modelInput.onchange = () => { ups.upscale_model = modelInput.value; node.saveState(); };
|
||||
modelGroup.appendChild(createInputGroup("Upscale Model", modelInput));
|
||||
|
||||
const sizeInput = createNumberInput(ups.upscale_size, 1.0, 4.0, 0.5, (v) => { ups.upscale_size = v; node.saveState(); });
|
||||
modelGroup.appendChild(createInputGroup("Upscale Size (multiplier)", sizeInput));
|
||||
|
||||
body.appendChild(modelGroup);
|
||||
|
||||
function updateVisibility() {
|
||||
const showHires = ups.mode === "hires_only" || ups.mode === "model_then_hires";
|
||||
const showModel = ups.mode === "model_only" || ups.mode === "model_then_hires";
|
||||
hiresGroup.style.display = showHires ? "block" : "none";
|
||||
modelGroup.style.display = showModel ? "block" : "none";
|
||||
}
|
||||
updateVisibility();
|
||||
|
||||
container.appendChild(body);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Helper: create a number input with min/max/step and onchange callback
|
||||
function createNumberInput(value, min, max, step, onChange) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.className = "cb-input";
|
||||
input.value = value;
|
||||
input.min = min;
|
||||
input.max = max;
|
||||
input.step = step;
|
||||
input.onchange = () => onChange(parseFloat(input.value));
|
||||
return input;
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: Find the appropriate place to insert `createUpscalingSection(node)` call — look for where the main panel is built and add it after the config arrays section but before the preview. Also check if `createInputGroup` already exists — it's likely already defined in the codebase. If `createNumberInput` already exists, don't duplicate it.
|
||||
|
||||
**Step 2: Output upscaling in convertStateToConfigs**
|
||||
|
||||
In `web/conf_builder/conf-builder-utilities.js`, in `convertStateToConfigs()`, at the end of the function (before `return configs;`), add session-level settings output:
|
||||
|
||||
```javascript
|
||||
// Session-level settings (not per-config, applied globally)
|
||||
const sessionSettings = {};
|
||||
|
||||
// Upscaling settings
|
||||
if (state.upscaling && state.upscaling.enabled) {
|
||||
sessionSettings.upscaling = { ...state.upscaling };
|
||||
}
|
||||
|
||||
// Attach session settings if any exist
|
||||
if (Object.keys(sessionSettings).length > 0) {
|
||||
configs._session_settings = sessionSettings;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update default state**
|
||||
|
||||
In `conf-builder-ui-components.js`, find where default state is initialized and add:
|
||||
|
||||
```javascript
|
||||
upscaling: {
|
||||
enabled: false,
|
||||
mode: "hires_only",
|
||||
upscale_ratio: 1.5,
|
||||
hires_denoise: 0.5,
|
||||
hires_steps: 0,
|
||||
tiled_vae: false,
|
||||
tile_size: 512,
|
||||
upscale_model: "",
|
||||
upscale_size: 2.0
|
||||
},
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js web/conf_builder/conf-builder-utilities.js web/conf_builder/conf-builder-ui-components.js
|
||||
git commit -m "feat: add upscaling settings UI to Builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: GPU Cooldown Settings (Builder UI)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/conf_builder/conf-builder-config-management.js` (add cooldown UI section)
|
||||
- Modify: `web/conf_builder/conf-builder-utilities.js` (output cooldown settings)
|
||||
- Modify: `web/conf_builder/conf-builder-ui-components.js` (default state)
|
||||
|
||||
**Step 1: Add cooldown UI section**
|
||||
|
||||
In `web/conf_builder/conf-builder-config-management.js`, add a cooldown section function (right after the upscaling section from Task 4):
|
||||
|
||||
```javascript
|
||||
// ============================================================================
|
||||
// GPU COOLDOWN SETTINGS (Session-level, applies to all configs)
|
||||
// ============================================================================
|
||||
function createCooldownSection(node) {
|
||||
const state = node.state;
|
||||
if (!state.cooldown) {
|
||||
state.cooldown = {
|
||||
enabled: false,
|
||||
seconds: 5,
|
||||
every_n: 1,
|
||||
clear_vram: false
|
||||
};
|
||||
}
|
||||
const cd = state.cooldown;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "cb-section";
|
||||
|
||||
// Header with enable toggle
|
||||
const header = document.createElement("div");
|
||||
header.className = "cb-section-header";
|
||||
header.style.cssText = "display: flex; align-items: center; gap: 8px; cursor: pointer;";
|
||||
|
||||
const enableCb = document.createElement("input");
|
||||
enableCb.type = "checkbox";
|
||||
enableCb.checked = cd.enabled;
|
||||
enableCb.onclick = (e) => e.stopPropagation();
|
||||
enableCb.onchange = () => {
|
||||
cd.enabled = enableCb.checked;
|
||||
body.style.display = enableCb.checked ? "block" : "none";
|
||||
node.saveState();
|
||||
};
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.textContent = "❄️ GPU Cooldown Breaks";
|
||||
title.style.cssText = "font-weight: bold; color: #66ccff;";
|
||||
|
||||
header.appendChild(enableCb);
|
||||
header.appendChild(title);
|
||||
container.appendChild(header);
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
body.style.display = cd.enabled ? "block" : "none";
|
||||
body.style.cssText += " padding: 8px; display: " + (cd.enabled ? "block" : "none") + ";";
|
||||
|
||||
const secondsInput = createNumberInput(cd.seconds, 1, 300, 1, (v) => { cd.seconds = v; node.saveState(); });
|
||||
body.appendChild(createInputGroup("Cooldown Seconds", secondsInput));
|
||||
|
||||
const everyNInput = createNumberInput(cd.every_n, 1, 100, 1, (v) => { cd.every_n = v; node.saveState(); });
|
||||
body.appendChild(createInputGroup("Every N Generations", everyNInput));
|
||||
|
||||
const vramCb = document.createElement("input");
|
||||
vramCb.type = "checkbox";
|
||||
vramCb.checked = cd.clear_vram;
|
||||
vramCb.onchange = () => {
|
||||
cd.clear_vram = vramCb.checked;
|
||||
node.saveState();
|
||||
};
|
||||
body.appendChild(createInputGroup("Clear VRAM During Cooldown", vramCb));
|
||||
|
||||
container.appendChild(body);
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
Add the `createCooldownSection(node)` call right after the upscaling section in the panel builder.
|
||||
|
||||
**Step 2: Output cooldown settings in convertStateToConfigs**
|
||||
|
||||
In `web/conf_builder/conf-builder-utilities.js`, in the session settings block added in Task 4 Step 2, add:
|
||||
|
||||
```javascript
|
||||
// Cooldown settings
|
||||
if (state.cooldown && state.cooldown.enabled) {
|
||||
sessionSettings.cooldown = { ...state.cooldown };
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update default state**
|
||||
|
||||
In `conf-builder-ui-components.js`, add to default state:
|
||||
|
||||
```javascript
|
||||
cooldown: {
|
||||
enabled: false,
|
||||
seconds: 5,
|
||||
every_n: 1,
|
||||
clear_vram: false
|
||||
},
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/conf_builder/conf-builder-config-management.js web/conf_builder/conf-builder-utilities.js web/conf_builder/conf-builder-ui-components.js
|
||||
git commit -m "feat: add GPU cooldown settings UI to Builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Upscaling Python Backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `image_generation.py:262-314` (add upscale_image function after generate_image, before decode_latent_with_vae)
|
||||
- Modify: `model_loader.py` (add load_upscale_model function)
|
||||
- Modify: `config_builder_node.py` (output session settings in generate_config)
|
||||
- Modify: `generation_orchestrator.py:1081-1121` (apply upscaling after generate_image)
|
||||
- Modify: `distribution_worker.py:592-595` (apply upscaling after generate_image in worker)
|
||||
|
||||
**Step 1: Add load_upscale_model to model_loader.py**
|
||||
|
||||
At the end of `model_loader.py`, add:
|
||||
|
||||
```python
|
||||
def load_upscale_model(model_name):
|
||||
"""
|
||||
Load an upscale model (ESRGAN, RealESRGAN, etc.) by name.
|
||||
|
||||
Args:
|
||||
model_name: Name of the upscale model file (e.g. "4x-UltraSharp.pth")
|
||||
|
||||
Returns:
|
||||
Loaded upscale model ready for use
|
||||
"""
|
||||
from comfy_extras.nodes_upscale_model import UpscaleModelLoader
|
||||
loader = UpscaleModelLoader()
|
||||
(upscale_model,) = loader.load_model(model_name)
|
||||
return upscale_model
|
||||
```
|
||||
|
||||
**Step 2: Add upscale_image to image_generation.py**
|
||||
|
||||
After the `generate_image()` function (after line 262 `return result[0], duration`) and before `decode_latent_with_vae()` (line 265), add:
|
||||
|
||||
```python
|
||||
def upscale_image(result_latent, vae, patched_model, upscaling_config, config, positive_conditioning, negative_conditioning, width, height):
|
||||
"""
|
||||
Apply upscaling to a generated latent based on upscaling settings.
|
||||
|
||||
Args:
|
||||
result_latent: Generated latent dict with "samples" key
|
||||
vae: VAE model for encode/decode
|
||||
patched_model: Patched model for re-sampling (HiRes fix)
|
||||
upscaling_config: Dict with mode, upscale_ratio, hires_denoise, etc.
|
||||
config: Current generation config (steps, sampler, scheduler, etc.)
|
||||
positive_conditioning: Positive conditioning
|
||||
negative_conditioning: Negative conditioning
|
||||
width: Original image width
|
||||
height: Original image height
|
||||
|
||||
Returns:
|
||||
Upscaled latent dict or PIL Image depending on mode
|
||||
"""
|
||||
import torch
|
||||
import time
|
||||
import comfy.utils
|
||||
|
||||
mode = upscaling_config.get("mode", "hires_only")
|
||||
upscale_ratio = float(upscaling_config.get("upscale_ratio", 1.5))
|
||||
hires_denoise = float(upscaling_config.get("hires_denoise", 0.5))
|
||||
hires_steps = int(upscaling_config.get("hires_steps", 0)) or config.get("steps", 20)
|
||||
tiled_vae = upscaling_config.get("tiled_vae", False)
|
||||
tile_size = int(upscaling_config.get("tile_size", 512))
|
||||
upscale_model_name = upscaling_config.get("upscale_model", "")
|
||||
|
||||
new_w = int(width * upscale_ratio)
|
||||
new_h = int(height * upscale_ratio)
|
||||
|
||||
t0 = time.time()
|
||||
print(f"[GridTester] 🔍 Upscaling: mode={mode}, ratio={upscale_ratio}, target={new_w}x{new_h}")
|
||||
|
||||
if mode == "model_only":
|
||||
# Decode → model upscale → return as PIL image
|
||||
from model_loader import load_upscale_model
|
||||
from comfy_extras.nodes_upscale_model import ImageUpscaleWithModel
|
||||
|
||||
pil_image = decode_latent_with_vae(vae, result_latent["samples"])
|
||||
|
||||
# Convert PIL to tensor for model upscale
|
||||
import numpy as np
|
||||
img_np = np.array(pil_image).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_np).unsqueeze(0) # (1, H, W, C)
|
||||
|
||||
upscaler = ImageUpscaleWithModel()
|
||||
up_model = load_upscale_model(upscale_model_name)
|
||||
(upscaled_tensor,) = upscaler.upscale(up_model, img_tensor)
|
||||
|
||||
# Convert back to PIL
|
||||
up_np = upscaled_tensor[0].cpu().float().numpy()
|
||||
up_np = np.clip(up_np * 255, 0, 255).astype(np.uint8)
|
||||
upscaled_image = Image.fromarray(up_np)
|
||||
|
||||
duration = round(time.time() - t0, 3)
|
||||
print(f"[GridTester] 🔍 Model upscale complete in {duration}s → {upscaled_image.size[0]}x{upscaled_image.size[1]}")
|
||||
return upscaled_image, duration
|
||||
|
||||
elif mode == "hires_only":
|
||||
# Upscale latent → re-sample with denoise
|
||||
latent_samples = result_latent["samples"]
|
||||
upscaled_latent = comfy.utils.common_upscale(
|
||||
latent_samples, new_w // 8, new_h // 8, "bilinear", "disabled"
|
||||
)
|
||||
|
||||
# Re-sample with denoise
|
||||
from comfy.sample import prepare_noise
|
||||
import comfy.samplers
|
||||
|
||||
hires_latent, hires_duration = generate_image(
|
||||
patched_model, config.get("seed", 0), hires_steps, config.get("cfg", 7),
|
||||
config.get("sampler", "euler"), config.get("scheduler", "normal"),
|
||||
positive_conditioning, negative_conditioning,
|
||||
{"samples": upscaled_latent}, hires_denoise,
|
||||
width=new_w, height=new_h
|
||||
)
|
||||
|
||||
duration = round(time.time() - t0, 3)
|
||||
print(f"[GridTester] 🔍 HiRes fix complete in {duration}s → {new_w}x{new_h}")
|
||||
return hires_latent, duration
|
||||
|
||||
elif mode == "model_then_hires":
|
||||
# Model upscale first, then encode to latent, then HiRes fix
|
||||
from model_loader import load_upscale_model
|
||||
from comfy_extras.nodes_upscale_model import ImageUpscaleWithModel
|
||||
|
||||
pil_image = decode_latent_with_vae(vae, result_latent["samples"])
|
||||
|
||||
import numpy as np
|
||||
img_np = np.array(pil_image).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_np).unsqueeze(0)
|
||||
|
||||
upscaler = ImageUpscaleWithModel()
|
||||
up_model = load_upscale_model(upscale_model_name)
|
||||
(upscaled_tensor,) = upscaler.upscale(up_model, img_tensor)
|
||||
|
||||
# Encode back to latent
|
||||
upscaled_tensor_permuted = upscaled_tensor.permute(0, 3, 1, 2) # NHWC → NCHW for VAE
|
||||
encoded_latent = vae.encode(upscaled_tensor[:, :, :, :3]) # VAE expects NHWC
|
||||
|
||||
up_h, up_w = upscaled_tensor.shape[1], upscaled_tensor.shape[2]
|
||||
|
||||
# HiRes fix on the model-upscaled latent
|
||||
hires_latent, hires_duration = generate_image(
|
||||
patched_model, config.get("seed", 0), hires_steps, config.get("cfg", 7),
|
||||
config.get("sampler", "euler"), config.get("scheduler", "normal"),
|
||||
positive_conditioning, negative_conditioning,
|
||||
{"samples": encoded_latent}, hires_denoise,
|
||||
width=up_w, height=up_h
|
||||
)
|
||||
|
||||
duration = round(time.time() - t0, 3)
|
||||
print(f"[GridTester] 🔍 Model+HiRes upscale complete in {duration}s → {up_w}x{up_h}")
|
||||
return hires_latent, duration
|
||||
|
||||
else:
|
||||
print(f"[GridTester] ⚠️ Unknown upscale mode: {mode}")
|
||||
return result_latent, 0
|
||||
```
|
||||
|
||||
**Step 3: Output session settings in generate_config**
|
||||
|
||||
In `config_builder_node.py`, in `generate_config()`, find where `configs_json` is built. The session settings (upscaling, cooldown) need to be output as a separate key. Find where the final configs list is serialized to JSON, and add:
|
||||
|
||||
```python
|
||||
# Session-level settings (upscaling, cooldown) — not per-config
|
||||
session_settings = {}
|
||||
|
||||
# Upscaling settings from Builder UI state
|
||||
upscaling_data = lora_config_data.get("upscaling", {})
|
||||
if upscaling_data and upscaling_data.get("enabled", False):
|
||||
session_settings["upscaling"] = upscaling_data
|
||||
|
||||
# Cooldown settings from Builder UI state
|
||||
cooldown_data = lora_config_data.get("cooldown", {})
|
||||
if cooldown_data and cooldown_data.get("enabled", False):
|
||||
session_settings["cooldown"] = cooldown_data
|
||||
|
||||
if session_settings:
|
||||
result["_session_settings"] = session_settings
|
||||
```
|
||||
|
||||
**Step 4: Apply upscaling in generation_orchestrator.py**
|
||||
|
||||
In `generation_orchestrator.py`, after the `generate_image()` call at line 1081, before the metadata creation at line 1111, add:
|
||||
|
||||
```python
|
||||
# ==== UPSCALING (if enabled) ====
|
||||
upscaling_config = session_settings.get("upscaling", {}) if session_settings else {}
|
||||
if upscaling_config.get("enabled", False) and result_latent is not None:
|
||||
from image_generation import upscale_image
|
||||
upscale_result, upscale_duration = upscale_image(
|
||||
result_latent, loaded_vae, patched_model, upscaling_config,
|
||||
conf, final_positive, final_negative, w, h
|
||||
)
|
||||
if isinstance(upscale_result, Image.Image):
|
||||
# Model-only mode returns PIL directly — save it
|
||||
upscaled_meta = create_image_metadata(
|
||||
conf, upscale_result.size[0], upscale_result.size[1],
|
||||
duration + upscale_duration, current_seed, batch_idx,
|
||||
actual_positive_prompt, actual_negative_prompt,
|
||||
gen_index=gen_index_offset + total_generated
|
||||
)
|
||||
upscaled_meta["upscaled"] = True
|
||||
upscaled_meta["upscale_mode"] = upscaling_config["mode"]
|
||||
# Save upscaled image directly (no VAE decode needed)
|
||||
upscaled_filename = f"upscaled_{total_generated:04d}.webp"
|
||||
upscale_result.save(
|
||||
os.path.join(paths["images"], upscaled_filename),
|
||||
"WEBP", quality=80
|
||||
)
|
||||
upscaled_meta["file"] = f"filename={upscaled_filename}"
|
||||
existing_data["items"].append(upscaled_meta)
|
||||
else:
|
||||
# HiRes modes return latent — replace result_latent
|
||||
result_latent = upscale_result
|
||||
duration += upscale_duration
|
||||
```
|
||||
|
||||
Also, at the top of the main generation function, extract session settings from configs:
|
||||
|
||||
```python
|
||||
# Extract session-level settings (upscaling, cooldown)
|
||||
session_settings = None
|
||||
if isinstance(configs_json, str):
|
||||
import json
|
||||
try:
|
||||
parsed = json.loads(configs_json)
|
||||
if isinstance(parsed, dict) and "_session_settings" in parsed:
|
||||
session_settings = parsed["_session_settings"]
|
||||
except:
|
||||
pass
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add image_generation.py model_loader.py config_builder_node.py generation_orchestrator.py
|
||||
git commit -m "feat: implement upscaling pipeline (HiRes fix, model upscale, combined)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: GPU Cooldown Python Backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `generation_orchestrator.py` (main generation loop — add cooldown logic)
|
||||
- Modify: `distribution_worker.py` (worker loop — add cooldown logic)
|
||||
|
||||
**Step 1: Add cooldown logic to generation_orchestrator.py**
|
||||
|
||||
In `generation_orchestrator.py`, after the pending_batch append at line 1120-1121, add:
|
||||
|
||||
```python
|
||||
# ==== GPU COOLDOWN (if enabled) ====
|
||||
cooldown_config = session_settings.get("cooldown", {}) if session_settings else {}
|
||||
if cooldown_config.get("enabled", False):
|
||||
cooldown_every_n = int(cooldown_config.get("every_n", 1))
|
||||
if total_generated > 0 and total_generated % cooldown_every_n == 0:
|
||||
cooldown_seconds = int(cooldown_config.get("seconds", 5))
|
||||
clear_vram = cooldown_config.get("clear_vram", False)
|
||||
|
||||
print(f"[GridTester] ❄️ GPU Cooldown: pausing {cooldown_seconds}s after {total_generated} generations")
|
||||
|
||||
if clear_vram:
|
||||
import comfy.model_management as mm_cooldown
|
||||
mm_cooldown.soft_empty_cache()
|
||||
mm_cooldown.unload_all_models()
|
||||
print(f"[GridTester] ❄️ VRAM cleared")
|
||||
# Force model reload flag so next iteration reloads
|
||||
cached_model_key = None
|
||||
|
||||
import time as time_module
|
||||
time_module.sleep(cooldown_seconds)
|
||||
print(f"[GridTester] ❄️ Cooldown complete, resuming generation")
|
||||
```
|
||||
|
||||
**Step 2: Add cooldown logic to distribution_worker.py**
|
||||
|
||||
In `distribution_worker.py`, in `_process_job()`, after the image is generated and returned, add similar cooldown logic. Find the end of `_process_job` where it returns results, and before the return, add:
|
||||
|
||||
```python
|
||||
# ==== GPU COOLDOWN (if enabled in session settings) ====
|
||||
cooldown_config = config.get("_session_cooldown", {})
|
||||
if cooldown_config.get("enabled", False):
|
||||
cooldown_every_n = int(cooldown_config.get("every_n", 1))
|
||||
# Workers track generation count internally
|
||||
if not hasattr(self, '_gen_count'):
|
||||
self._gen_count = 0
|
||||
self._gen_count += 1
|
||||
if self._gen_count % cooldown_every_n == 0:
|
||||
cooldown_seconds = int(cooldown_config.get("seconds", 5))
|
||||
clear_vram = cooldown_config.get("clear_vram", False)
|
||||
|
||||
print(f"[Worker] ❄️ GPU Cooldown: pausing {cooldown_seconds}s after {self._gen_count} generations")
|
||||
|
||||
if clear_vram:
|
||||
import comfy.model_management as mm_cooldown
|
||||
mm_cooldown.soft_empty_cache()
|
||||
mm_cooldown.unload_all_models()
|
||||
print(f"[Worker] ❄️ VRAM cleared")
|
||||
|
||||
import time as time_module
|
||||
time_module.sleep(cooldown_seconds)
|
||||
print(f"[Worker] ❄️ Cooldown complete, resuming")
|
||||
```
|
||||
|
||||
**Step 3: Pass cooldown settings to workers**
|
||||
|
||||
In `distribution_manager.py`, in `_job_to_dict()` at line 591, after building the result dict, add cooldown settings to the config:
|
||||
|
||||
```python
|
||||
# Attach session-level cooldown settings to worker config
|
||||
if hasattr(self, '_session_settings') and 'cooldown' in self._session_settings:
|
||||
safe_config["_session_cooldown"] = self._session_settings["cooldown"]
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add generation_orchestrator.py distribution_worker.py distribution_manager.py
|
||||
git commit -m "feat: implement GPU cooldown breaks with optional VRAM clearing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Integration Testing
|
||||
|
||||
**Step 1: Test seed behavior**
|
||||
|
||||
Create a small grid test in ComfyUI with:
|
||||
- 2 configs, one with `full_run_seed_behavior: "random_before"`, one with `"increment_after"`
|
||||
- Queue the workflow twice
|
||||
- Verify: first config gets a random seed each run, second config's seed increments by 1 between runs
|
||||
|
||||
**Step 2: Test canvas flicker**
|
||||
|
||||
Open the dashboard with images loaded. Hold left-click drag while simultaneously scrolling the mouse wheel. Verify no flicker or position jumps.
|
||||
|
||||
**Step 3: Test upscaling**
|
||||
|
||||
Enable upscaling in Builder UI, set to HiRes Fix mode with ratio 1.5 and denoise 0.5. Run a small grid test. Verify upscaled images appear alongside originals in the output folder.
|
||||
|
||||
**Step 4: Test cooldown**
|
||||
|
||||
Enable cooldown with 3 seconds every 2 generations. Run a grid test with 6+ jobs. Verify console shows cooldown messages every 2 generations.
|
||||
|
||||
**Step 5: Commit any test fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: integration test adjustments"
|
||||
```
|
||||
@@ -1,63 +0,0 @@
|
||||
# Favorites Workflow Packing & Cleanup Design
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the Dashboard's "Export Favorites" section with workflow embedding, manifest export, and non-favorite cleanup.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. New Checkboxes (3 new, after existing 4)
|
||||
|
||||
**Copy cleaned favorites-only manifest** (`copy-manifest-checkbox`, default checked)
|
||||
- Copies `manifest.json` filtered to only favorited items into `favorites/manifest.json`
|
||||
|
||||
**Pack full ComfyUI workflow into image metadata** (`pack-workflow-checkbox`, default unchecked)
|
||||
- Captures current ComfyUI graph via `app.graph.serialize()` from the parent window
|
||||
- Embeds as `"workflow"` PNG text chunk so dragging into ComfyUI loads the full node graph
|
||||
|
||||
**Pack config data as pure nodes workflow** (`pack-nodes-workflow-checkbox`, default unchecked)
|
||||
- Per-image: generates a standalone workflow (Checkpoint → LoRAs → CLIP → KSampler → VAEDecode → Preview) from that image's config data
|
||||
- Reuses logic from `copyConfigsAsComfyNodes()` in the REVISE modal
|
||||
- Embeds as `"workflow"` PNG text chunk
|
||||
- If both workflow checkboxes are checked, this one takes priority (more specific per-image)
|
||||
|
||||
### 2. Workflow Capture & Data Flow
|
||||
|
||||
**Current workflow capture (JS side):**
|
||||
- In `exportFavorites()`, when pack-workflow is checked: `app.graph.serialize() || window.parent?.app?.graph?.serialize()`
|
||||
- Serialized workflow JSON sent as `workflow_data` in POST body
|
||||
|
||||
**Per-image nodes workflow (JS side):**
|
||||
- Extract workflow-building logic from `copyConfigsAsComfyNodes()` into reusable `buildComfyNodesWorkflow(itemData)` returning JSON object
|
||||
- Generate per-image, send as array alongside favorited items
|
||||
|
||||
**Backend (`__init__.py`):**
|
||||
- `export_favorites` receives new params: `pack_workflow`, `pack_nodes_workflow`, `copy_manifest`, `workflow_data`, `nodes_workflows`
|
||||
- Pass workflow data through to `pack_metadata_into_image()`
|
||||
|
||||
**`metadata_packer.py` fix:**
|
||||
- Line 429: Change `if True:` to `if not workflow_data_full:` — fixes bug where `workflowExample` always overrides actual workflow
|
||||
|
||||
**Cleaned manifest:**
|
||||
- Filter `manifest["items"]` to favorited-only, write to `favorites/manifest.json`
|
||||
|
||||
### 3. DELETE ALL NON FAVORITED ITEMS
|
||||
|
||||
**UI:** Big red button below all checkboxes in Export Favorites section.
|
||||
|
||||
**Flow:**
|
||||
1. `window.confirm("Are you sure you want to delete all non-favorited items? This cannot be undone.")`
|
||||
2. POST `/config_tester/delete_non_favorites` with `session_name`
|
||||
3. Backend: load manifest, delete non-favorited image files, update manifest, save
|
||||
4. Frontend: show status, refresh dashboard
|
||||
|
||||
## Files Affected
|
||||
|
||||
### Python:
|
||||
- `__init__.py` — export_favorites endpoint (new params), new delete_non_favorites endpoint
|
||||
- `metadata_packer.py` — fix line 429 `if True:` bug
|
||||
|
||||
### JavaScript:
|
||||
- `resources/template.html` — 3 new checkboxes + delete button in Export Favorites section
|
||||
- `resources/logic_utils.js` — exportFavorites() new params, workflow capture, deleteNonFavorites() function
|
||||
- `resources/logic_ui.js` — extract `buildComfyNodesWorkflow()` from `copyConfigsAsComfyNodes()`
|
||||
@@ -1,765 +0,0 @@
|
||||
# Favorites Workflow Packing & Cleanup Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add workflow embedding, manifest export, and non-favorite deletion to the Dashboard's Export Favorites section.
|
||||
|
||||
**Architecture:** Three new checkboxes + one delete button in `template.html`. JS captures workflow data and sends to backend. Backend embeds workflow into PNG metadata via existing `pack_metadata_into_image()`. Separate endpoint for deleting non-favorites. Node workflow generation extracted from existing `copyConfigsAsComfyNodes()` into reusable function.
|
||||
|
||||
**Tech Stack:** Python (PIL, PngInfo, shutil, json), JavaScript (DOM, fetch, ComfyUI app API)
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL RULE
|
||||
|
||||
**DO NOT REMOVE ANY CODE. DO NOT REMOVE ANY COMMENTS. ONLY CHANGE WHAT IS NECESSARY.**
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add New Checkboxes + Delete Button to Template
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/template.html:219-228`
|
||||
|
||||
**Step 1: Add three new checkboxes and delete button**
|
||||
|
||||
In `resources/template.html`, find line 226 (the closing `</div>` of the export-prompt-txt checkbox). After line 226, and before line 228 (`<div id="export-status"`), add:
|
||||
|
||||
```html
|
||||
<div style="margin-top: 8px; display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" checked id="copy-manifest-checkbox"
|
||||
style="width: 14px; height: 14px; cursor: pointer;">
|
||||
<label for="copy-manifest-checkbox"
|
||||
style="font-size: 10px; color: #999; cursor: pointer; user-select: none;">
|
||||
Copy cleaned favorites-only manifest into favorites folder
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="pack-workflow-checkbox"
|
||||
style="width: 14px; height: 14px; cursor: pointer;">
|
||||
<label for="pack-workflow-checkbox"
|
||||
style="font-size: 10px; color: #999; cursor: pointer; user-select: none;">
|
||||
Pack full ComfyUI workflow into image metadata (drag into ComfyUI to load)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="pack-nodes-workflow-checkbox"
|
||||
style="width: 14px; height: 14px; cursor: pointer;">
|
||||
<label for="pack-nodes-workflow-checkbox"
|
||||
style="font-size: 10px; color: #999; cursor: pointer; user-select: none;">
|
||||
Pack config data as pure nodes workflow into image metadata
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
Then after the `<div id="export-status"...></div>` line (line 228), add the delete button:
|
||||
|
||||
```html
|
||||
<button class="session-action-btn"
|
||||
style="background: #cc2222; width: 100%; font-size: 11px; padding: 10px; margin-top: 12px; border: 2px solid #ff4444;"
|
||||
onclick="deleteNonFavorites()" title="Permanently delete all non-favorited images and update manifest">
|
||||
🗑️ DELETE ALL NON FAVORITED ITEMS
|
||||
</button>
|
||||
<div id="delete-status" style="margin-top: 4px; font-size: 10px; color: #666; min-height: 16px;"></div>
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/template.html
|
||||
git commit -m "feat: add workflow packing checkboxes and delete non-favorites button to template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extract buildComfyNodesWorkflow from copyConfigsAsComfyNodes
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/logic_ui.js:692-1001`
|
||||
|
||||
**Step 1: Create reusable buildComfyNodesWorkflow function**
|
||||
|
||||
In `resources/logic_ui.js`, add a new function BEFORE `copyConfigsAsComfyNodes` (before line 692). This function takes an item data object and returns a workflow JSON object. It contains the workflow-building logic extracted from `copyConfigsAsComfyNodes`:
|
||||
|
||||
```javascript
|
||||
// Build a pure ComfyUI nodes workflow JSON from image config data
|
||||
// Reusable version of the clipboard copy logic — returns the workflow object
|
||||
function buildComfyNodesWorkflow(d) {
|
||||
// Parse LoRA string into array
|
||||
const loras = [];
|
||||
if (d.lora && d.lora !== "None") {
|
||||
const loraEntries = d.lora.split(' + ').filter(e => e.trim().length > 0);
|
||||
loraEntries.forEach(entry => {
|
||||
const parts = entry.split(':');
|
||||
const name = parts[0];
|
||||
const strength_model = parseFloat(parts[1] || 1.0);
|
||||
const strength_clip = parseFloat(parts[2] || strength_model);
|
||||
loras.push({ name, strength_model, strength_clip });
|
||||
});
|
||||
}
|
||||
|
||||
// Generate node IDs
|
||||
let nodeId = 1;
|
||||
const checkpointNode = nodeId++;
|
||||
const loraNodes = loras.map(() => nodeId++);
|
||||
const positiveClipNode = nodeId++;
|
||||
const negativeClipNode = nodeId++;
|
||||
const emptyLatentNode = nodeId++;
|
||||
const ksamplerNode = nodeId++;
|
||||
const vaeDecodeNode = nodeId++;
|
||||
const previewNode = nodeId++;
|
||||
|
||||
if (!crypto.randomUUID) {
|
||||
crypto.randomUUID = function () {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const workflow = {
|
||||
id: crypto.randomUUID(),
|
||||
revision: 0,
|
||||
last_node_id: nodeId - 1,
|
||||
last_link_id: 100,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
workflowRendererVersion: "LG",
|
||||
ds: { scale: 0.573, offset: [488, 377] }
|
||||
},
|
||||
version: 0.4
|
||||
};
|
||||
|
||||
// Checkpoint
|
||||
workflow.nodes.push({
|
||||
id: checkpointNode,
|
||||
type: "CheckpointLoaderSimple",
|
||||
pos: [-200, 60],
|
||||
size: [315, 98],
|
||||
flags: {}, order: 0, mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: "MODEL", type: "MODEL", links: [] },
|
||||
{ name: "CLIP", type: "CLIP", links: [] },
|
||||
{ name: "VAE", type: "VAE", links: [] }
|
||||
],
|
||||
properties: { "Node name for S&R": "CheckpointLoaderSimple" },
|
||||
widgets_values: [d.model || ""]
|
||||
});
|
||||
|
||||
// LoRAs
|
||||
loras.forEach((lora, index) => {
|
||||
workflow.nodes.push({
|
||||
id: loraNodes[index],
|
||||
type: "LoraLoader",
|
||||
pos: [170 + (index * 312), 60],
|
||||
size: [270, 126],
|
||||
flags: {}, order: index + 1, mode: 0,
|
||||
inputs: [
|
||||
{ name: "model", type: "MODEL", link: null },
|
||||
{ name: "clip", type: "CLIP", link: null }
|
||||
],
|
||||
outputs: [
|
||||
{ name: "MODEL", type: "MODEL", links: [] },
|
||||
{ name: "CLIP", type: "CLIP", links: [] }
|
||||
],
|
||||
properties: { "Node name for S&R": "LoraLoader" },
|
||||
widgets_values: [String(lora.name).replace(/\//g, "\\"), lora.strength_model, lora.strength_clip]
|
||||
});
|
||||
});
|
||||
|
||||
const posX = 910 + (loras.length * 312);
|
||||
|
||||
// Positive Clip
|
||||
workflow.nodes.push({
|
||||
id: positiveClipNode,
|
||||
type: "CLIPTextEncode",
|
||||
pos: [posX, 3.52],
|
||||
size: [460, 190],
|
||||
flags: {}, order: loras.length + 1, mode: 0,
|
||||
inputs: [{ name: "clip", type: "CLIP", link: null }],
|
||||
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [] }],
|
||||
properties: { "Node name for S&R": "CLIPTextEncode" },
|
||||
widgets_values: [d.positive || ""]
|
||||
});
|
||||
|
||||
// Negative Clip
|
||||
workflow.nodes.push({
|
||||
id: negativeClipNode,
|
||||
type: "CLIPTextEncode",
|
||||
pos: [posX, 240],
|
||||
size: [470, 200],
|
||||
flags: {}, order: loras.length + 2, mode: 0,
|
||||
inputs: [{ name: "clip", type: "CLIP", link: null }],
|
||||
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [] }],
|
||||
properties: { "Node name for S&R": "CLIPTextEncode" },
|
||||
widgets_values: [d.negative || ""]
|
||||
});
|
||||
|
||||
// Empty Latent
|
||||
workflow.nodes.push({
|
||||
id: emptyLatentNode,
|
||||
type: "EmptyLatentImage",
|
||||
pos: [posX + 130, 510],
|
||||
size: [270, 106],
|
||||
flags: {}, order: 1, mode: 0,
|
||||
inputs: [],
|
||||
outputs: [{ name: "LATENT", type: "LATENT", links: [] }],
|
||||
properties: { "Node name for S&R": "EmptyLatentImage" },
|
||||
widgets_values: [d.width || 1080, d.height || 1584, 1]
|
||||
});
|
||||
|
||||
// KSampler
|
||||
const sampX = posX + 510;
|
||||
workflow.nodes.push({
|
||||
id: ksamplerNode,
|
||||
type: "KSampler",
|
||||
pos: [sampX, 23.52],
|
||||
size: [315, 708],
|
||||
flags: {}, order: loras.length + 3, mode: 0,
|
||||
inputs: [
|
||||
{ name: "model", type: "MODEL", link: null },
|
||||
{ name: "positive", type: "CONDITIONING", link: null },
|
||||
{ name: "negative", type: "CONDITIONING", link: null },
|
||||
{ name: "latent_image", type: "LATENT", link: null }
|
||||
],
|
||||
outputs: [{ name: "LATENT", type: "LATENT", links: [] }],
|
||||
properties: { "Node name for S&R": "KSampler" },
|
||||
widgets_values: [
|
||||
d.seed || 0, "fixed", d.steps || 25, d.cfg || 7,
|
||||
d.sampler || "dpmpp_2m", d.scheduler || "karras", d.denoise || 1
|
||||
]
|
||||
});
|
||||
|
||||
// VAE Decode
|
||||
workflow.nodes.push({
|
||||
id: vaeDecodeNode,
|
||||
type: "VAEDecode",
|
||||
pos: [sampX + 390, 33],
|
||||
size: [210, 46],
|
||||
flags: {}, order: loras.length + 4, mode: 0,
|
||||
inputs: [
|
||||
{ name: "samples", type: "LATENT", link: null },
|
||||
{ name: "vae", type: "VAE", link: null }
|
||||
],
|
||||
outputs: [{ name: "IMAGE", type: "IMAGE", links: [] }],
|
||||
properties: { "Node name for S&R": "VAEDecode" },
|
||||
widgets_values: []
|
||||
});
|
||||
|
||||
// Preview
|
||||
workflow.nodes.push({
|
||||
id: previewNode,
|
||||
type: "PreviewImage",
|
||||
pos: [sampX + 370, 173],
|
||||
size: [418, 556],
|
||||
flags: {}, order: loras.length + 5, mode: 0,
|
||||
inputs: [{ name: "images", type: "IMAGE", link: null }],
|
||||
outputs: [],
|
||||
properties: { "Node name for S&R": "PreviewImage" },
|
||||
widgets_values: []
|
||||
});
|
||||
|
||||
// Wire nodes
|
||||
const getNode = (id) => workflow.nodes.find(n => n.id === id);
|
||||
let linkId = 1;
|
||||
|
||||
let currentModelSource = { id: checkpointNode, slot: 0 };
|
||||
let currentClipSource = { id: checkpointNode, slot: 1 };
|
||||
|
||||
// Daisy chain LoRAs
|
||||
for (let i = 0; i < loras.length; i++) {
|
||||
const thisLoraId = loraNodes[i];
|
||||
workflow.links.push([linkId, currentModelSource.id, currentModelSource.slot, thisLoraId, 0, "MODEL"]);
|
||||
getNode(currentModelSource.id).outputs[currentModelSource.slot].links.push(linkId);
|
||||
getNode(thisLoraId).inputs[0].link = linkId;
|
||||
linkId++;
|
||||
workflow.links.push([linkId, currentClipSource.id, currentClipSource.slot, thisLoraId, 1, "CLIP"]);
|
||||
getNode(currentClipSource.id).outputs[currentClipSource.slot].links.push(linkId);
|
||||
getNode(thisLoraId).inputs[1].link = linkId;
|
||||
linkId++;
|
||||
currentModelSource = { id: thisLoraId, slot: 0 };
|
||||
currentClipSource = { id: thisLoraId, slot: 1 };
|
||||
}
|
||||
|
||||
// Final Model -> KSampler
|
||||
workflow.links.push([linkId, currentModelSource.id, currentModelSource.slot, ksamplerNode, 0, "MODEL"]);
|
||||
getNode(currentModelSource.id).outputs[currentModelSource.slot].links.push(linkId);
|
||||
getNode(ksamplerNode).inputs[0].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Final CLIP -> Positive Prompt
|
||||
workflow.links.push([linkId, currentClipSource.id, currentClipSource.slot, positiveClipNode, 0, "CLIP"]);
|
||||
getNode(currentClipSource.id).outputs[currentClipSource.slot].links.push(linkId);
|
||||
getNode(positiveClipNode).inputs[0].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Final CLIP -> Negative Prompt
|
||||
workflow.links.push([linkId, currentClipSource.id, currentClipSource.slot, negativeClipNode, 0, "CLIP"]);
|
||||
getNode(currentClipSource.id).outputs[currentClipSource.slot].links.push(linkId);
|
||||
getNode(negativeClipNode).inputs[0].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// VAE: Checkpoint -> VAE Decode
|
||||
workflow.links.push([linkId, checkpointNode, 2, vaeDecodeNode, 1, "VAE"]);
|
||||
getNode(checkpointNode).outputs[2].links.push(linkId);
|
||||
getNode(vaeDecodeNode).inputs[1].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Conditioning: Positive -> KSampler
|
||||
workflow.links.push([linkId, positiveClipNode, 0, ksamplerNode, 1, "CONDITIONING"]);
|
||||
getNode(positiveClipNode).outputs[0].links.push(linkId);
|
||||
getNode(ksamplerNode).inputs[1].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Conditioning: Negative -> KSampler
|
||||
workflow.links.push([linkId, negativeClipNode, 0, ksamplerNode, 2, "CONDITIONING"]);
|
||||
getNode(negativeClipNode).outputs[0].links.push(linkId);
|
||||
getNode(ksamplerNode).inputs[2].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Latent: Empty Latent -> KSampler
|
||||
workflow.links.push([linkId, emptyLatentNode, 0, ksamplerNode, 3, "LATENT"]);
|
||||
getNode(emptyLatentNode).outputs[0].links.push(linkId);
|
||||
getNode(ksamplerNode).inputs[3].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Latent: KSampler -> VAE Decode
|
||||
workflow.links.push([linkId, ksamplerNode, 0, vaeDecodeNode, 0, "LATENT"]);
|
||||
getNode(ksamplerNode).outputs[0].links.push(linkId);
|
||||
getNode(vaeDecodeNode).inputs[0].link = linkId;
|
||||
linkId++;
|
||||
|
||||
// Image: VAE Decode -> Preview
|
||||
workflow.links.push([linkId, vaeDecodeNode, 0, previewNode, 0, "IMAGE"]);
|
||||
getNode(vaeDecodeNode).outputs[0].links.push(linkId);
|
||||
getNode(previewNode).inputs[0].link = linkId;
|
||||
linkId++;
|
||||
|
||||
workflow.last_link_id = linkId;
|
||||
return workflow;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Refactor copyConfigsAsComfyNodes to use the new function**
|
||||
|
||||
In `copyConfigsAsComfyNodes` (line 692), replace lines 702-979 (everything inside the `try` block before the clipboard copy logic at line 980) with a call to the new function:
|
||||
|
||||
Replace from `// Parse LoRA string into array` through `workflow.last_link_id = linkId;` (lines 703-978) with:
|
||||
|
||||
```javascript
|
||||
const workflow = buildComfyNodesWorkflow(d);
|
||||
```
|
||||
|
||||
Keep the existing clipboard copy logic (lines 980-1001+) unchanged.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/logic_ui.js
|
||||
git commit -m "refactor: extract buildComfyNodesWorkflow from copyConfigsAsComfyNodes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update exportFavorites JS to Send New Parameters
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/logic_utils.js:252-340`
|
||||
|
||||
**Step 1: Add checkbox reads and workflow capture**
|
||||
|
||||
In `resources/logic_utils.js`, in the `exportFavorites()` function, after line 279 (the `exportPromptTxt` variable), add:
|
||||
|
||||
```javascript
|
||||
// Get copy manifest checkbox state
|
||||
const copyManifestCheckbox = document.getElementById('copy-manifest-checkbox');
|
||||
const copyManifest = copyManifestCheckbox ? copyManifestCheckbox.checked : true;
|
||||
|
||||
// Get pack workflow checkbox state
|
||||
const packWorkflowCheckbox = document.getElementById('pack-workflow-checkbox');
|
||||
const packWorkflow = packWorkflowCheckbox ? packWorkflowCheckbox.checked : false;
|
||||
|
||||
// Get pack nodes workflow checkbox state
|
||||
const packNodesWorkflowCheckbox = document.getElementById('pack-nodes-workflow-checkbox');
|
||||
const packNodesWorkflow = packNodesWorkflowCheckbox ? packNodesWorkflowCheckbox.checked : false;
|
||||
|
||||
// Capture current ComfyUI workflow if pack_workflow is checked
|
||||
let workflowData = null;
|
||||
if (packWorkflow) {
|
||||
try {
|
||||
const app = window.app || window.parent?.app;
|
||||
if (app && app.graph) {
|
||||
workflowData = app.graph.serialize();
|
||||
console.log('[Export] Captured current ComfyUI workflow');
|
||||
} else {
|
||||
console.warn('[Export] Could not access ComfyUI app.graph — workflow will not be packed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Export] Error capturing workflow:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate per-image nodes workflows if pack_nodes_workflow is checked
|
||||
let nodesWorkflows = null;
|
||||
if (packNodesWorkflow && typeof buildComfyNodesWorkflow === 'function') {
|
||||
try {
|
||||
nodesWorkflows = {};
|
||||
// activeData is the global array of all manifest items in the dashboard
|
||||
const favoritedItems = activeData.filter(item => item.favorited);
|
||||
for (const item of favoritedItems) {
|
||||
const wf = buildComfyNodesWorkflow(item);
|
||||
// Key by the item's file path so backend can match
|
||||
nodesWorkflows[item.file || item.id] = wf;
|
||||
}
|
||||
console.log(`[Export] Generated ${Object.keys(nodesWorkflows).length} per-image node workflows`);
|
||||
} catch (e) {
|
||||
console.warn('[Export] Error generating node workflows:', e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add new parameters to fetch body**
|
||||
|
||||
In the same function, update the `JSON.stringify` body (around line 296-302) to include the new parameters. Replace:
|
||||
|
||||
```javascript
|
||||
body: JSON.stringify({
|
||||
session_name: sessionName,
|
||||
pack_metadata: packMetadata,
|
||||
organize_by_prompt: organizeByPrompt,
|
||||
organize_by_lora: organizeByLora,
|
||||
export_prompt_txt: exportPromptTxt
|
||||
})
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
body: JSON.stringify({
|
||||
session_name: sessionName,
|
||||
pack_metadata: packMetadata,
|
||||
organize_by_prompt: organizeByPrompt,
|
||||
organize_by_lora: organizeByLora,
|
||||
export_prompt_txt: exportPromptTxt,
|
||||
copy_manifest: copyManifest,
|
||||
pack_workflow: packWorkflow,
|
||||
pack_nodes_workflow: packNodesWorkflow,
|
||||
workflow_data: workflowData,
|
||||
nodes_workflows: nodesWorkflows
|
||||
})
|
||||
```
|
||||
|
||||
**Step 3: Add deleteNonFavorites function**
|
||||
|
||||
After the `exportFavorites()` function (after line 340), add:
|
||||
|
||||
```javascript
|
||||
// Delete all non-favorited items from the session
|
||||
async function deleteNonFavorites() {
|
||||
if (!window.confirm("Are you sure you want to delete all non-favorited items? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('delete-status');
|
||||
const sessInput = document.getElementById('session-input');
|
||||
if (!sessInput) {
|
||||
if (statusEl) statusEl.innerText = '❌ Error: Session input not found';
|
||||
return;
|
||||
}
|
||||
const sessionName = sessInput.value;
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.innerText = '⏳ Deleting non-favorited items...';
|
||||
statusEl.style.color = '#ffaa00';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/config_tester/delete_non_favorites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_name: sessionName })
|
||||
});
|
||||
|
||||
const resultText = await response.text();
|
||||
|
||||
if (response.ok) {
|
||||
if (statusEl) {
|
||||
statusEl.innerText = '✅ ' + resultText;
|
||||
statusEl.style.color = '#4caf50';
|
||||
}
|
||||
// Reload the page to refresh the dashboard with updated manifest
|
||||
setTimeout(() => { location.reload(); }, 2000);
|
||||
} else {
|
||||
if (statusEl) {
|
||||
statusEl.innerText = '❌ Error: ' + resultText;
|
||||
statusEl.style.color = '#ff3860';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Delete] Error:', error);
|
||||
if (statusEl) {
|
||||
statusEl.innerText = '❌ Network error: ' + error.message;
|
||||
statusEl.style.color = '#ff3860';
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/logic_utils.js
|
||||
git commit -m "feat: add workflow capture, nodes workflow generation, and delete non-favorites to export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Fix metadata_packer.py Bug + Accept Workflow Data
|
||||
|
||||
**Files:**
|
||||
- Modify: `metadata_packer.py:429-430`
|
||||
|
||||
**Step 1: Fix the hardcoded workflowExample bug**
|
||||
|
||||
In `metadata_packer.py`, find line 429-430:
|
||||
|
||||
```python
|
||||
if True:
|
||||
workflow_data_full = workflowExample
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
if not workflow_data_full:
|
||||
workflow_data_full = workflowExample
|
||||
```
|
||||
|
||||
This ensures the `workflowExample` is only used as a fallback when no actual workflow data was provided, which was the original intent.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add metadata_packer.py
|
||||
git commit -m "fix: use workflowExample as fallback only when no workflow data provided"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Backend — Handle New Export Parameters + Delete Endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `__init__.py:434-621`
|
||||
|
||||
**Step 1: Add new parameters to export_favorites endpoint**
|
||||
|
||||
In `__init__.py`, in the `export_favorites` function (line 434), after line 447 (`export_prompt_txt = data.get(...)`), add:
|
||||
|
||||
```python
|
||||
copy_manifest = data.get("copy_manifest", True)
|
||||
pack_workflow = data.get("pack_workflow", False)
|
||||
pack_nodes_workflow = data.get("pack_nodes_workflow", False)
|
||||
workflow_data = data.get("workflow_data", None)
|
||||
nodes_workflows = data.get("nodes_workflows", None)
|
||||
```
|
||||
|
||||
**Step 2: Pass workflow data to pack_metadata_into_image**
|
||||
|
||||
In the same function, find line 579 where `pack_metadata_into_image` is called:
|
||||
|
||||
```python
|
||||
pack_metadata_into_image(source_path, dest_path, item, manifest.get("meta", {}))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
# Determine which workflow to embed
|
||||
item_workflow = None
|
||||
if pack_nodes_workflow and nodes_workflows:
|
||||
# Per-image nodes workflow takes priority
|
||||
item_workflow = nodes_workflows.get(item.get("file", ""))
|
||||
elif pack_workflow and workflow_data:
|
||||
# Full ComfyUI graph workflow
|
||||
item_workflow = workflow_data
|
||||
pack_metadata_into_image(source_path, dest_path, item, manifest.get("meta", {}), workflow_data=item_workflow)
|
||||
```
|
||||
|
||||
**Step 3: Add cleaned manifest copy**
|
||||
|
||||
After the `for item in favorited:` loop (after line 612, before the `result_msg` line), add:
|
||||
|
||||
```python
|
||||
# Copy cleaned favorites-only manifest if requested
|
||||
if copy_manifest:
|
||||
try:
|
||||
cleaned_manifest = {
|
||||
"items": favorited,
|
||||
"meta": manifest.get("meta", {}),
|
||||
"session_name": session_name
|
||||
}
|
||||
manifest_dest = os.path.join(export_base, "manifest.json")
|
||||
with open(manifest_dest, "w", encoding="utf-8") as f:
|
||||
json.dump(cleaned_manifest, f, indent=2, ensure_ascii=False)
|
||||
print(f"[Export] Saved cleaned favorites manifest to {manifest_dest}")
|
||||
except Exception as e:
|
||||
print(f"[Export] Error saving cleaned manifest: {e}")
|
||||
```
|
||||
|
||||
**Step 4: Update result message**
|
||||
|
||||
After the existing result message conditionals (after line 612 `if export_prompt_txt:`), add:
|
||||
|
||||
```python
|
||||
if copy_manifest:
|
||||
result_msg += " (with favorites manifest)"
|
||||
if pack_workflow:
|
||||
result_msg += " (with full workflow)"
|
||||
if pack_nodes_workflow:
|
||||
result_msg += " (with nodes workflows)"
|
||||
```
|
||||
|
||||
**Step 5: Add delete_non_favorites endpoint**
|
||||
|
||||
After the `export_favorites` function's closing `except` block (after line 621), add:
|
||||
|
||||
```python
|
||||
# =============================================================================
|
||||
# API: DELETE NON-FAVORITED ITEMS
|
||||
# =============================================================================
|
||||
|
||||
@server.PromptServer.instance.routes.post("/config_tester/delete_non_favorites")
|
||||
async def delete_non_favorites(request):
|
||||
"""
|
||||
Delete all non-favorited images from a session and update the manifest.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
session_name = data.get("session_name")
|
||||
|
||||
# Sanitize
|
||||
if session_name:
|
||||
session_name = re.sub(r'[^\w\-]', '', session_name)
|
||||
|
||||
if not session_name:
|
||||
return web.Response(status=400, text="Missing session_name")
|
||||
|
||||
# Paths
|
||||
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
|
||||
|
||||
if not _is_path_within(base_dir, _get_benchmarks_base()):
|
||||
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
|
||||
|
||||
manifest_path = os.path.join(base_dir, "manifest.json")
|
||||
images_dir = os.path.join(base_dir, "images")
|
||||
|
||||
# Load manifest
|
||||
if not os.path.exists(manifest_path):
|
||||
return web.Response(status=404, text=f"Session '{session_name}' not found")
|
||||
|
||||
with open(manifest_path, "r") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
items = manifest.get("items", [])
|
||||
favorited = [item for item in items if item.get("favorited", False)]
|
||||
non_favorited = [item for item in items if not item.get("favorited", False)]
|
||||
|
||||
if not non_favorited:
|
||||
return web.Response(status=200, text="No non-favorited items to delete")
|
||||
|
||||
# Delete non-favorited image files
|
||||
deleted_count = 0
|
||||
for item in non_favorited:
|
||||
file_path = item.get("file", "")
|
||||
|
||||
# Parse filename from various formats
|
||||
if file_path.startswith("/view?"):
|
||||
parsed_url = urllib.parse.urlparse(file_path)
|
||||
url_params = urllib.parse.parse_qs(parsed_url.query)
|
||||
filename = url_params.get("filename", [""])[0]
|
||||
elif file_path.startswith("./images/"):
|
||||
filename = file_path[9:]
|
||||
elif "filename=" in file_path:
|
||||
filename = file_path.split("filename=")[-1].split("&")[0]
|
||||
else:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
if filename:
|
||||
image_path = os.path.join(images_dir, filename)
|
||||
if os.path.exists(image_path):
|
||||
try:
|
||||
os.remove(image_path)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"[Delete] Error deleting {filename}: {e}")
|
||||
|
||||
# Update manifest to only contain favorited items
|
||||
manifest["items"] = favorited
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
result_msg = f"Deleted {deleted_count} non-favorited images. {len(favorited)} favorited items remain."
|
||||
print(f"[ConfigTester] 🗑️ {result_msg}")
|
||||
return web.Response(status=200, text=result_msg)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ConfigTester] Error deleting non-favorites: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.Response(status=500, text=str(e))
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add __init__.py
|
||||
git commit -m "feat: add workflow packing to export, cleaned manifest copy, and delete non-favorites endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Integration Verification
|
||||
|
||||
**Step 1: Verify template renders correctly**
|
||||
|
||||
Open ComfyUI, load a session in the Dashboard. Verify:
|
||||
- 3 new checkboxes appear below the existing 4
|
||||
- "Copy cleaned favorites-only manifest" is checked by default
|
||||
- The other two are unchecked by default
|
||||
- Red "DELETE ALL NON FAVORITED ITEMS" button appears below the status line
|
||||
|
||||
**Step 2: Verify export favorites with new options**
|
||||
|
||||
1. Favorite a few images in the dashboard
|
||||
2. Check "Pack full ComfyUI workflow" checkbox
|
||||
3. Click "COPY FAVORITES TO BENCHMARK FAVORITES FOLDER"
|
||||
4. Verify favorites folder contains images + `manifest.json`
|
||||
5. Drag a PNG from favorites into ComfyUI — should load the full workflow
|
||||
|
||||
**Step 3: Verify pack nodes workflow**
|
||||
|
||||
1. Uncheck "Pack full ComfyUI workflow"
|
||||
2. Check "Pack config data as pure nodes workflow"
|
||||
3. Export favorites again
|
||||
4. Drag a PNG into ComfyUI — should load a clean Checkpoint→LoRA→CLIP→KSampler workflow with that image's specific settings
|
||||
|
||||
**Step 4: Verify delete non-favorites**
|
||||
|
||||
1. Click "DELETE ALL NON FAVORITED ITEMS"
|
||||
2. Confirm the dialog appears
|
||||
3. Click Cancel — verify nothing happens
|
||||
4. Click the button again, confirm Yes
|
||||
5. Verify non-favorited images are deleted, manifest is updated, dashboard refreshes
|
||||
|
||||
**Step 5: Commit (if any fixes needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: integration fixes for favorites workflow packing"
|
||||
```
|
||||
Reference in New Issue
Block a user