Layout changes: - Default node width 380 → 440px (fits prompt fields better) - Moved seed + seed tools BELOW width/height/steps/cfg/guidance (grouped with other generation parameters, not as orphan group) Removed redundant buttons: - "Show Model Info" - info already updates live in panel on model change - "Copy Seed" / "Paste Seed" - use ComfyUI's native right-click menu Info panel redesign: - Removed heavy === and ~~~ separator bars, use clean ─ lines - Removed "BY MAXOMARAI" footer banner (name is already in the node title) - Lowercase field labels (model, clip, vae, sampler, steps, cfg) matches the lowercase output names for consistency - Warnings section only appears when there are warnings - "Sampler settings" single line: "sampler / scheduler" - Cleaner typography with ▸ arrow for node name Welcome text rewritten: - Quick start section with numbered steps - Connection guide with → arrows (not ASCII art) - Tools section lists all available tools Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1397 lines
58 KiB
Python
1397 lines
58 KiB
Python
from __future__ import annotations
|
||
|
||
import inspect
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import re
|
||
import struct
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
import torch
|
||
from aiohttp import web
|
||
from typing_extensions import override
|
||
|
||
import comfy.sd
|
||
import comfy.utils
|
||
import comfy.model_sampling
|
||
import comfy.model_management
|
||
import folder_paths
|
||
import node_helpers
|
||
import server
|
||
from comfy_api.latest import ComfyExtension, io
|
||
|
||
PRESETS_FILE = os.path.join(os.path.dirname(__file__), "presets.json")
|
||
WEB_DIRECTORY = "./web"
|
||
|
||
# --- GGUF support (optional) ---
|
||
_gguf_available = False
|
||
_gguf_loader = None
|
||
_gguf_ops = None
|
||
_gguf_nodes = None
|
||
|
||
try:
|
||
import importlib
|
||
_gguf_nodes = importlib.import_module("custom_nodes.ComfyUI-GGUF.nodes")
|
||
_gguf_loader = importlib.import_module("custom_nodes.ComfyUI-GGUF.loader")
|
||
_gguf_ops = importlib.import_module("custom_nodes.ComfyUI-GGUF.ops")
|
||
_gguf_available = True
|
||
logging.info("[TheLastModelSwitcher] GGUF support enabled")
|
||
except Exception:
|
||
try:
|
||
import sys
|
||
gguf_path = os.path.join(os.path.dirname(__file__), "..", "ComfyUI-GGUF")
|
||
if os.path.isdir(gguf_path) and gguf_path not in sys.path:
|
||
sys.path.insert(0, gguf_path)
|
||
from loader import gguf_sd_loader as _sd_fn, gguf_clip_loader as _clip_fn
|
||
from ops import GGMLOps as _ops_cls
|
||
import types
|
||
_gguf_loader = types.SimpleNamespace(gguf_sd_loader=_sd_fn, gguf_clip_loader=_clip_fn)
|
||
_gguf_ops = types.SimpleNamespace(GGMLOps=_ops_cls)
|
||
_gguf_available = True
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _is_gguf(filename: str) -> bool:
|
||
return filename.lower().endswith(".gguf")
|
||
|
||
|
||
def _load_diffusion_model_gguf(unet_path: str):
|
||
ops = _gguf_ops.GGMLOps()
|
||
sd, extra = _gguf_loader.gguf_sd_loader(unet_path)
|
||
kwargs = {}
|
||
valid_params = inspect.signature(comfy.sd.load_diffusion_model_state_dict).parameters
|
||
if "metadata" in valid_params:
|
||
kwargs["metadata"] = extra.get("metadata", {})
|
||
model = comfy.sd.load_diffusion_model_state_dict(
|
||
sd, model_options={"custom_operations": ops}, **kwargs,
|
||
)
|
||
if model is None:
|
||
raise RuntimeError(f"Could not detect model type: {unet_path}")
|
||
model = _gguf_nodes.GGUFModelPatcher.clone(model)
|
||
model.patch_on_device = False
|
||
return model
|
||
|
||
|
||
def _load_clip_gguf(clip_paths: list[str], clip_type):
|
||
clip_data = []
|
||
for p in clip_paths:
|
||
if _is_gguf(p):
|
||
clip_data.append(_gguf_loader.gguf_clip_loader(p))
|
||
else:
|
||
clip_data.append(comfy.utils.load_torch_file(p, safe_load=True))
|
||
clip = comfy.sd.load_text_encoder_state_dicts(
|
||
clip_type=clip_type, state_dicts=clip_data,
|
||
model_options={"custom_operations": _gguf_ops.GGMLOps,
|
||
"initial_device": comfy.model_management.text_encoder_offload_device()},
|
||
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||
)
|
||
clip.patcher = _gguf_nodes.GGUFModelPatcher.clone(clip.patcher)
|
||
return clip
|
||
|
||
|
||
def load_presets() -> dict:
|
||
with open(PRESETS_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# Model cache - avoids reloading from disk on every execution
|
||
# ──────────────────────────────────────────────────────────
|
||
_cache = {
|
||
"model_key": None,
|
||
"clip_key": None,
|
||
"vae_key": None,
|
||
"model": None,
|
||
"clip": None,
|
||
"vae": None,
|
||
"clip_files": None,
|
||
}
|
||
|
||
|
||
def _cache_get_model(key):
|
||
if _cache["model_key"] == key and _cache["model"] is not None:
|
||
return _cache["model"]
|
||
return None
|
||
|
||
|
||
def _cache_set_model(key, model):
|
||
_cache["model_key"] = key
|
||
_cache["model"] = model
|
||
|
||
|
||
def _cache_get_clip(key):
|
||
if _cache["clip_key"] == key and _cache["clip"] is not None:
|
||
return _cache["clip"]
|
||
return None
|
||
|
||
|
||
def _cache_set_clip(key, clip, clip_files):
|
||
_cache["clip_key"] = key
|
||
_cache["clip"] = clip
|
||
_cache["clip_files"] = clip_files
|
||
|
||
|
||
def _cache_get_vae(key):
|
||
if _cache["vae_key"] == key and _cache["vae"] is not None:
|
||
return _cache["vae"]
|
||
return None
|
||
|
||
|
||
def _cache_set_vae(key, vae):
|
||
_cache["vae_key"] = key
|
||
_cache["vae"] = vae
|
||
|
||
|
||
def _cache_clear():
|
||
"""Clear all cached models to free memory."""
|
||
_cache["model_key"] = None
|
||
_cache["clip_key"] = None
|
||
_cache["vae_key"] = None
|
||
_cache["model"] = None
|
||
_cache["clip"] = None
|
||
_cache["vae"] = None
|
||
_cache["clip_files"] = None
|
||
comfy.model_management.cleanup_models()
|
||
comfy.model_management.soft_empty_cache()
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# API endpoints
|
||
# ──────────────────────────────────────────────────────────
|
||
|
||
@server.PromptServer.instance.routes.get("/the_last_model_switcher/presets")
|
||
async def get_presets_api(request):
|
||
return web.json_response(load_presets())
|
||
|
||
@server.PromptServer.instance.routes.get("/the_last_model_switcher/preset_info")
|
||
async def get_preset_info_api(request):
|
||
preset_name = request.query.get("name", "")
|
||
presets = load_presets()
|
||
if preset_name not in presets or presets[preset_name] is None:
|
||
return web.json_response({"error": f"Not found: {preset_name}"}, status=404)
|
||
cfg = presets[preset_name]
|
||
sampler = cfg.get("sampler", {})
|
||
is_flux = cfg.get("apply_model_sampling_flux", False)
|
||
return web.json_response({
|
||
"name": preset_name,
|
||
"description": cfg.get("description", ""),
|
||
"diffusion_model": cfg.get("diffusion_model") or cfg.get("checkpoint", "N/A"),
|
||
"clip_type": cfg.get("clip_type", "N/A"),
|
||
"vae": cfg.get("vae") or "from checkpoint",
|
||
"resolutions": cfg.get("resolutions", {}),
|
||
"default_resolution": cfg.get("default_resolution", ""),
|
||
"compatible_clips": cfg.get("compatible_clips", {}),
|
||
"sampler": sampler.get("sampler_name", "euler"),
|
||
"scheduler": sampler.get("scheduler", "normal"),
|
||
"steps": sampler.get("steps", 20),
|
||
"cfg": sampler.get("cfg", 1.0),
|
||
"missing_files": _validate_preset(preset_name, cfg),
|
||
"is_gguf": _is_gguf(cfg.get("diffusion_model", "")),
|
||
"is_checkpoint": bool(cfg.get("checkpoint")),
|
||
"guidance": cfg.get("guidance", 0.0),
|
||
"is_flux": is_flux,
|
||
"megapixels": cfg.get("megapixels", 1.0),
|
||
"negative_prompt_supported": cfg.get("negative_prompt_supported", not is_flux),
|
||
"info_text": cfg.get("info_text", ""),
|
||
"supported_megapixels": cfg.get("supported_megapixels", []),
|
||
"default_megapixels": cfg.get("default_megapixels", ""),
|
||
})
|
||
|
||
@server.PromptServer.instance.routes.get("/the_last_model_switcher/reload")
|
||
async def reload_presets_api(request):
|
||
presets = load_presets()
|
||
return web.json_response({"count": len(presets), "names": list(presets.keys())})
|
||
|
||
@server.PromptServer.instance.routes.get("/the_last_model_switcher/presets_path")
|
||
async def get_presets_path_api(request):
|
||
return web.json_response({"path": os.path.abspath(PRESETS_FILE)})
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# AI features (multi-provider: Anthropic, OpenAI, or any
|
||
# OpenAI-compatible API like Ollama, LM Studio, etc.)
|
||
# ──────────────────────────────────────────────────────────
|
||
|
||
# Settings keys stored in ComfyUI's settings system
|
||
_SETTINGS_KEYS = {
|
||
"provider": "tlms.ai_provider", # "anthropic", "openai", "custom"
|
||
"api_key": "tlms.ai_api_key", # API key
|
||
"base_url": "tlms.ai_base_url", # Custom base URL (for custom provider)
|
||
"model": "tlms.ai_model", # Model name override
|
||
}
|
||
|
||
# Defaults per provider
|
||
_PROVIDER_DEFAULTS = {
|
||
"anthropic": {
|
||
"base_url": "https://api.anthropic.com/v1/messages",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"env_key": "ANTHROPIC_API_KEY",
|
||
"requires_key": True,
|
||
},
|
||
"openai": {
|
||
"base_url": "https://api.openai.com/v1/chat/completions",
|
||
"model": "gpt-4o-mini",
|
||
"env_key": "OPENAI_API_KEY",
|
||
"requires_key": True,
|
||
},
|
||
"ollama": {
|
||
"base_url": "http://localhost:11434/v1/chat/completions",
|
||
"model": "llama3.2",
|
||
"env_key": "",
|
||
"requires_key": False,
|
||
},
|
||
"custom": {
|
||
"base_url": "http://localhost:11434/v1/chat/completions",
|
||
"model": "llama3.2",
|
||
"env_key": "",
|
||
"requires_key": False,
|
||
},
|
||
}
|
||
|
||
|
||
def _get_ai_settings() -> dict:
|
||
"""Get AI settings from ComfyUI settings or environment."""
|
||
settings = {}
|
||
try:
|
||
user_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "user", "default")
|
||
settings_file = os.path.join(user_dir, "comfy.settings.json")
|
||
if os.path.exists(settings_file):
|
||
with open(settings_file, "r") as f:
|
||
all_settings = json.load(f)
|
||
for key, setting_id in _SETTINGS_KEYS.items():
|
||
settings[key] = all_settings.get(setting_id, "")
|
||
except Exception:
|
||
pass
|
||
return settings
|
||
|
||
|
||
def _resolve_ai_config() -> dict:
|
||
"""Resolve AI config from saved settings and environment variables.
|
||
|
||
Security: API keys are NEVER accepted from HTTP request bodies.
|
||
They can only come from ComfyUI's settings file or env vars."""
|
||
settings = _get_ai_settings()
|
||
provider = settings.get("provider") or "anthropic"
|
||
defaults = _PROVIDER_DEFAULTS.get(provider, _PROVIDER_DEFAULTS["anthropic"])
|
||
|
||
api_key = settings.get("api_key") or ""
|
||
if not api_key and defaults["env_key"]:
|
||
api_key = os.environ.get(defaults["env_key"], "")
|
||
|
||
return {
|
||
"provider": provider,
|
||
"api_key": api_key,
|
||
"base_url": settings.get("base_url") or defaults["base_url"],
|
||
"model": settings.get("model") or defaults["model"],
|
||
}
|
||
|
||
|
||
def _call_ai(prompt_text: str, config: dict, max_tokens: int = 500) -> str:
|
||
"""Call AI API (Anthropic or OpenAI-compatible). Returns response text.
|
||
|
||
Supports:
|
||
- anthropic Claude via Anthropic API (needs API key)
|
||
- openai OpenAI GPT models (needs API key)
|
||
- ollama Local LLM via Ollama (no key, OpenAI-compatible endpoint)
|
||
- custom Any OpenAI-compatible endpoint (LM Studio, etc.)
|
||
"""
|
||
provider = config["provider"]
|
||
api_key = config["api_key"]
|
||
base_url = config["base_url"]
|
||
model = config["model"]
|
||
|
||
if provider == "anthropic":
|
||
req_body = json.dumps({
|
||
"model": model,
|
||
"max_tokens": max_tokens,
|
||
"messages": [{"role": "user", "content": prompt_text}],
|
||
}).encode("utf-8")
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"x-api-key": api_key,
|
||
"anthropic-version": "2023-06-01",
|
||
}
|
||
else:
|
||
# OpenAI-compatible format (OpenAI, Ollama, LM Studio, vLLM, etc.)
|
||
req_body = json.dumps({
|
||
"model": model,
|
||
"max_tokens": max_tokens,
|
||
"messages": [{"role": "user", "content": prompt_text}],
|
||
}).encode("utf-8")
|
||
headers = {"Content-Type": "application/json"}
|
||
if api_key:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
|
||
# Local providers may need longer timeout (first request loads the model)
|
||
is_local = provider in ("ollama", "custom") or "localhost" in base_url or "127.0.0.1" in base_url
|
||
timeout = 120 if is_local else 30
|
||
|
||
req = urllib.request.Request(base_url, data=req_body, headers=headers)
|
||
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
resp_data = json.loads(resp.read().decode("utf-8"))
|
||
|
||
# Extract text from response (handle both Anthropic and OpenAI formats)
|
||
if provider == "anthropic":
|
||
return resp_data.get("content", [{}])[0].get("text", "")
|
||
else:
|
||
return resp_data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
|
||
|
||
def _parse_ai_json(text: str) -> dict:
|
||
"""Parse JSON from AI response, handling markdown code blocks."""
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||
if text.endswith("```"):
|
||
text = text[:-3]
|
||
text = text.strip()
|
||
return json.loads(text)
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/the_last_model_switcher/ai_settings")
|
||
async def get_ai_settings_api(request):
|
||
"""Return current AI settings. The API key itself is never returned -
|
||
only a boolean indicating whether one is configured."""
|
||
config = _resolve_ai_config()
|
||
provider_defaults = _PROVIDER_DEFAULTS.get(config["provider"], {})
|
||
return web.json_response({
|
||
"provider": config["provider"],
|
||
"has_key": bool(config["api_key"]),
|
||
"requires_key": provider_defaults.get("requires_key", True),
|
||
"base_url": config["base_url"],
|
||
"model": config["model"],
|
||
"providers": list(_PROVIDER_DEFAULTS.keys()),
|
||
})
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/the_last_model_switcher/ai_test")
|
||
async def ai_test_api(request):
|
||
"""Test AI connection by sending a minimal prompt."""
|
||
config = _resolve_ai_config()
|
||
provider_defaults = _PROVIDER_DEFAULTS.get(config["provider"], {})
|
||
|
||
if not config["api_key"] and provider_defaults.get("requires_key", True):
|
||
return web.json_response({
|
||
"ok": False,
|
||
"error": f"No API key configured for {config['provider']}.",
|
||
}, status=400)
|
||
|
||
try:
|
||
response = _call_ai(
|
||
"Reply with just the word: OK",
|
||
config,
|
||
max_tokens=10,
|
||
)
|
||
return web.json_response({
|
||
"ok": True,
|
||
"provider": config["provider"],
|
||
"model": config["model"],
|
||
"response": response.strip()[:100],
|
||
})
|
||
except urllib.error.URLError as e:
|
||
hint = ""
|
||
if config["provider"] in ("ollama", "custom"):
|
||
hint = " (is Ollama running? Try: ollama serve)"
|
||
return web.json_response({
|
||
"ok": False,
|
||
"error": f"Connection failed: {e.reason if hasattr(e, 'reason') else e}{hint}",
|
||
}, status=500)
|
||
except Exception as e:
|
||
return web.json_response({
|
||
"ok": False,
|
||
"error": f"Test failed: {str(e)[:300]}",
|
||
}, status=500)
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/the_last_model_switcher/ai_identify")
|
||
async def ai_identify_api(request):
|
||
"""Use AI to identify a model and suggest optimal settings."""
|
||
data = await request.json()
|
||
preset_name = data.get("preset_name", "")
|
||
config = _resolve_ai_config()
|
||
|
||
provider_defaults = _PROVIDER_DEFAULTS.get(config["provider"], {})
|
||
if not config["api_key"] and provider_defaults.get("requires_key", True):
|
||
return web.json_response({
|
||
"error": f"No API key configured for {config['provider']}. "
|
||
f"Click 'AI Settings' to set up your AI provider.",
|
||
}, status=400)
|
||
|
||
presets = load_presets()
|
||
if preset_name not in presets or presets[preset_name] is None:
|
||
return web.json_response({"error": f"Preset not found: {preset_name}"}, status=404)
|
||
|
||
pcfg = presets[preset_name]
|
||
model_file = pcfg.get("diffusion_model") or pcfg.get("checkpoint", "")
|
||
|
||
# Gather model info for the AI
|
||
filepath = None
|
||
for folder_key in ("checkpoints", "diffusion_models", "unet"):
|
||
try:
|
||
p = folder_paths.get_full_path(folder_key, model_file)
|
||
if p and os.path.exists(p):
|
||
filepath = p
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
file_size_gb = 0
|
||
sample_keys = []
|
||
if filepath:
|
||
file_size_gb = round(os.path.getsize(filepath) / (1024**3), 2)
|
||
keys = _read_safetensors_keys(filepath)
|
||
# Send a sample of keys for architecture identification
|
||
sample_keys = keys[:30] + keys[-10:] if len(keys) > 40 else keys
|
||
|
||
prompt = f"""You are an expert on AI image generation models used in ComfyUI. Identify this model and provide EXACT optimal settings.
|
||
|
||
MODEL INFO:
|
||
- Filename: {model_file}
|
||
- Preset name: {preset_name}
|
||
- File size: {file_size_gb} GB
|
||
- Current detected type: {pcfg.get('clip_type', 'unknown')}
|
||
- Current description: {pcfg.get('description', '')}
|
||
- Sample tensor keys: {json.dumps(sample_keys[:20])}
|
||
- Total tensor count: {len(sample_keys)}
|
||
|
||
Based on the filename and architecture, identify this model and respond with a JSON object (ONLY JSON, no markdown, no explanation) with these exact fields:
|
||
{{
|
||
"model_name": "Human-readable name for this model",
|
||
"description": "One-line description of what this model is and what it's good at",
|
||
"sampler_name": "recommended sampler (euler, euler_ancestral, dpm_2, dpmpp_2m, dpmpp_sde, uni_pc, etc.)",
|
||
"scheduler": "recommended scheduler (normal, simple, karras, sgm_uniform, etc.)",
|
||
"steps": recommended_steps_as_integer,
|
||
"cfg": recommended_cfg_as_float,
|
||
"guidance": flux_guidance_as_float_or_0,
|
||
"negative_prompt_supported": true_or_false,
|
||
"info_text": "Detailed recommendation text including tips, what this model is best at, prompt style tips, etc.",
|
||
"confidence": "high/medium/low - how confident you are in this identification"
|
||
}}
|
||
|
||
Be precise. Use common knowledge about popular models. If the filename matches a known model (e.g. dreamshaperXL, realvisxl, juggernaut, pony, animagine, flux, sd3.5, etc.), use the widely-known optimal settings for that specific model."""
|
||
|
||
try:
|
||
ai_text = _call_ai(prompt, config)
|
||
ai_result = _parse_ai_json(ai_text)
|
||
|
||
# Update preset with AI suggestions
|
||
if ai_result.get("sampler_name"):
|
||
pcfg["sampler"]["sampler_name"] = ai_result["sampler_name"]
|
||
if ai_result.get("scheduler"):
|
||
pcfg["sampler"]["scheduler"] = ai_result["scheduler"]
|
||
if ai_result.get("steps"):
|
||
pcfg["sampler"]["steps"] = int(ai_result["steps"])
|
||
if "cfg" in ai_result:
|
||
pcfg["sampler"]["cfg"] = float(ai_result["cfg"])
|
||
if "guidance" in ai_result:
|
||
pcfg["guidance"] = float(ai_result["guidance"])
|
||
if "negative_prompt_supported" in ai_result:
|
||
pcfg["negative_prompt_supported"] = bool(ai_result["negative_prompt_supported"])
|
||
if ai_result.get("description"):
|
||
pcfg["description"] = ai_result["description"]
|
||
if ai_result.get("info_text"):
|
||
pcfg["info_text"] = ai_result["info_text"]
|
||
|
||
# Save updated presets
|
||
with open(PRESETS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(presets, f, indent=4, ensure_ascii=False)
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"ai_result": ai_result,
|
||
"updated_preset": preset_name,
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return web.json_response({"error": f"Could not parse AI response as JSON: {ai_text[:200]}"}, status=500)
|
||
except Exception as e:
|
||
return web.json_response({"error": f"AI identification failed: {str(e)}"}, status=500)
|
||
|
||
|
||
@server.PromptServer.instance.routes.post("/the_last_model_switcher/enhance_prompt")
|
||
async def enhance_prompt_api(request):
|
||
"""Use AI to enhance/rewrite a prompt for the selected model."""
|
||
data = await request.json()
|
||
prompt_text = data.get("prompt", "")
|
||
model_name = data.get("model_name", "")
|
||
clip_type = data.get("clip_type", "")
|
||
style = data.get("style", "enhance")
|
||
custom_instruction = data.get("custom_instruction", "")
|
||
config = _resolve_ai_config()
|
||
|
||
provider_defaults = _PROVIDER_DEFAULTS.get(config["provider"], {})
|
||
if not config["api_key"] and provider_defaults.get("requires_key", True):
|
||
return web.json_response({
|
||
"error": f"No API key configured for {config['provider']}. "
|
||
f"Click 'AI Settings' to set up your AI provider.",
|
||
}, status=400)
|
||
if not prompt_text.strip():
|
||
return web.json_response({"error": "No prompt to enhance."}, status=400)
|
||
|
||
# Build style instruction
|
||
style_instructions = {
|
||
"enhance": "Enhance and add detail to this prompt while keeping the original intent. Add descriptive details about lighting, composition, style, and quality.",
|
||
"detailed": "Expand this into a highly detailed, verbose prompt with extensive descriptions of every element - lighting, textures, materials, atmosphere, camera angle, etc.",
|
||
"concise": "Rewrite this as a concise, efficient prompt. Remove fluff, keep only the most impactful descriptors.",
|
||
"creative": "Take creative liberties with this prompt. Add unexpected artistic choices, unique style combinations, and creative interpretations while keeping the core subject.",
|
||
"fix": "Fix and improve this prompt. Correct any issues with prompt structure, remove contradictions, and optimize for best results.",
|
||
}
|
||
|
||
style_text = style_instructions.get(style, style_instructions["enhance"])
|
||
if custom_instruction:
|
||
style_text = custom_instruction
|
||
|
||
# Model-specific prompt advice
|
||
model_advice = ""
|
||
if "pony" in model_name.lower() or "animagine" in model_name.lower():
|
||
model_advice = "This model uses Danbooru-style tags. Use comma-separated tags like: 1girl, solo, long_hair, blue_eyes, smile, outdoors, masterpiece, best quality. Start with quality tags (score_9, score_8_up, masterpiece)."
|
||
elif "flux" in clip_type.lower():
|
||
model_advice = "Flux models work best with natural language descriptions, not tag-based prompts. Write flowing sentences. No need for quality boosters like 'masterpiece' or 'best quality'."
|
||
elif clip_type in ("sdxl", "stable_diffusion"):
|
||
model_advice = "This SD/SDXL model works well with both natural language and weighted tags. Quality boosters help: 'masterpiece, best quality, highly detailed'. Use (emphasis:1.2) for weight."
|
||
|
||
ai_prompt = f"""You are an expert AI image generation prompt writer. {style_text}
|
||
|
||
MODEL: {model_name}
|
||
CLIP TYPE: {clip_type}
|
||
{f"MODEL-SPECIFIC ADVICE: {model_advice}" if model_advice else ""}
|
||
|
||
USER'S ORIGINAL PROMPT:
|
||
{prompt_text}
|
||
|
||
Write ONLY the enhanced prompt text. No explanations, no quotes, no markdown. Just the prompt ready to paste."""
|
||
|
||
try:
|
||
enhanced = _call_ai(ai_prompt, config).strip()
|
||
return web.json_response({"enhanced": enhanced, "style": style})
|
||
|
||
except Exception as e:
|
||
return web.json_response({"error": f"AI enhance failed: {str(e)}"}, status=500)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# Model auto-detection (reads safetensors header only)
|
||
# ──────────────────────────────────────────────────────────
|
||
|
||
def _read_safetensors_keys(filepath: str) -> list[str]:
|
||
"""Read tensor key names from safetensors header without loading weights.
|
||
Uses raw header parsing (no safetensors library needed, instant)."""
|
||
try:
|
||
with open(filepath, "rb") as f:
|
||
header_len = struct.unpack("<Q", f.read(8))[0]
|
||
if header_len > 100_000_000: # Sanity check: header > 100MB is invalid
|
||
return []
|
||
header_json = f.read(header_len).decode("utf-8")
|
||
header = json.loads(header_json)
|
||
return [k for k in header.keys() if k != "__metadata__"]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _detect_model_type(filepath: str) -> dict | None:
|
||
"""Detect model architecture from safetensors header keys.
|
||
Returns a preset template dict or None if unknown."""
|
||
|
||
if _is_gguf(filepath):
|
||
return None # Can't inspect GGUF headers easily
|
||
|
||
keys = _read_safetensors_keys(filepath)
|
||
if not keys:
|
||
return None
|
||
|
||
filename = os.path.basename(filepath)
|
||
name_lower = filename.lower()
|
||
|
||
# ── Check if it's a full checkpoint (has embedded UNet + CLIP) ──
|
||
is_checkpoint = any(k.startswith("model.diffusion_model.") for k in keys)
|
||
has_conditioner = any(k.startswith("conditioner.") for k in keys)
|
||
has_cond_stage = any(k.startswith("cond_stage_model.") for k in keys)
|
||
|
||
# ── Check diffusion model architecture ──
|
||
has_joint_blocks = any(k.startswith("joint_blocks.") for k in keys)
|
||
has_double_blocks = any(k.startswith("double_blocks.") for k in keys)
|
||
has_single_blocks = any(k.startswith("single_blocks.") for k in keys)
|
||
has_input_blocks = any(k.startswith("input_blocks.") or k.startswith("model.diffusion_model.input_blocks.") for k in keys)
|
||
|
||
# Flux-specific detection (ComfyUI's actual detection logic from model_detection.py)
|
||
has_img_attn_norm = any("double_blocks.0.img_attn.norm.key_norm." in k for k in keys)
|
||
has_img_in = any(k == "img_in.weight" or k.endswith(".img_in.weight") for k in keys)
|
||
is_flux_arch = has_double_blocks and has_single_blocks and has_img_attn_norm and has_img_in
|
||
|
||
# Flux 2 vs Flux 1: the definitive differentiator (model_detection.py line 198)
|
||
has_double_stream_modulation = any("double_stream_modulation_img" in k for k in keys)
|
||
|
||
# Flux Dev vs Schnell: guidance layer presence (model_detection.py line 278)
|
||
has_guidance_embed = any(k.startswith("guidance_in.") or k.endswith(".guidance_in.in_layer.weight") for k in keys)
|
||
|
||
# Exclude other architectures that share double_blocks/single_blocks
|
||
is_hunyuan_video = any("individual_token_refiner" in k for k in keys)
|
||
is_chroma = any("distilled_guidance_layer" in k for k in keys)
|
||
|
||
# ── Detect distilled/turbo/lightning variants from filename ──
|
||
is_turbo = "turbo" in name_lower
|
||
is_lightning = "lightning" in name_lower
|
||
is_lcm = "lcm" in name_lower
|
||
is_distilled = is_turbo or is_lightning or is_lcm or "distill" in name_lower
|
||
|
||
# Detect step count from filename (e.g. "4step", "8step")
|
||
step_match = re.search(r"(\d+)\s*step", name_lower)
|
||
distilled_steps = int(step_match.group(1)) if step_match else (4 if is_turbo or is_lightning else 8 if is_lcm else 0)
|
||
|
||
# ── SDXL checkpoint (must have 2+ conditioner embedders to exclude SD2) ──
|
||
has_dual_conditioner = any(k.startswith("conditioner.embedders.1.") for k in keys)
|
||
if is_checkpoint and has_conditioner and has_dual_conditioner:
|
||
# Adjust settings for distilled variants
|
||
if is_distilled:
|
||
sampler_cfg = {"sampler_name": "euler", "scheduler": "sgm_uniform",
|
||
"steps": distilled_steps or 4, "cfg": 1.0}
|
||
variant = "Lightning" if is_lightning else "Turbo" if is_turbo else "LCM" if is_lcm else "Distilled"
|
||
info = f"Auto-detected SDXL {variant}. euler/sgm_uniform, {distilled_steps or 4} steps, CFG 1.0. No negative prompt."
|
||
neg_support = False
|
||
else:
|
||
sampler_cfg = {"sampler_name": "euler", "scheduler": "normal", "steps": 25, "cfg": 6.0}
|
||
info = "Auto-detected SDXL checkpoint. euler/normal, 25 steps, CFG 6.0."
|
||
neg_support = True
|
||
|
||
return {
|
||
"description": f"SDXL checkpoint (auto-detected from {filename})",
|
||
"checkpoint": filename,
|
||
"clip_type": "sdxl",
|
||
"default_clip": None,
|
||
"compatible_clips": {},
|
||
"resolutions": {
|
||
"1:1 Square (1024x1024)": [1024, 1024],
|
||
"3:2 Photo (1216x832)": [1216, 832],
|
||
"2:3 Portrait (832x1216)": [832, 1216],
|
||
"16:9 Wide (1344x768)": [1344, 768],
|
||
"9:16 Tall (768x1344)": [768, 1344],
|
||
"4:3 Standard (1152x896)": [1152, 896],
|
||
"3:4 Portrait Standard (896x1152)": [896, 1152],
|
||
},
|
||
"default_resolution": "1:1 Square (1024x1024)",
|
||
"megapixels": 1.0,
|
||
"sampler": sampler_cfg,
|
||
"guidance": 0.0,
|
||
"apply_model_sampling_flux": False,
|
||
"negative_prompt_supported": neg_support,
|
||
"info_text": info,
|
||
}
|
||
|
||
# ── SD 1.5 checkpoint ──
|
||
if is_checkpoint and (has_cond_stage or has_input_blocks):
|
||
return {
|
||
"description": f"SD 1.5 checkpoint (auto-detected from {filename})",
|
||
"checkpoint": filename,
|
||
"clip_type": "stable_diffusion",
|
||
"default_clip": None,
|
||
"compatible_clips": {},
|
||
"resolutions": {
|
||
"1:1 Square (512x512)": [512, 512],
|
||
"3:2 Photo (768x512)": [768, 512],
|
||
"2:3 Portrait (512x768)": [512, 768],
|
||
"16:9 Wide (912x512)": [912, 512],
|
||
"9:16 Tall (512x912)": [512, 912],
|
||
},
|
||
"default_resolution": "1:1 Square (512x512)",
|
||
"megapixels": 0.26,
|
||
"sampler": {"sampler_name": "euler_ancestral", "scheduler": "normal", "steps": 25, "cfg": 7.0},
|
||
"guidance": 0.0,
|
||
"apply_model_sampling_flux": False,
|
||
"negative_prompt_supported": True,
|
||
"info_text": "Auto-detected SD 1.5 checkpoint. euler_ancestral/normal, 25 steps, CFG 7.0.",
|
||
}
|
||
|
||
# ── SD3 / SD3.5 diffusion model ──
|
||
if has_joint_blocks:
|
||
if is_distilled:
|
||
sd3_sampler = {"sampler_name": "euler", "scheduler": "simple",
|
||
"steps": distilled_steps or 4, "cfg": 1.0}
|
||
sd3_info = f"Auto-detected SD3 Turbo. euler/simple, {distilled_steps or 4} steps, CFG 1.0."
|
||
sd3_neg = False
|
||
else:
|
||
sd3_sampler = {"sampler_name": "euler", "scheduler": "simple", "steps": 28, "cfg": 4.5}
|
||
sd3_info = "Auto-detected SD3/SD3.5. euler/simple, 28 steps, CFG 4.5."
|
||
sd3_neg = True
|
||
|
||
return {
|
||
"description": f"SD3/SD3.5 model (auto-detected from {filename})",
|
||
"diffusion_model": filename,
|
||
"vae": "sd3_vae.safetensors",
|
||
"clip_type": "sd3",
|
||
"default_clip": ["clip_l.safetensors", "clip_g.safetensors", "t5xxl_fp8_e4m3fn_scaled.safetensors"],
|
||
"compatible_clips": {
|
||
"clip-l + clip-g + T5 XXL (fp8)": ["clip_l.safetensors", "clip_g.safetensors", "t5xxl_fp8_e4m3fn_scaled.safetensors"],
|
||
"clip-l + clip-g + T5 XXL (fp16)": ["clip_l.safetensors", "clip_g.safetensors", "t5xxl_fp16.safetensors"],
|
||
"clip-l + clip-g (no T5)": ["clip_l.safetensors", "clip_g.safetensors"],
|
||
},
|
||
"resolutions": {
|
||
"1:1 Square (1024x1024)": [1024, 1024],
|
||
"3:2 Photo (1216x832)": [1216, 832],
|
||
"2:3 Portrait (832x1216)": [832, 1216],
|
||
"16:9 Wide (1344x768)": [1344, 768],
|
||
"9:16 Tall (768x1344)": [768, 1344],
|
||
},
|
||
"default_resolution": "1:1 Square (1024x1024)",
|
||
"megapixels": 1.0,
|
||
"sampler": sd3_sampler,
|
||
"guidance": 0.0,
|
||
"apply_model_sampling_flux": False,
|
||
"negative_prompt_supported": sd3_neg,
|
||
"info_text": sd3_info,
|
||
}
|
||
|
||
# ── Flux architecture ──
|
||
# Must be actual Flux (not HunyuanVideo, Chroma, etc. which share double_blocks)
|
||
if is_flux_arch and not is_hunyuan_video and not is_chroma:
|
||
|
||
if has_double_stream_modulation:
|
||
return {
|
||
"description": f"Flux 2 model (auto-detected from {filename})",
|
||
"diffusion_model": filename,
|
||
"vae": "flux2-vae.safetensors",
|
||
"clip_type": "flux2",
|
||
"default_clip": ["qwen_3_4b_fp8_mixed.safetensors"],
|
||
"compatible_clips": {
|
||
"Qwen 3 4B (fp8)": ["qwen_3_4b_fp8_mixed.safetensors"],
|
||
"Qwen 3 4B (bf16)": ["qwen_3_4b.safetensors"],
|
||
"Mistral 3 Small (bf16)": ["mistral_3_small_flux2_bf16.safetensors"],
|
||
},
|
||
"resolutions": {
|
||
"1:1 Square (1024x1024)": [1024, 1024],
|
||
"3:2 Photo (1216x832)": [1216, 832],
|
||
"2:3 Portrait (832x1216)": [832, 1216],
|
||
"16:9 Wide (1344x768)": [1344, 768],
|
||
"9:16 Tall (768x1344)": [768, 1344],
|
||
"4:3 Standard (1152x896)": [1152, 896],
|
||
"3:4 Portrait Standard (896x1152)": [896, 1152],
|
||
},
|
||
"default_resolution": "1:1 Square (1024x1024)",
|
||
"megapixels": 1.0,
|
||
"supported_megapixels": ["0.25 MP", "0.5 MP", "1.0 MP", "1.5 MP", "2.0 MP", "4.0 MP"],
|
||
"default_megapixels": "1.0 MP",
|
||
"sampler": {"sampler_name": "euler", "scheduler": "simple", "steps": 20, "cfg": 1.0},
|
||
"guidance": 3.5,
|
||
"apply_model_sampling_flux": True,
|
||
"negative_prompt_supported": False,
|
||
"info_text": "Auto-detected Flux 2. euler/simple, 20 steps, CFG 1.0, guidance 3.5.",
|
||
}
|
||
else:
|
||
# Flux 1
|
||
is_schnell = not has_guidance_embed
|
||
return {
|
||
"description": f"Flux 1 {'Schnell' if is_schnell else 'Dev'} (auto-detected from {filename})",
|
||
"diffusion_model": filename,
|
||
"vae": "ae.safetensors",
|
||
"clip_type": "flux",
|
||
"default_clip": ["clip_l.safetensors", "t5xxl_fp16.safetensors"],
|
||
"compatible_clips": {
|
||
"clip-l + T5 XXL (fp16)": ["clip_l.safetensors", "t5xxl_fp16.safetensors"],
|
||
"clip-l + T5 XXL (fp8)": ["clip_l.safetensors", "t5xxl_fp8_e4m3fn_scaled.safetensors"],
|
||
},
|
||
"resolutions": {
|
||
"1:1 Square (1024x1024)": [1024, 1024],
|
||
"3:2 Photo (1216x832)": [1216, 832],
|
||
"2:3 Portrait (832x1216)": [832, 1216],
|
||
"16:9 Wide (1344x768)": [1344, 768],
|
||
"9:16 Tall (768x1344)": [768, 1344],
|
||
},
|
||
"default_resolution": "1:1 Square (1024x1024)",
|
||
"megapixels": 1.0,
|
||
"sampler": {"sampler_name": "euler", "scheduler": "simple",
|
||
"steps": 4 if is_schnell else 20, "cfg": 1.0},
|
||
"guidance": 0.0 if is_schnell else 3.5,
|
||
"apply_model_sampling_flux": not is_schnell,
|
||
"negative_prompt_supported": False,
|
||
"info_text": f"Auto-detected Flux 1 {'Schnell (4 steps)' if is_schnell else 'Dev (20 steps)'}.",
|
||
}
|
||
|
||
return None
|
||
|
||
|
||
def _get_all_model_files() -> list[tuple[str, str, str]]:
|
||
"""Return list of (filename, full_path, folder_type) for all model files."""
|
||
files = []
|
||
for folder_key in ("checkpoints", "diffusion_models", "unet"):
|
||
try:
|
||
for name in folder_paths.get_filename_list(folder_key):
|
||
if name.lower().endswith((".safetensors", ".gguf", ".ckpt")):
|
||
try:
|
||
path = folder_paths.get_full_path(folder_key, name)
|
||
if path and os.path.exists(path):
|
||
files.append((name, path, folder_key))
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return files
|
||
|
||
|
||
def _make_preset_name(filename: str, model_type: str) -> str:
|
||
"""Generate a clean preset name from filename."""
|
||
name = os.path.splitext(filename)[0]
|
||
# Clean up common suffixes
|
||
for suffix in ("_fp8", "_fp16", "_bf16", "-fp8", "-fp16", "-bf16",
|
||
"_fp8mixed", "_fp8_e4m3fn", "_fp8_scaled",
|
||
"_fp8_e4m3fn_scaled", ".fp8", ".fp16"):
|
||
if name.lower().endswith(suffix):
|
||
name = name[:len(name) - len(suffix)]
|
||
# Replace underscores/hyphens with spaces, title case
|
||
name = name.replace("_", " ").replace("-", " ")
|
||
# Add type suffix
|
||
name = f"{name} ({model_type})"
|
||
return name
|
||
|
||
|
||
@server.PromptServer.instance.routes.get("/the_last_model_switcher/scan")
|
||
async def scan_models_api(request):
|
||
"""Scan model directories, detect new models, add to presets."""
|
||
presets = load_presets()
|
||
all_files = _get_all_model_files()
|
||
|
||
# Find files already in presets
|
||
known_files = set()
|
||
for name, cfg in presets.items():
|
||
if cfg is None:
|
||
continue
|
||
if cfg.get("checkpoint"):
|
||
known_files.add(cfg["checkpoint"])
|
||
if cfg.get("diffusion_model"):
|
||
known_files.add(cfg["diffusion_model"])
|
||
|
||
added = []
|
||
skipped = []
|
||
|
||
for filename, filepath, folder_key in all_files:
|
||
if filename in known_files:
|
||
continue
|
||
|
||
detected = _detect_model_type(filepath)
|
||
if detected is None:
|
||
skipped.append(filename)
|
||
continue
|
||
|
||
# Generate preset name
|
||
if detected.get("checkpoint"):
|
||
model_type = "SDXL" if detected.get("clip_type") == "sdxl" else "SD 1.5"
|
||
elif "flux2" in detected.get("clip_type", ""):
|
||
model_type = "Flux 2"
|
||
elif "flux" in detected.get("clip_type", ""):
|
||
model_type = "Flux 1"
|
||
elif "sd3" in detected.get("clip_type", ""):
|
||
model_type = "SD3"
|
||
else:
|
||
model_type = "auto"
|
||
|
||
preset_name = _make_preset_name(filename, model_type)
|
||
|
||
# Avoid duplicate names
|
||
if preset_name in presets:
|
||
preset_name = f"{preset_name} [{filename[:8]}]"
|
||
|
||
presets[preset_name] = detected
|
||
known_files.add(filename)
|
||
added.append({"name": preset_name, "file": filename, "type": model_type})
|
||
|
||
# Save updated presets
|
||
if added:
|
||
with open(PRESETS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(presets, f, indent=4, ensure_ascii=False)
|
||
|
||
return web.json_response({
|
||
"added": added,
|
||
"skipped": skipped,
|
||
"total_presets": len([k for k, v in presets.items() if v is not None]),
|
||
})
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# Helpers
|
||
# ──────────────────────────────────────────────────────────
|
||
|
||
def _file_exists(folder_key: str, filename: str) -> bool:
|
||
try:
|
||
path = folder_paths.get_full_path(folder_key, filename)
|
||
return path is not None and os.path.exists(path)
|
||
except Exception:
|
||
return False
|
||
|
||
def _resolve_clip_path(filename: str) -> str:
|
||
for key in ("text_encoders", "clip"):
|
||
path = folder_paths.get_full_path(key, filename)
|
||
if path and os.path.exists(path):
|
||
return path
|
||
return folder_paths.get_full_path_or_raise("text_encoders", filename)
|
||
|
||
def _validate_preset(name: str, cfg: dict) -> list[str]:
|
||
missing = []
|
||
if cfg.get("checkpoint") and not _file_exists("checkpoints", cfg["checkpoint"]):
|
||
missing.append(cfg["checkpoint"])
|
||
if cfg.get("diffusion_model"):
|
||
dm = cfg["diffusion_model"]
|
||
found = _file_exists("diffusion_models", dm)
|
||
if not found:
|
||
try:
|
||
p = folder_paths.get_full_path("unet", dm)
|
||
found = p is not None and os.path.exists(p)
|
||
except Exception:
|
||
pass
|
||
if not found:
|
||
missing.append(dm)
|
||
if cfg.get("vae") and not _file_exists("vae", cfg["vae"]):
|
||
missing.append(cfg["vae"])
|
||
return missing
|
||
|
||
|
||
def _build_dynamic_options() -> list[io.DynamicCombo.Option]:
|
||
presets = load_presets()
|
||
options = []
|
||
for name, cfg in presets.items():
|
||
if cfg is None:
|
||
continue
|
||
missing = _validate_preset(name, cfg)
|
||
if missing:
|
||
continue
|
||
sub_inputs = []
|
||
|
||
clips = cfg.get("compatible_clips", {})
|
||
if clips:
|
||
clip_names = list(clips.keys())
|
||
default_key = clip_names[0]
|
||
dc = cfg.get("default_clip")
|
||
if dc:
|
||
for cn, cf in clips.items():
|
||
if cf == dc:
|
||
default_key = cn
|
||
break
|
||
sub_inputs.append(io.Combo.Input("clip_variant", options=clip_names,
|
||
default=default_key, tooltip="Text encoder / CLIP variant for this model."))
|
||
|
||
resolutions = cfg.get("resolutions", {})
|
||
if resolutions:
|
||
res_names = list(resolutions.keys())
|
||
sub_inputs.append(io.Combo.Input("resolution", options=res_names,
|
||
default=cfg.get("default_resolution", res_names[0]),
|
||
tooltip="Output resolution / aspect ratio."))
|
||
|
||
mp_options = cfg.get("supported_megapixels")
|
||
if mp_options:
|
||
sub_inputs.append(io.Combo.Input("megapixels", options=mp_options,
|
||
default=cfg.get("default_megapixels", mp_options[0]),
|
||
tooltip="Target megapixels. Scales resolution while keeping aspect ratio."))
|
||
|
||
options.append(io.DynamicCombo.Option(key=name, inputs=sub_inputs))
|
||
return options
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# The node
|
||
# ──────────────────────────────────────────────────────────
|
||
|
||
class TheLastModelSwitcher(io.ComfyNode):
|
||
"""
|
||
The Last Model Switcher by Maxomarai
|
||
|
||
One-click model switching with auto-configured CLIP, VAE, resolution,
|
||
prompts, seed, and sampler settings.
|
||
|
||
OUTPUTS:
|
||
model / vae Core model components ready for KSampler
|
||
positive / negative CONDITIONING encoded from your prompts
|
||
(FluxGuidance baked into positive for Flux)
|
||
width / height Resolution (preset, custom, or megapixel-scaled)
|
||
steps / cfg Sampler settings (editable)
|
||
seed Seed value for reproducibility
|
||
"""
|
||
|
||
@classmethod
|
||
def define_schema(cls) -> io.Schema:
|
||
return io.Schema(
|
||
node_id="TheLastModelSwitcher",
|
||
display_name="The Last Model Switcher",
|
||
category="loaders/Maxomarai",
|
||
is_output_node=True,
|
||
description=(
|
||
"The Last Model Switcher by Maxomarai\n\n"
|
||
"Switch models with one click. Auto-selects compatible CLIP, VAE, "
|
||
"and resolution. Outputs recommended sampler settings.\n"
|
||
"Supports Flux 1, Flux 2, SDXL, and more."
|
||
),
|
||
inputs=[
|
||
io.DynamicCombo.Input("model", options=_build_dynamic_options(),
|
||
tooltip="Select your model. Compatible CLIP and resolution options appear automatically."),
|
||
|
||
# ── Prompt inputs ──
|
||
io.String.Input("positive_prompt", default="", multiline=True,
|
||
tooltip="Positive prompt. Encoded with the selected CLIP model."),
|
||
io.String.Input("negative_prompt", default="", multiline=True,
|
||
tooltip="Negative prompt. Only used for models that support it (SD, SDXL). Ignored for Flux."),
|
||
|
||
# ── Seed ──
|
||
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff,
|
||
control_after_generate=True,
|
||
tooltip="Random seed. Use the control to randomize, increment, or keep fixed between generations."),
|
||
|
||
# ── Value inputs (auto-populated from preset, editable) ──
|
||
io.Int.Input("width", default=1024, min=64, max=8192, step=8,
|
||
tooltip="Output width. Auto-set from preset, or type your own value."),
|
||
io.Int.Input("height", default=1024, min=64, max=8192, step=8,
|
||
tooltip="Output height. Auto-set from preset, or type your own value."),
|
||
io.Int.Input("steps", default=20, min=1, max=10000,
|
||
tooltip="Sampling steps. Auto-set from preset, or type your own value."),
|
||
io.Float.Input("cfg", default=1.0, min=0.0, max=100.0, step=0.1, round=0.01,
|
||
tooltip="CFG scale. Auto-set from preset, or type your own value."),
|
||
io.Float.Input("guidance", default=3.5, min=0.0, max=100.0, step=0.5,
|
||
tooltip="Flux guidance. Auto-set from preset, or type your own value."),
|
||
|
||
# ── Advanced ──
|
||
io.Combo.Input("weight_dtype", options=["default", "fp8_e4m3fn", "fp8_e5m2"],
|
||
default="default", tooltip="Weight dtype override for diffusion model.",
|
||
optional=True, advanced=True),
|
||
],
|
||
outputs=[
|
||
io.Model.Output(display_name="model"),
|
||
io.Vae.Output(display_name="vae"),
|
||
io.Conditioning.Output(display_name="positive",
|
||
tooltip="Positive conditioning. FluxGuidance applied automatically for Flux models."),
|
||
io.Conditioning.Output(display_name="negative",
|
||
tooltip="Negative conditioning. Empty for Flux models, safe to keep connected."),
|
||
io.Int.Output(display_name="width"),
|
||
io.Int.Output(display_name="height"),
|
||
io.Int.Output(display_name="steps"),
|
||
io.Float.Output(display_name="cfg"),
|
||
io.Int.Output(display_name="seed",
|
||
tooltip="Seed value. Connect to KSampler seed input."),
|
||
],
|
||
)
|
||
|
||
@classmethod
|
||
def execute(
|
||
cls, model: dict,
|
||
positive_prompt: str = "",
|
||
negative_prompt: str = "",
|
||
seed: int = 0,
|
||
width: int = 1024,
|
||
height: int = 1024,
|
||
steps: int = 20,
|
||
cfg: float = 1.0,
|
||
guidance: float = 3.5,
|
||
weight_dtype: str = "default",
|
||
) -> io.NodeOutput:
|
||
|
||
# ── Parse preset config ──
|
||
presets = load_presets()
|
||
preset_name = model["model"]
|
||
clip_variant = model.get("clip_variant")
|
||
resolution_name = model.get("resolution")
|
||
megapixels_str = model.get("megapixels", "")
|
||
|
||
if preset_name not in presets:
|
||
raise ValueError(f"Model '{preset_name}' not found in presets")
|
||
|
||
pcfg = presets[preset_name]
|
||
embedding_dirs = folder_paths.get_folder_paths("embeddings")
|
||
is_flux = pcfg.get("apply_model_sampling_flux", False)
|
||
loaded_model = None
|
||
clip_obj = None
|
||
vae = None
|
||
clip_files = None
|
||
cache_status = []
|
||
|
||
# ── Build cache keys and resolve CLIP files ──
|
||
dm_file = pcfg.get("diffusion_model", "")
|
||
ckpt_file = pcfg.get("checkpoint", "")
|
||
model_cache_key = (ckpt_file or dm_file, weight_dtype)
|
||
|
||
clips_map = pcfg.get("compatible_clips", {})
|
||
dc = pcfg.get("default_clip")
|
||
if clip_variant and clip_variant in clips_map:
|
||
clip_files = clips_map[clip_variant]
|
||
elif dc:
|
||
clip_files = dc
|
||
elif clips_map:
|
||
clip_files = list(clips_map.values())[0]
|
||
clip_cache_key = (tuple(clip_files) if clip_files else None, pcfg.get("clip_type", ""))
|
||
|
||
vae_file = pcfg.get("vae", "")
|
||
vae_cache_key = vae_file
|
||
|
||
# ── Clear cache if model changed (free VRAM) ──
|
||
if _cache["model_key"] is not None and _cache["model_key"] != model_cache_key:
|
||
logging.info(f"[TheLastModelSwitcher] Model changed, clearing cache and freeing VRAM")
|
||
_cache_clear()
|
||
|
||
# ── Load MODEL (with cache) ──
|
||
if pcfg.get("checkpoint"):
|
||
cached = _cache_get_model(model_cache_key)
|
||
if cached is not None:
|
||
loaded_model = cached
|
||
clip_obj = _cache_get_clip(model_cache_key)
|
||
vae = _cache_get_vae(model_cache_key)
|
||
clip_files = _cache.get("clip_files")
|
||
cache_status.append("MODEL/CLIP/VAE from cache")
|
||
else:
|
||
try:
|
||
ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", pcfg["checkpoint"])
|
||
out = comfy.sd.load_checkpoint_guess_config(
|
||
ckpt_path, output_vae=True, output_clip=True, embedding_directory=embedding_dirs)
|
||
loaded_model, clip_obj, vae = out[:3]
|
||
_cache_set_model(model_cache_key, loaded_model)
|
||
_cache_set_clip(model_cache_key, clip_obj, None)
|
||
_cache_set_vae(model_cache_key, vae)
|
||
cache_status.append("MODEL/CLIP/VAE loaded from disk")
|
||
except Exception as e:
|
||
_cache_clear()
|
||
raise RuntimeError(
|
||
f"Failed to load checkpoint '{pcfg['checkpoint']}': {e}\n"
|
||
f"The file may be corrupted or incomplete. Try re-downloading it."
|
||
) from e
|
||
elif dm_file:
|
||
cached = _cache_get_model(model_cache_key)
|
||
if cached is not None:
|
||
loaded_model = cached
|
||
cache_status.append("MODEL from cache")
|
||
else:
|
||
try:
|
||
if _is_gguf(dm_file):
|
||
if not _gguf_available:
|
||
raise RuntimeError("GGUF required but ComfyUI-GGUF not installed.")
|
||
unet_path = folder_paths.get_full_path("unet", dm_file)
|
||
if not unet_path:
|
||
unet_path = folder_paths.get_full_path_or_raise("diffusion_models", dm_file)
|
||
loaded_model = _load_diffusion_model_gguf(unet_path)
|
||
else:
|
||
mo = {}
|
||
if weight_dtype == "fp8_e4m3fn": mo["dtype"] = torch.float8_e4m3fn
|
||
elif weight_dtype == "fp8_e5m2": mo["dtype"] = torch.float8_e5m2
|
||
unet_path = folder_paths.get_full_path_or_raise("diffusion_models", dm_file)
|
||
loaded_model = comfy.sd.load_diffusion_model(unet_path, model_options=mo)
|
||
_cache_set_model(model_cache_key, loaded_model)
|
||
cache_status.append("MODEL loaded from disk")
|
||
except Exception as e:
|
||
_cache_clear()
|
||
raise RuntimeError(
|
||
f"Failed to load model '{dm_file}': {e}\n"
|
||
f"The file may be corrupted or incomplete. Try re-downloading it."
|
||
) from e
|
||
|
||
# ── Load CLIP (with cache) ──
|
||
if clip_obj is None and clip_files:
|
||
cached = _cache_get_clip(clip_cache_key)
|
||
if cached is not None:
|
||
clip_obj = cached
|
||
clip_files = _cache.get("clip_files") or clip_files
|
||
cache_status.append("CLIP from cache")
|
||
else:
|
||
try:
|
||
ct_str = pcfg.get("clip_type", "stable_diffusion")
|
||
ct = getattr(comfy.sd.CLIPType, ct_str.upper(), comfy.sd.CLIPType.STABLE_DIFFUSION)
|
||
clip_paths = [_resolve_clip_path(f) for f in clip_files]
|
||
if any(_is_gguf(f) for f in clip_files):
|
||
if not _gguf_available:
|
||
raise RuntimeError("GGUF CLIP required but ComfyUI-GGUF not installed.")
|
||
clip_obj = _load_clip_gguf(clip_paths, ct)
|
||
else:
|
||
clip_obj = comfy.sd.load_clip(ckpt_paths=clip_paths,
|
||
embedding_directory=embedding_dirs, clip_type=ct)
|
||
_cache_set_clip(clip_cache_key, clip_obj, clip_files)
|
||
cache_status.append("CLIP loaded from disk")
|
||
except Exception as e:
|
||
raise RuntimeError(
|
||
f"Failed to load CLIP '{', '.join(clip_files)}': {e}\n"
|
||
f"Make sure the text encoder files exist in your text_encoders folder."
|
||
) from e
|
||
|
||
# ── Load VAE (with cache) ──
|
||
if vae is None and vae_file:
|
||
cached = _cache_get_vae(vae_cache_key)
|
||
if cached is not None:
|
||
vae = cached
|
||
cache_status.append("VAE from cache")
|
||
else:
|
||
try:
|
||
vae_path = folder_paths.get_full_path_or_raise("vae", vae_file)
|
||
sd, metadata = comfy.utils.load_torch_file(vae_path, return_metadata=True)
|
||
vae = comfy.sd.VAE(sd=sd, metadata=metadata)
|
||
vae.throw_exception_if_invalid()
|
||
_cache_set_vae(vae_cache_key, vae)
|
||
cache_status.append("VAE loaded from disk")
|
||
except Exception as e:
|
||
raise RuntimeError(
|
||
f"Failed to load VAE '{vae_file}': {e}\n"
|
||
f"Make sure the VAE file exists in your vae folder."
|
||
) from e
|
||
|
||
# ── Validate loaded_model ──
|
||
if loaded_model is None:
|
||
raise ValueError(f"Model '{preset_name}' has no model file configured.")
|
||
|
||
# ── Parse sampler settings (steps, cfg, guidance_value) ──
|
||
sc = pcfg.get("sampler", {})
|
||
rec_sampler = sc.get("sampler_name", "euler")
|
||
rec_scheduler = sc.get("scheduler", "normal")
|
||
out_steps = steps
|
||
out_cfg = cfg
|
||
guidance_value = guidance
|
||
|
||
# ── Apply megapixel scaling to width/height ──
|
||
selected_mp = 0.0
|
||
if megapixels_str:
|
||
mp_match = re.match(r"([\d.]+)", megapixels_str)
|
||
if mp_match:
|
||
selected_mp = float(mp_match.group(1))
|
||
|
||
if selected_mp > 0:
|
||
# Scale width/height by megapixel target, keeping aspect ratio
|
||
aspect = width / height if height > 0 else 1.0
|
||
total = selected_mp * 1024 * 1024
|
||
h = math.sqrt(total / aspect)
|
||
width = round((h * aspect) / 8) * 8
|
||
height = round(h / 8) * 8
|
||
else:
|
||
width = round(width / 8) * 8
|
||
height = round(height / 8) * 8
|
||
|
||
target_mp = (width * height) / (1024 * 1024)
|
||
|
||
# ── Apply ModelSamplingFlux ──
|
||
if is_flux and loaded_model is not None:
|
||
m = loaded_model.clone()
|
||
max_shift, base_shift = 1.15, 0.5
|
||
x1, x2 = 256, 4096
|
||
mm = (max_shift - base_shift) / (x2 - x1)
|
||
b = base_shift - mm * x1
|
||
shift = (width * height / (8 * 8 * 2 * 2)) * mm + b
|
||
|
||
class ModelSamplingAdvanced(comfy.model_sampling.ModelSamplingFlux, comfy.model_sampling.CONST):
|
||
pass
|
||
|
||
model_sampling = ModelSamplingAdvanced(loaded_model.model.model_config)
|
||
model_sampling.set_parameters(shift=shift)
|
||
m.add_object_patch("model_sampling", model_sampling)
|
||
loaded_model = m
|
||
|
||
# Negative prompt support (computed once, used below)
|
||
neg_support = pcfg.get("negative_prompt_supported", not is_flux)
|
||
|
||
# ── Encode prompts to CONDITIONING ──
|
||
positive_cond = None
|
||
negative_cond = None
|
||
if clip_obj is not None:
|
||
# Positive prompt
|
||
pos_tokens = clip_obj.tokenize(positive_prompt)
|
||
positive_cond = clip_obj.encode_from_tokens_scheduled(pos_tokens)
|
||
|
||
# Apply Flux guidance directly to positive conditioning
|
||
# (replaces the need for a separate FluxGuidance node)
|
||
if is_flux and guidance_value > 0:
|
||
positive_cond = node_helpers.conditioning_set_values(
|
||
positive_cond, {"guidance": guidance_value})
|
||
|
||
# Negative prompt (empty for Flux, user text for SD/SDXL)
|
||
neg_text = negative_prompt if (negative_prompt and neg_support) else ""
|
||
neg_tokens = clip_obj.tokenize(neg_text)
|
||
negative_cond = clip_obj.encode_from_tokens_scheduled(neg_tokens)
|
||
|
||
# ── Enable VAE tiling ──
|
||
if vae is not None:
|
||
try:
|
||
if hasattr(vae, "enable_tiling"):
|
||
vae.enable_tiling()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Build info text ──
|
||
custom_info = pcfg.get("info_text", "")
|
||
hr = "─" * 46
|
||
model_file = pcfg.get("diffusion_model") or pcfg.get("checkpoint", "N/A")
|
||
clip_text = ", ".join(clip_files) if clip_files else "from checkpoint"
|
||
|
||
info = [
|
||
f"▸ {preset_name}",
|
||
]
|
||
if pcfg.get("description"):
|
||
info.append(f" {pcfg.get('description')}")
|
||
|
||
info += [
|
||
"",
|
||
hr,
|
||
f" model {model_file}",
|
||
f" clip {clip_text} ({pcfg.get('clip_type', 'N/A')})",
|
||
f" vae {pcfg.get('vae') or 'from checkpoint'}",
|
||
f" size {width} × {height} ({target_mp:.2f} MP)",
|
||
"",
|
||
hr,
|
||
" Sampler settings",
|
||
hr,
|
||
f" sampler {rec_sampler} / {rec_scheduler}",
|
||
f" steps {out_steps}",
|
||
f" cfg {out_cfg}",
|
||
]
|
||
if guidance_value > 0 and is_flux:
|
||
info.append(f" guidance {guidance_value} (baked into positive)")
|
||
info.append(f" negative {'used' if neg_support else 'not used (Flux)'}")
|
||
|
||
if custom_info:
|
||
info.append("")
|
||
info.append(f" {custom_info}")
|
||
|
||
# Warnings
|
||
warnings = []
|
||
missing = _validate_preset(preset_name, pcfg)
|
||
if missing:
|
||
warnings.append(f"Missing files: {', '.join(missing)}")
|
||
if warnings:
|
||
info.append("")
|
||
info.append(hr)
|
||
info.append(" Warnings")
|
||
info.append(hr)
|
||
for w in warnings:
|
||
info.append(f" • {w}")
|
||
|
||
if cache_status:
|
||
info.append("")
|
||
info.append(f" Cache: {' • '.join(cache_status)}")
|
||
|
||
# ── Output labels with values ──
|
||
output_values = {
|
||
"width": str(width),
|
||
"height": str(height),
|
||
"steps": str(out_steps),
|
||
"cfg": str(out_cfg),
|
||
"seed": str(seed),
|
||
"is_flux": is_flux,
|
||
"guidance_value": str(guidance_value),
|
||
"negative_prompt_supported": neg_support,
|
||
}
|
||
|
||
# ── Return NodeOutput ──
|
||
return io.NodeOutput(
|
||
loaded_model, vae,
|
||
positive_cond, negative_cond,
|
||
width, height,
|
||
out_steps, out_cfg,
|
||
seed,
|
||
ui={
|
||
"text": ("\n".join(info),),
|
||
"output_values": (output_values,),
|
||
},
|
||
)
|
||
|
||
|
||
class TheLastModelSwitcherExtension(ComfyExtension):
|
||
@override
|
||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||
return [TheLastModelSwitcher]
|
||
|
||
|
||
async def comfy_entrypoint() -> TheLastModelSwitcherExtension:
|
||
return TheLastModelSwitcherExtension()
|
||
|
||
|
||
__all__ = ["WEB_DIRECTORY", "comfy_entrypoint"]
|