diff --git a/Roadmap.md b/Roadmap.md index a4646b2..a8655f1 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -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) diff --git a/config_utils.py b/config_utils.py index 02f4eb8..0af246a 100644 --- a/config_utils.py +++ b/config_utils.py @@ -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 diff --git a/generation_orchestrator.py b/generation_orchestrator.py index 102d685..6b2a633 100644 --- a/generation_orchestrator.py +++ b/generation_orchestrator.py @@ -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,) \ No newline at end of file diff --git a/image_generation.py b/image_generation.py index 61e47e8..9350995 100644 --- a/image_generation.py +++ b/image_generation.py @@ -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 diff --git a/resources/logic_events.js b/resources/logic_events.js index c6be7c7..0479012 100644 --- a/resources/logic_events.js +++ b/resources/logic_events.js @@ -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; diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 97db2b5..3c19c55 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -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'); diff --git a/resources/report.css b/resources/report.css index 0f9bcc9..fd59e4c 100644 --- a/resources/report.css +++ b/resources/report.css @@ -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; diff --git a/resources/template.html b/resources/template.html index ee303c4..23b30ac 100644 --- a/resources/template.html +++ b/resources/template.html @@ -22,21 +22,10 @@
-
- -
- -
- Cols - -
-
Sort -
- @@ -57,12 +46,42 @@
- + +
+ + +
- + +