From d178bd064f72097bffa96bde2bb4daeeb5706865 Mon Sep 17 00:00:00 2001 From: JasonHoku Date: Sat, 14 Feb 2026 23:13:13 -0800 Subject: [PATCH] Change cache busting static js serve technique to conform to ComfyUI best practices --- .claude/settings.local.json | 3 +- __init__.py | 64 ---- config_builder_node.py | 77 +++- config_utils.py | 21 +- generation_orchestrator.py | 176 ++++++--- image_generation.py | 3 +- model_loader.py | 199 +++++++++++ sampler_node.py | 2 +- .../conf-builder-config-management.js | 338 +++++++++++++++--- web/conf_builder/conf-builder-main.js | 70 +++- .../conf-builder-ui-components.js | 5 +- .../conf_builder/conf-builder-utilities.js | 135 ++++++- 12 files changed, 900 insertions(+), 193 deletions(-) rename {js => web}/conf_builder/conf-builder-config-management.js (85%) rename {js => web}/conf_builder/conf-builder-ui-components.js (96%) rename {js => web}/conf_builder/conf-builder-utilities.js (77%) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f5d4769..ce2a053 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(git config --global --add safe.directory '%\\(prefix\\)///DESKTOP-2GIRPC5/comfy/comfy_v0.12.3/ComfyUI/custom_nodes/ComfyUI-Ultimate-Auto-Sampler-Config-Grid-Testing-Suite/.claude/worktrees/hungry-gagarin')", - "Bash(python -c:*)" + "Bash(python -c:*)", + "WebFetch(domain:api.comfy.org)" ] } } diff --git a/__init__.py b/__init__.py index 3d60838..a6a03d0 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,6 @@ from aiohttp import web import json import os import urllib.parse -import mimetypes import folder_paths import shutil from .sampler_node import SamplerGridTester @@ -24,69 +23,6 @@ _whitelisted_directories = set() CONFIGS_DIR = os.path.join(folder_paths.get_output_directory(), "ultimate-configs") os.makedirs(CONFIGS_DIR, exist_ok=True) -# --- CONFIG BUILDER JS DIRECTORY --- -EXTENSION_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_BUILDER_JS_DIR = os.path.join(EXTENSION_DIR, "js") - -# ============================================================================= -# SERVE CONFIG BUILDER JS FILES (NOT AUTO-LOADED) -# ============================================================================= - -@server.PromptServer.instance.routes.get("/ultimate_config_sampler/js/{filename:.*}") -async def serve_config_builder_js(request): - r""" - Serve JavaScript files from /js/ directory with cache-busting headers. - Supports subdirectories with both / and \ (e.g., config-builder/utilities.js) - """ - try: - filename = request.match_info['filename'] - print(f"[ConfigBuilder] Request for: {filename}") - - # Security: prevent directory traversal with .. - if '..' in filename or filename.startswith('/') or filename.startswith('\\'): - print(f"[ConfigBuilder] FORBIDDEN: {filename}") - return web.Response(status=403, text="Forbidden") - - # Normalize path separators (convert backslashes to forward slashes) - filename = filename.replace('\\', '/') - - # Build full path and normalize it - file_path = os.path.normpath(os.path.join(CONFIG_BUILDER_JS_DIR, filename)) - - # Double-check the resolved path is still within our JS directory - normalized_base = os.path.normpath(CONFIG_BUILDER_JS_DIR) - - if not file_path.startswith(normalized_base): - print(f"[ConfigBuilder] FORBIDDEN - Outside base dir: {file_path}") - return web.Response(status=403, text="Forbidden - path outside base directory") - - if not os.path.exists(file_path): - print(f"[ConfigBuilder] NOT FOUND: {file_path}") - return web.Response(status=404, text=f"File not found: {filename}") - - print(f"[ConfigBuilder] Reading file: {file_path}") - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - print(f"[ConfigBuilder] File read successfully, {len(content)} bytes") - - # Return with cache-busting headers - return web.Response( - text=content, - content_type='application/javascript', - headers={ - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - } - ) - - except Exception as e: - print(f"[ConfigBuilder] ERROR: {e}") - import traceback - traceback.print_exc() - return web.Response(status=500, text=f"Server error: {str(e)}") - # ============================================================================= # API: CONFIG MANAGEMENT diff --git a/config_builder_node.py b/config_builder_node.py index 2bde00b..e40950f 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -510,12 +510,21 @@ class UltimateConfigBuilder: scheduler_list = self.parse_comma_list(config_array.get("schedulers", "normal")) steps_list = self.parse_int_list(config_array.get("steps", "20")) cfg_list = self.parse_number_list(config_array.get("cfg", "7.0")) - models = config_array.get("models", ["None"]) + models_raw = config_array.get("models", ["None"]) omit_triggers = config_array.get("lora_omit_triggers", []) lora_triggerwords_append_settings = config_array.get("lora_triggerwords_append_settings", {}) - - # Process models - model_strings = [str(m) for m in models if m and m != "None"] + + # Process models - handle both object format {path, type} and legacy string format + model_strings = [] + model_type = "checkpoint" # default + for m in models_raw: + if isinstance(m, dict): + path = m.get("path", "") + if path and path != "None": + model_strings.append(str(path)) + model_type = m.get("type", "checkpoint") + elif isinstance(m, str) and m and m != "None": + model_strings.append(str(m)) # Process loras for this config lora_strings = self.process_lora_array(config_array, actual_include_none) @@ -536,6 +545,20 @@ class UltimateConfigBuilder: if seed_behavior == "randomize": config["seed_behavior"] = "randomize" + # Add model_type and related fields for non-checkpoint models + if model_type != "checkpoint": + config["model_type"] = model_type + text_encoders = config_array.get("text_encoders", []) + if text_encoders: + config["text_encoders"] = [te for te in text_encoders if te and te != "None"] + clip_type = config_array.get("clip_type", "") + if clip_type: + config["clip_type"] = clip_type + if model_type == "gguf": + gguf_options = config_array.get("gguf_options", {}) + if gguf_options: + config["gguf_options"] = gguf_options + # Add omit triggers if present if omit_triggers: config["lora_omit_triggers"] = omit_triggers @@ -706,6 +729,52 @@ async def lookup_lora_metadata_endpoint(request): }, status=500) +# API endpoint to get all model lists for unified model selector +@server.PromptServer.instance.routes.get("/configbuilder/model_lists") +async def get_model_lists_endpoint(request): + """Return all model lists for the config builder's unified model selector.""" + try: + checkpoints = folder_paths.get_filename_list("checkpoints") + diffusion_models = folder_paths.get_filename_list("diffusion_models") + text_encoders = folder_paths.get_filename_list("text_encoders") + + # GGUF lists - may not exist if ComfyUI-GGUF is not installed + try: + unet_gguf = folder_paths.get_filename_list("unet_gguf") + except (KeyError, Exception): + unet_gguf = [] + try: + clip_gguf = folder_paths.get_filename_list("clip_gguf") + except (KeyError, Exception): + clip_gguf = [] + + # CLIPType options (matching ComfyUI's CLIPLoader and DualCLIPLoader) + clip_types = [ + "stable_diffusion", "stable_cascade", "sd3", "stable_audio", + "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", + "hidream", "chroma", "ace", "flux" + ] + dual_clip_types = [ + "sdxl", "sd3", "flux", "hunyuan_video", "hidream", + "hunyuan_image", "hunyuan_video_15" + ] + + return web.json_response({ + "checkpoints": checkpoints, + "diffusion_models": diffusion_models, + "unet_gguf": unet_gguf, + "text_encoders": text_encoders, + "clip_gguf": clip_gguf, + "clip_types": clip_types, + "dual_clip_types": dual_clip_types + }) + except Exception as e: + print(f"[ConfigBuilder] Error in model_lists endpoint: {e}") + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + + # API endpoint to refresh model/lora lists (triggered by "Update Node Definitions") @server.PromptServer.instance.routes.post("/configbuilder/refresh_models") async def refresh_models_endpoint(request): diff --git a/config_utils.py b/config_utils.py index e1dc1a5..b6b78d7 100644 --- a/config_utils.py +++ b/config_utils.py @@ -332,9 +332,22 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, cfgs = to_list(entry.get("cfg", 7.0)) clip_skips = to_list(entry.get("clip_skip", 0)) - # Expand model folders + # Extract model type and related fields for non-checkpoint models + model_type = entry.get("model_type", "checkpoint") + clip_type_str = entry.get("clip_type", "stable_diffusion") + text_encoders = entry.get("text_encoders", []) + gguf_options = entry.get("gguf_options", {}) + + # Expand model folders (using correct folder type based on model_type) raw_models = to_list(entry.get("model", "Default")) expanded_models = [] + if model_type == "gguf": + model_folder = "unet_gguf" + elif model_type == "diffusion_model": + model_folder = "diffusion_models" + else: + model_folder = "checkpoints" + for m in raw_models: if m == "Default": # Use the checkpoint name from node input instead of "Default" @@ -343,7 +356,7 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, else: expanded_models.append("Default") else: - expanded_models.extend(get_files_from_folder(m, "checkpoints")) + expanded_models.extend(get_files_from_folder(m, model_folder)) # Expand LoRA stacks (strengths are now in the lora string itself) expanded_loras = expand_lora_stack(entry.get("lora", "None")) @@ -375,6 +388,10 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, "model": combo[8], "seed": seed, "seed_behavior": entry.get("seed_behavior", "fixed"), + "model_type": model_type, + "clip_type": clip_type_str, + "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) }) diff --git a/generation_orchestrator.py b/generation_orchestrator.py index de7ab18..de8b530 100644 --- a/generation_orchestrator.py +++ b/generation_orchestrator.py @@ -19,7 +19,7 @@ from .manifest_utils import load_existing_manifest, save_manifest from .model_loader import ( load_checkpoint, load_loras, cleanup_model_references, get_latent_channels, load_loras_for_preencoding, - print_incompatible_loras_summary + print_incompatible_loras_summary, load_diffusion_model_and_clip ) from .lora_utils import expand_lora_folder from .image_generation import ( @@ -127,6 +127,45 @@ def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx, return -1 +def get_model_cache_key(conf): + """Generate a cache key that uniquely identifies the model+clip combination.""" + model_type = conf.get("model_type", "checkpoint") + if model_type == "checkpoint": + return conf["model"] + else: + te_key = "|".join(sorted(conf.get("text_encoders", []))) + return f"{conf['model']}::{model_type}::{te_key}" + + +def load_model_by_type(conf, ckpt_name, use_remote_vae, optional_model, optional_clip, optional_vae, + optional_positive, optional_negative, loaded_clip, loaded_vae, model_cache): + """Dispatch to correct loader based on model_type in config.""" + model_type = conf.get("model_type", "checkpoint") + target = conf["model"] + + if model_type == "checkpoint": + return load_checkpoint( + target, ckpt_name, use_remote_vae, + optional_model, optional_clip, optional_vae, + optional_positive, optional_negative, + loaded_clip, loaded_vae, model_cache=model_cache + ) + else: + # diffusion_model or gguf + return load_diffusion_model_and_clip( + model_name=target, + model_type=model_type, + text_encoder_paths=conf.get("text_encoders", []), + clip_type_str=conf.get("clip_type", "stable_diffusion"), + gguf_options=conf.get("gguf_options"), + use_remote_vae=use_remote_vae, + optional_model=optional_model, + optional_clip=optional_clip, + optional_vae=optional_vae, + model_cache=model_cache + ) + + def run_generation_loop( self, ckpt_name, positive_text, negative_text, seed, denoise, vae_batch_size, @@ -180,7 +219,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['model'], x['lora'], x['positive'], x['negative'])) + expanded.sort(key=lambda x: (x.get('model_type', 'checkpoint'), x['model'], tuple(x.get('text_encoders', [])), x['lora'], x['positive'], x['negative'])) # ==== EXPAND LORA FOLDERS ==== print(f"[GridTester] 🎲 Expanding LoRA folders and random selections...") @@ -205,6 +244,16 @@ def run_generation_loop( conf["lora_expanded"] = "None" print(f"[GridTester] ✅ LoRA expansion complete") + # ==== VAE VALIDATION FOR NON-CHECKPOINT MODELS ==== + has_non_checkpoint = any(c.get("model_type", "checkpoint") != "checkpoint" for c in expanded) + if has_non_checkpoint and not use_remote_vae and optional_vae is None: + raise ValueError( + "GGUF and diffusion models do not include a bundled VAE.\n" + "You must either:\n" + " 1. Connect a VAE to the optional_vae input on the sampler node, or\n" + " 2. Enable remote VAE decoding via remote_vae_endpoint\n" + ) + # ==== REGISTER SMART CACHE SCHEDULE ==== if model_cache: model_cache.register_schedule(expanded) @@ -263,19 +312,22 @@ def run_generation_loop( ) # ==== PRE-ENCODING STAGE ==== - unique_models = set(conf["model"] for conf in expanded) - - if not (optional_positive and optional_negative) and expanded and len(unique_models) == 1: + unique_model_keys = set(get_model_cache_key(conf) for conf in expanded) + + if not (optional_positive and optional_negative) and expanded and len(unique_model_keys) == 1: first_conf = expanded[0] target_model_name = first_conf["model"] - + print(f"[GridTester] ✅ Single model detected ({target_model_name}) - enabling pre-encoding") - - loaded_model, loaded_clip, loaded_vae = load_checkpoint( - target_model_name, ckpt_name, use_remote_vae, + + loaded_model, loaded_clip, loaded_vae = load_model_by_type( + first_conf, ckpt_name, use_remote_vae, optional_model, optional_clip, optional_vae, optional_positive, optional_negative, None, None, model_cache=model_cache ) + # VAE fallback for non-checkpoint models + if loaded_vae is None and optional_vae is not None: + loaded_vae = optional_vae if first_conf["lora_expanded"] != "None": patched_model, patched_clip = load_loras_for_preencoding( @@ -303,14 +355,14 @@ def run_generation_loop( patched_clip, unique_positives, unique_negatives, cond_cache, clip_skip, enable_disk_cache=save_conditioning_cache_to_file ) - cached_model_key = target_model_name + cached_model_key = get_model_cache_key(first_conf) cached_lora_key = first_conf["lora_expanded"] if model_cache: - cached_lora_cache_key = model_cache._get_lora_cache_key(target_model_name, first_conf["lora_expanded"]) + cached_lora_cache_key = model_cache._get_lora_cache_key(cached_model_key, first_conf["lora_expanded"]) else: cached_lora_cache_key = None latent_channels = get_latent_channels(loaded_model, optional_latent) - elif len(unique_models) > 1: + elif len(unique_model_keys) > 1: print(f"[GridTester] ⚠️ Multiple models detected ({len(unique_models)} different models) - pre-encoding DISABLED") cond_cache = None latent_channels = 4 @@ -422,7 +474,8 @@ def run_generation_loop( # ==== MODEL SWITCHING ==== target_model_name = conf["model"] - if target_model_name != cached_model_key: + target_model_key = get_model_cache_key(conf) + if target_model_key != cached_model_key: if cached_model_key is not None: patched_model, patched_clip = cleanup_model_references( patched_model, patched_clip, conditioning_cache @@ -430,14 +483,17 @@ def run_generation_loop( # CRITICAL FIX: Restore dict structure after clear conditioning_cache["positive"] = {} conditioning_cache["negative"] = {} - - loaded_model, loaded_clip, loaded_vae = load_checkpoint( - target_model_name, ckpt_name, use_remote_vae, + + loaded_model, loaded_clip, loaded_vae = load_model_by_type( + conf, ckpt_name, use_remote_vae, optional_model, optional_clip, optional_vae, optional_positive, optional_negative, loaded_clip, loaded_vae, model_cache=model_cache ) - - cached_model_key = target_model_name + # VAE fallback for non-checkpoint models + if loaded_vae is None and optional_vae is not None: + loaded_vae = optional_vae + + cached_model_key = target_model_key cached_lora_key = None cached_lora_cache_key = None latent_channels = get_latent_channels(loaded_model, optional_latent) @@ -449,7 +505,7 @@ def run_generation_loop( current_lora_string = conf["lora_expanded"] if model_cache: - current_cache_key = model_cache._get_lora_cache_key(target_model_name, current_lora_string) + current_cache_key = model_cache._get_lora_cache_key(target_model_key, current_lora_string) need_to_load = (current_cache_key != cached_lora_cache_key) or patched_model is None else: need_to_load = (current_lora_string != cached_lora_key) or patched_model is None @@ -477,7 +533,7 @@ def run_generation_loop( for future_idx in range(conf_idx, len(expanded)): future_conf = expanded[future_idx] - if future_conf["model"] == target_model_name: + if get_model_cache_key(future_conf) == target_model_key: future_positive, _ = build_prompt_with_triggers( future_conf, lora_triggerwords_mode ) @@ -557,45 +613,63 @@ def run_generation_loop( if next_job_idx < len(input_jobs): next_conf = expanded[next_conf_idx] - # RESOLVE "Default" to actual ckpt name - next_model_raw = next_conf["model"] - next_model_resolved = ckpt_name if next_model_raw == "Default" else next_model_raw + # RESOLVE cache keys for next conf + next_model_key = get_model_cache_key(next_conf) next_lora = next_conf["lora_expanded"] - + # CASE 1: Same Base, Different LoRA - if next_model_resolved == target_model_name and next_lora != current_lora_string: + if next_model_key == target_model_key and next_lora != current_lora_string: def _preload_lora_worker(): return load_loras( loaded_model, loaded_clip, next_lora, - next_model_resolved, {}, model_cache=None + target_model_name, {}, model_cache=None ) - model_cache.preload_lora_model(next_model_resolved, next_lora, _preload_lora_worker) - + model_cache.preload_lora_model(target_model_key, next_lora, _preload_lora_worker) + # CASE 2: Different Base Model - elif next_model_resolved != target_model_name: - print(f"[GridTester] 🔮 Pre-loading Base Model: {next_model_resolved}") - - def _preload_base_worker(path_to_load=next_model_resolved): - try: - import comfy.sd # Re-import locally to be safe in thread - ckpt_path = folder_paths.get_full_path("checkpoints", path_to_load) - if use_remote_vae: - out = comfy.sd.load_checkpoint_guess_config( - ckpt_path, output_vae=False, output_clip=True, - embedding_directory=folder_paths.get_folder_paths("embeddings") + elif next_model_key != target_model_key: + next_model_type = next_conf.get("model_type", "checkpoint") + next_model_name = next_conf["model"] + print(f"[GridTester] 🔮 Pre-loading Base Model: {next_model_name} ({next_model_type})") + + if next_model_type == "checkpoint": + next_model_resolved = ckpt_name if next_model_name == "Default" else next_model_name + def _preload_base_worker(path_to_load=next_model_resolved): + try: + import comfy.sd + ckpt_path = folder_paths.get_full_path("checkpoints", path_to_load) + if use_remote_vae: + out = comfy.sd.load_checkpoint_guess_config( + ckpt_path, output_vae=False, output_clip=True, + embedding_directory=folder_paths.get_folder_paths("embeddings") + ) + return out[0], out[1], None + else: + out = comfy.sd.load_checkpoint_guess_config( + ckpt_path, output_vae=True, output_clip=True, + embedding_directory=folder_paths.get_folder_paths("embeddings") + ) + return out[0], out[1], out[2] + except Exception as e: + print(f"[GridTester] ❌ Async Worker Error: {e}") + return None, None, None + model_cache.preload_base_model(next_model_key, _preload_base_worker) + else: + # GGUF/diffusion model preload + def _preload_diff_worker(conf_to_load=next_conf): + try: + return load_diffusion_model_and_clip( + model_name=conf_to_load["model"], + model_type=conf_to_load.get("model_type"), + text_encoder_paths=conf_to_load.get("text_encoders", []), + clip_type_str=conf_to_load.get("clip_type", "stable_diffusion"), + gguf_options=conf_to_load.get("gguf_options"), + use_remote_vae=use_remote_vae, ) - return out[0], out[1], None - else: - out = comfy.sd.load_checkpoint_guess_config( - ckpt_path, output_vae=True, output_clip=True, - embedding_directory=folder_paths.get_folder_paths("embeddings") - ) - return out[0], out[1], out[2] - except Exception as e: - print(f"[GridTester] ❌ Async Worker Error: {e}") - return None, None, None - - model_cache.preload_base_model(next_model_resolved, _preload_base_worker) + except Exception as e: + print(f"[GridTester] ❌ Async Worker Error: {e}") + return None, None, None + model_cache.preload_base_model(next_model_key, _preload_diff_worker) # ========================================================= # Generate image diff --git a/image_generation.py b/image_generation.py index d1e9ab8..61e47e8 100644 --- a/image_generation.py +++ b/image_generation.py @@ -137,7 +137,8 @@ def create_image_metadata(config, width, height, duration, seed, batch_idx, actu global_settings_to_remove = [ "lora_triggerwords_append_settings", "lora_omit_triggers", - "seed_behavior" + "seed_behavior", + "gguf_options" ] for key in global_settings_to_remove: diff --git a/model_loader.py b/model_loader.py index 28fcc5e..046cfe7 100644 --- a/model_loader.py +++ b/model_loader.py @@ -166,6 +166,205 @@ def load_checkpoint( return loaded_model, loaded_clip, loaded_vae +def _import_gguf(): + """ + Lazy import of ComfyUI-GGUF modules. + Returns (GGMLOps, gguf_sd_loader, gguf_clip_loader, GGUFModelPatcher). + Raises RuntimeError if ComfyUI-GGUF is not installed. + """ + try: + from ComfyUI_GGUF.ops import GGMLOps + from ComfyUI_GGUF.loader import gguf_sd_loader, gguf_clip_loader + from ComfyUI_GGUF.nodes import GGUFModelPatcher + return GGMLOps, gguf_sd_loader, gguf_clip_loader, GGUFModelPatcher + except ImportError: + raise RuntimeError( + "ComfyUI-GGUF is required for GGUF model loading but is not installed.\n" + "Install it from: https://github.com/city96/ComfyUI-GGUF" + ) + + +def resolve_text_encoder_path(filename): + """ + Resolve a text encoder filename to its full path. + Searches text_encoders, clip, and clip_gguf folder paths. + """ + for folder_key in ["text_encoders", "clip", "clip_gguf"]: + try: + path = folder_paths.get_full_path(folder_key, filename) + if path: + return path + except (KeyError, Exception): + continue + raise FileNotFoundError(f"Text encoder not found: {filename}") + + +def load_diffusion_model_and_clip( + model_name, + model_type, + text_encoder_paths, + clip_type_str, + gguf_options=None, + use_remote_vae=False, + optional_model=None, + optional_clip=None, + optional_vae=None, + model_cache=None +): + """ + Load a diffusion model (unet) + separate CLIP text encoders. + Used for GGUF and standalone diffusion models. + + Args: + model_name: Filename of the diffusion model / GGUF model + model_type: "diffusion_model" or "gguf" + text_encoder_paths: List of text encoder filenames + clip_type_str: CLIPType string (e.g. "flux", "sdxl", "stable_diffusion") + gguf_options: Dict with dequant_dtype, patch_dtype, patch_on_device (GGUF only) + use_remote_vae: Whether remote VAE is enabled + optional_model: Optional pre-loaded model override + optional_clip: Optional pre-loaded CLIP override + optional_vae: Optional pre-loaded VAE override + model_cache: ModelCache instance (optional) + + Returns: + tuple: (loaded_model, loaded_clip, loaded_vae) + Note: loaded_vae is always optional_vae or None (no bundled VAE) + """ + import comfy.model_management + + # Build cache key for this model+text_encoder combo + te_key = "|".join(sorted(text_encoder_paths)) if text_encoder_paths else "" + cache_key = f"{model_name}::{model_type}::{te_key}" + + # Check cache first + if model_cache is not None: + cached = model_cache.get_base_model(cache_key) + if cached is not None: + cached_model, cached_clip, cached_vae = cached + final_model = optional_model if optional_model is not None else cached_model + final_clip = optional_clip if optional_clip is not None else cached_clip + final_vae = optional_vae if optional_vae is not None else cached_vae + return final_model, final_clip, final_vae + + # Use optional overrides if all provided + if optional_model is not None and optional_clip is not None: + print(f"[GridTester] 🔌 Using optional MODEL and CLIP (skipping {model_type} load)") + return optional_model, optional_clip, optional_vae + + start_time = time.time() + + # ---- Load the UNet / diffusion model ---- + loaded_model = optional_model + if loaded_model is None: + print(f"[GridTester] 💿 LOADING {model_type.upper()}: {model_name}") + + if model_type == "gguf": + GGMLOps, gguf_sd_loader, _, GGUFModelPatcher = _import_gguf() + + # Resolve path - try unet_gguf first, then unet/diffusion_models + unet_path = None + for folder_key in ["unet_gguf", "unet", "diffusion_models"]: + try: + unet_path = folder_paths.get_full_path(folder_key, model_name) + if unet_path: + break + except (KeyError, Exception): + continue + if unet_path is None: + raise FileNotFoundError(f"GGUF model not found: {model_name}") + + ops = GGMLOps() + if gguf_options: + dequant = gguf_options.get("dequant_dtype", "default") + if dequant not in ("default", None): + if dequant == "target": + ops.Linear.dequant_dtype = "target" + else: + ops.Linear.dequant_dtype = getattr(torch, dequant) + patch = gguf_options.get("patch_dtype", "default") + if patch not in ("default", None): + if patch == "target": + ops.Linear.patch_dtype = "target" + else: + ops.Linear.patch_dtype = getattr(torch, patch) + + sd, extra = gguf_sd_loader(unet_path) + loaded_model = comfy.sd.load_diffusion_model_state_dict( + sd, model_options={"custom_operations": ops} + ) + if loaded_model is None: + raise RuntimeError(f"Could not detect model type of GGUF: {model_name}") + loaded_model = GGUFModelPatcher.clone(loaded_model) + if gguf_options and gguf_options.get("patch_on_device"): + loaded_model.patch_on_device = True + + elif model_type == "diffusion_model": + unet_path = folder_paths.get_full_path("diffusion_models", model_name) + if unet_path is None: + # Also try "unet" folder key + unet_path = folder_paths.get_full_path("unet", model_name) + if unet_path is None: + raise FileNotFoundError(f"Diffusion model not found: {model_name}") + + loaded_model = comfy.sd.load_diffusion_model(unet_path, model_options={}) + + print(f"[GridTester] ✅ Model loaded in {time.time() - start_time:.2f}s") + + # ---- Load CLIP text encoders ---- + loaded_clip = optional_clip + if loaded_clip is None and text_encoder_paths: + clip_start = time.time() + print(f"[GridTester] 💿 LOADING TEXT ENCODERS: {text_encoder_paths}") + + clip_type = getattr(comfy.sd.CLIPType, clip_type_str.upper(), comfy.sd.CLIPType.STABLE_DIFFUSION) + resolved_paths = [resolve_text_encoder_path(p) for p in text_encoder_paths] + is_any_gguf = any(p.endswith(".gguf") for p in text_encoder_paths) + + if is_any_gguf: + GGMLOps, _, gguf_clip_loader, GGUFModelPatcher = _import_gguf() + + clip_data = [] + for p in resolved_paths: + if p.endswith(".gguf"): + clip_data.append(gguf_clip_loader(p)) + else: + sd_te = comfy.utils.load_torch_file(p, safe_load=True) + clip_data.append(sd_te) + + loaded_clip = comfy.sd.load_text_encoder_state_dicts( + clip_type=clip_type, + state_dicts=clip_data, + model_options={ + "custom_operations": GGMLOps, + "initial_device": comfy.model_management.text_encoder_offload_device() + }, + embedding_directory=folder_paths.get_folder_paths("embeddings") + ) + loaded_clip.patcher = GGUFModelPatcher.clone(loaded_clip.patcher) + else: + loaded_clip = comfy.sd.load_clip( + ckpt_paths=resolved_paths, + embedding_directory=folder_paths.get_folder_paths("embeddings"), + clip_type=clip_type + ) + + print(f"[GridTester] ✅ Text encoders loaded in {time.time() - clip_start:.2f}s") + + elapsed = time.time() - start_time + print(f"[GridTester] ✅ Total {model_type} loading: {elapsed:.2f}s") + + # VAE: non-checkpoint models have no bundled VAE + loaded_vae = optional_vae # Could be None + + # Cache the loaded model + if model_cache is not None and loaded_model is not None: + model_cache.record_disk_load(is_base_model=True, load_time=elapsed) + model_cache.put_base_model(cache_key, loaded_model, loaded_clip, loaded_vae) + + return loaded_model, loaded_clip, loaded_vae + + def load_loras(base_model, base_clip, lora_string, target_model_name, incompatible_loras, model_cache=None): """ Load and patch LoRAs onto model and CLIP. diff --git a/sampler_node.py b/sampler_node.py index 3822947..5357b52 100644 --- a/sampler_node.py +++ b/sampler_node.py @@ -52,7 +52,7 @@ class SamplerGridTester: def INPUT_TYPES(s): return { "required": { - "ckpt_name": (folder_paths.get_filename_list("checkpoints"), ), + "ckpt_name": (folder_paths.get_filename_list("checkpoints"), {"tooltip": "Default checkpoint model. Used when config has model set to 'Default'. Configs can also specify GGUF or diffusion models directly via model_type."}), "positive_text": ("STRING", {"multiline": True, "default": "masterpiece, best quality, 1girl"}), "negative_text": ("STRING", {"multiline": True, "default": "bad quality, worst quality, lowres"}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), diff --git a/js/conf_builder/conf-builder-config-management.js b/web/conf_builder/conf-builder-config-management.js similarity index 85% rename from js/conf_builder/conf-builder-config-management.js rename to web/conf_builder/conf-builder-config-management.js index b63baf7..2855d97 100644 --- a/js/conf_builder/conf-builder-config-management.js +++ b/web/conf_builder/conf-builder-config-management.js @@ -12,14 +12,14 @@ import { convertStateToConfigs, countPromptCombinations, expandPromptPreview -} from '/ultimate_config_sampler/js/conf_builder/conf-builder-utilities.js'; +} from './conf-builder-utilities.js'; import { createSearchableSelect, createSlider, createInputGroup, getStyles -} from '/ultimate_config_sampler/js/conf_builder/conf-builder-ui-components.js'; +} from './conf-builder-ui-components.js'; // --- SESSION SECTION RENDERER --- @@ -108,7 +108,7 @@ export function renderConfigSection(node, container, availableConfigs) { saveBtn.style.width = "100%"; saveBtn.onclick = async () => { await node.saveConfigToBackend(); - const { getAvailableConfigs } = await import('/ultimate_config_sampler/js/conf_builder/conf-builder-utilities.js'); + const { getAvailableConfigs } = await import('./conf-builder-utilities.js'); await getAvailableConfigs(); node.renderUI(); }; @@ -236,15 +236,28 @@ export function createConfigArrayElement(node, configArray, arrayIdx) { controlsBar.appendChild(deleteBtn); div.appendChild(controlsBar); + + // Label Mode overlay — shows config name at bottom center of card + if (node.state.label_mode && configArray.name) { + const labelOverlay = document.createElement("div"); + labelOverlay.className = "cb-config-label-overlay"; + labelOverlay.textContent = configArray.name; + div.appendChild(labelOverlay); + } + return div; } // --- MODEL ELEMENT CREATOR --- -export function createModelElement(node, modelStr, arrayIdx, modelIdx, availableModels, modelFolders) { +export function createModelElement(node, modelEntry, arrayIdx, modelIdx, modelLists) { + // Handle both string format (legacy) and object format {path, type} + const modelPath = typeof modelEntry === 'string' ? modelEntry : (modelEntry?.path || "None"); + const modelType = typeof modelEntry === 'string' ? 'checkpoint' : (modelEntry?.type || 'checkpoint'); + const isFolder = modelPath.endsWith("/"); + const div = document.createElement("div"); div.className = "cb-item-card model-card"; - const isFolder = modelStr.endsWith("/"); const uid = `${arrayIdx}_${modelIdx}`; // Initial State @@ -258,7 +271,7 @@ export function createModelElement(node, modelStr, arrayIdx, modelIdx, available leftGroup.className = "cb-header-left"; const toggleArrow = document.createElement("span"); - toggleArrow.textContent = isCollapsed ? "▶" : "▼"; // Right arrow if collapsed, Down if open + toggleArrow.textContent = isCollapsed ? "▶" : "▼"; toggleArrow.style.color = "#aaa"; toggleArrow.style.fontSize = "10px"; toggleArrow.style.width = "12px"; @@ -270,9 +283,17 @@ export function createModelElement(node, modelStr, arrayIdx, modelIdx, available label.style.whiteSpace = "nowrap"; leftGroup.appendChild(label); + // Show model type badge + if (modelType !== "checkpoint") { + const typeBadge = document.createElement("span"); + typeBadge.textContent = modelType === "gguf" ? "GGUF" : "DM"; + typeBadge.style.cssText = "background: #553300; color: #ffaa44; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: bold;"; + leftGroup.appendChild(typeBadge); + } + const nameSpan = document.createElement("span"); nameSpan.className = "cb-header-name"; - nameSpan.textContent = getShortName(modelStr); + nameSpan.textContent = getShortName(modelPath); leftGroup.appendChild(nameSpan); header.appendChild(leftGroup); @@ -290,42 +311,75 @@ export function createModelElement(node, modelStr, arrayIdx, modelIdx, available header.appendChild(deleteBtn); div.appendChild(header); - // Content Container (Rendered but hidden if collapsed) + // Content Container const contentDiv = document.createElement("div"); contentDiv.style.display = isCollapsed ? "none" : "flex"; contentDiv.style.flexDirection = "column"; contentDiv.style.gap = "6px"; contentDiv.style.width = "100%"; - // --- TOGGLE LOGIC (INSTANT) --- header.onclick = (e) => { - if (e.target.tagName === 'BUTTON') return; // Ignore delete button - + if (e.target.tagName === 'BUTTON') return; const isNowCollapsed = contentDiv.style.display !== "none"; contentDiv.style.display = isNowCollapsed ? "none" : "flex"; toggleArrow.textContent = isNowCollapsed ? "▶" : "▼"; - - // Save state silently node.uiState.modelsCollapsed[uid] = isNowCollapsed; }; - // Type Select + // Model Type Select (Checkpoint / Diffusion Model / GGUF) + const modelTypeSelect = document.createElement("select"); + modelTypeSelect.className = "cb-select"; + modelTypeSelect.innerHTML = ` + + + + `; + modelTypeSelect.onchange = () => { + const newType = modelTypeSelect.value; + node.state.config_arrays[arrayIdx].models[modelIdx] = newType === "checkpoint" + ? "None" + : { path: "None", type: newType }; + node.saveState(); + node.renderUI(); + }; + contentDiv.appendChild(modelTypeSelect); + + // File/Folder Type Select const typeSelect = document.createElement("select"); typeSelect.className = "cb-select"; + const fileLabel = modelType === "gguf" ? "GGUF File" : modelType === "diffusion_model" ? "Diffusion File" : "Checkpoint File"; typeSelect.innerHTML = ` - + `; typeSelect.onchange = () => { - node.state.config_arrays[arrayIdx].models[modelIdx] = typeSelect.value === "folder" ? "/" : "None"; + const newVal = typeSelect.value === "folder" ? "/" : "None"; + if (modelType === "checkpoint") { + node.state.config_arrays[arrayIdx].models[modelIdx] = newVal; + } else { + node.state.config_arrays[arrayIdx].models[modelIdx] = { path: newVal, type: modelType }; + } node.saveState(); node.renderUI(); }; contentDiv.appendChild(typeSelect); + // Pick the correct file list and folder list based on model type + let fileOptions, folderOptions; + if (modelType === 'gguf') { + fileOptions = modelLists.ggufModels || []; + folderOptions = modelLists.ggufFolders || ["/"]; + } else if (modelType === 'diffusion_model') { + fileOptions = modelLists.diffusionModels || []; + folderOptions = modelLists.diffusionFolders || ["/"]; + } else { + fileOptions = modelLists.checkpoints || []; + folderOptions = modelLists.checkpointFolders || ["/"]; + } + // Searchable Select - const options = isFolder ? modelFolders : availableModels; - const currentVal = modelStr; + const options = isFolder ? folderOptions : fileOptions; + const currentVal = modelPath; const optionsList = (options && options.includes(currentVal)) || currentVal === "None" || currentVal === "/" ? options || ["None"] : [currentVal, ...(options || ["None"])]; @@ -334,7 +388,12 @@ export function createModelElement(node, modelStr, arrayIdx, modelIdx, available optionsList, currentVal, (value) => { - node.state.config_arrays[arrayIdx].models[modelIdx] = normalizePath(value); + const normalized = normalizePath(value); + if (modelType === "checkpoint") { + node.state.config_arrays[arrayIdx].models[modelIdx] = normalized; + } else { + node.state.config_arrays[arrayIdx].models[modelIdx] = { path: normalized, type: modelType }; + } node.saveState(); node.renderUI(); }, @@ -342,7 +401,8 @@ export function createModelElement(node, modelStr, arrayIdx, modelIdx, available ); contentDiv.appendChild(nameSearchable); - if (isFolder && modelStr !== "None" && modelStr !== "/") { + // Folder expand button + if (isFolder && modelPath !== "None" && modelPath !== "/") { const expandBtn = document.createElement("button"); expandBtn.className = "cb-button"; expandBtn.style.borderLeft = "3px solid #cc6600"; @@ -352,14 +412,15 @@ export function createModelElement(node, modelStr, arrayIdx, modelIdx, available expandBtn.textContent = "📂 Add all individually"; expandBtn.onclick = () => { const normalize = (str) => str.replace(/\\/g, "/"); - const folderPrefix = normalize(modelStr); - const matchingModels = availableModels ? availableModels.filter(m => normalize(m).startsWith(folderPrefix)) : []; + const folderPrefix = normalize(modelPath); + const matchingModels = fileOptions ? fileOptions.filter(m => normalize(m).startsWith(folderPrefix)) : []; if (matchingModels.length > 0) { - node.state.config_arrays[arrayIdx].models.splice(modelIdx, 1, ...matchingModels); + const expanded = matchingModels.map(m => modelType === "checkpoint" ? m : { path: m, type: modelType }); + node.state.config_arrays[arrayIdx].models.splice(modelIdx, 1, ...expanded); node.saveState(); node.renderUI(); } else { - alert(`No Checkpoints found in folder: ${folderPrefix}`); + alert(`No models found in folder: ${folderPrefix}`); } }; contentDiv.appendChild(expandBtn); @@ -911,12 +972,11 @@ async function showLoraMetadataModal(node, arrayIdx, loraName) { // --- RENDER MODELS AND LORAS SECTIONS --- -export function renderModelsSection(node, div, configArray, arrayIdx, availableModels, modelFolders) { +export function renderModelsSection(node, div, configArray, arrayIdx, modelLists) { if (!configArray.models || configArray.models.length === 0) configArray.models = ["None"]; const isSectionCollapsed = node.uiState.modelsSectionCollapsed[arrayIdx] || false; - // FIX: Restore class for Grid Layout const modelGrid = document.createElement("div"); modelGrid.className = "cb-list-grid"; @@ -924,13 +984,21 @@ export function renderModelsSection(node, div, configArray, arrayIdx, availableM modelHeader.className = "cb-section-toggle"; modelHeader.style.cssText = "padding: 8px; background: #3a3a3a; border-radius: 4px; margin-bottom: 8px; font-weight: bold; color: #cc6600;"; + // Count total models (handle both string and object format) let totalModels = 0; configArray.models.forEach(m => { - if (m === "None") totalModels++; - else if (m.endsWith("/")) { - const norm = normalizePath(m); - if (norm === "/") totalModels += availableModels ? availableModels.length : 1; - else totalModels += availableModels ? availableModels.filter(am => normalizePath(am).startsWith(norm)).length : 1; + const mPath = typeof m === 'string' ? m : (m?.path || "None"); + const mType = typeof m === 'string' ? 'checkpoint' : (m?.type || 'checkpoint'); + let list; + if (mType === 'gguf') list = modelLists.ggufModels; + else if (mType === 'diffusion_model') list = modelLists.diffusionModels; + else list = modelLists.checkpoints; + + if (mPath === "None") totalModels++; + else if (mPath.endsWith("/")) { + const norm = normalizePath(mPath); + if (norm === "/") totalModels += list ? list.length : 1; + else totalModels += list ? list.filter(am => normalizePath(am).startsWith(norm)).length : 1; } else totalModels++; }); @@ -939,20 +1007,16 @@ export function renderModelsSection(node, div, configArray, arrayIdx, availableM modelHeader.appendChild(titleSpan); const arrowSpan = document.createElement("span"); - arrowSpan.textContent = isSectionCollapsed ? "▶" : "▼"; // Initial icon + arrowSpan.textContent = isSectionCollapsed ? "▶" : "▼"; modelHeader.appendChild(arrowSpan); modelGrid.appendChild(modelHeader); - // CONTENT CONTAINER (Always render, just toggle display) const contentContainer = document.createElement("div"); - contentContainer.style.display = isSectionCollapsed ? "none" : "contents"; // 'contents' keeps the grid layout working! - // Note: 'contents' acts as if the container isn't there, so children become grid items. + contentContainer.style.display = isSectionCollapsed ? "none" : "contents"; - // --- HEADER CLICK (INSTANT) --- modelHeader.onclick = () => { const isNowCollapsed = contentContainer.style.display === "none"; - // Toggle if (isNowCollapsed) { contentContainer.style.display = "contents"; arrowSpan.textContent = "▼"; @@ -964,12 +1028,10 @@ export function renderModelsSection(node, div, configArray, arrayIdx, availableM } }; - // Always render items configArray.models.forEach((model, modelIdx) => { - contentContainer.appendChild(createModelElement(node, model, arrayIdx, modelIdx, availableModels, modelFolders)); + contentContainer.appendChild(createModelElement(node, model, arrayIdx, modelIdx, modelLists)); }); - // FIX: Add Model Button (Bottom) const addRow = document.createElement("div"); addRow.style.width = "100%"; addRow.style.padding = "4px 0"; @@ -987,10 +1049,181 @@ export function renderModelsSection(node, div, configArray, arrayIdx, availableM addRow.appendChild(addBtn); contentContainer.appendChild(addRow); + // --- TEXT ENCODERS SECTION (shown when any model is non-checkpoint) --- + const hasNonCheckpoint = configArray.models.some(m => + typeof m === 'object' && m !== null && m.type && m.type !== 'checkpoint' + ); + + if (hasNonCheckpoint) { + renderTextEncodersSection(node, contentContainer, configArray, arrayIdx, modelLists); + } + + // --- GGUF OPTIONS SECTION (shown when any model is GGUF) --- + const hasGGUF = configArray.models.some(m => + typeof m === 'object' && m !== null && m.type === 'gguf' + ); + + if (hasGGUF) { + renderGGUFOptionsSection(node, contentContainer, configArray, arrayIdx); + } + modelGrid.appendChild(contentContainer); div.appendChild(modelGrid); } +// --- TEXT ENCODERS SECTION --- +function renderTextEncodersSection(node, container, configArray, arrayIdx, modelLists) { + const section = document.createElement("div"); + section.style.cssText = "width: 100%; border-top: 1px solid #444; margin-top: 8px; padding-top: 8px;"; + + const header = document.createElement("div"); + header.style.cssText = "font-size: 11px; font-weight: bold; color: #44aaff; margin-bottom: 6px;"; + header.textContent = "TEXT ENCODERS (CLIP)"; + section.appendChild(header); + + // Clip Type selector + const clipRow = document.createElement("div"); + clipRow.style.cssText = "display: flex; gap: 6px; align-items: center; margin-bottom: 6px;"; + const clipLabel = document.createElement("label"); + clipLabel.textContent = "CLIP Type:"; + clipLabel.style.cssText = "color: #aaa; font-size: 11px; white-space: nowrap;"; + clipRow.appendChild(clipLabel); + + const clipSelect = document.createElement("select"); + clipSelect.className = "cb-select"; + // Combine single and dual clip types + const allClipTypes = [...new Set([...(modelLists.clipTypes || []), ...(modelLists.dualClipTypes || [])])]; + allClipTypes.forEach(ct => { + const opt = document.createElement("option"); + opt.value = ct; + opt.textContent = ct; + if (ct === (configArray.clip_type || "stable_diffusion")) opt.selected = true; + clipSelect.appendChild(opt); + }); + clipSelect.onchange = () => { + node.state.config_arrays[arrayIdx].clip_type = clipSelect.value; + node.saveState(); + }; + clipRow.appendChild(clipSelect); + section.appendChild(clipRow); + + // Text encoder entries + const teList = configArray.text_encoders || []; + teList.forEach((te, teIdx) => { + const teRow = document.createElement("div"); + teRow.style.cssText = "display: flex; gap: 4px; align-items: center; margin-bottom: 4px;"; + + const teSearchable = createSearchableSelect( + modelLists.textEncoders || ["None"], + te || "None", + (value) => { + node.state.config_arrays[arrayIdx].text_encoders[teIdx] = normalizePath(value); + node.saveState(); + }, + "Search text encoders..." + ); + teSearchable.style.flex = "1"; + teRow.appendChild(teSearchable); + + const delBtn = document.createElement("button"); + delBtn.className = "cb-button danger"; + delBtn.style.cssText = "padding: 2px 6px; font-size: 10px;"; + delBtn.textContent = "✖"; + delBtn.onclick = () => { + node.state.config_arrays[arrayIdx].text_encoders.splice(teIdx, 1); + node.saveState(); + node.renderUI(); + }; + teRow.appendChild(delBtn); + section.appendChild(teRow); + }); + + // Add text encoder button + const addTeBtn = document.createElement("button"); + addTeBtn.className = "cb-button"; + addTeBtn.style.cssText = "width: 100%; border: 1px dashed #446; background: rgba(0,50,100,0.2); color: #88bbff; font-size: 11px;"; + addTeBtn.textContent = "+ Add Text Encoder"; + addTeBtn.onclick = () => { + if (!node.state.config_arrays[arrayIdx].text_encoders) { + node.state.config_arrays[arrayIdx].text_encoders = []; + } + node.state.config_arrays[arrayIdx].text_encoders.push("None"); + node.saveState(); + node.renderUI(); + }; + section.appendChild(addTeBtn); + + container.appendChild(section); +} + +// --- GGUF OPTIONS SECTION --- +function renderGGUFOptionsSection(node, container, configArray, arrayIdx) { + const section = document.createElement("details"); + section.style.cssText = "width: 100%; border-top: 1px solid #444; margin-top: 8px; padding-top: 8px;"; + + const summary = document.createElement("summary"); + summary.textContent = "GGUF Options"; + summary.style.cssText = "cursor: pointer; color: #aaa; font-size: 11px; font-weight: bold;"; + section.appendChild(summary); + + const opts = configArray.gguf_options || {}; + const grid = document.createElement("div"); + grid.style.cssText = "display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 6px;"; + + // Helper for select rows + const addSelectRow = (label, options, currentVal, onChange) => { + const lbl = document.createElement("label"); + lbl.textContent = label; + lbl.style.cssText = "color: #aaa; font-size: 11px; display: flex; align-items: center;"; + grid.appendChild(lbl); + + const sel = document.createElement("select"); + sel.className = "cb-select"; + options.forEach(o => { + const opt = document.createElement("option"); + opt.value = o; + opt.textContent = o; + if (o === currentVal) opt.selected = true; + sel.appendChild(opt); + }); + sel.onchange = () => onChange(sel.value); + grid.appendChild(sel); + }; + + const dtypeOptions = ["default", "target", "float32", "float16", "bfloat16"]; + + addSelectRow("Dequant dtype:", dtypeOptions, opts.dequant_dtype || "default", (val) => { + if (!node.state.config_arrays[arrayIdx].gguf_options) node.state.config_arrays[arrayIdx].gguf_options = {}; + node.state.config_arrays[arrayIdx].gguf_options.dequant_dtype = val; + node.saveState(); + }); + + addSelectRow("Patch dtype:", dtypeOptions, opts.patch_dtype || "default", (val) => { + if (!node.state.config_arrays[arrayIdx].gguf_options) node.state.config_arrays[arrayIdx].gguf_options = {}; + node.state.config_arrays[arrayIdx].gguf_options.patch_dtype = val; + node.saveState(); + }); + + // Patch on device checkbox + const podLabel = document.createElement("label"); + podLabel.textContent = "Patch on device:"; + podLabel.style.cssText = "color: #aaa; font-size: 11px; display: flex; align-items: center;"; + grid.appendChild(podLabel); + + const podCheck = document.createElement("input"); + podCheck.type = "checkbox"; + podCheck.checked = opts.patch_on_device || false; + podCheck.onchange = () => { + if (!node.state.config_arrays[arrayIdx].gguf_options) node.state.config_arrays[arrayIdx].gguf_options = {}; + node.state.config_arrays[arrayIdx].gguf_options.patch_on_device = podCheck.checked; + node.saveState(); + }; + grid.appendChild(podCheck); + + section.appendChild(grid); + container.appendChild(section); +} + export function renderLorasSection(node, div, configArray, arrayIdx, availableLoras, loraFolders) { if (!configArray.loras || configArray.loras.length === 0) configArray.loras = ["None"]; @@ -1807,7 +2040,7 @@ export function updatePreview(node) { preview.textContent = JSON.stringify(configs, null, 2); } -export async function renderUI(node, availableLoras, availableModels, loraFolders, modelFolders, availableSessions, availableConfigs, refreshAllConfigBuilders) { +export async function renderUI(node, availableLoras, modelLists, loraFolders, availableSessions, availableConfigs, refreshAllConfigBuilders) { const scrollContainer = node.htmlContainer.querySelector(".cb-container"); const savedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0; @@ -1829,7 +2062,7 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder configSection.innerHTML = '
⚙️ Config Arrays
'; const headerBar = document.createElement("div"); - headerBar.style.marginBottom = "10px"; + headerBar.style.cssText = "margin-bottom: 10px; display: flex; align-items: center; gap: 12px;"; const addConfigBtn = document.createElement("button"); addConfigBtn.className = "cb-button primary"; addConfigBtn.textContent = "➕ Add Config Array"; @@ -1842,6 +2075,9 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder cfg: "7.0", seed_behavior: "fixed", models: ["None"], + text_encoders: [], + clip_type: "stable_diffusion", + gguf_options: {}, loras: ["None"], lora_omit_triggers: [], lora_triggerwords_append_settings: {}, @@ -1854,6 +2090,22 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder node.renderUI(); }; headerBar.appendChild(addConfigBtn); + + const labelModeLabel = document.createElement("label"); + labelModeLabel.className = "cb-toggle"; + labelModeLabel.style.fontSize = "12px"; + const labelModeCheckbox = document.createElement("input"); + labelModeCheckbox.type = "checkbox"; + labelModeCheckbox.checked = node.state.label_mode || false; + labelModeCheckbox.onchange = () => { + node.state.label_mode = labelModeCheckbox.checked; + node.saveState(); + node.renderUI(); + }; + labelModeLabel.appendChild(labelModeCheckbox); + labelModeLabel.appendChild(document.createTextNode(" \u{1F3F7}\uFE0F Label Mode")); + headerBar.appendChild(labelModeLabel); + configSection.appendChild(headerBar); const arraysContainer = document.createElement("div"); @@ -1862,7 +2114,7 @@ export async function renderUI(node, availableLoras, availableModels, loraFolder node.state.config_arrays.forEach((configArray, arrayIdx) => { const arrayElement = createConfigArrayElement(node, configArray, arrayIdx); renderConfigPromptsSection(node, arrayElement, configArray, arrayIdx); - renderModelsSection(node, arrayElement, configArray, arrayIdx, availableModels, modelFolders); + renderModelsSection(node, arrayElement, configArray, arrayIdx, modelLists); renderLorasSection(node, arrayElement, configArray, arrayIdx, availableLoras, loraFolders); arraysContainer.appendChild(arrayElement); }); diff --git a/web/conf_builder/conf-builder-main.js b/web/conf_builder/conf-builder-main.js index b5f6329..c148bc9 100644 --- a/web/conf_builder/conf-builder-main.js +++ b/web/conf_builder/conf-builder-main.js @@ -19,9 +19,9 @@ function ensureModulesLoaded() { console.log('[ConfigBuilder] Loading modules with cache-bust:', CACHE_BUST); moduleLoadPromise = (async () => { - const utilitiesModule = await import(`/ultimate_config_sampler/js/conf_builder/conf-builder-utilities.js?v=${CACHE_BUST}`); - const uiComponentsModule = await import(`/ultimate_config_sampler/js/conf_builder/conf-builder-ui-components.js?v=${CACHE_BUST}`); - const configManagementModule = await import(`/ultimate_config_sampler/js/conf_builder/conf-builder-config-management.js?v=${CACHE_BUST}`); + const utilitiesModule = await import(`./conf-builder-utilities.js?v=${CACHE_BUST}`); + const uiComponentsModule = await import(`./conf-builder-ui-components.js?v=${CACHE_BUST}`); + const configManagementModule = await import(`./conf-builder-config-management.js?v=${CACHE_BUST}`); utilities = utilitiesModule; uiComponents = uiComponentsModule; @@ -77,6 +77,7 @@ app.registerExtension({ config_name: "default_config", auto_save: false, include_none: false, + label_mode: false, global_positive_groups: [], global_negative: "", config_arrays: [{ @@ -86,6 +87,9 @@ app.registerExtension({ steps: "20, 30", cfg: "7.0", models: ["None"], + text_encoders: [], + clip_type: "stable_diffusion", + gguf_options: {}, loras: ["None"], lora_omit_triggers: [], lora_triggerwords_append_settings: {}, @@ -169,18 +173,32 @@ app.registerExtension({ if (!utilities || !configManagement) return; const availableLoras = await utilities.getAvailableLoras(); - const availableModels = await utilities.getAvailableModels(); const loraFolders = await utilities.getLoraFolders(); - const modelFolders = await utilities.getModelFolders(); const availableSessions = await utilities.getAvailableSessions(); const availableConfigs = await utilities.getAvailableConfigs(); + // Fetch all model type lists via unified endpoint + await utilities.getModelLists(); + + // Build modelLists object for config management + const modelLists = { + checkpoints: await utilities.getAvailableModels(), + checkpointFolders: await utilities.getModelFolders(), + diffusionModels: utilities.getAvailableDiffusionModels(), + diffusionFolders: utilities.getDiffusionModelFolders(), + ggufModels: utilities.getAvailableGGUFModels(), + ggufFolders: utilities.getGGUFModelFolders(), + textEncoders: utilities.getAvailableTextEncoders(), + textEncoderFolders: utilities.getTextEncoderFolders(), + clipTypes: utilities.getClipTypes(), + dualClipTypes: utilities.getDualClipTypes() + }; + await configManagement.renderUI( this, availableLoras, - availableModels, + modelLists, loraFolders, - modelFolders, availableSessions, availableConfigs, utilities.refreshAllConfigBuilders @@ -224,12 +242,26 @@ app.registerExtension({ return loraStr; }); - arr.models = arr.models.map(modelStr => { - const normModel = normalize(modelStr); + // Handle both string and object-format models + arr.models = arr.models.map(modelEntry => { + if (typeof modelEntry === 'object' && modelEntry !== null) { + // Object format: {path, type} — normalize path + return { + ...modelEntry, + path: normalize(modelEntry.path || "") + }; + } + // String format (checkpoint) — normalize as before + const normModel = normalize(modelEntry); if (normModel.endsWith("/")) return normModel; if (modelFolders && modelFolders.includes(normModel + "/")) return normModel + "/"; return normModel; }); + + // Ensure new fields exist + if (!arr.text_encoders) arr.text_encoders = []; + if (!arr.clip_type) arr.clip_type = "stable_diffusion"; + if (!arr.gguf_options) arr.gguf_options = {}; }); this.state.config_arrays = loadedArrays; @@ -264,6 +296,9 @@ app.registerExtension({ steps: oldState.steps || "20", cfg: oldState.cfg || "7.0", models: oldState.model ? [utilities.normalizePath(oldState.model)] : ["None"], + text_encoders: [], + clip_type: "stable_diffusion", + gguf_options: {}, loras: arr.loras ? arr.loras.map(l => utilities.normalizePath(l)) : ["None"], lora_omit_triggers: [], lora_triggerwords_append_settings: {}, @@ -292,6 +327,7 @@ app.registerExtension({ this.state = existing; if (!this.state.config_name) this.state.config_name = "default_config"; if (this.state.auto_save === undefined) this.state.auto_save = false; + if (this.state.label_mode === undefined) this.state.label_mode = false; // Migration: ensure global prompt fields exist if (!this.state.global_positive_groups) this.state.global_positive_groups = []; @@ -304,7 +340,13 @@ app.registerExtension({ delete arr.model; } if (!arr.models) arr.models = ["None"]; - arr.models = arr.models.map(utilities.normalizePath); + // Normalize models — handle both string and object formats + arr.models = arr.models.map(m => { + if (typeof m === 'object' && m !== null) { + return { ...m, path: utilities.normalizePath(m.path || "") }; + } + return utilities.normalizePath(m); + }); arr.loras = arr.loras ? arr.loras.map(l => { const p = utilities.parseLoraString(l); return utilities.buildLoraString(p.name, p.model_str, p.clip_str); @@ -320,6 +362,11 @@ app.registerExtension({ if (!arr.positive_prompt_groups) arr.positive_prompt_groups = []; if (arr.negative_prompt === undefined) arr.negative_prompt = ""; if (arr.use_custom_prompts === undefined) arr.use_custom_prompts = false; + + // Migration: ensure new model type fields exist + if (!arr.text_encoders) arr.text_encoders = []; + if (!arr.clip_type) arr.clip_type = "stable_diffusion"; + if (!arr.gguf_options) arr.gguf_options = {}; }); } else if (existing.lora_config) { this.state = this.migrateOldFormat(existing); @@ -330,8 +377,7 @@ app.registerExtension({ await Promise.all([ utilities.getAvailableLoras(), utilities.getLoraFolders(), - utilities.getAvailableModels(), - utilities.getModelFolders(), + utilities.getModelLists(), utilities.getAvailableSessions(), utilities.getAvailableConfigs() ]); diff --git a/js/conf_builder/conf-builder-ui-components.js b/web/conf_builder/conf-builder-ui-components.js similarity index 96% rename from js/conf_builder/conf-builder-ui-components.js rename to web/conf_builder/conf-builder-ui-components.js index 50a405f..0793b3a 100644 --- a/js/conf_builder/conf-builder-ui-components.js +++ b/web/conf_builder/conf-builder-ui-components.js @@ -3,7 +3,7 @@ * Reusable UI elements and component builders */ -import { normalizePath } from '/ultimate_config_sampler/js/conf_builder/conf-builder-utilities.js'; +import { normalizePath } from './conf-builder-utilities.js'; // --- SEARCHABLE SELECT COMPONENT --- @@ -200,7 +200,8 @@ export function getStyles() { .cb-button.primary:hover { background: #0077ee; } .cb-button.danger { background: #cc3333; } .cb-button.danger:hover { background: #dd4444; } - .cb-array { background: #333; border-radius: 4px; padding: 12px; margin: 8px 0; border: 1px solid #3a3a3a; width: 100%; } + .cb-array { background: #333; border-radius: 4px; padding: 12px; margin: 8px 0; border: 1px solid #3a3a3a; width: 100%; position: relative; } + .cb-config-label-overlay { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); background: rgba(0, 102, 204, 0.85); color: #fff; padding: 4px 16px; border-radius: 12px; font-size: 13px; font-weight: bold; font-family: monospace; pointer-events: none; z-index: 10; white-space: nowrap; max-width: 80%; overflow: hidden; text-overflow: ellipsis; text-shadow: 0 1px 2px rgba(0,0,0,0.5); } .cb-arrays-container { display: flex; flex-direction: column; gap: 12px; } .cb-list-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px dashed #444; } .cb-item-card { background: #2a2a2a; border-radius: 4px; padding: 10px; border-left: 3px solid #0066cc; flex: 1 1 300px; min-width: 250px; display: flex; flex-direction: column; gap: 6px; } diff --git a/js/conf_builder/conf-builder-utilities.js b/web/conf_builder/conf-builder-utilities.js similarity index 77% rename from js/conf_builder/conf-builder-utilities.js rename to web/conf_builder/conf-builder-utilities.js index 5e296e7..7a955a1 100644 --- a/js/conf_builder/conf-builder-utilities.js +++ b/web/conf_builder/conf-builder-utilities.js @@ -8,6 +8,14 @@ let availableLoras = null; let loraFolders = null; let availableModels = null; let modelFolders = null; +let availableDiffusionModels = null; +let diffusionModelFolders = null; +let availableGGUFModels = null; +let ggufModelFolders = null; +let availableTextEncoders = null; +let textEncoderFolders = null; +let clipTypes = []; +let dualClipTypes = []; let availableSessions = ["None"]; let availableConfigs = ["None"]; @@ -22,6 +30,14 @@ export function clearAllCaches() { loraFolders = null; availableModels = null; modelFolders = null; + availableDiffusionModels = null; + diffusionModelFolders = null; + availableGGUFModels = null; + ggufModelFolders = null; + availableTextEncoders = null; + textEncoderFolders = null; + clipTypes = []; + dualClipTypes = []; } export async function refreshAllConfigBuilders() { @@ -129,6 +145,55 @@ export async function getModelFolders() { return modelFolders; } +// --- UNIFIED MODEL LISTS (for GGUF, Diffusion Models, Text Encoders) --- + +export async function getModelLists() { + // Fetch all model lists from the unified endpoint + try { + const resp = await fetch("/configbuilder/model_lists", { + headers: { "X-Config-Builder-Internal": "true" } + }); + const data = await resp.json(); + + if (data.checkpoints) { + availableModels = data.checkpoints.map(normalizePath); + modelFolders = extractFolders(availableModels); + } + availableDiffusionModels = (data.diffusion_models || []).map(normalizePath); + diffusionModelFolders = extractFolders(availableDiffusionModels); + availableGGUFModels = (data.unet_gguf || []).map(normalizePath); + ggufModelFolders = extractFolders(availableGGUFModels); + + // Combine text_encoders and clip_gguf, deduplicate + const teSet = new Set([ + ...(data.text_encoders || []).map(normalizePath), + ...(data.clip_gguf || []).map(normalizePath) + ]); + availableTextEncoders = Array.from(teSet).sort(); + textEncoderFolders = extractFolders(availableTextEncoders); + + clipTypes = data.clip_types || []; + dualClipTypes = data.dual_clip_types || []; + + console.log(`[ConfigBuilder] Model lists: ${availableModels?.length || 0} checkpoints, ` + + `${availableDiffusionModels.length} diffusion, ${availableGGUFModels.length} GGUF, ` + + `${availableTextEncoders.length} text encoders`); + return data; + } catch (e) { + console.error("[ConfigBuilder] Error fetching model lists:", e); + return {}; + } +} + +export function getAvailableDiffusionModels() { return availableDiffusionModels || []; } +export function getDiffusionModelFolders() { return diffusionModelFolders || ["/"]; } +export function getAvailableGGUFModels() { return availableGGUFModels || []; } +export function getGGUFModelFolders() { return ggufModelFolders || ["/"]; } +export function getAvailableTextEncoders() { return availableTextEncoders || []; } +export function getTextEncoderFolders() { return textEncoderFolders || ["/"]; } +export function getClipTypes() { return clipTypes; } +export function getDualClipTypes() { return dualClipTypes; } + export async function getAvailableSessions() { try { const resp = await fetch("/object_info", { headers: { "X-Config-Builder-Internal": "true" } }); @@ -227,24 +292,31 @@ export function getIterationCount(configArray) { const st_count = countSplit(configArray.steps); const c_count = countSplit(configArray.cfg); - // 2. Models + // 2. Models (handles both string format and object format {path, type}) let m_count = 0; if (!configArray.models || configArray.models.length === 0) { m_count = 1; // Defaults to None } else { configArray.models.forEach(m => { - if (m === "None") { + const modelPath = typeof m === 'string' ? m : (m?.path || "None"); + const modelType = typeof m === 'string' ? 'checkpoint' : (m?.type || 'checkpoint'); + + // Pick the correct file list for folder expansion + let modelList; + if (modelType === 'gguf') modelList = availableGGUFModels; + else if (modelType === 'diffusion_model') modelList = availableDiffusionModels; + else modelList = availableModels; + + if (modelPath === "None") { m_count += 1; - } else if (m.endsWith("/")) { - // Folder - const norm = normalizePath(m); + } else if (modelPath.endsWith("/")) { + const norm = normalizePath(modelPath); if (norm === "/" || norm === "") { - m_count += availableModels ? availableModels.length : 1; + m_count += modelList ? modelList.length : 1; } else { - m_count += availableModels ? availableModels.filter(am => normalizePath(am).startsWith(norm)).length : 1; + m_count += modelList ? modelList.filter(am => normalizePath(am).startsWith(norm)).length : 1; } } else { - // Single m_count += 1; } }); @@ -314,8 +386,19 @@ export function convertStateToConfigs(state) { loraValue = loras.join(" + "); } - // Process Models - let finalModels = configArray.models?.filter(m => m && m !== "None") || []; + // Process Models - handle object format {path, type} and string format + let modelType = "checkpoint"; + let finalModels = []; + (configArray.models || []).forEach(m => { + if (typeof m === 'object' && m !== null) { + if (m.path && m.path !== "None") { + finalModels.push(m.path); + modelType = m.type || "checkpoint"; + } + } else if (typeof m === 'string' && m && m !== "None") { + finalModels.push(m); + } + }); const config = { sampler: split(configArray.samplers), @@ -326,6 +409,17 @@ export function convertStateToConfigs(state) { model: finalModels.length > 1 ? finalModels : finalModels[0] || "None" }; + // Add model_type and related fields for non-checkpoint models + if (modelType !== "checkpoint") { + config.model_type = modelType; + const textEncoders = (configArray.text_encoders || []).filter(te => te && te !== "None"); + if (textEncoders.length > 0) config.text_encoders = textEncoders; + if (configArray.clip_type) config.clip_type = configArray.clip_type; + if (modelType === "gguf" && configArray.gguf_options) { + config.gguf_options = configArray.gguf_options; + } + } + // Add lora_omit_triggers if present if (configArray.lora_omit_triggers && configArray.lora_omit_triggers.length > 0) { config.lora_omit_triggers = configArray.lora_omit_triggers; @@ -388,6 +482,9 @@ export function convertConfigsToConfigArrays(configs) { cfg: "7.0", seed_behavior: "fixed", models: ["None"], + text_encoders: [], + clip_type: "stable_diffusion", + gguf_options: {}, loras: ["None"], lora_omit_triggers: [], lora_triggerwords_append_settings: {}, @@ -432,8 +529,16 @@ export function convertConfigsToConfigArrays(configs) { let models = config.model; if (!Array.isArray(models)) models = models ? [models] : ["None"]; - // Normalize loaded models - models = models.map(normalizePath); + // Determine model type from config + const loadedModelType = config.model_type || "checkpoint"; + + // Convert models to object format if non-checkpoint, keep string for checkpoint + if (loadedModelType !== "checkpoint") { + models = models.map(m => ({ path: normalizePath(m), type: loadedModelType })); + } else { + models = models.map(normalizePath); + } + // Normalize loaded loras loras = loras.map(normalizePath); @@ -497,6 +602,9 @@ export function convertConfigsToConfigArrays(configs) { cfg: toString(config.cfg || "7.0"), seed_behavior: config.seed_behavior || "fixed", models: models, + text_encoders: config.text_encoders || [], + clip_type: config.clip_type || "stable_diffusion", + gguf_options: config.gguf_options || {}, loras: loras, lora_omit_triggers: omitTriggers, lora_triggerwords_append_settings: triggerPlacements, @@ -517,6 +625,9 @@ export function convertConfigsToConfigArrays(configs) { cfg: "7.0", seed_behavior: "fixed", models: ["None"], + text_encoders: [], + clip_type: "stable_diffusion", + gguf_options: {}, loras: ["None"], lora_omit_triggers: [], lora_triggerwords_append_settings: {},