UI updates, model based prompting, attention mode selection, recursive cartesian prompting, detect connected node changes for smarter job resuming, eta progress bar in ui, condensed dashboard top bar, close modal hotkeys,
This commit is contained in:
+21
-54
@@ -42,31 +42,21 @@ Check Roadmap.md for some tasks and do them. don't do any marked as (low priorit
|
||||
|
||||
# New to-do items, needs more info/explaining/numbering
|
||||
|
||||
## 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.
|
||||
## ~~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
|
||||
|
||||
## 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 Should NOT run by default if optional inputs are connected because changes to the optional inputs are not currently being 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())
|
||||
|
||||
## Add attention options, xformers, sdpa, sage, flash, etc, option for test all, test all should clear ram & vram between each test.
|
||||
## ~~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**
|
||||
#### **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.
|
||||
|
||||
Causes problems with:
|
||||
|
||||
* Grid view using wrong model names
|
||||
* Job continuation/skipping/resuming
|
||||
|
||||
|
||||
* **Instruction:**
|
||||
1. **Modify `sampler_node.py`:** Implement the `IS_CHANGED` class method. Check if any optional input keys are present in the `kwargs`. If yes, return `float("NaN")` (standard ComfyUI trick to force execution) or a `uuid.uuid4().hex`.
|
||||
2. **Modify `generation_orchestrator.py`:** Update `check_if_job_completed`. If optional inputs were used, you cannot trust the `manifest.json` history for that specific job. Force a re-run or calculate a hash of the optional input's internal state (e.g., `model.model.diffusion_model`) if possible, otherwise force execution.
|
||||
|
||||
|
||||
* **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`
|
||||
|
||||
|
||||
@@ -82,7 +72,7 @@ Check Roadmap.md for some tasks and do them. don't do any marked as (low priorit
|
||||
* **Target Files:** `__init__.py`, `lora_utils.py`, `web/config_builder.js`
|
||||
|
||||
|
||||
#### **9. Tag/Token-Based Omit Logic**
|
||||
#### **9. Tag/Token-Based Omit Logic** COMPLETED
|
||||
|
||||
* **Problem:** Current logic (`if word in tag`) is too broad or too strict.
|
||||
* **Instruction:**
|
||||
@@ -90,7 +80,7 @@ Check Roadmap.md for some tasks and do them. don't do any marked as (low priorit
|
||||
2. **Regex:** Instead of `if omit in tag`, use `re.search(r'\b' + re.escape(omit) + r'\b', tag, re.IGNORECASE)`. This ensures "blue" removes "blue" but not "blueberry".
|
||||
3. **Tokenization:** Split the tag into words, filter against the omit set, and rejoin.
|
||||
|
||||
|
||||
* **Status:** Already implemented in `_should_omit_trigger()` function with regex word boundary matching.
|
||||
* **Target Files:** `trigger_words.py`
|
||||
|
||||
#### **10. Validation Warning (Omit vs Lookup) - Warn user if omits are added but lookup is off** (low priority)
|
||||
@@ -104,38 +94,23 @@ Check Roadmap.md for some tasks and do them. don't do any marked as (low priorit
|
||||
|
||||
* **Target Files:** `web/config_builder.js`
|
||||
|
||||
#### **11. Model-Specific Prompts**
|
||||
#### **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).
|
||||
* **Instruction:**
|
||||
1. **Backend:** Add `model_specific_prompts` input to `SamplerGridTester` (dict or JSON).
|
||||
2. **Orchestrator:** In `generation_orchestrator.py`, inside the loop, check the current `conf["model"]`.
|
||||
3. **Injection:** Look up the model name in the map. If found, prepend/append the specific tags to `actual_positive_prompt` before encoding.
|
||||
* **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`
|
||||
|
||||
|
||||
* **Target Files:** `sampler_node.py`, `generation_orchestrator.py`, `trigger_words.py`
|
||||
|
||||
#### **12. Arrays in LoRA Weights**
|
||||
#### **12. Arrays in LoRA Weights** COMPLETED
|
||||
|
||||
* **Problem:** Cannot grid search LoRA weights like `lora.safetensors:[0.5, 0.8]:1.0`.
|
||||
* **Instruction:**
|
||||
1. **Modify `config_utils.py`:** Update `expand_configs`.
|
||||
2. **Logic:** Detect brackets `[]` inside the LoRA string part. If found, treat it as a list.
|
||||
3. **Expansion:** Use `itertools.product` to generate separate config entries for each weight in the array (e.g., one config with 0.5, one with 0.8).
|
||||
|
||||
|
||||
* **Status:** Implemented `_expand_lora_weight_arrays()` in `config_utils.py`. Supports `[0.5, 0.8]` syntax in both model_strength and clip_strength positions, with Cartesian product for multiple arrays.
|
||||
* **Target Files:** `config_utils.py`
|
||||
|
||||
#### **13. Real-Time ETA**
|
||||
#### **13. Real-Time ETA** COMPLETED
|
||||
|
||||
* **Problem:** ETA only prints to server console.
|
||||
* **Instruction:**
|
||||
1. **Backend (`generation_orchestrator.py`):** In `print_generation_progress`, calculate ETA. Use `PromptServer.instance.send_sync("ultimate_grid.progress", { eta: "..." })` to send it to the frontend.
|
||||
2. **Frontend (`resources/logic_events.js`):** Add a listener for `ultimate_grid.progress`.
|
||||
3. **UI:** Update a DOM element (e.g., `#header-eta`) in `template.html`.
|
||||
|
||||
|
||||
* **Target Files:** `generation_orchestrator.py`, `resources/logic_events.js`, `resources/template.html`
|
||||
* **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)
|
||||
|
||||
@@ -149,16 +124,11 @@ Check Roadmap.md for some tasks and do them. don't do any marked as (low priorit
|
||||
|
||||
|
||||
|
||||
#### **17. Menu Refactor (Cog Wheel)**
|
||||
#### **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.
|
||||
|
||||
|
||||
* **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)
|
||||
|
||||
@@ -172,15 +142,12 @@ Check Roadmap.md for some tasks and do them. don't do any marked as (low priorit
|
||||
* **Target Files:** `image_generation.py`, `generation_orchestrator.py`
|
||||
|
||||
|
||||
#### **20. Hotkeys Reference List**
|
||||
#### **20. Hotkeys Reference List** COMPLETED
|
||||
|
||||
* **Problem:** Users don't know shortcuts.
|
||||
* **Instruction:**
|
||||
1. **Modify `resources/logic_ui.js`:** In the function that renders the settings menu, append a table.
|
||||
2. **Content:** `F: Fullscreen`, `Arrows: Pan`, `+/-: Zoom`, `Space: Scroll`.
|
||||
* **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.
|
||||
|
||||
|
||||
* **Target Files:** `resources/logic_ui.js`
|
||||
* **Target Files:** `resources/template.html`
|
||||
|
||||
#### **21. Virtual DOM Pan/Zoom (Canvas Builder)** (low priority)
|
||||
|
||||
|
||||
+189
-64
@@ -9,72 +9,77 @@ import comfy.samplers
|
||||
|
||||
def parse_prompt_input_nested(prompt_input: str):
|
||||
"""
|
||||
Parse prompt input that supports plain text, single-level, and nested arrays.
|
||||
Creates Cartesian products from nested arrays.
|
||||
|
||||
Parse prompt input that supports plain text and arbitrarily deep nested arrays.
|
||||
Recursively creates Cartesian products from nested lists.
|
||||
|
||||
Rules:
|
||||
- A plain string is a single prompt option
|
||||
- A flat list of strings ["a", "b"] = multiple OPTIONS (OR logic)
|
||||
- A list containing sub-lists = SEQUENCE (AND logic, Cartesian product)
|
||||
- Nesting can be arbitrarily deep
|
||||
|
||||
Args:
|
||||
prompt_input: Plain text string OR JSON string containing prompts
|
||||
|
||||
|
||||
Returns:
|
||||
List of final prompt strings
|
||||
|
||||
|
||||
Examples:
|
||||
Plain text: "my prompt" -> ["my prompt"]
|
||||
Plain text with quotes: '"my prompt"' -> ["my prompt"]
|
||||
Simple: '["a", "b", "c"]' -> ["a", "b", "c"]
|
||||
Nested: '[["a", "b"], ["1", "2"]]' -> ["a, 1", "a, 2", "b, 1", "b, 2"]
|
||||
Plain text: "my prompt" -> ["my prompt"]
|
||||
Simple options: '["a", "b", "c"]' -> ["a", "b", "c"]
|
||||
Cartesian: '[["a", "b"], ["1", "2"]]' -> ["a, 1", "a, 2", "b, 1", "b, 2"]
|
||||
Recursive: '["Photo of", ["a cat", "a dog"], ["in space", ["eating pizza", "sleeping"]]]'
|
||||
-> ["Photo of, a cat, in space, eating pizza",
|
||||
"Photo of, a cat, in space, sleeping",
|
||||
"Photo of, a dog, in space, eating pizza",
|
||||
"Photo of, a dog, in space, sleeping"]
|
||||
"""
|
||||
import json
|
||||
import itertools
|
||||
|
||||
# Handle None or empty string input
|
||||
|
||||
def recursive_cartesian(item):
|
||||
"""Recursively expand nested prompt structures into flat string options."""
|
||||
# Base Case: string/primitive -> single option
|
||||
if not isinstance(item, list):
|
||||
return [str(item)]
|
||||
|
||||
# Check if this list contains any sub-lists
|
||||
has_nested = any(isinstance(sub, list) for sub in item)
|
||||
|
||||
# Flat list of strings -> OPTIONS (OR logic)
|
||||
if not has_nested:
|
||||
return [str(x) for x in item]
|
||||
|
||||
# List containing sub-lists -> SEQUENCE (AND logic, Cartesian product)
|
||||
# Each element is recursively resolved into its options, then we take the product
|
||||
normalized_groups = []
|
||||
for sub in item:
|
||||
normalized_groups.append(recursive_cartesian(sub))
|
||||
|
||||
# Generate Cartesian product across the resolved groups
|
||||
combinations = itertools.product(*normalized_groups)
|
||||
return [", ".join(combo) for combo in combinations]
|
||||
|
||||
# Handle None or empty string
|
||||
if not prompt_input or prompt_input.strip() == "":
|
||||
return [""]
|
||||
|
||||
# Strip whitespace
|
||||
|
||||
prompt_input = prompt_input.strip()
|
||||
|
||||
# Try to parse as JSON first
|
||||
|
||||
# Try parsing as JSON
|
||||
try:
|
||||
parsed = json.loads(prompt_input)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Not valid JSON - treat as plain text
|
||||
# Remove quotes if they exist (user might have typed "my prompt")
|
||||
if (prompt_input.startswith('"') and prompt_input.endswith('"')) or \
|
||||
(prompt_input.startswith("'") and prompt_input.endswith("'")):
|
||||
prompt_input = prompt_input[1:-1]
|
||||
return [prompt_input]
|
||||
|
||||
|
||||
# Handle empty parsed result
|
||||
if not parsed:
|
||||
return [""]
|
||||
|
||||
# If it's a single string from JSON
|
||||
if isinstance(parsed, str):
|
||||
return [parsed]
|
||||
|
||||
# Check if this is a nested structure (has any lists inside)
|
||||
has_nested = any(isinstance(item, list) for item in parsed)
|
||||
|
||||
if not has_nested:
|
||||
# Simple list of strings: ["a", "b", "c"]
|
||||
return [str(item) for item in parsed]
|
||||
|
||||
# Nested structure - expand to Cartesian product
|
||||
normalized = []
|
||||
for item in parsed:
|
||||
if isinstance(item, list):
|
||||
normalized.append([str(x) for x in item])
|
||||
else:
|
||||
normalized.append([str(item)])
|
||||
|
||||
# Generate Cartesian product
|
||||
combinations = list(itertools.product(*normalized))
|
||||
|
||||
# Join each combination with ", "
|
||||
result = [", ".join(combo) for combo in combinations]
|
||||
|
||||
return result
|
||||
|
||||
# Start the recursion
|
||||
return recursive_cartesian(parsed)
|
||||
|
||||
|
||||
def normalize_str(s):
|
||||
@@ -181,39 +186,135 @@ def parse_lora_definition(lora_string):
|
||||
return definitions
|
||||
|
||||
|
||||
def _expand_lora_weight_arrays(lora_string):
|
||||
"""
|
||||
Expand LoRA weight arrays into individual LoRA strings.
|
||||
|
||||
Supports array notation in strength values for grid searching:
|
||||
- "lora.safetensors:[0.5, 0.8]:1.0" -> ["lora.safetensors:0.5:1.0", "lora.safetensors:0.8:1.0"]
|
||||
- "lora.safetensors:1.0:[0.5, 0.8]" -> ["lora.safetensors:1.0:0.5", "lora.safetensors:1.0:0.8"]
|
||||
- "lora.safetensors:[0.5, 0.8]:[0.6, 1.0]" -> 4 combinations via Cartesian product
|
||||
- "lora1:[0.5, 0.8]:1.0 + lora2:1.0:1.0" -> expands lora1 weights, keeps lora2 fixed in each
|
||||
|
||||
Args:
|
||||
lora_string: Single LoRA definition (may contain " + " for stacked LoRAs)
|
||||
|
||||
Returns:
|
||||
List of expanded LoRA strings with concrete weight values
|
||||
"""
|
||||
if lora_string == "None":
|
||||
return ["None"]
|
||||
|
||||
# Split stacked LoRAs
|
||||
stack_parts = lora_string.split(" + ")
|
||||
|
||||
# For each part, check if it has array weights
|
||||
parts_expansions = []
|
||||
has_arrays = False
|
||||
|
||||
for part in stack_parts:
|
||||
part = part.strip()
|
||||
|
||||
# Check for bracket notation in strength values
|
||||
# Match pattern: name:[array_or_value]:[array_or_value] or name:[array_or_value]
|
||||
bracket_match = re.search(r'\[[\d.,\s]+\]', part)
|
||||
if not bracket_match:
|
||||
# No arrays in this part, keep as-is
|
||||
parts_expansions.append([part])
|
||||
continue
|
||||
|
||||
has_arrays = True
|
||||
|
||||
# Parse the part: split on ":" but respect brackets
|
||||
# Strategy: find the name (everything before first ":"), then parse strength fields
|
||||
segments = []
|
||||
current = ""
|
||||
depth = 0
|
||||
for ch in part:
|
||||
if ch == '[':
|
||||
depth += 1
|
||||
current += ch
|
||||
elif ch == ']':
|
||||
depth -= 1
|
||||
current += ch
|
||||
elif ch == ':' and depth == 0:
|
||||
segments.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
segments.append(current)
|
||||
|
||||
name = segments[0]
|
||||
|
||||
# Parse each strength segment - could be "[0.5, 0.8]" or just "1.0"
|
||||
strength_lists = []
|
||||
for i in range(1, len(segments)):
|
||||
seg = segments[i].strip()
|
||||
if seg.startswith('[') and seg.endswith(']'):
|
||||
# Array notation - parse values
|
||||
inner = seg[1:-1]
|
||||
values = [v.strip() for v in inner.split(',') if v.strip()]
|
||||
strength_lists.append(values)
|
||||
else:
|
||||
strength_lists.append([seg])
|
||||
|
||||
# Generate all combinations of strengths for this LoRA
|
||||
if strength_lists:
|
||||
combos = list(itertools.product(*strength_lists))
|
||||
expanded = [f"{name}:{':'.join(combo)}" for combo in combos]
|
||||
parts_expansions.append(expanded)
|
||||
else:
|
||||
parts_expansions.append([part])
|
||||
|
||||
if not has_arrays:
|
||||
return [lora_string]
|
||||
|
||||
# Cartesian product across stacked LoRAs
|
||||
result = []
|
||||
for combo in itertools.product(*parts_expansions):
|
||||
result.append(" + ".join(combo))
|
||||
|
||||
if len(result) > 1:
|
||||
print(f"[GridTester] 🎛️ LoRA weight arrays expanded to {len(result)} combinations")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def expand_lora_stack(lora_input):
|
||||
"""
|
||||
Expand LoRA stacks with folder support.
|
||||
|
||||
Expand LoRA stacks with folder support and weight array expansion.
|
||||
|
||||
Handles formats like:
|
||||
- "lora.safetensors"
|
||||
- "lora.safetensors:0.8:0.6"
|
||||
- "lora.safetensors:[0.5, 0.8]:1.0" (weight array - grid searches strengths)
|
||||
- "lora1:0.8:0.6 + lora2:1.0:1.0"
|
||||
- "folder/ + lora.safetensors" (expands each LoRA individually)
|
||||
- "folder/* + lora.safetensors" (stacks ALL LoRAs in folder together)
|
||||
- "folder/:0.8:0.6 + lora.safetensors"
|
||||
- "folder/*:0.8:0.6" (stacks ALL LoRAs with same strength)
|
||||
|
||||
|
||||
Args:
|
||||
lora_input: LoRA specification string or list
|
||||
|
||||
|
||||
Returns:
|
||||
List of expanded LoRA stack strings
|
||||
"""
|
||||
def to_list(x):
|
||||
return x if isinstance(x, list) else [x]
|
||||
|
||||
|
||||
raw_loras = to_list(lora_input)
|
||||
expanded_loras = []
|
||||
|
||||
|
||||
for l in raw_loras:
|
||||
if l == "None":
|
||||
expanded_loras.append("None")
|
||||
continue
|
||||
|
||||
|
||||
stack_parts = l.split(" + ")
|
||||
expanded_parts = []
|
||||
|
||||
|
||||
for part in stack_parts:
|
||||
if ":" in part:
|
||||
p_split = part.split(":", 1)
|
||||
@@ -222,15 +323,15 @@ def expand_lora_stack(lora_input):
|
||||
else:
|
||||
base_path = part.strip()
|
||||
args = ""
|
||||
|
||||
|
||||
norm_path = base_path.replace("\\", "/")
|
||||
|
||||
|
||||
# Check for folder/* syntax (stack all together)
|
||||
if norm_path.endswith("/*") or (norm_path.endswith("*") and "/" in norm_path):
|
||||
# Remove the /* or * suffix
|
||||
folder_path = norm_path.rstrip("/*").rstrip("*").rstrip("/")
|
||||
found_files = get_files_from_folder(folder_path + "/", "loras")
|
||||
|
||||
|
||||
if found_files:
|
||||
# Stack ALL files together as a single entry (not separate combinations)
|
||||
stacked = " + ".join([f"{f}{args}" for f in found_files])
|
||||
@@ -239,21 +340,24 @@ def expand_lora_stack(lora_input):
|
||||
else:
|
||||
print(f"[GridTester] ⚠️ No LoRAs found in folder: {folder_path}")
|
||||
expanded_parts.append([])
|
||||
|
||||
|
||||
# Check for regular folder/ syntax (expand individually)
|
||||
elif norm_path.endswith("/"):
|
||||
found_files = get_files_from_folder(base_path, "loras")
|
||||
expanded_parts.append([f"{f}{args}" for f in found_files])
|
||||
else:
|
||||
expanded_parts.append([part])
|
||||
|
||||
|
||||
# Skip if any part expanded to empty
|
||||
if not all(expanded_parts):
|
||||
continue
|
||||
|
||||
|
||||
for combo in itertools.product(*expanded_parts):
|
||||
expanded_loras.append(" + ".join(combo))
|
||||
|
||||
joined = " + ".join(combo)
|
||||
# Now expand any weight arrays in the joined result
|
||||
weight_expanded = _expand_lora_weight_arrays(joined)
|
||||
expanded_loras.extend(weight_expanded)
|
||||
|
||||
return expanded_loras
|
||||
|
||||
|
||||
@@ -374,10 +478,28 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed,
|
||||
if not isinstance(lora_triggerwords_append_settings, dict):
|
||||
lora_triggerwords_append_settings = {}
|
||||
|
||||
# Get model-specific prompt prefix/suffix (quality tags for specific model families)
|
||||
model_prompt_prefix = entry.get("model_prompt_prefix", "")
|
||||
model_prompt_suffix = entry.get("model_prompt_suffix", "")
|
||||
if not isinstance(model_prompt_prefix, str):
|
||||
model_prompt_prefix = str(model_prompt_prefix)
|
||||
if not isinstance(model_prompt_suffix, str):
|
||||
model_prompt_suffix = str(model_prompt_suffix)
|
||||
|
||||
# Get attention mode(s) for testing different attention implementations
|
||||
# Valid: "default", "xformers", "pytorch", "flash", "sage", "sage3", "sub_quad", "split", "*"
|
||||
VALID_ATTENTION_MODES = ["default", "xformers", "pytorch", "flash", "sage", "sage3", "sub_quad", "split"]
|
||||
raw_attention = to_list(entry.get("attention_mode", "default"))
|
||||
if raw_attention == ["*"]:
|
||||
attention_modes = VALID_ATTENTION_MODES
|
||||
else:
|
||||
attention_modes = [a for a in raw_attention if a in VALID_ATTENTION_MODES] or ["default"]
|
||||
|
||||
# Build all combinations
|
||||
base_combos = []
|
||||
for combo in itertools.product(samplers, schedulers, steps_l, cfgs, clip_skips, expanded_loras,
|
||||
denoise_values, entry_prompt_pairs, expanded_models, raw_vaes):
|
||||
denoise_values, entry_prompt_pairs, expanded_models, raw_vaes,
|
||||
attention_modes):
|
||||
base_combos.append({
|
||||
"sampler": combo[0],
|
||||
"scheduler": combo[1],
|
||||
@@ -390,6 +512,7 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed,
|
||||
"negative": combo[7][1],
|
||||
"model": combo[8],
|
||||
"vae": combo[9],
|
||||
"attention_mode": combo[10],
|
||||
"seed": seed,
|
||||
"seed_behavior": entry.get("seed_behavior", "fixed"),
|
||||
"model_type": model_type,
|
||||
@@ -397,7 +520,9 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed,
|
||||
"text_encoders": list(text_encoders),
|
||||
"gguf_options": dict(gguf_options) if gguf_options else {},
|
||||
"lora_omit_triggers": list(lora_omit_triggers),
|
||||
"lora_triggerwords_append_settings": dict(lora_triggerwords_append_settings)
|
||||
"lora_triggerwords_append_settings": dict(lora_triggerwords_append_settings),
|
||||
"model_prompt_prefix": model_prompt_prefix.strip(),
|
||||
"model_prompt_suffix": model_prompt_suffix.strip()
|
||||
})
|
||||
|
||||
# Apply base seed and extra seeds
|
||||
|
||||
+111
-25
@@ -97,35 +97,55 @@ def calculate_clip_hash(clip_model):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx, positive_prompt, negative_prompt):
|
||||
"""Independent check to see if a specific generation job already exists."""
|
||||
def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx, positive_prompt, negative_prompt, has_optional_inputs=False):
|
||||
"""Independent check to see if a specific generation job already exists.
|
||||
|
||||
When has_optional_inputs is True, we skip the model/lora/prompt matching since
|
||||
those values came from upstream nodes whose changes we cannot reliably track.
|
||||
We still match on seed/resolution/sampler/scheduler/steps/cfg/denoise so that
|
||||
if the user is ONLY changing upstream model/conditioning, old jobs get re-run.
|
||||
"""
|
||||
FLOAT_TOLERANCE = 0.0001
|
||||
|
||||
|
||||
for idx, item in enumerate(existing_items):
|
||||
if item.get("seed") != seed: continue
|
||||
if item.get("width") != width: continue
|
||||
if item.get("height") != height: continue
|
||||
if item.get("batch_idx", 0) != batch_idx: continue
|
||||
if item.get("model") != conf["model"]: continue
|
||||
if item.get("sampler") != conf["sampler"]: continue
|
||||
if item.get("scheduler") != conf["scheduler"]: continue
|
||||
|
||||
|
||||
# Check attention mode (default = no attention_mode key or "default")
|
||||
item_attn = item.get("attention_mode", "default")
|
||||
conf_attn = conf.get("attention_mode", "default")
|
||||
if item_attn != conf_attn: continue
|
||||
|
||||
try:
|
||||
if abs(float(item.get("steps")) - float(conf["steps"])) > FLOAT_TOLERANCE: continue
|
||||
if abs(float(item.get("cfg")) - float(conf["cfg"])) > FLOAT_TOLERANCE: continue
|
||||
if abs(float(item.get("denoise")) - float(conf["denoise"])) > FLOAT_TOLERANCE: continue
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if item.get("positive", "").strip() != positive_prompt.strip(): continue
|
||||
if item.get("negative", "").strip() != negative_prompt.strip(): continue
|
||||
|
||||
item_lora = item.get("lora", "None")
|
||||
conf_lora = conf.get("lora_expanded", "None")
|
||||
if item_lora != conf_lora: continue
|
||||
continue
|
||||
|
||||
if has_optional_inputs:
|
||||
# When optional inputs are connected, we can't reliably match on model,
|
||||
# lora, or prompts because those may come from upstream nodes whose
|
||||
# changes we can't detect. Skip these checks entirely so the job
|
||||
# is always re-run when optional inputs are connected (unless the user
|
||||
# explicitly sets overwrite_existing=False, handled by the caller).
|
||||
pass
|
||||
else:
|
||||
# Standard matching - check model, lora, and prompts
|
||||
if item.get("model") != conf["model"]: continue
|
||||
if item.get("positive", "").strip() != positive_prompt.strip(): continue
|
||||
if item.get("negative", "").strip() != negative_prompt.strip(): continue
|
||||
|
||||
item_lora = item.get("lora", "None")
|
||||
conf_lora = conf.get("lora_expanded", "None")
|
||||
if item_lora != conf_lora: continue
|
||||
|
||||
return idx
|
||||
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
@@ -221,7 +241,7 @@ def run_generation_loop(
|
||||
extra_seeds = [random.randint(0, 2**32 - 1) for _ in range(add_random_seeds_to_gens)]
|
||||
|
||||
expanded = expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, extra_seeds, ckpt_name)
|
||||
expanded.sort(key=lambda x: (x.get('model_type', 'checkpoint'), x['model'], tuple(x.get('text_encoders', [])), x.get('vae', 'Default'), x['lora'], x['positive'], x['negative']))
|
||||
expanded.sort(key=lambda x: (x.get('model_type', 'checkpoint'), x['model'], tuple(x.get('text_encoders', [])), x.get('vae', 'Default'), x['lora'], x.get('attention_mode', 'default'), x['positive'], x['negative']))
|
||||
|
||||
# ==== EXPAND LORA FOLDERS ====
|
||||
print(f"[GridTester] 🎲 Expanding LoRA folders and random selections...")
|
||||
@@ -275,13 +295,31 @@ def run_generation_loop(
|
||||
print(f"[GridTester] 📋 {len(expanded)} configs × {len(input_jobs)} resolutions = {total_jobs} total jobs")
|
||||
print(f"{'='*80}")
|
||||
|
||||
# ==== OPTIONAL INPUT DETECTION ====
|
||||
# Track whether any optional inputs are connected - this affects skip/resume logic
|
||||
has_optional_inputs = any([
|
||||
optional_model is not None,
|
||||
optional_clip is not None,
|
||||
optional_vae is not None,
|
||||
optional_positive is not None,
|
||||
optional_negative is not None,
|
||||
optional_latent is not None
|
||||
])
|
||||
|
||||
if has_optional_inputs and not overwrite_existing:
|
||||
print(f"[GridTester] ⚠️ Optional inputs connected with Resume mode (overwrite_existing=False).")
|
||||
print(f"[GridTester] ⚠️ Changes to upstream nodes (models, LoRAs, prompts) connected via optional inputs")
|
||||
print(f"[GridTester] ⚠️ CANNOT be automatically detected. Jobs matching sampler/scheduler/steps/cfg/denoise/seed")
|
||||
print(f"[GridTester] ⚠️ will be SKIPPED even if upstream model/conditioning changed.")
|
||||
print(f"[GridTester] ⚠️ Set overwrite_existing=True to force re-generation of all jobs.")
|
||||
|
||||
# ==== OPTIONAL CONDITIONING SETUP ====
|
||||
if optional_positive or optional_negative:
|
||||
pos_hash = hashlib.md5(str(optional_positive).encode()).hexdigest()[:16] if optional_positive else None
|
||||
neg_hash = hashlib.md5(str(optional_negative).encode()).hexdigest()[:16] if optional_negative else None
|
||||
else:
|
||||
pos_hash, neg_hash = None, None
|
||||
|
||||
|
||||
# ==== REMOTE VAE SETUP (use_remote_vae already defined above for VAE validation) ====
|
||||
|
||||
try:
|
||||
@@ -478,13 +516,14 @@ def run_generation_loop(
|
||||
|
||||
# ==== CHECK EXISTING MATCH ====
|
||||
match_index = check_if_job_completed(
|
||||
existing_data["items"],
|
||||
conf,
|
||||
current_seed,
|
||||
w, h,
|
||||
batch_idx,
|
||||
actual_positive_prompt,
|
||||
actual_negative_prompt
|
||||
existing_data["items"],
|
||||
conf,
|
||||
current_seed,
|
||||
w, h,
|
||||
batch_idx,
|
||||
actual_positive_prompt,
|
||||
actual_negative_prompt,
|
||||
has_optional_inputs=has_optional_inputs
|
||||
)
|
||||
|
||||
if match_index != -1:
|
||||
@@ -785,17 +824,44 @@ def run_generation_loop(
|
||||
|
||||
result_latent = None
|
||||
try:
|
||||
attention_mode = conf.get("attention_mode", "default")
|
||||
if attention_mode != "default":
|
||||
print(f"[GridTester] 🧠 Using attention mode: {attention_mode}")
|
||||
|
||||
result_latent, duration = generate_image(
|
||||
patched_model, current_seed, conf["steps"], conf["cfg"],
|
||||
conf["sampler"], conf["scheduler"], final_positive, final_negative,
|
||||
latent_in, conf["denoise"]
|
||||
latent_in, conf["denoise"], attention_mode=attention_mode
|
||||
)
|
||||
|
||||
job_durations.append(duration)
|
||||
eta_info = calculate_eta(job_durations, current_job, total_jobs)
|
||||
if eta_info:
|
||||
print_generation_progress(current_job, total_jobs, conf, w, h, duration, eta_info)
|
||||
|
||||
# Send progress to dashboard frontend
|
||||
if PromptServer is not None:
|
||||
try:
|
||||
progress_pct = int((current_job / total_jobs) * 100)
|
||||
if eta_info['hours'] > 0:
|
||||
eta_str = f"{eta_info['hours']}h {eta_info['minutes']}m"
|
||||
elif eta_info['minutes'] > 0:
|
||||
eta_str = f"{eta_info['minutes']}m {eta_info['seconds']}s"
|
||||
else:
|
||||
eta_str = f"{eta_info['seconds']}s"
|
||||
PromptServer.instance.send_sync("ultimate_grid.progress", {
|
||||
"node": unique_id,
|
||||
"session_name": session_name,
|
||||
"current_job": current_job,
|
||||
"total_jobs": total_jobs,
|
||||
"progress_pct": progress_pct,
|
||||
"eta_str": eta_str,
|
||||
"finish_time": eta_info['finish_formatted'],
|
||||
"avg_duration": round(eta_info['avg_duration'], 1),
|
||||
"last_duration": round(duration, 1)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
meta = create_image_metadata(
|
||||
conf, w, h, duration, current_seed, batch_idx,
|
||||
actual_positive_prompt, actual_negative_prompt
|
||||
@@ -922,5 +988,25 @@ def run_generation_loop(
|
||||
print(f"[GridTester] ⏱️ {total_hours}h {total_minutes}m {total_seconds}s total")
|
||||
print(f"[GridTester] 📊 {avg_per_job:.1f}s average per job")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Send completion event to dashboard
|
||||
if PromptServer is not None:
|
||||
try:
|
||||
PromptServer.instance.send_sync("ultimate_grid.progress", {
|
||||
"node": unique_id,
|
||||
"session_name": session_name,
|
||||
"current_job": total_jobs,
|
||||
"total_jobs": total_jobs,
|
||||
"progress_pct": 100,
|
||||
"eta_str": "Done",
|
||||
"finish_time": time.strftime("%H:%M:%S"),
|
||||
"avg_duration": round(avg_per_job, 1),
|
||||
"last_duration": 0,
|
||||
"complete": True,
|
||||
"total_elapsed": f"{total_hours}h {total_minutes}m {total_seconds}s",
|
||||
"total_generated": total_generated
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (html,)
|
||||
+56
-22
@@ -23,11 +23,12 @@ def generate_image(
|
||||
positive_conditioning,
|
||||
negative_conditioning,
|
||||
latent_input,
|
||||
denoise
|
||||
denoise,
|
||||
attention_mode="default"
|
||||
):
|
||||
"""
|
||||
Generate a single image using ComfyUI's KSampler.
|
||||
|
||||
|
||||
Args:
|
||||
patched_model: Model (potentially with LoRAs applied)
|
||||
seed: Random seed
|
||||
@@ -39,27 +40,54 @@ def generate_image(
|
||||
negative_conditioning: Negative conditioning tensor
|
||||
latent_input: Input latent tensor dict
|
||||
denoise: Denoise strength (0.0-1.0)
|
||||
|
||||
attention_mode: Attention implementation to use ("default", "xformers", "pytorch",
|
||||
"flash", "sage", "sage3", "sub_quad", "split")
|
||||
|
||||
Returns:
|
||||
tuple: (result_latent_dict, generation_duration_seconds)
|
||||
"""
|
||||
# Apply attention mode override if not "default"
|
||||
original_attn_override = None
|
||||
if attention_mode and attention_mode != "default":
|
||||
try:
|
||||
from comfy.ldm.modules.attention import get_attention_function
|
||||
attn_func = get_attention_function(attention_mode, default=None)
|
||||
if attn_func is not None:
|
||||
# Save original override (if any) and set the new one
|
||||
original_attn_override = patched_model.model_options.get("transformer_options", {}).get("optimized_attention_override")
|
||||
if "transformer_options" not in patched_model.model_options:
|
||||
patched_model.model_options["transformer_options"] = {}
|
||||
patched_model.model_options["transformer_options"]["optimized_attention_override"] = attn_func
|
||||
else:
|
||||
print(f"[GridTester] ⚠️ Attention mode '{attention_mode}' not available, using default")
|
||||
except Exception as e:
|
||||
print(f"[GridTester] ⚠️ Could not set attention mode '{attention_mode}': {e}")
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
result = nodes.common_ksampler(
|
||||
model=patched_model,
|
||||
seed=seed,
|
||||
steps=steps,
|
||||
cfg=cfg,
|
||||
sampler_name=sampler_name,
|
||||
scheduler=scheduler,
|
||||
positive=positive_conditioning,
|
||||
negative=negative_conditioning,
|
||||
latent=latent_input,
|
||||
denoise=denoise
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
result = nodes.common_ksampler(
|
||||
model=patched_model,
|
||||
seed=seed,
|
||||
steps=steps,
|
||||
cfg=cfg,
|
||||
sampler_name=sampler_name,
|
||||
scheduler=scheduler,
|
||||
positive=positive_conditioning,
|
||||
negative=negative_conditioning,
|
||||
latent=latent_input,
|
||||
denoise=denoise
|
||||
)
|
||||
finally:
|
||||
# Restore original attention override
|
||||
if attention_mode and attention_mode != "default":
|
||||
if original_attn_override is not None:
|
||||
patched_model.model_options["transformer_options"]["optimized_attention_override"] = original_attn_override
|
||||
elif "transformer_options" in patched_model.model_options and "optimized_attention_override" in patched_model.model_options["transformer_options"]:
|
||||
del patched_model.model_options["transformer_options"]["optimized_attention_override"]
|
||||
|
||||
duration = round(time.time() - t0, 3)
|
||||
|
||||
|
||||
return result[0], duration
|
||||
|
||||
|
||||
@@ -138,12 +166,18 @@ def create_image_metadata(config, width, height, duration, seed, batch_idx, actu
|
||||
"lora_triggerwords_append_settings",
|
||||
"lora_omit_triggers",
|
||||
"seed_behavior",
|
||||
"gguf_options"
|
||||
"gguf_options",
|
||||
"model_prompt_prefix",
|
||||
"model_prompt_suffix"
|
||||
]
|
||||
|
||||
|
||||
for key in global_settings_to_remove:
|
||||
meta.pop(key, None)
|
||||
|
||||
|
||||
# Remove attention_mode if it's "default" (keep manifest backward-compatible)
|
||||
if meta.get("attention_mode") == "default":
|
||||
meta.pop("attention_mode", None)
|
||||
|
||||
meta.update({
|
||||
"width": width,
|
||||
"height": height,
|
||||
@@ -153,7 +187,7 @@ def create_image_metadata(config, width, height, duration, seed, batch_idx, actu
|
||||
"positive": actual_positive_prompt,
|
||||
"negative": actual_negative_prompt
|
||||
})
|
||||
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,33 @@ let lastUpdateId = 0;
|
||||
|
||||
// WebSocket Listener - OPTIMIZED
|
||||
window.addEventListener('message', (event) => {
|
||||
// Handle real-time ETA/progress updates
|
||||
if (event.data && event.data.type === 'progress_update') {
|
||||
const p = event.data.data;
|
||||
if (!p) return;
|
||||
const etaBar = document.getElementById('eta-bar');
|
||||
if (etaBar) {
|
||||
etaBar.style.display = 'flex';
|
||||
const etaText = document.getElementById('eta-text');
|
||||
const etaProgress = document.getElementById('eta-progress');
|
||||
if (p.complete) {
|
||||
// Generation complete
|
||||
if (etaText) etaText.textContent = `Complete! ${p.total_generated} images in ${p.total_elapsed} (${p.avg_duration}s/job)`;
|
||||
if (etaProgress) etaProgress.style.width = '100%';
|
||||
// Auto-hide after 30 seconds
|
||||
setTimeout(() => { if (etaBar) etaBar.style.display = 'none'; }, 30000);
|
||||
} else {
|
||||
if (etaText) {
|
||||
etaText.textContent = `Job ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str} | ~${p.finish_time} | ${p.avg_duration}s/job`;
|
||||
}
|
||||
if (etaProgress) {
|
||||
etaProgress.style.width = `${p.progress_pct}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'update_data') {
|
||||
const payload = event.data.data;
|
||||
if (!payload) return;
|
||||
|
||||
@@ -90,9 +90,40 @@ function toggleSessionPopup() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cog Wheel Settings Menu
|
||||
function toggleCogMenu() {
|
||||
const dropdown = document.getElementById('cog-menu-dropdown');
|
||||
if (!dropdown) return;
|
||||
const isHidden = dropdown.style.display === 'none' || !dropdown.style.display;
|
||||
dropdown.style.display = isHidden ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Close cog menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const dropdown = document.getElementById('cog-menu-dropdown');
|
||||
const container = e.target.closest('.cog-menu-container');
|
||||
if (dropdown && dropdown.style.display !== 'none' && !container) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Close popup on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Close Revise modal first (highest priority)
|
||||
const reviseModal = document.getElementById('modal');
|
||||
if (reviseModal && reviseModal.style.display !== 'none' && reviseModal.style.display !== '') {
|
||||
closeM();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close cog menu
|
||||
const cogMenu = document.getElementById('cog-menu-dropdown');
|
||||
if (cogMenu && cogMenu.style.display !== 'none') {
|
||||
cogMenu.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const filtersPopup = document.getElementById('filters-popup');
|
||||
const sessionPopup = document.getElementById('session-popup');
|
||||
|
||||
|
||||
+76
-8
@@ -796,14 +796,37 @@ body {
|
||||
}
|
||||
|
||||
/* MODAL */
|
||||
#modal {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.95);
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
#modal {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.95);
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-close-x {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
background: #cc3333;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1001;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close-x:hover {
|
||||
background: #ee4444;
|
||||
}
|
||||
|
||||
.modal-main {
|
||||
@@ -1241,6 +1264,51 @@ body {
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
/* --- COG WHEEL MENU --- */
|
||||
.cog-menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
min-width: 140px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cog-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.cog-menu-item .meta-label {
|
||||
min-width: 38px;
|
||||
}
|
||||
|
||||
.cog-menu-item input {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
color: #ccc;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
height: 22px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cog-menu-item input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- SORT POPUP STYLES --- */
|
||||
.sort-container {
|
||||
position: relative;
|
||||
|
||||
+35
-15
@@ -22,21 +22,10 @@
|
||||
|
||||
<div style="flex-grow: 1;"></div>
|
||||
|
||||
<div class="meta-box">
|
||||
<input type="text" inputmode="numeric" id="goto-input" min="1" placeholder="Go to # "
|
||||
title="Jump to image number (press Enter)" style="width: 50px;">
|
||||
</div>
|
||||
|
||||
<div class="meta-box">
|
||||
<span class="meta-label">Cols</span>
|
||||
<input type="text" inputmode="numeric" id="col-count" min="0" placeholder="Auto"
|
||||
title="Set grid columns (0 = Auto)">
|
||||
</div>
|
||||
|
||||
<div class="meta-box" style="overflow: visible;"> <span class="meta-label">Sort</span>
|
||||
|
||||
<div class="sort-container" id="sort-container" ">
|
||||
<button class=" sort-btn" id="sort-main-btn" onclick="toggleSortPopup()">
|
||||
<div class="sort-container" id="sort-container">
|
||||
<button class="sort-btn" id="sort-main-btn" onclick="toggleSortPopup()">
|
||||
<span id="sort-primary-label">Oldest</span>
|
||||
<span style="opacity:0.5; font-size:8px;">▼</span>
|
||||
</button>
|
||||
@@ -57,12 +46,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="action-btn" onclick="resetZoom()" title="Reset Zoom (0)">⊙</button>
|
||||
<!-- COG WHEEL MENU -->
|
||||
<div class="cog-menu-container" style="position: relative; overflow: visible;">
|
||||
<button class="action-btn" onclick="toggleCogMenu()" title="Settings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="cog-menu-dropdown" class="cog-menu-dropdown" style="display: none;">
|
||||
<div class="cog-menu-item">
|
||||
<span class="meta-label">Go To</span>
|
||||
<input type="text" inputmode="numeric" id="goto-input" min="1" placeholder="#"
|
||||
title="Jump to image number (press Enter)" style="width: 45px;">
|
||||
</div>
|
||||
<div class="cog-menu-item">
|
||||
<span class="meta-label">Cols</span>
|
||||
<input type="text" inputmode="numeric" id="col-count" min="0" placeholder="Auto"
|
||||
title="Set grid columns (0 = Auto)">
|
||||
</div>
|
||||
<div class="cog-menu-item">
|
||||
<button class="action-btn" onclick="resetZoom(); toggleCogMenu();" title="Reset Zoom (0)" style="width: 100%; justify-content: center;">⊙ Reset Zoom</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="fs-btn" onclick="toggleFullscreen()" title="Toggle Fullscreen">⛶</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- REAL-TIME ETA BAR -->
|
||||
<div id="eta-bar" style="display: none; align-items: center; gap: 10px; padding: 4px 12px; background: #1a1a2e; border-bottom: 1px solid #333; font-size: 11px; color: #aaa; position: relative;">
|
||||
<span style="color: #0af; font-weight: bold;">ETA</span>
|
||||
<span id="eta-text" style="flex: 1;">--</span>
|
||||
<div style="position: absolute; bottom: 0; left: 0; height: 2px; width: 100%; background: #222;">
|
||||
<div id="eta-progress" style="height: 100%; width: 0%; background: linear-gradient(90deg, #0af, #0f6); transition: width 0.5s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="filters-popup" class="filters-popup" style="display: none;">
|
||||
@@ -325,6 +344,7 @@
|
||||
</div>
|
||||
|
||||
<div id="modal">
|
||||
<button class="modal-close-x" onclick="closeM()" title="Close (Esc)">×</button>
|
||||
<div class="modal-main">
|
||||
<div class="preview"><img id="m-img" src=""></div>
|
||||
<div class="controls">
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import time
|
||||
import random
|
||||
import hashlib
|
||||
import uuid
|
||||
import folder_paths
|
||||
import nodes
|
||||
import comfy.utils
|
||||
@@ -98,6 +99,48 @@ class SamplerGridTester:
|
||||
FUNCTION = "run_tests"
|
||||
CATEGORY = "sampling/testing"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""
|
||||
Force re-execution when optional inputs are connected.
|
||||
|
||||
ComfyUI's standard IS_CHANGED uses input hash comparisons to decide
|
||||
whether to skip execution. However, for complex objects like MODEL, CLIP,
|
||||
VAE, and CONDITIONING passed via optional inputs, the hash doesn't reliably
|
||||
change when the upstream node's internal state changes (e.g., user swaps
|
||||
model in a loader node).
|
||||
|
||||
When ANY optional input is connected, we return a unique value each time
|
||||
to force ComfyUI to always re-execute this node, ensuring the grid tester
|
||||
picks up upstream changes to models, LoRAs, prompts, etc.
|
||||
|
||||
When NO optional inputs are connected, we return a deterministic hash
|
||||
based on the text/numeric inputs so ComfyUI can properly cache.
|
||||
"""
|
||||
optional_keys = [
|
||||
"optional_model", "optional_clip", "optional_vae",
|
||||
"optional_positive", "optional_negative", "optional_latent"
|
||||
]
|
||||
|
||||
# Check if any optional input is connected (not None and present in kwargs)
|
||||
has_optional = any(
|
||||
kwargs.get(key) is not None for key in optional_keys
|
||||
)
|
||||
|
||||
if has_optional:
|
||||
# Force re-execution every time - optional inputs can't be reliably hashed
|
||||
return float("NaN")
|
||||
|
||||
# No optional inputs - return deterministic hash so ComfyUI can cache properly
|
||||
# Hash the text/numeric inputs that we CAN reliably track
|
||||
hash_parts = []
|
||||
for key in sorted(kwargs.keys()):
|
||||
if key not in optional_keys and key != "unique_id":
|
||||
val = kwargs.get(key)
|
||||
if val is not None:
|
||||
hash_parts.append(f"{key}={val}")
|
||||
|
||||
return hashlib.md5("|".join(hash_parts).encode()).hexdigest()
|
||||
|
||||
# --- HELPER METHODS ---
|
||||
def is_float_equal(self, a, b, tolerance=1e-5):
|
||||
|
||||
+59
-22
@@ -167,7 +167,7 @@ def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mod
|
||||
|
||||
for conf in expanded_configs:
|
||||
full_positive = conf["positive"]
|
||||
|
||||
|
||||
if lora_triggerwords_mode != "None" and conf["lora"] != "None":
|
||||
omit_list = conf.get("lora_omit_triggers", [])
|
||||
trigger_list = get_filtered_lora_triggers(
|
||||
@@ -175,24 +175,24 @@ def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mod
|
||||
omit_list,
|
||||
lookup_triggers=True
|
||||
)
|
||||
|
||||
|
||||
if trigger_list:
|
||||
# Parse the lora string to get individual loras
|
||||
active_loras = parse_lora_definition(conf["lora"])
|
||||
|
||||
|
||||
# Separate triggers by placement
|
||||
start_triggers = []
|
||||
end_triggers = []
|
||||
|
||||
|
||||
for lora_def in active_loras:
|
||||
lname, _, _ = lora_def
|
||||
placement = get_trigger_placement_for_lora(lname, conf, lora_triggerwords_mode)
|
||||
|
||||
|
||||
# Get triggers specific to this lora
|
||||
try:
|
||||
lora_triggers = load_and_save_tags(lname, force_fetch=False)
|
||||
omit_normalized = [str(t).lower().strip().rstrip(',').strip() for t in omit_list]
|
||||
|
||||
|
||||
for tag in lora_triggers:
|
||||
cleaned_tag = tag.strip().rstrip(',').strip()
|
||||
if not _should_omit_trigger(cleaned_tag.lower(), omit_normalized):
|
||||
@@ -202,7 +202,7 @@ def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mod
|
||||
end_triggers.append(cleaned_tag)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# Build the full prompt with triggers in correct positions
|
||||
parts = []
|
||||
if start_triggers:
|
||||
@@ -210,9 +210,9 @@ def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mod
|
||||
parts.append(conf['positive'])
|
||||
if end_triggers:
|
||||
parts.append(", ".join(end_triggers))
|
||||
|
||||
|
||||
full_positive = ", ".join(parts)
|
||||
|
||||
|
||||
# Show omitted triggers only once during pre-encoding
|
||||
if omit_list:
|
||||
all_triggers = []
|
||||
@@ -224,11 +224,14 @@ def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mod
|
||||
all_triggers.extend([t.strip().rstrip(',').strip() for t in tags])
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
omitted = set(all_triggers) - set(start_triggers + end_triggers)
|
||||
if omitted:
|
||||
print(f"[GridTester] 🚫 Omitted triggers: {', '.join(omitted)}")
|
||||
|
||||
|
||||
# Apply model-specific prompt prefix/suffix (wraps entire prompt+triggers)
|
||||
full_positive = _apply_model_prompt_affixes(full_positive, conf)
|
||||
|
||||
unique_positives.add(full_positive)
|
||||
unique_negatives.add(conf["negative"])
|
||||
|
||||
@@ -238,9 +241,39 @@ def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mod
|
||||
_build_prompt_cache = {}
|
||||
|
||||
|
||||
def _apply_model_prompt_affixes(prompt, config):
|
||||
"""
|
||||
Apply model-specific prompt prefix and suffix to a prompt string.
|
||||
|
||||
These are quality tags that should always wrap the prompt for specific model families,
|
||||
e.g. "score_9, score_8_up" for Pony or "masterpiece, best quality" for SD1.5.
|
||||
|
||||
Args:
|
||||
prompt: The assembled prompt string (may already include LoRA triggers)
|
||||
config: Configuration dictionary with optional model_prompt_prefix/suffix
|
||||
|
||||
Returns:
|
||||
The prompt with prefix/suffix applied
|
||||
"""
|
||||
prefix = config.get("model_prompt_prefix", "").strip()
|
||||
suffix = config.get("model_prompt_suffix", "").strip()
|
||||
|
||||
if not prefix and not suffix:
|
||||
return prompt
|
||||
|
||||
parts = []
|
||||
if prefix:
|
||||
parts.append(prefix)
|
||||
parts.append(prompt)
|
||||
if suffix:
|
||||
parts.append(suffix)
|
||||
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def build_prompt_with_triggers(config, lora_triggerwords_mode):
|
||||
"""
|
||||
Build final prompt with LoRA triggers applied.
|
||||
Build final prompt with LoRA triggers and model-specific prefix/suffix applied.
|
||||
Results are cached based on relevant config fields to avoid redundant work.
|
||||
|
||||
Args:
|
||||
@@ -251,7 +284,8 @@ def build_prompt_with_triggers(config, lora_triggerwords_mode):
|
||||
tuple: (final_prompt, trigger_string)
|
||||
"""
|
||||
if config["lora"] == "None" or lora_triggerwords_mode == "None":
|
||||
return config["positive"], ""
|
||||
prompt = _apply_model_prompt_affixes(config["positive"], config)
|
||||
return prompt, ""
|
||||
|
||||
omit_list = config.get("lora_omit_triggers", [])
|
||||
append_settings = config.get("lora_triggerwords_append_settings", {})
|
||||
@@ -262,7 +296,9 @@ def build_prompt_with_triggers(config, lora_triggerwords_mode):
|
||||
config["positive"],
|
||||
tuple(omit_list),
|
||||
tuple(sorted(append_settings.items())) if append_settings else (),
|
||||
lora_triggerwords_mode
|
||||
lora_triggerwords_mode,
|
||||
config.get("model_prompt_prefix", ""),
|
||||
config.get("model_prompt_suffix", "")
|
||||
)
|
||||
|
||||
if cache_key in _build_prompt_cache:
|
||||
@@ -270,21 +306,19 @@ def build_prompt_with_triggers(config, lora_triggerwords_mode):
|
||||
|
||||
# Parse the lora string to get individual loras
|
||||
active_loras = parse_lora_definition(config["lora"])
|
||||
|
||||
|
||||
# Separate triggers by placement
|
||||
start_triggers = []
|
||||
end_triggers = []
|
||||
|
||||
|
||||
for lora_def in active_loras:
|
||||
lname, _, _ = lora_def
|
||||
placement = get_trigger_placement_for_lora(lname, config, lora_triggerwords_mode)
|
||||
# print('placement')
|
||||
# print(placement)
|
||||
# Get triggers specific to this lora
|
||||
try:
|
||||
lora_triggers = load_and_save_tags(lname, force_fetch=False)
|
||||
omit_normalized = [str(t).lower().strip().rstrip(',').strip() for t in omit_list]
|
||||
|
||||
|
||||
for tag in lora_triggers:
|
||||
cleaned_tag = tag.strip().rstrip(',').strip()
|
||||
if not _should_omit_trigger(cleaned_tag.lower(), omit_normalized):
|
||||
@@ -294,13 +328,13 @@ def build_prompt_with_triggers(config, lora_triggerwords_mode):
|
||||
end_triggers.append(cleaned_tag)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# Build the trigger string for return value
|
||||
all_triggers = start_triggers + end_triggers
|
||||
trigger_string = ""
|
||||
if all_triggers:
|
||||
trigger_string = ", " + ", ".join(all_triggers)
|
||||
|
||||
|
||||
# Build the full prompt with triggers in correct positions
|
||||
parts = []
|
||||
if start_triggers:
|
||||
@@ -308,9 +342,12 @@ def build_prompt_with_triggers(config, lora_triggerwords_mode):
|
||||
parts.append(config['positive'])
|
||||
if end_triggers:
|
||||
parts.append(", ".join(end_triggers))
|
||||
|
||||
|
||||
final_prompt = ", ".join(parts)
|
||||
|
||||
# Apply model-specific prefix/suffix (wraps the entire prompt+triggers)
|
||||
final_prompt = _apply_model_prompt_affixes(final_prompt, config)
|
||||
|
||||
result = (final_prompt, trigger_string)
|
||||
|
||||
# Store in cache (limit size to prevent memory issues)
|
||||
|
||||
@@ -272,9 +272,31 @@ function createChipListBuilder({ label, stateKey, options, node, arrayIdx, confi
|
||||
|
||||
// --- CONFIG ARRAY ELEMENT CREATOR ---
|
||||
|
||||
// Color palette for config array headers — cycles for easy visual distinction
|
||||
const CONFIG_COLORS = ["#0088ff", "#ff6600", "#00cc66", "#cc44cc", "#ffaa00", "#44cccc", "#ff4466", "#88aa00"];
|
||||
|
||||
export function createConfigArrayElement(node, configArray, arrayIdx, modelLists) {
|
||||
const accentColor = CONFIG_COLORS[arrayIdx % CONFIG_COLORS.length];
|
||||
const div = document.createElement("div");
|
||||
div.className = "cb-array";
|
||||
div.id = `cb-config-${arrayIdx}`;
|
||||
div.style.borderLeft = `4px solid ${accentColor}`;
|
||||
|
||||
// Prominent config header banner
|
||||
const configBanner = document.createElement("div");
|
||||
configBanner.style.cssText = `display: flex; align-items: center; gap: 10px; padding: 8px 12px; margin: -12px -12px 12px -12px; background: linear-gradient(90deg, ${accentColor}22, transparent); border-bottom: 2px solid ${accentColor}44; border-radius: 4px 4px 0 0;`;
|
||||
|
||||
const configIndex = document.createElement("span");
|
||||
configIndex.style.cssText = `background: ${accentColor}; color: #000; font-weight: 900; font-size: 13px; padding: 2px 8px; border-radius: 4px; font-family: monospace; min-width: 20px; text-align: center;`;
|
||||
configIndex.textContent = `${arrayIdx + 1}`;
|
||||
configBanner.appendChild(configIndex);
|
||||
|
||||
const configTitle = document.createElement("span");
|
||||
configTitle.style.cssText = `color: ${accentColor}; font-weight: 800; font-size: 14px; flex: 1;`;
|
||||
configTitle.textContent = configArray.name || `Config ${arrayIdx + 1}`;
|
||||
configBanner.appendChild(configTitle);
|
||||
|
||||
div.appendChild(configBanner);
|
||||
|
||||
const settingsGrid = document.createElement("div");
|
||||
settingsGrid.className = "cb-flex-grid";
|
||||
@@ -304,6 +326,17 @@ export function createConfigArrayElement(node, configArray, arrayIdx, modelLists
|
||||
});
|
||||
settingsGrid.appendChild(createInputGroup("Schedulers", schedulersBuilder));
|
||||
|
||||
// Attention Mode - dropdown + chips list builder
|
||||
const ATTENTION_MODES = ["default", "xformers", "pytorch", "flash", "sage", "sage3", "sub_quad", "split"];
|
||||
if (!configArray.attention_modes || configArray.attention_modes.length === 0) {
|
||||
configArray.attention_modes = ["default"];
|
||||
}
|
||||
const attentionBuilder = createChipListBuilder({
|
||||
label: "Attention", stateKey: "attention_modes", options: ATTENTION_MODES,
|
||||
node, arrayIdx, configArray, accentColor: "#cc44cc", placeholder: "default"
|
||||
});
|
||||
settingsGrid.appendChild(createInputGroup("Attention", attentionBuilder));
|
||||
|
||||
addInput("Steps", "steps");
|
||||
addInput("CFG", "cfg");
|
||||
|
||||
@@ -984,9 +1017,21 @@ async function showLoraMetadataModal(node, arrayIdx, loraName) {
|
||||
closeX.style.cssText = "position: absolute; top: 10px; right: 10px; background: #cc3333; color: white; border: none; border-radius: 4px; width: 28px; height: 28px; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 1;";
|
||||
closeX.onmouseover = () => closeX.style.background = "#dd4444";
|
||||
closeX.onmouseout = () => closeX.style.background = "#cc3333";
|
||||
closeX.onclick = () => document.body.removeChild(overlay);
|
||||
// Shared close function to clean up modal and event listener
|
||||
const closeModal = () => {
|
||||
if (overlay.parentNode) document.body.removeChild(overlay);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
|
||||
closeX.onclick = closeModal;
|
||||
modal.appendChild(closeX);
|
||||
|
||||
// Close on Escape key
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "🔍 LoRA Metadata Lookup";
|
||||
title.style.cssText = "margin: 0 0 15px 0; color: #9966cc;";
|
||||
@@ -1004,7 +1049,7 @@ async function showLoraMetadataModal(node, arrayIdx, loraName) {
|
||||
closeBtn.className = "cb-button";
|
||||
closeBtn.textContent = "Close";
|
||||
closeBtn.style.marginTop = "15px";
|
||||
closeBtn.onclick = () => document.body.removeChild(overlay);
|
||||
closeBtn.onclick = closeModal;
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
@@ -1799,15 +1844,35 @@ function renderOmitTriggersSection(node, div, configArray, arrayIdx) {
|
||||
div.appendChild(omitSection);
|
||||
}
|
||||
|
||||
// --- TRIGGER LOOKUP MODAL (Kept same as before) ---
|
||||
// --- TRIGGER LOOKUP MODAL ---
|
||||
export async function showTriggerLookupModal(node, arrayIdx) {
|
||||
// ... (This function remains unchanged from previous versions,
|
||||
// just ensure it's present in the file)
|
||||
const configArray = node.state.config_arrays[arrayIdx];
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 10000;`;
|
||||
const modal = document.createElement("div");
|
||||
modal.style.cssText = `background: #2a2a2a; border: 2px solid #0066cc; border-radius: 8px; padding: 20px; max-width: 600px; max-height: 80vh; overflow-y: auto; color: white;`;
|
||||
modal.style.cssText = `position: relative; background: #2a2a2a; border: 2px solid #0066cc; border-radius: 8px; padding: 20px; max-width: 600px; max-height: 80vh; overflow-y: auto; color: white;`;
|
||||
|
||||
// Shared close function to clean up modal and event listener
|
||||
const closeModal = () => {
|
||||
if (overlay.parentNode) document.body.removeChild(overlay);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
|
||||
// X close button in top right
|
||||
const closeX = document.createElement("button");
|
||||
closeX.textContent = "\u2716";
|
||||
closeX.style.cssText = "position: absolute; top: 10px; right: 10px; background: #cc3333; color: white; border: none; border-radius: 4px; width: 28px; height: 28px; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 1;";
|
||||
closeX.onmouseover = () => closeX.style.background = "#dd4444";
|
||||
closeX.onmouseout = () => closeX.style.background = "#cc3333";
|
||||
closeX.onclick = closeModal;
|
||||
modal.appendChild(closeX);
|
||||
|
||||
// Close on Escape key
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "🔎 LoRA Trigger Words Lookup";
|
||||
title.style.cssText = "margin: 0 0 15px 0; color: #0066cc;";
|
||||
@@ -1827,7 +1892,7 @@ export async function showTriggerLookupModal(node, arrayIdx) {
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "cb-button";
|
||||
closeBtn.textContent = "Close";
|
||||
closeBtn.onclick = () => document.body.removeChild(overlay);
|
||||
closeBtn.onclick = closeModal;
|
||||
buttonBar.appendChild(addAllBtn);
|
||||
buttonBar.appendChild(closeBtn);
|
||||
modal.appendChild(buttonBar);
|
||||
@@ -1965,22 +2030,20 @@ function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc
|
||||
|
||||
const rawTextarea = document.createElement("textarea");
|
||||
rawTextarea.className = "cb-prompt-raw-editor";
|
||||
rawTextarea.placeholder = '[["variation1", "variation2"], ["subject1", "subject2"]]';
|
||||
rawTextarea.placeholder = '[["variation1", "variation2"], ["subject1", "subject2"]]\nRecursive: ["fixed text", ["optA", "optB"], ["sub", ["nested1", "nested2"]]]';
|
||||
rawTextarea.value = groups.length > 0 ? JSON.stringify(groups, null, 2) : "";
|
||||
|
||||
rawTextarea.onchange = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(rawTextarea.value);
|
||||
if (Array.isArray(parsed)) {
|
||||
// Normalize: ensure each item is an array
|
||||
const normalized = parsed.map(item =>
|
||||
Array.isArray(item) ? item.map(String) : [String(item)]
|
||||
);
|
||||
onChange(normalized);
|
||||
// Accept recursive structures directly - the backend and preview
|
||||
// functions handle arbitrary nesting depth
|
||||
onChange(parsed);
|
||||
rawTextarea.style.borderColor = "#00aa44";
|
||||
setTimeout(() => { rawTextarea.style.borderColor = "#3a3a3a"; }, 1000);
|
||||
renderVisualGroups(normalized);
|
||||
renderPreview(normalized);
|
||||
renderVisualGroups(parsed);
|
||||
renderPreview(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
rawTextarea.style.borderColor = "#cc3333";
|
||||
@@ -2015,11 +2078,11 @@ function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc
|
||||
|
||||
function renderPreview(currentGroups) {
|
||||
previewContainer.innerHTML = "";
|
||||
const validGroups = (currentGroups || []).filter(g => Array.isArray(g) && g.length > 0);
|
||||
if (validGroups.length === 0) return;
|
||||
if (!currentGroups || !Array.isArray(currentGroups) || currentGroups.length === 0) return;
|
||||
|
||||
const count = countPromptCombinations(validGroups);
|
||||
const previews = expandPromptPreview(validGroups, 20);
|
||||
// Pass the full structure to count/preview — they handle recursive nesting
|
||||
const count = countPromptCombinations(currentGroups);
|
||||
const previews = expandPromptPreview(currentGroups, 20);
|
||||
|
||||
const previewDiv = document.createElement("div");
|
||||
previewDiv.className = "cb-prompt-preview";
|
||||
@@ -2043,7 +2106,45 @@ function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc
|
||||
function renderVisualGroups(currentGroups) {
|
||||
visualContainer.innerHTML = "";
|
||||
|
||||
// Detect if structure uses deep recursive nesting (anything beyond 2 levels)
|
||||
const hasDeepNesting = (currentGroups || []).some(item => {
|
||||
if (!Array.isArray(item)) return false;
|
||||
return item.some(sub => Array.isArray(sub));
|
||||
});
|
||||
|
||||
if (hasDeepNesting) {
|
||||
// Show read-only notice for recursive structures - edit in JSON mode
|
||||
const notice = document.createElement("div");
|
||||
notice.style.cssText = "padding: 8px; background: #2a2a3a; border: 1px solid #5544aa; border-radius: 4px; margin-bottom: 8px;";
|
||||
notice.innerHTML = `
|
||||
<div style="font-size: 11px; color: #aa88ff; font-weight: bold; margin-bottom: 4px;">🔀 Recursive Cartesian Structure Detected</div>
|
||||
<div style="font-size: 10px; color: #888;">This prompt uses nested arrays for recursive Cartesian products. Use the <b>JSON</b> editor to modify it.
|
||||
<br><br><b>Format:</b> <code style="color: #aaa;">["fixed text", ["option A", "option B"], ["sub1", ["nested1", "nested2"]]]</code>
|
||||
<br><b>Rules:</b> Strings = literal text, flat lists = options (OR), lists containing lists = sequence (AND, Cartesian product)</div>
|
||||
`;
|
||||
visualContainer.appendChild(notice);
|
||||
|
||||
// Show flattened read-only preview of the structure
|
||||
(currentGroups || []).forEach((item, idx) => {
|
||||
const itemDiv = document.createElement("div");
|
||||
itemDiv.style.cssText = "padding: 4px 8px; font-size: 11px; color: #aaa; font-family: monospace; background: #1a1a2a; border-radius: 3px; margin-bottom: 2px;";
|
||||
if (Array.isArray(item)) {
|
||||
itemDiv.textContent = `[${idx}] ${JSON.stringify(item)}`;
|
||||
itemDiv.style.color = "#88aaff";
|
||||
} else {
|
||||
itemDiv.textContent = `[${idx}] "${item}"`;
|
||||
itemDiv.style.color = "#aaffaa";
|
||||
}
|
||||
visualContainer.appendChild(itemDiv);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard 2-level visual editor for flat groups
|
||||
(currentGroups || []).forEach((group, groupIdx) => {
|
||||
// Normalize: if group is a string, wrap it for display
|
||||
const groupArr = Array.isArray(group) ? group : [String(group)];
|
||||
|
||||
const groupDiv = document.createElement("div");
|
||||
groupDiv.className = "cb-prompt-group";
|
||||
|
||||
@@ -2052,7 +2153,7 @@ function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc
|
||||
groupHeader.className = "cb-prompt-group-header";
|
||||
|
||||
const groupLabel = document.createElement("span");
|
||||
groupLabel.textContent = `Group ${groupIdx + 1} (${group.length} variation${group.length !== 1 ? 's' : ''})`;
|
||||
groupLabel.textContent = `Group ${groupIdx + 1} (${groupArr.length} variation${groupArr.length !== 1 ? 's' : ''})`;
|
||||
groupHeader.appendChild(groupLabel);
|
||||
|
||||
const groupDeleteBtn = document.createElement("button");
|
||||
@@ -2075,20 +2176,20 @@ function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc
|
||||
const chipsDiv = document.createElement("div");
|
||||
chipsDiv.className = "cb-prompt-chips";
|
||||
|
||||
group.forEach((variation, varIdx) => {
|
||||
groupArr.forEach((variation, varIdx) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "cb-prompt-chip";
|
||||
|
||||
const chipText = document.createElement("span");
|
||||
chipText.textContent = variation;
|
||||
chipText.title = variation;
|
||||
chipText.textContent = String(variation);
|
||||
chipText.title = String(variation);
|
||||
chip.appendChild(chipText);
|
||||
|
||||
const chipClose = document.createElement("span");
|
||||
chipClose.className = "chip-close";
|
||||
chipClose.textContent = "×";
|
||||
chipClose.onclick = () => {
|
||||
const newGroups = currentGroups.map(g => [...g]);
|
||||
const newGroups = currentGroups.map(g => Array.isArray(g) ? [...g] : [String(g)]);
|
||||
newGroups[groupIdx].splice(varIdx, 1);
|
||||
// Remove group if empty
|
||||
if (newGroups[groupIdx].length === 0) {
|
||||
@@ -2125,7 +2226,7 @@ function createPromptGroupEditor(groups, onChange, label, borderColor = "#0066cc
|
||||
const addVariation = () => {
|
||||
const val = input.value.trim();
|
||||
if (val) {
|
||||
const newGroups = currentGroups.map(g => [...g]);
|
||||
const newGroups = currentGroups.map(g => Array.isArray(g) ? [...g] : [String(g)]);
|
||||
newGroups[groupIdx].push(val);
|
||||
onChange(newGroups);
|
||||
renderVisualGroups(newGroups);
|
||||
@@ -2392,6 +2493,64 @@ export function renderConfigPromptsSection(node, div, configArray, arrayIdx) {
|
||||
contentDiv.appendChild(infoText);
|
||||
}
|
||||
|
||||
// --- Model Prompt Prefix / Suffix (always shown, applies to all prompts for this config) ---
|
||||
const affixSection = document.createElement("div");
|
||||
affixSection.style.cssText = "width: 100%; margin-top: 10px; padding-top: 8px; border-top: 1px solid #3a3a3a;";
|
||||
|
||||
const affixLabel = document.createElement("div");
|
||||
affixLabel.style.cssText = "font-size: 11px; font-weight: bold; color: #88aa00; margin-bottom: 6px;";
|
||||
affixLabel.textContent = "🏷️ Model Quality Tags (Prefix / Suffix)";
|
||||
affixSection.appendChild(affixLabel);
|
||||
|
||||
const affixHint = document.createElement("div");
|
||||
affixHint.style.cssText = "font-size: 10px; color: #666; margin-bottom: 6px; font-style: italic;";
|
||||
affixHint.textContent = "Added before/after ALL prompts for this config. Great for model-specific quality tags (e.g. 'score_9, score_8_up' for Pony, 'masterpiece, best quality' for SD1.5).";
|
||||
affixSection.appendChild(affixHint);
|
||||
|
||||
// Prefix input
|
||||
const prefixRow = document.createElement("div");
|
||||
prefixRow.style.cssText = "display: flex; align-items: center; gap: 6px; margin-bottom: 4px;";
|
||||
const prefixLabel = document.createElement("span");
|
||||
prefixLabel.style.cssText = "font-size: 11px; color: #aaa; min-width: 45px;";
|
||||
prefixLabel.textContent = "Prefix:";
|
||||
prefixRow.appendChild(prefixLabel);
|
||||
|
||||
const prefixInput = document.createElement("input");
|
||||
prefixInput.type = "text";
|
||||
prefixInput.className = "cb-input";
|
||||
prefixInput.style.cssText = "flex: 1; font-size: 11px;";
|
||||
prefixInput.placeholder = "e.g. score_9, score_8_up, score_7_up";
|
||||
prefixInput.value = configArray.model_prompt_prefix || "";
|
||||
prefixInput.onchange = () => {
|
||||
node.state.config_arrays[arrayIdx].model_prompt_prefix = prefixInput.value;
|
||||
node.saveState();
|
||||
};
|
||||
prefixRow.appendChild(prefixInput);
|
||||
affixSection.appendChild(prefixRow);
|
||||
|
||||
// Suffix input
|
||||
const suffixRow = document.createElement("div");
|
||||
suffixRow.style.cssText = "display: flex; align-items: center; gap: 6px;";
|
||||
const suffixLabel = document.createElement("span");
|
||||
suffixLabel.style.cssText = "font-size: 11px; color: #aaa; min-width: 45px;";
|
||||
suffixLabel.textContent = "Suffix:";
|
||||
suffixRow.appendChild(suffixLabel);
|
||||
|
||||
const suffixInput = document.createElement("input");
|
||||
suffixInput.type = "text";
|
||||
suffixInput.className = "cb-input";
|
||||
suffixInput.style.cssText = "flex: 1; font-size: 11px;";
|
||||
suffixInput.placeholder = "e.g. highly detailed, 8k resolution";
|
||||
suffixInput.value = configArray.model_prompt_suffix || "";
|
||||
suffixInput.onchange = () => {
|
||||
node.state.config_arrays[arrayIdx].model_prompt_suffix = suffixInput.value;
|
||||
node.saveState();
|
||||
};
|
||||
suffixRow.appendChild(suffixInput);
|
||||
affixSection.appendChild(suffixRow);
|
||||
|
||||
contentDiv.appendChild(affixSection);
|
||||
|
||||
section.appendChild(contentDiv);
|
||||
div.appendChild(section);
|
||||
}
|
||||
@@ -2461,7 +2620,10 @@ export async function renderUI(node, availableLoras, modelLists, loraFolders, av
|
||||
combine: false,
|
||||
positive_prompt_groups: [],
|
||||
negative_prompt: "",
|
||||
use_custom_prompts: false
|
||||
use_custom_prompts: false,
|
||||
model_prompt_prefix: "",
|
||||
model_prompt_suffix: "",
|
||||
attention_modes: ["default"]
|
||||
});
|
||||
node.saveState();
|
||||
node.renderUI();
|
||||
@@ -2485,6 +2647,31 @@ export async function renderUI(node, availableLoras, modelLists, loraFolders, av
|
||||
|
||||
configSection.appendChild(headerBar);
|
||||
|
||||
// Quick-jump navigation bar (only show when there are 2+ configs)
|
||||
if (node.state.config_arrays.length > 1) {
|
||||
const navBar = document.createElement("div");
|
||||
navBar.style.cssText = "display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; padding: 8px; background: #252525; border-radius: 4px; position: sticky; top: 0; z-index: 50;";
|
||||
|
||||
const navLabel = document.createElement("span");
|
||||
navLabel.style.cssText = "color: #888; font-size: 11px; font-weight: bold; display: flex; align-items: center; margin-right: 4px;";
|
||||
navLabel.textContent = "JUMP TO:";
|
||||
navBar.appendChild(navLabel);
|
||||
|
||||
node.state.config_arrays.forEach((ca, idx) => {
|
||||
const color = CONFIG_COLORS[idx % CONFIG_COLORS.length];
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "cb-button";
|
||||
btn.style.cssText = `padding: 3px 10px; font-size: 11px; font-weight: bold; border-left: 3px solid ${color}; color: ${color};`;
|
||||
btn.textContent = `${idx + 1}. ${ca.name || 'Config ' + (idx + 1)}`;
|
||||
btn.onclick = () => {
|
||||
const target = configSection.querySelector(`#cb-config-${idx}`);
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
navBar.appendChild(btn);
|
||||
});
|
||||
configSection.appendChild(navBar);
|
||||
}
|
||||
|
||||
const arraysContainer = document.createElement("div");
|
||||
arraysContainer.className = "cb-arrays-container";
|
||||
|
||||
|
||||
@@ -101,7 +101,10 @@ app.registerExtension({
|
||||
combine: false,
|
||||
positive_prompt_groups: [],
|
||||
negative_prompt: "",
|
||||
use_custom_prompts: false
|
||||
use_custom_prompts: false,
|
||||
model_prompt_prefix: "",
|
||||
model_prompt_suffix: "",
|
||||
attention_modes: ["default"]
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -316,7 +319,10 @@ app.registerExtension({
|
||||
combine: arr.combine || false,
|
||||
positive_prompt_groups: [],
|
||||
negative_prompt: "",
|
||||
use_custom_prompts: false
|
||||
use_custom_prompts: false,
|
||||
model_prompt_prefix: "",
|
||||
model_prompt_suffix: "",
|
||||
attention_modes: ["default"]
|
||||
}))
|
||||
};
|
||||
};
|
||||
@@ -379,6 +385,13 @@ app.registerExtension({
|
||||
|
||||
// Migration: ensure VAE field exists
|
||||
if (!arr.vaes) arr.vaes = ["None"];
|
||||
|
||||
// Migration: ensure model prompt prefix/suffix fields exist
|
||||
if (arr.model_prompt_prefix === undefined) arr.model_prompt_prefix = "";
|
||||
if (arr.model_prompt_suffix === undefined) arr.model_prompt_suffix = "";
|
||||
|
||||
// Migration: ensure attention modes field exists
|
||||
if (!arr.attention_modes) arr.attention_modes = ["default"];
|
||||
});
|
||||
} else if (existing.lora_config) {
|
||||
this.state = this.migrateOldFormat(existing);
|
||||
|
||||
@@ -274,39 +274,77 @@ export function buildLoraString(name, modelStr, clipStr) {
|
||||
* Each group is an array of variations. The total is the product of all group sizes.
|
||||
* Example: [["a", "b"], ["c"]] = 2 * 1 = 2 combinations
|
||||
*/
|
||||
/**
|
||||
* Recursively resolve a nested prompt structure into flat string options.
|
||||
* Rules:
|
||||
* - String/primitive → single option ["text"]
|
||||
* - Flat list of strings ["a", "b"] → OPTIONS (OR logic)
|
||||
* - List containing sub-lists → SEQUENCE (AND logic, Cartesian product)
|
||||
* - Nesting can be arbitrarily deep
|
||||
*
|
||||
* Example: ["Photo of", ["a cat", "a dog"], ["in space", ["eating", "sleeping"]]]
|
||||
* → ["Photo of, a cat, in space, eating", "Photo of, a cat, in space, sleeping",
|
||||
* "Photo of, a dog, in space, eating", "Photo of, a dog, in space, sleeping"]
|
||||
*/
|
||||
function recursiveCartesian(item) {
|
||||
if (!Array.isArray(item)) return [String(item)];
|
||||
|
||||
const hasNested = item.some(sub => Array.isArray(sub));
|
||||
|
||||
// Flat list of strings → OPTIONS (OR)
|
||||
if (!hasNested) return item.map(String);
|
||||
|
||||
// List with sub-lists → SEQUENCE (AND, Cartesian product)
|
||||
const resolvedGroups = item.map(sub => recursiveCartesian(sub));
|
||||
|
||||
// Cartesian product across resolved groups
|
||||
let combos = [[]];
|
||||
for (const group of resolvedGroups) {
|
||||
const newCombos = [];
|
||||
for (const existing of combos) {
|
||||
for (const opt of group) {
|
||||
newCombos.push([...existing, opt]);
|
||||
}
|
||||
}
|
||||
combos = newCombos;
|
||||
}
|
||||
return combos.map(c => c.join(", "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently count total prompt combinations from a (possibly recursive) nested structure
|
||||
* without generating all combinations. O(n) where n is structure size.
|
||||
*/
|
||||
function recursiveCount(item) {
|
||||
if (!Array.isArray(item)) return 1;
|
||||
|
||||
const hasNested = item.some(sub => Array.isArray(sub));
|
||||
|
||||
// Flat list of strings → OPTIONS count
|
||||
if (!hasNested) return item.length || 1;
|
||||
|
||||
// List with sub-lists → product of each sub-item's count
|
||||
return item.reduce((total, sub) => total * recursiveCount(sub), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total prompt combinations from a (possibly recursive) nested structure.
|
||||
* Handles both old format [["a","b"], ["c","d"]] and new recursive format.
|
||||
*/
|
||||
export function countPromptCombinations(groups) {
|
||||
if (!groups || !Array.isArray(groups) || groups.length === 0) return 1;
|
||||
// Filter out empty groups
|
||||
const validGroups = groups.filter(g => Array.isArray(g) && g.length > 0);
|
||||
if (validGroups.length === 0) return 1;
|
||||
return validGroups.reduce((total, group) => total * group.length, 1);
|
||||
return recursiveCount(groups) || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview of expanded prompt combinations from nested groups.
|
||||
* Returns array of strings, capped at `limit` entries.
|
||||
* Example: [["a", "b"], ["1", "2"]] -> ["a, 1", "a, 2", "b, 1", "b, 2"]
|
||||
* Supports arbitrarily deep recursive nesting.
|
||||
*/
|
||||
export function expandPromptPreview(groups, limit = 20) {
|
||||
if (!groups || !Array.isArray(groups) || groups.length === 0) return [];
|
||||
const validGroups = groups.filter(g => Array.isArray(g) && g.length > 0);
|
||||
if (validGroups.length === 0) return [];
|
||||
|
||||
// Iterative Cartesian product with early cutoff
|
||||
let combinations = [[]];
|
||||
for (const group of validGroups) {
|
||||
const newCombinations = [];
|
||||
for (const existing of combinations) {
|
||||
for (const item of group) {
|
||||
newCombinations.push([...existing, item]);
|
||||
if (newCombinations.length > limit) {
|
||||
return newCombinations.slice(0, limit).map(combo => combo.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
combinations = newCombinations;
|
||||
}
|
||||
return combinations.map(combo => combo.join(", "));
|
||||
const all = recursiveCartesian(groups);
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
|
||||
// --- ITERATION COUNT CALCULATION ---
|
||||
@@ -403,7 +441,10 @@ export function getIterationCount(configArray) {
|
||||
p_count = countPromptCombinations(configArray.positive_prompt_groups);
|
||||
}
|
||||
|
||||
return m_count * l_count * v_count * s_count * sch_count * st_count * c_count * p_count;
|
||||
// 6. Attention modes
|
||||
const a_count = (configArray.attention_modes && configArray.attention_modes.length > 0) ? configArray.attention_modes.length : 1;
|
||||
|
||||
return m_count * l_count * v_count * s_count * sch_count * st_count * c_count * p_count * a_count;
|
||||
}
|
||||
|
||||
// --- CONFIG CONVERSION ---
|
||||
@@ -460,6 +501,12 @@ export function convertStateToConfigs(state) {
|
||||
model: finalModels.length > 1 ? finalModels : finalModels[0] || "None"
|
||||
};
|
||||
|
||||
// Add attention_mode if not just "default"
|
||||
const attentionModes = (configArray.attention_modes || ["default"]).filter(a => a);
|
||||
if (attentionModes.length > 0 && !(attentionModes.length === 1 && attentionModes[0] === "default")) {
|
||||
config.attention_mode = attentionModes.length > 1 ? attentionModes : attentionModes[0];
|
||||
}
|
||||
|
||||
// Add VAE if any are selected (not "None")
|
||||
if (vaes.length > 0) {
|
||||
config.vae = vaes.length > 1 ? vaes : vaes[0];
|
||||
@@ -523,6 +570,15 @@ export function convertStateToConfigs(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==== MODEL PROMPT PREFIX/SUFFIX ====
|
||||
// Quality tags prepended/appended to ALL prompts for this config
|
||||
if (configArray.model_prompt_prefix && configArray.model_prompt_prefix.trim()) {
|
||||
config.model_prompt_prefix = configArray.model_prompt_prefix.trim();
|
||||
}
|
||||
if (configArray.model_prompt_suffix && configArray.model_prompt_suffix.trim()) {
|
||||
config.model_prompt_suffix = configArray.model_prompt_suffix.trim();
|
||||
}
|
||||
|
||||
configs.push(config);
|
||||
});
|
||||
return configs;
|
||||
@@ -549,7 +605,10 @@ export function convertConfigsToConfigArrays(configs) {
|
||||
combine: false,
|
||||
positive_prompt_groups: [],
|
||||
negative_prompt: "",
|
||||
use_custom_prompts: false
|
||||
use_custom_prompts: false,
|
||||
model_prompt_prefix: "",
|
||||
model_prompt_suffix: "",
|
||||
attention_modes: ["default"]
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -638,12 +697,14 @@ export function convertConfigsToConfigArrays(configs) {
|
||||
// Config has per-config prompts
|
||||
useCustomPrompts = true;
|
||||
if (Array.isArray(config.positive)) {
|
||||
// Could be nested array [["a", "b"], ["c"]] or simple array ["a", "b"]
|
||||
if (config.positive.length > 0 && Array.isArray(config.positive[0])) {
|
||||
// Nested array format - use as-is
|
||||
// Check if this is a recursive/nested structure (any element is a sub-array)
|
||||
const hasAnyNesting = config.positive.some(item => Array.isArray(item));
|
||||
if (hasAnyNesting) {
|
||||
// Recursive or nested array format - preserve structure as-is
|
||||
// This handles both classic [["a","b"],["c"]] and recursive ["text",["a","b"],["x",["y","z"]]]
|
||||
positivePromptGroups = config.positive;
|
||||
} else {
|
||||
// Simple array - wrap as single group
|
||||
// Simple flat array of strings - wrap as single group
|
||||
positivePromptGroups = [config.positive];
|
||||
}
|
||||
} else if (typeof config.positive === 'string' && config.positive.trim()) {
|
||||
@@ -680,7 +741,12 @@ export function convertConfigsToConfigArrays(configs) {
|
||||
combine: hasCombined,
|
||||
positive_prompt_groups: positivePromptGroups,
|
||||
negative_prompt: negativePrompt,
|
||||
use_custom_prompts: useCustomPrompts
|
||||
use_custom_prompts: useCustomPrompts,
|
||||
model_prompt_prefix: config.model_prompt_prefix || "",
|
||||
model_prompt_suffix: config.model_prompt_suffix || "",
|
||||
attention_modes: config.attention_mode
|
||||
? (Array.isArray(config.attention_mode) ? config.attention_mode : [config.attention_mode])
|
||||
: ["default"]
|
||||
});
|
||||
});
|
||||
|
||||
@@ -704,6 +770,9 @@ export function convertConfigsToConfigArrays(configs) {
|
||||
combine: false,
|
||||
positive_prompt_groups: [],
|
||||
negative_prompt: "",
|
||||
use_custom_prompts: false
|
||||
use_custom_prompts: false,
|
||||
model_prompt_prefix: "",
|
||||
model_prompt_suffix: "",
|
||||
attention_modes: ["default"]
|
||||
}];
|
||||
}
|
||||
@@ -94,6 +94,32 @@ app.registerExtension({
|
||||
console.log('[UltimateGrid] ✅ Update listener installed');
|
||||
}
|
||||
|
||||
// PROGRESS/ETA LISTENER (SINGLETON - forward ETA to dashboard iframe)
|
||||
if (!window.__ultimateGridProgressListenerInstalled) {
|
||||
api.addEventListener("ultimate_grid.progress", (event) => {
|
||||
const payload = event.detail;
|
||||
const { session_name } = payload;
|
||||
|
||||
const dashboardNodes = app.graph._nodes.filter(n => n.type === "UltimateGridDashboard");
|
||||
dashboardNodes.forEach(dashboardNode => {
|
||||
const sessionWidget = dashboardNode.widgets.find(w => w.name === "session_name");
|
||||
if (!sessionWidget) return;
|
||||
|
||||
if (sessionWidget.value === session_name &&
|
||||
dashboardNode.loaded_session === session_name &&
|
||||
dashboardNode.iframe?.contentWindow) {
|
||||
try {
|
||||
dashboardNode.iframe.contentWindow.postMessage({
|
||||
type: 'progress_update',
|
||||
data: payload
|
||||
}, '*');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
window.__ultimateGridProgressListenerInstalled = true;
|
||||
}
|
||||
|
||||
// 3. FULLSCREEN LISTENER (SINGLETON - only register once)
|
||||
if (!window.__ultimateGridFullscreenListenerInstalled) {
|
||||
window.addEventListener("message", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user