重构核心模块

This commit is contained in:
Gin3601
2026-05-19 16:39:41 +08:00
parent fa36d0329f
commit b02dd7a1a3
20 changed files with 1176 additions and 181 deletions
Binary file not shown.
+114 -60
View File
@@ -7,31 +7,38 @@ CATEGORY = "LLS/Conditioning"
"""
from __future__ import annotations
# ---------- 防御性导入 ----------
# (本节点直接调用 clip 对象方法,无需额外导入 comfy.sd)
from ..utils.model_info import is_flux_family, is_sdxl_family, parse_model_info
_CLIP_SKIP_CHOICES = [None] + list(range(-1, -25, -1))
def _encode_conditioning(clip, prompt: str):
"""
兼容新旧 ComfyUI 的文本编码入口。
def _normalize_clip_skip(value) -> int:
if value in (None, "", "None"):
return -1
try:
normalized = int(value)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"[LLS] Invalid clip_skip value: {value!r}") from exc
if normalized < -24 or normalized > -1:
raise RuntimeError(f"[LLS] clip_skip out of supported range [-24, -1]: {normalized}")
return normalized
新版本优先使用 `encode_from_tokens_scheduled()`;
若运行环境较旧,仅存在 `encode_from_tokens()`,则手动包装成
ComfyUI 约定的 CONDITIONING 结构。
"""
tokens = clip.tokenize(prompt)
def _encode_tokens(clip, tokens, add_dict=None):
scheduled = getattr(clip, "encode_from_tokens_scheduled", None)
if callable(scheduled):
return scheduled(tokens)
try:
if add_dict:
return scheduled(tokens, add_dict=add_dict)
return scheduled(tokens)
except TypeError:
return scheduled(tokens)
plain_encode = getattr(clip, "encode_from_tokens", None)
if not callable(plain_encode):
raise AttributeError(
"CLIP object does not expose encode_from_tokens_scheduled() "
"or encode_from_tokens()."
"CLIP object does not expose encode_from_tokens_scheduled() or encode_from_tokens()."
)
try:
@@ -51,30 +58,65 @@ def _encode_conditioning(clip, prompt: str):
return [[cond, pooled_dict]]
def _normalize_clip_skip(value) -> int:
"""
兼容旧工作流中的 null / None,以及字符串形式的 clip_skip。
"""
if value in (None, "", "None"):
return -1
try:
normalized = int(value)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"[LLS] Invalid clip_skip value: {value!r}") from exc
if normalized < -24 or normalized > -1:
raise RuntimeError(f"[LLS] clip_skip out of supported range [-24, -1]: {normalized}")
return normalized
def _encode_standard_prompt(clip, prompt: str):
tokens = clip.tokenize(prompt)
return _encode_tokens(clip, tokens)
# ---------- 节点类 ----------
def _build_sdxl_tokens(clip, prompt: str):
tokens = clip.tokenize(prompt)
local_tokens = clip.tokenize(prompt)
if "l" in local_tokens:
tokens["l"] = local_tokens["l"]
if "g" not in tokens and "g" in local_tokens:
tokens["g"] = local_tokens["g"]
if "l" in tokens and "g" in tokens and len(tokens["l"]) != len(tokens["g"]):
empty = clip.tokenize("")
while len(tokens["l"]) < len(tokens["g"]):
tokens["l"] += empty.get("l", [])
while len(tokens["g"]) < len(tokens["l"]):
tokens["g"] += empty.get("g", [])
return tokens
def _encode_sdxl_prompt(clip, prompt: str, width: int, height: int):
tokens = _build_sdxl_tokens(clip, prompt)
add_dict = {
"width": width,
"height": height,
"crop_w": 0,
"crop_h": 0,
"target_width": width,
"target_height": height,
}
return _encode_tokens(clip, tokens, add_dict=add_dict)
def _build_flux_tokens(clip, prompt: str):
tokens = clip.tokenize(prompt)
t5_tokens = clip.tokenize(prompt)
if "t5xxl" in t5_tokens:
tokens["t5xxl"] = t5_tokens["t5xxl"]
return tokens
def _encode_flux_prompt(clip, prompt: str, guidance):
tokens = _build_flux_tokens(clip, prompt)
add_dict = {}
if guidance is not None:
add_dict["guidance"] = guidance
return _encode_tokens(clip, tokens, add_dict=add_dict or None)
class LLSSimplePromptEncode:
"""
简化版提示词编码节点。
优先复用 ComfyUI 原生 clip.encode_from_tokens_scheduled(),
旧版环境下自动回退到 encode_from_tokens(),
以兼容 SD1.5 / SDXL 的常见 CLIP 编码路径。
- 默认仍兼容原有 SD 风格 CLIP 编码。
- 若连接 model_info,则按 family 自动切换到 SDXL / FLUX 编码路径。
"""
CATEGORY = "LLS/Conditioning"
@@ -82,67 +124,79 @@ class LLSSimplePromptEncode:
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "STRING")
RETURN_NAMES = ("positive", "negative", "prompt_info")
DESCRIPTION = (
"Encode positive and negative prompts into CONDITIONING tensors. "
"Prefers ComfyUI native encode_from_tokens_scheduled and falls back to "
"encode_from_tokens for older runtimes."
"Encode prompts into CONDITIONING tensors. Uses model_info when available to "
"dispatch between SD15, SDXL, and FLUX text-encoding paths."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip": ("CLIP",),
"text_encoder": ("CLIP",),
"positive_prompt": ("STRING", {"default": "", "multiline": True}),
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
"clip_skip": (_CLIP_SKIP_CHOICES, {"default": -1}),
}
},
"optional": {
"model_info": ("STRING", {"default": ""}),
},
}
def encode(self, clip, positive_prompt: str, negative_prompt: str, clip_skip):
if clip is None:
def encode(
self,
text_encoder,
positive_prompt: str,
negative_prompt: str,
clip_skip,
model_info: str | None = None,
):
if text_encoder is None:
raise RuntimeError(
"[LLS] CLIP is None. Make sure a checkpoint is loaded before connecting to this node."
"[LLS] text_encoder is None. Connect the loader output or load a compatible text encoder first."
)
info = parse_model_info(model_info)
family = info["family"]
clip_skip = _normalize_clip_skip(clip_skip)
# 应用 clip_skip(ComfyUI CLIP 对象支持 clip_layer)
if clip_skip != -1:
try:
clip = clip.clone()
clip.clip_layer(clip_skip)
text_encoder = text_encoder.clone()
text_encoder.clip_layer(clip_skip)
except Exception:
# clip_layer 不可用时静默忽略,不阻断流程
pass
# 编码 positive — 复用 ComfyUI 原生编码入口
try:
positive = _encode_conditioning(clip, positive_prompt)
if is_flux_family(family):
guidance = info.get("guidance") if info.get("guidance_embed") else None
positive = _encode_flux_prompt(text_encoder, positive_prompt, guidance)
negative = _encode_flux_prompt(text_encoder, "", guidance)
negative_note = "negative_ignored_for_flux"
elif is_sdxl_family(family):
width = int(info.get("base_width", 1024))
height = int(info.get("base_height", 1024))
positive = _encode_sdxl_prompt(text_encoder, positive_prompt, width, height)
negative = _encode_sdxl_prompt(text_encoder, negative_prompt, width, height)
negative_note = "negative_used"
else:
positive = _encode_standard_prompt(text_encoder, positive_prompt)
negative = _encode_standard_prompt(text_encoder, negative_prompt)
negative_note = "negative_used"
except Exception as exc:
raise RuntimeError(
f"[LLS] Failed to encode positive prompt: {exc}"
) from exc
# 编码 negative — 复用 ComfyUI 原生编码入口
try:
negative = _encode_conditioning(clip, negative_prompt)
except Exception as exc:
raise RuntimeError(
f"[LLS] Failed to encode negative prompt: {exc}"
) from exc
raise RuntimeError(f"[LLS] Failed to encode prompts for family {family}: {exc}") from exc
pos_preview = positive_prompt[:60].replace("\n", " ")
neg_preview = negative_prompt[:40].replace("\n", " ")
if is_flux_family(family):
neg_preview = ""
prompt_info = (
f"clip_skip={clip_skip} "
f"| pos=\"{pos_preview}...\" | neg=\"{neg_preview}...\""
f"family={family} | clip_skip={clip_skip} | "
f"pos=\"{pos_preview}...\" | neg=\"{neg_preview}...\" | {negative_note}"
)
return (positive, negative, prompt_info)
# ---------- 注册表 ----------
NODE_CLASS_MAPPINGS: dict[str, type] = {
"LLSSimplePromptEncode": LLSSimplePromptEncode,
}
Binary file not shown.
+16 -4
View File
@@ -7,6 +7,8 @@ CATEGORY = "LLS/Image"
"""
from __future__ import annotations
from ..utils.model_info import get_latent_spec, parse_model_info
# ---------- 节点类 ----------
@@ -32,10 +34,13 @@ class LLSSimpleVAEDecode:
"required": {
"samples": ("LATENT",),
"vae": ("VAE",),
}
},
"optional": {
"model_info": ("STRING", {"default": ""}),
},
}
def decode(self, samples, vae):
def decode(self, samples, vae, model_info: str | None = None):
if vae is None:
raise RuntimeError(
"[LLS] VAE is None. Make sure a checkpoint is loaded and VAE is connected."
@@ -65,9 +70,16 @@ class LLSSimpleVAEDecode:
if len(getattr(image, "shape", ())) == 5:
image = image.reshape(-1, image.shape[-3], image.shape[-2], image.shape[-1])
h, w = latent_tensor.shape[2] * 8, latent_tensor.shape[3] * 8
info = parse_model_info(model_info)
latent_spec = get_latent_spec(info)
downscale_ratio = int(samples.get("downscale_ratio_spacial", latent_spec["downscale_ratio"]))
h, w = latent_tensor.shape[2] * downscale_ratio, latent_tensor.shape[3] * downscale_ratio
batch = latent_tensor.shape[0]
decode_info = f"decoded={w}x{h} | batch={batch} | latent_shape={list(latent_tensor.shape)}"
decode_info = (
f"family={info['family']} | decoded={w}x{h} | batch={batch} "
f"| downscale={downscale_ratio} | latent_shape={list(latent_tensor.shape)}"
)
return (image, decode_info)
Binary file not shown.
+36 -33
View File
@@ -7,8 +7,6 @@ CATEGORY = "LLS/Latent"
"""
from __future__ import annotations
# ---------- 防御性导入 ----------
try:
import torch
except Exception as exc:
@@ -22,16 +20,14 @@ try:
except Exception:
model_management = None
from ..utils.model_info import get_latent_spec, parse_model_info
# ---------- 工具函数 ----------
_SIZE_PRESETS = [
"Custom",
# SD1.5 推荐尺寸
"512x512",
"512x768",
"768x512",
# SDXL 推荐尺寸
"1024x1024",
"832x1216",
"1216x832",
@@ -42,17 +38,16 @@ _SIZE_PRESETS = [
]
def _round_to_8(value: int) -> int:
"""将整数修正为最近的 8 的倍数。"""
return max(8, round(value / 8) * 8)
def _round_to_multiple(value: int, multiple: int) -> int:
return max(multiple, ((value + multiple - 1) // multiple) * multiple)
# ---------- 节点类 ----------
class LLSSimpleEmptyLatent:
"""
生成空白 Latent,作为 txt2img 推理的起点。
支持常用分辨率预设,自动修正宿高为 8 的倍数。
默认保持 SD 风格 4 通道 / 8x 下采样;
若提供 model_info,则可自动切换到 FLUX 的 128 通道 / 16x 下采样。
"""
CATEGORY = "LLS/Latent"
@@ -61,9 +56,7 @@ class LLSSimpleEmptyLatent:
RETURN_NAMES = ("latent", "width", "height", "latent_info")
DESCRIPTION = (
"Create an empty latent image as the starting point for txt2img. "
"Select a preset or enter custom width/height. "
"Width and height are automatically rounded to multiples of 8. "
"SD1.5 recommended: 512x512; SDXL recommended: 1024x1024 or 896x1152."
"When model_info is connected, latent channels and downscale ratio adapt to the model family."
)
@classmethod
@@ -74,30 +67,39 @@ class LLSSimpleEmptyLatent:
"width": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
}
},
"optional": {
"model_info": ("STRING", {"default": ""}),
},
}
def create_empty_latent(self, size_preset: str, width: int, height: int, batch_size: int):
def create_empty_latent(
self,
size_preset: str,
width: int,
height: int,
batch_size: int,
model_info: str | None = None,
):
if torch is None:
raise RuntimeError(
"[LLS] PyTorch is not available. "
"Make sure this node runs inside a ComfyUI environment."
"[LLS] PyTorch is not available. Make sure this node runs inside a ComfyUI environment."
) from _TORCH_ERR
info = parse_model_info(model_info)
latent_spec = get_latent_spec(info)
latent_channels = latent_spec["latent_channels"]
downscale_ratio = latent_spec["downscale_ratio"]
notes = []
# 如果选择了预设,覆盖 width / height
if size_preset != "Custom":
try:
w_str, h_str = size_preset.split("x")
width = int(w_str)
height = int(h_str)
width, height = [int(part) for part in size_preset.split("x", 1)]
except Exception:
notes.append(f"preset parse failed, using custom {width}x{height}")
# 修正为 8 的倍数
corrected_w = _round_to_8(width)
corrected_h = _round_to_8(height)
corrected_w = _round_to_multiple(width, downscale_ratio)
corrected_h = _round_to_multiple(height, downscale_ratio)
if corrected_w != width or corrected_h != height:
notes.append(f"size corrected {width}x{height} -> {corrected_w}x{corrected_h}")
width, height = corrected_w, corrected_h
@@ -114,25 +116,26 @@ class LLSSimpleEmptyLatent:
except Exception:
dtype = torch.float32
# 生成空白 Latent(与 ComfyUI EmptyLatentImage 格式一致)
# Latent 尺寸是图像尺寸的 1/8
latent = torch.zeros(
[batch_size, 4, height // 8, width // 8],
[batch_size, latent_channels, height // downscale_ratio, width // downscale_ratio],
device=device,
dtype=dtype,
)
note_str = " | ".join(notes) if notes else "ok"
latent_info = (
f"size={width}x{height} | batch={batch_size} "
f"| preset={size_preset} | {note_str}"
f"family={info['family']} | size={width}x{height} | batch={batch_size} | "
f"channels={latent_channels} | downscale={downscale_ratio} | preset={size_preset} | {note_str}"
)
return ({"samples": latent, "downscale_ratio_spacial": 8}, width, height, latent_info)
return (
{"samples": latent, "downscale_ratio_spacial": downscale_ratio},
width,
height,
latent_info,
)
# ---------- 注册表 ----------
NODE_CLASS_MAPPINGS: dict[str, type] = {
"LLSSimpleEmptyLatent": LLSSimpleEmptyLatent,
}
Binary file not shown.
+343 -71
View File
@@ -7,7 +7,7 @@ CATEGORY = "LLS/Model Loader"
"""
from __future__ import annotations
# ---------- 防御性导入 ----------
import importlib
try:
import folder_paths
@@ -25,118 +25,390 @@ except Exception as exc:
else:
_COMFY_SD_ERR = None
# 注:comfy.utils 在本模块未使用,无需导入
try:
comfy_core_nodes = importlib.import_module("nodes")
except Exception as exc:
comfy_core_nodes = None
_CORE_NODES_ERR = exc
else:
_CORE_NODES_ERR = None
from ..utils.model_info import (
MODEL_FAMILY_CHOICES,
build_model_info,
canonicalize_family,
family_requires_guidance,
get_latent_spec,
infer_family_from_name,
is_flux_family,
is_sdxl_family,
)
# ---------- 工具函数 ----------
AUTO_PLACEHOLDER = "(auto)"
NO_MODELS_PLACEHOLDER = "(no models found)"
NO_VAE_PLACEHOLDER = "(no vae found)"
NO_TEXT_ENCODER_PLACEHOLDER = "(no text encoders found)"
LOAD_MODE_CHOICES = ["simple", "advanced"]
VAE_SOURCE_CHOICES = ["auto", "embedded", "external", "none"]
TEXT_ENCODER_SOURCE_CHOICES = ["auto", "embedded", "external", "manual"]
def _get_checkpoint_names() -> list[str]:
def _get_filename_list(category: str) -> list[str]:
if folder_paths is None:
return ["(ComfyUI not available)"]
return []
try:
names = folder_paths.get_filename_list("checkpoints")
return names if names else ["(no checkpoints found)"]
return list(folder_paths.get_filename_list(category))
except Exception:
return ["(no checkpoints found)"]
return []
def _detect_family(ckpt_name: str, model_family: str) -> str:
"""根据 model_family 参数或文件名推断模型家族。"""
if model_family in ("SD1.5", "SDXL"):
return model_family
# Auto:通过文件名关键词简单判断
name_lower = ckpt_name.lower()
if any(kw in name_lower for kw in ("sdxl", "_xl", "-xl", "xl_")):
return "SDXL"
return "SD1.5"
def _with_placeholder(names: list[str], placeholder: str) -> list[str]:
cleaned = [name for name in names if name]
if not cleaned:
return [AUTO_PLACEHOLDER, placeholder]
return [AUTO_PLACEHOLDER] + cleaned
# ---------- 节点类 ----------
def _get_model_name_choices() -> list[str]:
checkpoints = _get_filename_list("checkpoints")
diffusion_models = [f"diffusion_models/{name}" for name in _get_filename_list("diffusion_models")]
names = list(dict.fromkeys(checkpoints + diffusion_models))
return names if names else [NO_MODELS_PLACEHOLDER]
def _get_vae_choices() -> list[str]:
if comfy_core_nodes is not None:
vae_loader = getattr(comfy_core_nodes, "VAELoader", None)
if vae_loader is not None and hasattr(vae_loader, "vae_list"):
try:
names = list(vae_loader.vae_list(vae_loader))
return _with_placeholder(names, NO_VAE_PLACEHOLDER)
except Exception:
pass
return _with_placeholder(_get_filename_list("vae"), NO_VAE_PLACEHOLDER)
def _get_text_encoder_choices() -> list[str]:
return _with_placeholder(_get_filename_list("text_encoders"), NO_TEXT_ENCODER_PLACEHOLDER)
def _normalize_choice(value: str | None, placeholder: str) -> str | None:
if value in (None, "", AUTO_PLACEHOLDER, placeholder):
return None
return value
def _get_full_path(category: str, name: str) -> str | None:
if folder_paths is None:
return None
try:
return folder_paths.get_full_path_or_raise(category, name)
except AttributeError:
return folder_paths.get_full_path(category, name)
except FileNotFoundError:
return None
def _resolve_model_path(model_name: str, family: str) -> tuple[str, str]:
if model_name == NO_MODELS_PLACEHOLDER:
raise RuntimeError(
"[LLS] No models were found in ComfyUI models/checkpoints or models/diffusion_models."
)
if model_name.startswith("diffusion_models/"):
if not is_flux_family(family):
raise RuntimeError(
f"[LLS] {family} expects a checkpoint file. Select a checkpoint from models/checkpoints/."
)
short_name = model_name.split("/", 1)[1]
model_path = _get_full_path("diffusion_models", short_name)
if model_path:
return ("diffusion_models", model_path)
checkpoint_path = _get_full_path("checkpoints", model_name)
if checkpoint_path:
return ("checkpoints", checkpoint_path)
if is_flux_family(family):
diffusion_path = _get_full_path("diffusion_models", model_name)
if diffusion_path:
return ("diffusion_models", diffusion_path)
raise RuntimeError(
f"[LLS] Model '{model_name}' was not found in the expected ComfyUI model directories."
)
def _load_checkpoint_bundle(model_path: str):
try:
return comfy_sd.load_checkpoint_guess_config(
model_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
except Exception as exc:
raise RuntimeError(f"[LLS] Failed to load checkpoint '{model_path}': {exc}") from exc
def _load_model(model_source: str, model_path: str):
if model_source == "checkpoints":
loaded = _load_checkpoint_bundle(model_path)
return loaded[0], loaded[1], loaded[2]
try:
model = comfy_sd.load_diffusion_model(model_path)
except Exception as exc:
raise RuntimeError(f"[LLS] Failed to load diffusion model '{model_path}': {exc}") from exc
return model, None, None
def _load_external_vae(vae_name: str):
if comfy_core_nodes is None:
raise RuntimeError(
"[LLS] ComfyUI core VAELoader is unavailable, so external VAE loading cannot be used."
) from _CORE_NODES_ERR
vae_loader_cls = getattr(comfy_core_nodes, "VAELoader", None)
if vae_loader_cls is None:
raise RuntimeError("[LLS] ComfyUI core VAELoader class was not found.")
return vae_loader_cls().load_vae(vae_name)[0]
def _load_external_text_encoder(family: str, name1: str | None, name2: str | None):
if comfy_core_nodes is None:
raise RuntimeError(
"[LLS] ComfyUI core CLIP loaders are unavailable, so external text encoder loading cannot be used."
) from _CORE_NODES_ERR
if family == "SD15":
if not name1:
raise RuntimeError(
"[LLS] SD15 external text encoder loading requires external_text_encoder_1."
)
clip_loader_cls = getattr(comfy_core_nodes, "CLIPLoader", None)
if clip_loader_cls is None:
raise RuntimeError("[LLS] ComfyUI core CLIPLoader class was not found.")
return clip_loader_cls().load_clip(name1, type="stable_diffusion")[0]
if is_sdxl_family(family):
if not name1 or not name2:
raise RuntimeError(
"[LLS] SDXL external text encoder loading requires clip_l and clip_g via "
"external_text_encoder_1/external_text_encoder_2."
)
dual_loader_cls = getattr(comfy_core_nodes, "DualCLIPLoader", None)
if dual_loader_cls is None:
raise RuntimeError("[LLS] ComfyUI core DualCLIPLoader class was not found.")
return dual_loader_cls().load_clip(name1, name2, type="sdxl")[0]
if not name1 or not name2:
raise RuntimeError(
"[LLS] FLUX text encoder loading requires clip_l and t5xxl via "
"external_text_encoder_1/external_text_encoder_2."
)
dual_loader_cls = getattr(comfy_core_nodes, "DualCLIPLoader", None)
if dual_loader_cls is None:
raise RuntimeError("[LLS] ComfyUI core DualCLIPLoader class was not found.")
return dual_loader_cls().load_clip(name1, name2, type="flux")[0]
def _resolve_text_encoder(
family: str,
source: str,
embedded_text_encoder,
external_name_1: str | None,
external_name_2: str | None,
) -> tuple[object | None, str]:
if source == "manual":
return None, "manual"
if source == "embedded":
if embedded_text_encoder is None:
raise RuntimeError(
f"[LLS] {family} was set to use embedded text encoders, but the selected model did not provide them."
)
return embedded_text_encoder, "embedded"
if source == "external":
return _load_external_text_encoder(family, external_name_1, external_name_2), "external"
if embedded_text_encoder is not None:
return embedded_text_encoder, "embedded"
if external_name_1:
return _load_external_text_encoder(family, external_name_1, external_name_2), "external"
if is_flux_family(family):
raise RuntimeError(
"[LLS] FLUX models need text encoders. Provide clip_l and t5xxl via "
"external_text_encoder_1/external_text_encoder_2, or use a checkpoint that embeds them."
)
raise RuntimeError(
f"[LLS] {family} did not expose an embedded text encoder. "
"Switch text_encoder_source to 'external' and choose the required encoder files."
)
def _resolve_vae(
family: str,
source: str,
embedded_vae,
external_vae_name: str | None,
) -> tuple[object | None, str]:
if source == "none":
if is_flux_family(family):
raise RuntimeError(
"[LLS] FLUX generation requires a VAE/AE. Provide external_vae_name or use a model with an embedded VAE."
)
return None, "none"
if source == "embedded":
if embedded_vae is None:
raise RuntimeError(
f"[LLS] {family} was set to use an embedded VAE, but the selected model did not provide one."
)
return embedded_vae, "embedded"
if source == "external":
if not external_vae_name:
raise RuntimeError("[LLS] External VAE loading requires external_vae_name.")
return _load_external_vae(external_vae_name), "external"
if embedded_vae is not None:
return embedded_vae, "embedded"
if external_vae_name:
return _load_external_vae(external_vae_name), "external"
if is_flux_family(family):
raise RuntimeError(
"[LLS] FLUX models usually need an external AE/VAE. Provide external_vae_name or use a model with an embedded VAE."
)
return None, "none"
def _text_encoder_type_for_family(family: str) -> str:
if family == "SD15":
return "clip"
if is_sdxl_family(family):
return "sdxl_dual_clip"
return "flux_clip_l_t5xxl"
def _required_text_encoders_for_family(family: str) -> list[str]:
if family == "SD15":
return ["clip"]
if is_sdxl_family(family):
return ["clip_l", "clip_g"]
return ["clip_l", "t5xxl"]
def _required_vae_for_family(family: str) -> str:
return "required" if is_flux_family(family) else "optional"
class LLSSimpleCheckpointLoader:
"""
简化版 Checkpoint 加载器,支持 SD1.5 / SDXL 基础流程。
自动识别模型家族,输出 MODEL / CLIP / VAE 以及模型信息字符串。
内部复用 ComfyUI 原生 load_checkpoint_guess_config,已自动适配两种架构。
统一外观的模型加载节点。
对外仍然输出 MODEL / CLIP / VAE / STRING 以兼容旧工作流;
但第二个端口实际语义已经升级为 text_encoder,资源分派策略则由
model_family + source 选项控制。
"""
CATEGORY = "LLS/Model Loader"
FUNCTION = "load_checkpoint"
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
RETURN_NAMES = ("model", "clip", "vae", "model_info")
DESCRIPTION = "Load a SD1.5 or SDXL checkpoint. Outputs MODEL, CLIP, VAE and a model_info string."
_FAMILY_CHOICES = ["Auto", "SD1.5", "SDXL"]
RETURN_NAMES = ("model", "text_encoder", "vae", "model_info")
DESCRIPTION = (
"Load SD15, SDXL, SDXL Turbo, and FLUX families with family-specific resource "
"dispatch. The CLIP-typed output now represents the resolved text encoder bundle."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"ckpt_name": (_get_checkpoint_names(),),
"model_family": (cls._FAMILY_CHOICES, {"default": "Auto"}),
"ckpt_name": (_get_model_name_choices(),),
"model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}),
"load_mode": (LOAD_MODE_CHOICES, {"default": "simple"}),
"vae_source": (VAE_SOURCE_CHOICES, {"default": "auto"}),
"text_encoder_source": (TEXT_ENCODER_SOURCE_CHOICES, {"default": "auto"}),
"external_vae_name": (_get_vae_choices(), {"default": AUTO_PLACEHOLDER}),
"external_text_encoder_1": (_get_text_encoder_choices(), {"default": AUTO_PLACEHOLDER}),
"external_text_encoder_2": (_get_text_encoder_choices(), {"default": AUTO_PLACEHOLDER}),
}
}
def load_checkpoint(self, ckpt_name: str, model_family: str):
def load_checkpoint(
self,
ckpt_name: str,
model_family: str,
load_mode: str,
vae_source: str,
text_encoder_source: str,
external_vae_name: str,
external_text_encoder_1: str,
external_text_encoder_2: str,
):
if folder_paths is None:
raise RuntimeError(
"[LLS] folder_paths is not available. "
"Make sure this node runs inside a ComfyUI environment."
"[LLS] folder_paths is not available. Make sure this node runs inside ComfyUI."
) from _FOLDER_PATHS_ERR
if comfy_sd is None:
raise RuntimeError(
"[LLS] comfy.sd is not available. "
"Make sure this node runs inside a ComfyUI environment."
"[LLS] comfy.sd is not available. Make sure this node runs inside ComfyUI."
) from _COMFY_SD_ERR
# 解析模型路径(使用 ComfyUI 原生方法,自动抛出文件不存在异常)
try:
ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", ckpt_name)
except (AttributeError, TypeError):
# get_full_path_or_raise 在旧版 ComfyUI 中可能不存在,回退到 get_full_path
ckpt_path = folder_paths.get_full_path("checkpoints", ckpt_name)
if not ckpt_path:
raise RuntimeError(
f"[LLS] Checkpoint '{ckpt_name}' not found in ComfyUI checkpoints directories. "
f"Please add the file to models/checkpoints/ and restart ComfyUI."
)
except FileNotFoundError as exc:
raise RuntimeError(
f"[LLS] Checkpoint '{ckpt_name}' not found: {exc}"
) from exc
family = canonicalize_family(model_family)
if model_family == "Auto":
family = infer_family_from_name(ckpt_name, family)
# 加载 checkpoint(复用 ComfyUI 原生加载逻辑,自动适配 SD1.5 / SDXL)
try:
out = comfy_sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
except Exception as exc:
raise RuntimeError(
f"[LLS] Failed to load checkpoint '{ckpt_name}': {exc}"
) from exc
model, clip, vae = out[0], out[1], out[2]
model_source, model_path = _resolve_model_path(ckpt_name, family)
model, embedded_text_encoder, embedded_vae = _load_model(model_source, model_path)
if model is None:
raise RuntimeError(f"[LLS] Checkpoint '{ckpt_name}' did not produce a valid MODEL.")
if clip is None:
raise RuntimeError(f"[LLS] Checkpoint '{ckpt_name}' did not produce a valid CLIP.")
if vae is None:
raise RuntimeError(f"[LLS] Checkpoint '{ckpt_name}' did not produce a valid VAE.")
raise RuntimeError(f"[LLS] The selected model '{ckpt_name}' did not produce a valid MODEL.")
detected_family = _detect_family(ckpt_name, model_family)
external_vae_name = _normalize_choice(external_vae_name, NO_VAE_PLACEHOLDER)
external_text_encoder_1 = _normalize_choice(external_text_encoder_1, NO_TEXT_ENCODER_PLACEHOLDER)
external_text_encoder_2 = _normalize_choice(external_text_encoder_2, NO_TEXT_ENCODER_PLACEHOLDER)
model_info = (
f"ckpt={ckpt_name} | family={detected_family}"
text_encoder, resolved_text_encoder_source = _resolve_text_encoder(
family=family,
source=text_encoder_source,
embedded_text_encoder=embedded_text_encoder,
external_name_1=external_text_encoder_1,
external_name_2=external_text_encoder_2,
)
vae, resolved_vae_source = _resolve_vae(
family=family,
source=vae_source,
embedded_vae=embedded_vae,
external_vae_name=external_vae_name,
)
return (model, clip, vae, model_info)
latent_spec = get_latent_spec({"family": family})
model_info = build_model_info(
family=family,
model_name=ckpt_name,
model_source=model_source,
load_mode=load_mode,
text_encoder_source=resolved_text_encoder_source,
vae_source=resolved_vae_source,
text_encoder_type=_text_encoder_type_for_family(family),
has_embedded_vae=embedded_vae is not None,
has_embedded_text_encoder=embedded_text_encoder is not None,
required_text_encoders=_required_text_encoders_for_family(family),
required_vae=_required_vae_for_family(family),
is_turbo=(family == "SDXL_TURBO"),
guidance_embed=family_requires_guidance(family),
latent_channels=latent_spec["latent_channels"],
downscale_ratio=latent_spec["downscale_ratio"],
)
return (model, text_encoder, vae, model_info)
# ---------- 注册表 ----------
NODE_CLASS_MAPPINGS: dict[str, type] = {
"LLSSimpleCheckpointLoader": LLSSimpleCheckpointLoader,
Binary file not shown.
+15 -13
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import random
from ..utils.model_info import get_sampling_preset, parse_model_info
# ---------- 防御性导入 ----------
try:
@@ -53,12 +55,6 @@ except Exception:
_QUALITY_PRESETS = ["Manual", "Fast", "Balanced", "High Quality"]
_PRESET_PARAMS = {
"Fast": {"steps": 12, "cfg": 6.5},
"Balanced": {"steps": 20, "cfg": 7.0},
"High Quality": {"steps": 30, "cfg": 7.5},
}
_DEFAULT_SAMPLERS = ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral",
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral",
"dpmpp_sde", "dpmpp_2m", "dpmpp_2m_sde", "ddim", "uni_pc"]
@@ -208,7 +204,10 @@ class LLSSimpleKSampler:
"sampler_name": (_get_samplers(), {"default": "euler_ancestral"}),
"scheduler": (_get_schedulers(), {"default": "karras"}),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
},
"optional": {
"model_info": ("STRING", {"default": ""}),
},
}
def sample(
@@ -224,6 +223,7 @@ class LLSSimpleKSampler:
sampler_name: str,
scheduler: str,
denoise: float,
model_info: str | None = None,
):
if comfy_sample is None:
raise RuntimeError(
@@ -236,11 +236,13 @@ class LLSSimpleKSampler:
"Make sure this node runs inside a ComfyUI environment."
) from _COMFY_SAMPLERS_ERR
# 应用 quality_preset(Manual 不覆盖用户输入)
if quality_preset in _PRESET_PARAMS:
preset = _PRESET_PARAMS[quality_preset]
steps = preset["steps"]
cfg = preset["cfg"]
info = parse_model_info(model_info)
family = info["family"]
preset = get_sampling_preset(info, quality_preset)
if preset is not None:
steps = int(preset["steps"])
cfg = float(preset["cfg"])
# seed = -1 时随机生成
actual_seed = seed
@@ -269,7 +271,7 @@ class LLSSimpleKSampler:
sample_info = (
f"seed={actual_seed} | steps={steps} | cfg={cfg} "
f"| sampler={sampler_name} | scheduler={scheduler} "
f"| denoise={denoise} | preset={quality_preset}"
f"| denoise={denoise} | preset={quality_preset} | family={family}"
)
return (result_latent, sample_info)
+418
View File
@@ -0,0 +1,418 @@
import importlib.util
import json
import pathlib
import sys
import types
import unittest
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[1]
def load_plugin_package():
spec = importlib.util.spec_from_file_location(
"lls_node_test_refactor",
ROOT / "__init__.py",
submodule_search_locations=[str(ROOT)],
)
module = importlib.util.module_from_spec(spec)
sys.modules["lls_node_test_refactor"] = module
spec.loader.exec_module(module)
return module
class FolderPathsStub:
def __init__(self):
self._files = {
"checkpoints": [
"sd15.safetensors",
"sdxl_turbo.safetensors",
"flux-dev-checkpoint.safetensors",
],
"diffusion_models": [
"flux-schnell.safetensors",
],
"vae": [
"ae.safetensors",
],
"text_encoders": [
"clip_l.safetensors",
"clip_g.safetensors",
"t5xxl_fp16.safetensors",
],
}
def get_filename_list(self, category):
return list(self._files.get(category, []))
def get_full_path_or_raise(self, category, name):
if name in self._files.get(category, []):
return f"/fake/{category}/{name}"
raise FileNotFoundError(name)
def get_full_path(self, category, name):
if name in self._files.get(category, []):
return f"/fake/{category}/{name}"
return None
def get_folder_paths(self, category):
return [f"/fake/{category}"]
class ComfySDStub:
class CLIPType:
STABLE_DIFFUSION = "stable_diffusion"
FLUX = "flux"
def load_checkpoint_guess_config(
self,
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=None,
):
if ckpt_path.endswith("sd15.safetensors"):
return ("MODEL::SD15", "CLIP::SD15", "VAE::SD15")
if ckpt_path.endswith("sdxl_turbo.safetensors"):
return ("MODEL::SDXL_TURBO", None, None)
if ckpt_path.endswith("flux-dev-checkpoint.safetensors"):
return ("MODEL::FLUX_DEV", None, None)
raise AssertionError(f"unexpected checkpoint path: {ckpt_path}")
def load_diffusion_model(self, unet_path, model_options=None, disable_dynamic=False):
return f"MODEL::{pathlib.Path(unet_path).name}"
def load_clip(
self,
ckpt_paths,
embedding_directory=None,
clip_type=None,
model_options=None,
disable_dynamic=False,
):
joined = "+".join(pathlib.Path(path).name for path in ckpt_paths)
return f"CLIP::{clip_type}::{joined}"
class CoreNodesStub:
class VAELoader:
@staticmethod
def vae_list(_self):
return ["ae.safetensors"]
def load_vae(self, vae_name):
return (f"VAE::{vae_name}",)
class CLIPLoader:
def load_clip(self, clip_name, type="stable_diffusion", device="default"):
return (f"CLIP::{type}::{clip_name}",)
class DualCLIPLoader:
def load_clip(self, clip_name1, clip_name2, type, device="default"):
return (f"CLIP::{type}::{clip_name1}+{clip_name2}",)
class RecordingClip:
def __init__(self, calls=None):
self.calls = [] if calls is None else calls
self.layer = None
def clone(self):
cloned = RecordingClip(self.calls)
cloned.layer = self.layer
return cloned
def clip_layer(self, layer):
self.layer = layer
def tokenize(self, text):
return {
"text": text,
"g": [f"g::{text}"],
"l": [f"l::{text}"],
"t5xxl": [f"t5::{text}"],
}
def encode_from_tokens_scheduled(self, tokens, add_dict=None):
self.calls.append(
{
"tokens": dict(tokens),
"add_dict": dict(add_dict or {}),
"layer": self.layer,
}
)
return [[f"cond::{tokens.get('text', '')}", {"pooled_output": dict(add_dict or {})}]]
class FakeVAE:
def decode(self, latent_tensor):
batch, _channels, height, width = latent_tensor.shape
return FakeTensor((batch, height, width, 3))
class FakeTensor:
def __init__(self, shape):
self.shape = tuple(shape)
self.dtype = "float32"
self.layout = "strided"
self.device = "cpu"
self.is_nested = False
def size(self):
return self.shape
def reshape(self, *shape):
return FakeTensor(shape)
class FakeTorch:
float32 = "float32"
@staticmethod
def zeros(shape, device=None, dtype=None, layout=None):
return FakeTensor(shape)
class TestLoaderPromptRefactor(unittest.TestCase):
def test_loader_schema_exposes_new_family_and_source_controls(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleCheckpointLoader"]
required = node_cls.INPUT_TYPES()["required"]
self.assertEqual(
tuple(required.keys()),
(
"ckpt_name",
"model_family",
"load_mode",
"vae_source",
"text_encoder_source",
"external_vae_name",
"external_text_encoder_1",
"external_text_encoder_2",
),
)
family_choices = required["model_family"][0]
self.assertIn("SD15", family_choices)
self.assertIn("SDXL_TURBO", family_choices)
self.assertIn("FLUX_SCHNELL", family_choices)
self.assertIn("FLUX_DEV", family_choices)
def test_loader_returns_json_model_info_for_sd15_embedded_resources(self):
load_plugin_package()
from lls_node_test_refactor.model_loader import nodes as loader_nodes
with mock.patch.object(loader_nodes, "folder_paths", FolderPathsStub()), mock.patch.object(
loader_nodes,
"comfy_sd",
ComfySDStub(),
), mock.patch.object(loader_nodes, "comfy_core_nodes", CoreNodesStub()):
node = loader_nodes.LLSSimpleCheckpointLoader()
model, text_encoder, vae, model_info = node.load_checkpoint(
"sd15.safetensors",
"SD15",
"simple",
"auto",
"auto",
"(auto)",
"(auto)",
"(auto)",
)
info = json.loads(model_info)
self.assertEqual(model, "MODEL::SD15")
self.assertEqual(text_encoder, "CLIP::SD15")
self.assertEqual(vae, "VAE::SD15")
self.assertEqual(info["family"], "SD15")
self.assertEqual(info["text_encoder_type"], "clip")
self.assertTrue(info["has_embedded_vae"])
self.assertEqual(info["required_text_encoders"], ["clip"])
self.assertEqual(info["required_vae"], "optional")
self.assertFalse(info["is_turbo"])
def test_loader_builds_flux_resources_from_external_models(self):
load_plugin_package()
from lls_node_test_refactor.model_loader import nodes as loader_nodes
with mock.patch.object(loader_nodes, "folder_paths", FolderPathsStub()), mock.patch.object(
loader_nodes,
"comfy_sd",
ComfySDStub(),
), mock.patch.object(loader_nodes, "comfy_core_nodes", CoreNodesStub()):
node = loader_nodes.LLSSimpleCheckpointLoader()
model, text_encoder, vae, model_info = node.load_checkpoint(
"diffusion_models/flux-schnell.safetensors",
"FLUX_SCHNELL",
"advanced",
"external",
"external",
"ae.safetensors",
"clip_l.safetensors",
"t5xxl_fp16.safetensors",
)
info = json.loads(model_info)
self.assertEqual(model, "MODEL::flux-schnell.safetensors")
self.assertEqual(text_encoder, "CLIP::flux::clip_l.safetensors+t5xxl_fp16.safetensors")
self.assertEqual(vae, "VAE::ae.safetensors")
self.assertEqual(info["family"], "FLUX_SCHNELL")
self.assertEqual(info["text_encoder_type"], "flux_clip_l_t5xxl")
self.assertEqual(info["required_text_encoders"], ["clip_l", "t5xxl"])
self.assertEqual(info["required_vae"], "required")
def test_loader_raises_clear_error_when_flux_resources_are_missing(self):
load_plugin_package()
from lls_node_test_refactor.model_loader import nodes as loader_nodes
with mock.patch.object(loader_nodes, "folder_paths", FolderPathsStub()), mock.patch.object(
loader_nodes,
"comfy_sd",
ComfySDStub(),
), mock.patch.object(loader_nodes, "comfy_core_nodes", CoreNodesStub()):
node = loader_nodes.LLSSimpleCheckpointLoader()
with self.assertRaisesRegex(RuntimeError, "clip_l.*t5xxl"):
node.load_checkpoint(
"diffusion_models/flux-schnell.safetensors",
"FLUX_DEV",
"simple",
"external",
"external",
"ae.safetensors",
"(auto)",
"(auto)",
)
def test_prompt_encode_dispatches_sdxl_using_model_info_dimensions(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"]
node = node_cls()
clip = RecordingClip()
model_info = json.dumps({"family": "SDXL", "base_width": 1216, "base_height": 832})
positive, negative, prompt_info = node.encode(
clip,
"a castle",
"low quality",
-1,
model_info=model_info,
)
self.assertEqual(len(clip.calls), 2)
self.assertIn("g", clip.calls[0]["tokens"])
self.assertIn("l", clip.calls[0]["tokens"])
self.assertEqual(
clip.calls[0]["add_dict"],
{
"width": 1216,
"height": 832,
"crop_w": 0,
"crop_h": 0,
"target_width": 1216,
"target_height": 832,
},
)
self.assertEqual(positive[0][0], "cond::a castle")
self.assertEqual(negative[0][0], "cond::low quality")
self.assertIn("family=SDXL", prompt_info)
def test_prompt_encode_dispatches_flux_and_neutralizes_negative_prompt(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"]
node = node_cls()
clip = RecordingClip()
model_info = json.dumps({"family": "FLUX_DEV", "guidance": 3.5, "guidance_embed": True})
positive, negative, prompt_info = node.encode(
clip,
"a robot in a forest",
"ugly, blurry",
-1,
model_info=model_info,
)
self.assertEqual(len(clip.calls), 2)
self.assertIn("t5xxl", clip.calls[0]["tokens"])
self.assertEqual(clip.calls[0]["add_dict"]["guidance"], 3.5)
self.assertEqual(clip.calls[1]["tokens"]["text"], "")
self.assertEqual(positive[0][0], "cond::a robot in a forest")
self.assertEqual(negative[0][0], "cond::")
self.assertIn("negative_ignored_for_flux", prompt_info)
def test_empty_latent_uses_flux_shape_when_model_info_requests_it(self):
load_plugin_package()
from lls_node_test_refactor.latent import nodes as latent_nodes
node = latent_nodes.LLSSimpleEmptyLatent()
model_info = json.dumps({"family": "FLUX_SCHNELL", "latent_channels": 128, "downscale_ratio": 16})
with mock.patch.object(latent_nodes, "torch", FakeTorch()):
latent, width, height, latent_info = node.create_empty_latent(
"Custom",
1000,
1000,
2,
model_info=model_info,
)
self.assertEqual((width, height), (1008, 1008))
self.assertEqual(tuple(latent["samples"].shape), (2, 128, 63, 63))
self.assertEqual(latent["downscale_ratio_spacial"], 16)
self.assertIn("family=FLUX_SCHNELL", latent_info)
def test_ksampler_uses_turbo_preset_defaults_when_model_info_requests_it(self):
load_plugin_package()
from lls_node_test_refactor.sampling import nodes as sampling_nodes
recorded = {}
def fake_common_ksampler(**kwargs):
recorded.update(kwargs)
return kwargs["latent"]
with mock.patch.object(sampling_nodes, "comfy_sample", object()), mock.patch.object(
sampling_nodes,
"comfy_samplers",
object(),
), mock.patch.object(
sampling_nodes,
"_common_ksampler",
side_effect=fake_common_ksampler,
):
node = sampling_nodes.LLSSimpleKSampler()
latent, sample_info = node.sample(
model="model",
positive="positive",
negative="negative",
latent_image={"samples": FakeTensor((1, 4, 64, 64))},
quality_preset="Balanced",
seed=123,
steps=30,
cfg=7.0,
sampler_name="euler",
scheduler="normal",
denoise=1.0,
model_info=json.dumps({"family": "SDXL_TURBO", "is_turbo": True}),
)
self.assertEqual(recorded["steps"], 6)
self.assertEqual(recorded["cfg"], 1.5)
self.assertEqual(latent["samples"].shape, (1, 4, 64, 64))
self.assertIn("family=SDXL_TURBO", sample_info)
def test_vae_decode_uses_model_info_downscale_ratio_for_flux(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleVAEDecode"]
node = node_cls()
image, decode_info = node.decode(
{"samples": FakeTensor((1, 128, 64, 64))},
FakeVAE(),
model_info=json.dumps({"family": "FLUX_DEV", "downscale_ratio": 16}),
)
self.assertEqual(tuple(image.shape), (1, 64, 64, 3))
self.assertIn("decoded=1024x1024", decode_info)
if __name__ == "__main__":
unittest.main()
Binary file not shown.
Binary file not shown.
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
import json
import re
from typing import Any
AUTO_FAMILY = "Auto"
LEGACY_FAMILY_ALIASES: dict[str, str] = {
"SD1.5": "SD15",
"SD15": "SD15",
"SDXL": "SDXL",
"SDXL_TURBO": "SDXL_TURBO",
"FLUX": "FLUX_DEV",
"FLUX_SCHNELL": "FLUX_SCHNELL",
"FLUX_DEV": "FLUX_DEV",
}
MODEL_FAMILY_CHOICES: list[str] = [
AUTO_FAMILY,
"SD15",
"SD1.5",
"SDXL",
"SDXL_TURBO",
"FLUX_SCHNELL",
"FLUX_DEV",
]
TEXT_ENCODER_TYPES: dict[str, str] = {
"SD15": "clip",
"SDXL": "sdxl_dual_clip",
"SDXL_TURBO": "sdxl_dual_clip",
"FLUX_SCHNELL": "flux_clip_l_t5xxl",
"FLUX_DEV": "flux_clip_l_t5xxl",
}
REQUIRED_TEXT_ENCODERS: dict[str, list[str]] = {
"SD15": ["clip"],
"SDXL": ["clip_l", "clip_g"],
"SDXL_TURBO": ["clip_l", "clip_g"],
"FLUX_SCHNELL": ["clip_l", "t5xxl"],
"FLUX_DEV": ["clip_l", "t5xxl"],
}
REQUIRED_VAE: dict[str, str] = {
"SD15": "optional",
"SDXL": "optional",
"SDXL_TURBO": "optional",
"FLUX_SCHNELL": "required",
"FLUX_DEV": "required",
}
LATENT_SPECS: dict[str, dict[str, int]] = {
"SD15": {"latent_channels": 4, "downscale_ratio": 8},
"SDXL": {"latent_channels": 4, "downscale_ratio": 8},
"SDXL_TURBO": {"latent_channels": 4, "downscale_ratio": 8},
"FLUX_SCHNELL": {"latent_channels": 128, "downscale_ratio": 16},
"FLUX_DEV": {"latent_channels": 128, "downscale_ratio": 16},
}
BASE_RESOLUTIONS: dict[str, tuple[int, int]] = {
"SD15": (512, 512),
"SDXL": (1024, 1024),
"SDXL_TURBO": (1024, 1024),
"FLUX_SCHNELL": (1024, 1024),
"FLUX_DEV": (1024, 1024),
}
GUIDANCE_EMBED: dict[str, bool] = {
"SD15": False,
"SDXL": False,
"SDXL_TURBO": False,
"FLUX_SCHNELL": False,
"FLUX_DEV": True,
}
DEFAULT_GUIDANCE: dict[str, float | None] = {
"SD15": None,
"SDXL": None,
"SDXL_TURBO": None,
"FLUX_SCHNELL": None,
"FLUX_DEV": 3.5,
}
SAMPLING_PRESETS: dict[str, dict[str, dict[str, float | int]]] = {
"SD15": {
"Fast": {"steps": 12, "cfg": 6.5},
"Balanced": {"steps": 20, "cfg": 7.0},
"High Quality": {"steps": 30, "cfg": 7.5},
},
"SDXL": {
"Fast": {"steps": 16, "cfg": 5.5},
"Balanced": {"steps": 24, "cfg": 6.0},
"High Quality": {"steps": 36, "cfg": 6.5},
},
"SDXL_TURBO": {
"Fast": {"steps": 4, "cfg": 1.0},
"Balanced": {"steps": 6, "cfg": 1.5},
"High Quality": {"steps": 8, "cfg": 2.0},
},
"FLUX_SCHNELL": {
"Fast": {"steps": 4, "cfg": 1.0},
"Balanced": {"steps": 6, "cfg": 1.0},
"High Quality": {"steps": 8, "cfg": 1.0},
},
"FLUX_DEV": {
"Fast": {"steps": 18, "cfg": 1.0},
"Balanced": {"steps": 28, "cfg": 1.0},
"High Quality": {"steps": 36, "cfg": 1.0},
},
}
_LEGACY_MODEL_INFO_PATTERN = re.compile(r"(\w+)=([^|]+)")
def canonicalize_family(family: str | None) -> str:
if family is None:
return "SD15"
normalized = str(family).strip()
if normalized == AUTO_FAMILY:
return "SD15"
return LEGACY_FAMILY_ALIASES.get(normalized, normalized or "SD15")
def infer_family_from_name(model_name: str | None, fallback: str = "SD15") -> str:
if not model_name:
return canonicalize_family(fallback)
name = str(model_name).lower()
if "flux" in name:
if "schnell" in name:
return "FLUX_SCHNELL"
return "FLUX_DEV"
if "turbo" in name and "xl" in name:
return "SDXL_TURBO"
if any(token in name for token in ("sdxl", "_xl", "-xl", "xl_")):
return "SDXL"
return canonicalize_family(fallback)
def is_flux_family(family: str | None) -> bool:
return canonicalize_family(family) in {"FLUX_SCHNELL", "FLUX_DEV"}
def is_sdxl_family(family: str | None) -> bool:
return canonicalize_family(family) in {"SDXL", "SDXL_TURBO"}
def family_requires_guidance(family: str | None) -> bool:
return bool(GUIDANCE_EMBED.get(canonicalize_family(family), False))
def get_latent_spec(model_info: dict[str, Any] | str | None) -> dict[str, int]:
info = parse_model_info(model_info)
family = info["family"]
return {
"latent_channels": int(info.get("latent_channels", LATENT_SPECS[family]["latent_channels"])),
"downscale_ratio": int(info.get("downscale_ratio", LATENT_SPECS[family]["downscale_ratio"])),
}
def get_sampling_preset(model_info: dict[str, Any] | str | None, quality_preset: str) -> dict[str, float | int] | None:
if quality_preset == "Manual":
return None
info = parse_model_info(model_info)
family = info["family"]
family_presets = SAMPLING_PRESETS.get(family, SAMPLING_PRESETS["SD15"])
return family_presets.get(quality_preset)
def parse_model_info(model_info: dict[str, Any] | str | None) -> dict[str, Any]:
raw: dict[str, Any] = {}
if isinstance(model_info, dict):
raw = dict(model_info)
elif isinstance(model_info, str):
stripped = model_info.strip()
if stripped:
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
raw = {
key: value.strip()
for key, value in _LEGACY_MODEL_INFO_PATTERN.findall(stripped)
}
else:
if isinstance(parsed, dict):
raw = dict(parsed)
family = canonicalize_family(raw.get("family") or infer_family_from_name(raw.get("model_name") or raw.get("ckpt_name") or raw.get("ckpt"), "SD15"))
width, height = BASE_RESOLUTIONS.get(family, BASE_RESOLUTIONS["SD15"])
latent_spec = LATENT_SPECS.get(family, LATENT_SPECS["SD15"])
required_text_encoders = raw.get("required_text_encoders", REQUIRED_TEXT_ENCODERS.get(family, ["clip"]))
if isinstance(required_text_encoders, str):
required_text_encoders = [required_text_encoders]
info: dict[str, Any] = {
"family": family,
"text_encoder_type": raw.get("text_encoder_type", TEXT_ENCODER_TYPES.get(family, "clip")),
"has_embedded_vae": bool(raw.get("has_embedded_vae", False)),
"required_text_encoders": list(required_text_encoders),
"required_vae": raw.get("required_vae", REQUIRED_VAE.get(family, "optional")),
"is_turbo": bool(raw.get("is_turbo", family == "SDXL_TURBO")),
"guidance_embed": bool(raw.get("guidance_embed", GUIDANCE_EMBED.get(family, False))),
"guidance": raw.get("guidance", DEFAULT_GUIDANCE.get(family)),
"latent_channels": int(raw.get("latent_channels", latent_spec["latent_channels"])),
"downscale_ratio": int(raw.get("downscale_ratio", latent_spec["downscale_ratio"])),
"base_width": int(raw.get("base_width", width)),
"base_height": int(raw.get("base_height", height)),
}
for key in (
"model_name",
"model_source",
"text_encoder_source",
"vae_source",
"load_mode",
"has_embedded_text_encoder",
):
if key in raw:
info[key] = raw[key]
return info
def build_model_info(**kwargs: Any) -> str:
info = parse_model_info(kwargs)
for key, value in kwargs.items():
if value is not None:
info[key] = value
info["family"] = canonicalize_family(info.get("family"))
info.setdefault("text_encoder_type", TEXT_ENCODER_TYPES.get(info["family"], "clip"))
return json.dumps(info, ensure_ascii=True, sort_keys=True)