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:
JasonHoku
2026-02-16 00:21:58 -08:00
parent 3393a5b60c
commit d74a51dd15
14 changed files with 1002 additions and 269 deletions
+21 -54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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;
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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)">&times;</button>
<div class="modal-main">
<div class="preview"><img id="m-img" src=""></div>
<div class="controls">
+43
View File
@@ -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
View File
@@ -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";
+15 -2
View File
@@ -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);
+100 -31
View File
@@ -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"]
}];
}
+26
View File
@@ -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) => {