From d7aae497c2f23e3a5fa1b2739490454abd4a25db Mon Sep 17 00:00:00 2001 From: Gin3601 Date: Fri, 22 May 2026 16:54:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=AE=BE=E8=AE=A1=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8A=A0=E8=BD=BD=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LLS-universal-inpaint-workflow.json | 668 +++++++++++++++++++++++++++ LLS-universal-txt2img-workflow.json | 552 ++++++++++++++++++++++ conditioning/nodes.py | 103 +++++ model_loader/nodes.py | 248 +++++++--- tests/test_loader_prompt_refactor.py | 156 +++++++ 5 files changed, 1654 insertions(+), 73 deletions(-) create mode 100644 LLS-universal-inpaint-workflow.json create mode 100644 LLS-universal-txt2img-workflow.json diff --git a/LLS-universal-inpaint-workflow.json b/LLS-universal-inpaint-workflow.json new file mode 100644 index 0000000..6383b04 --- /dev/null +++ b/LLS-universal-inpaint-workflow.json @@ -0,0 +1,668 @@ +{ + "id": "dcac7a27-30f1-4cd1-a4a4-5b994457b3a3", + "revision": 0, + "last_node_id": 26, + "last_link_id": 20, + "nodes": [ + { + "id": 20, + "type": "LoadImage", + "pos": [ + 80, + 180 + ], + "size": [ + 340, + 520 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "label": "IMAGE", + "slot_index": 0, + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "label": "MASK", + "slot_index": 1, + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example.png", + "image", + "" + ] + }, + { + "id": 21, + "type": "LLSUniversalModelLoader", + "pos": [ + 460, + 80 + ], + "size": [ + 360, + 260 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "slot_index": 0, + "links": [ + 3 + ] + }, + { + "name": "text_encoder", + "type": "CLIP", + "slot_index": 1, + "links": [ + 4 + ] + }, + { + "name": "vae", + "type": "VAE", + "slot_index": 2, + "links": [ + 5, + 6 + ] + }, + { + "name": "model_info", + "type": "STRING", + "slot_index": 3, + "links": [ + 7, + 8, + 9 + ] + } + ], + "properties": { + "Node name for S&R": "LLSUniversalModelLoader" + }, + "widgets_values": [ + "diffusion_models/flux1-fill-dev.safetensors", + "Auto", + "simple", + "auto", + "auto", + "(auto)", + "(auto)", + "(auto)" + ] + }, + { + "id": 22, + "type": "LLSUniversalPromptEncode", + "pos": [ + 880, + 80 + ], + "size": [ + 360, + 190 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "text_encoder", + "type": "CLIP", + "link": 4, + "label": "text_encoder" + }, + { + "name": "model_info", + "type": "STRING", + "link": 7, + "label": "model_info" + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 10 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "slot_index": 1, + "links": [ + 11 + ] + }, + { + "name": "prompt_info", + "type": "STRING", + "slot_index": 2, + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "LLSUniversalPromptEncode" + }, + "widgets_values": [ + "a small brown dog sitting in the masked area", + "", + -1 + ] + }, + { + "id": 23, + "type": "LLSNativeInpaintConditioning", + "pos": [ + 880, + 300 + ], + "size": [ + 400, + 230 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 3, + "label": "model" + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 10, + "label": "positive" + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 11, + "label": "negative" + }, + { + "name": "vae", + "type": "VAE", + "link": 5, + "label": "vae" + }, + { + "name": "image", + "type": "IMAGE", + "link": 1, + "label": "image" + }, + { + "name": "mask", + "type": "MASK", + "link": 2, + "label": "mask" + }, + { + "name": "model_info", + "type": "STRING", + "link": 8, + "label": "model_info" + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "slot_index": 0, + "links": [ + 13 + ] + }, + { + "name": "positive", + "type": "CONDITIONING", + "slot_index": 1, + "links": [ + 14 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "slot_index": 2, + "links": [ + 15 + ] + }, + { + "name": "latent", + "type": "LATENT", + "slot_index": 3, + "links": [ + 16 + ] + }, + { + "name": "inpaint_info", + "type": "STRING", + "slot_index": 4, + "links": null + } + ], + "properties": { + "Node name for S&R": "LLSNativeInpaintConditioning" + }, + "widgets_values": [ + "auto", + 1.0, + true + ] + }, + { + "id": 24, + "type": "LLSSimpleKSampler", + "pos": [ + 1330, + 220 + ], + "size": [ + 380, + 320 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 13, + "label": "model" + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 14, + "label": "positive" + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 15, + "label": "negative" + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 16, + "label": "latent_image" + }, + { + "name": "repair_info", + "type": "LLS_REPAIR_INFO", + "link": null, + "label": "repair_info" + }, + { + "name": "guidance_stack", + "type": "LLS_GUIDANCE_STACK", + "link": null, + "label": "guidance_stack" + }, + { + "name": "model_info", + "type": "STRING", + "link": 9, + "label": "model_info" + } + ], + "outputs": [ + { + "name": "latent", + "type": "LATENT", + "slot_index": 0, + "links": [ + 17 + ] + }, + { + "name": "sample_info", + "type": "STRING", + "slot_index": 1, + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "LLSSimpleKSampler" + }, + "widgets_values": [ + "Family Default", + -1, + 20, + 7.0, + "euler", + "normal", + 1.0, + "manual", + "auto", + 3.5, + "Auto" + ] + }, + { + "id": 25, + "type": "LLSSimpleVAEDecode", + "pos": [ + 1750, + 240 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 17, + "label": "samples" + }, + { + "name": "vae", + "type": "VAE", + "link": 6, + "label": "vae" + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 19 + ] + }, + { + "name": "decode_info", + "type": "STRING", + "slot_index": 1, + "links": [ + 20 + ] + } + ], + "properties": { + "Node name for S&R": "LLSSimpleVAEDecode" + }, + "widgets_values": [] + }, + { + "id": 26, + "type": "LLSSaveImage", + "pos": [ + 2060, + 180 + ], + "size": [ + 320, + 220 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 19, + "label": "image" + }, + { + "name": "prompt_info", + "type": "STRING", + "link": 12, + "label": "prompt_info" + }, + { + "name": "latent_info", + "type": "STRING", + "link": null, + "label": "latent_info" + }, + { + "name": "sample_info", + "type": "STRING", + "link": 18, + "label": "sample_info" + }, + { + "name": "decode_info", + "type": "STRING", + "link": 20, + "label": "decode_info" + }, + { + "name": "upscale_info", + "type": "STRING", + "link": null, + "label": "upscale_info" + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "LLSSaveImage" + }, + "widgets_values": [ + "LLS-Inpaint", + "preview_only", + true + ] + } + ], + "links": [ + [ + 1, + 20, + 0, + 23, + 4, + "IMAGE" + ], + [ + 2, + 20, + 1, + 23, + 5, + "MASK" + ], + [ + 3, + 21, + 0, + 23, + 0, + "MODEL" + ], + [ + 4, + 21, + 1, + 22, + 0, + "CLIP" + ], + [ + 5, + 21, + 2, + 23, + 3, + "VAE" + ], + [ + 6, + 21, + 2, + 25, + 1, + "VAE" + ], + [ + 7, + 21, + 3, + 22, + 1, + "STRING" + ], + [ + 8, + 21, + 3, + 23, + 6, + "STRING" + ], + [ + 9, + 21, + 3, + 24, + 6, + "STRING" + ], + [ + 10, + 22, + 0, + 23, + 1, + "CONDITIONING" + ], + [ + 11, + 22, + 1, + 23, + 2, + "CONDITIONING" + ], + [ + 12, + 22, + 2, + 26, + 1, + "STRING" + ], + [ + 13, + 23, + 0, + 24, + 0, + "MODEL" + ], + [ + 14, + 23, + 1, + 24, + 1, + "CONDITIONING" + ], + [ + 15, + 23, + 2, + 24, + 2, + "CONDITIONING" + ], + [ + 16, + 23, + 3, + 24, + 3, + "LATENT" + ], + [ + 17, + 24, + 0, + 25, + 0, + "LATENT" + ], + [ + 18, + 24, + 1, + 26, + 3, + "STRING" + ], + [ + 19, + 25, + 0, + 26, + 0, + "IMAGE" + ], + [ + 20, + 25, + 1, + 26, + 4, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1.0, + "offset": [ + 0.0, + 0.0 + ] + } + }, + "version": 0.4 +} diff --git a/LLS-universal-txt2img-workflow.json b/LLS-universal-txt2img-workflow.json new file mode 100644 index 0000000..d9637ca --- /dev/null +++ b/LLS-universal-txt2img-workflow.json @@ -0,0 +1,552 @@ +{ + "id": "0794c057-df8e-49ea-a3b6-8feed69f2af4", + "revision": 0, + "last_node_id": 15, + "last_link_id": 15, + "nodes": [ + { + "id": 10, + "type": "LLSUniversalModelLoader", + "pos": [ + 80, + 80 + ], + "size": [ + 360, + 260 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "slot_index": 0, + "links": [ + 1, + 11 + ] + }, + { + "name": "text_encoder", + "type": "CLIP", + "slot_index": 1, + "links": [ + 2 + ] + }, + { + "name": "vae", + "type": "VAE", + "slot_index": 2, + "links": [ + 3 + ] + }, + { + "name": "model_info", + "type": "STRING", + "slot_index": 3, + "links": [ + 4, + 5 + ] + } + ], + "properties": { + "Node name for S&R": "LLSUniversalModelLoader" + }, + "widgets_values": [ + "diffusion_models/flux1-schnell.safetensors", + "Auto", + "simple", + "auto", + "auto", + "(auto)", + "(auto)", + "(auto)" + ] + }, + { + "id": 11, + "type": "LLSUniversalPromptEncode", + "pos": [ + 500, + 80 + ], + "size": [ + 360, + 190 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "text_encoder", + "type": "CLIP", + "link": 2, + "label": "text_encoder" + }, + { + "name": "model_info", + "type": "STRING", + "link": 4, + "label": "model_info" + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 6 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "slot_index": 1, + "links": [ + 7 + ] + }, + { + "name": "prompt_info", + "type": "STRING", + "slot_index": 2, + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "LLSUniversalPromptEncode" + }, + "widgets_values": [ + "a small brown dog standing on the grass", + "", + -1 + ] + }, + { + "id": 12, + "type": "LLSSimpleEmptyLatent", + "pos": [ + 500, + 320 + ], + "size": [ + 360, + 230 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1, + "label": "model" + }, + { + "name": "image", + "type": "IMAGE", + "link": null, + "label": "image" + }, + { + "name": "vae", + "type": "VAE", + "link": null, + "label": "vae" + } + ], + "outputs": [ + { + "name": "latent", + "type": "LATENT", + "slot_index": 0, + "links": [ + 9 + ] + }, + { + "name": "width", + "type": "INT", + "slot_index": 1, + "links": null + }, + { + "name": "height", + "type": "INT", + "slot_index": 2, + "links": null + }, + { + "name": "latent_info", + "type": "STRING", + "slot_index": 3, + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "LLSSimpleEmptyLatent" + }, + "widgets_values": [ + "Family Default", + 1024, + 1024, + 1, + "Auto", + "keep_aspect" + ] + }, + { + "id": 13, + "type": "LLSSimpleKSampler", + "pos": [ + 920, + 180 + ], + "size": [ + 380, + 320 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 11, + "label": "model" + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 6, + "label": "positive" + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 7, + "label": "negative" + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 9, + "label": "latent_image" + }, + { + "name": "repair_info", + "type": "LLS_REPAIR_INFO", + "link": null, + "label": "repair_info" + }, + { + "name": "guidance_stack", + "type": "LLS_GUIDANCE_STACK", + "link": null, + "label": "guidance_stack" + }, + { + "name": "model_info", + "type": "STRING", + "link": 5, + "label": "model_info" + } + ], + "outputs": [ + { + "name": "latent", + "type": "LATENT", + "slot_index": 0, + "links": [ + 12 + ] + }, + { + "name": "sample_info", + "type": "STRING", + "slot_index": 1, + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "LLSSimpleKSampler" + }, + "widgets_values": [ + "Family Default", + -1, + 20, + 7.0, + "euler", + "normal", + 1.0, + "manual", + "auto", + 3.5, + "Auto" + ] + }, + { + "id": 14, + "type": "LLSSimpleVAEDecode", + "pos": [ + 1330, + 200 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 12, + "label": "samples" + }, + { + "name": "vae", + "type": "VAE", + "link": 3, + "label": "vae" + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 14 + ] + }, + { + "name": "decode_info", + "type": "STRING", + "slot_index": 1, + "links": [ + 15 + ] + } + ], + "properties": { + "Node name for S&R": "LLSSimpleVAEDecode" + }, + "widgets_values": [] + }, + { + "id": 15, + "type": "LLSSaveImage", + "pos": [ + 1640, + 140 + ], + "size": [ + 320, + 220 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 14, + "label": "image" + }, + { + "name": "prompt_info", + "type": "STRING", + "link": 8, + "label": "prompt_info" + }, + { + "name": "latent_info", + "type": "STRING", + "link": 10, + "label": "latent_info" + }, + { + "name": "sample_info", + "type": "STRING", + "link": 13, + "label": "sample_info" + }, + { + "name": "decode_info", + "type": "STRING", + "link": 15, + "label": "decode_info" + }, + { + "name": "upscale_info", + "type": "STRING", + "link": null, + "label": "upscale_info" + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "LLSSaveImage" + }, + "widgets_values": [ + "LLS", + "preview_only", + true + ] + } + ], + "links": [ + [ + 1, + 10, + 0, + 12, + 0, + "MODEL" + ], + [ + 2, + 10, + 1, + 11, + 0, + "CLIP" + ], + [ + 3, + 10, + 2, + 14, + 1, + "VAE" + ], + [ + 4, + 10, + 3, + 11, + 1, + "STRING" + ], + [ + 5, + 10, + 3, + 13, + 6, + "STRING" + ], + [ + 6, + 11, + 0, + 13, + 1, + "CONDITIONING" + ], + [ + 7, + 11, + 1, + 13, + 2, + "CONDITIONING" + ], + [ + 8, + 11, + 2, + 15, + 1, + "STRING" + ], + [ + 9, + 12, + 0, + 13, + 3, + "LATENT" + ], + [ + 10, + 12, + 3, + 15, + 2, + "STRING" + ], + [ + 11, + 10, + 0, + 13, + 0, + "MODEL" + ], + [ + 12, + 13, + 0, + 14, + 0, + "LATENT" + ], + [ + 13, + 13, + 1, + 15, + 3, + "STRING" + ], + [ + 14, + 14, + 0, + 15, + 0, + "IMAGE" + ], + [ + 15, + 14, + 1, + 15, + 4, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1.0, + "offset": [ + 0.0, + 0.0 + ] + } + }, + "version": 0.4 +} diff --git a/conditioning/nodes.py b/conditioning/nodes.py index aae73dd..32bff7a 100644 --- a/conditioning/nodes.py +++ b/conditioning/nodes.py @@ -13,6 +13,7 @@ from ..utils.model_info import ( info_to_json, is_flux_family, is_sdxl_family, + parse_jsonish_info, resolve_model_family, ) @@ -221,10 +222,112 @@ class LLSSimplePromptEncode: return (positive, negative, prompt_info) +class LLSUniversalPromptEncode: + """ + 统一文本编码节点。 + - 只公开一个 `text_encoder` 输入 + - SDXL / FLUX 的双编码器在 Loader 内部组装,对下游透明 + - 可选 `model_info` 用于明确 family / checkpoint / encoder metadata + """ + + CATEGORY = "LLS/Conditioning" + FUNCTION = "encode" + RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "STRING") + RETURN_NAMES = ("positive", "negative", "prompt_info") + DESCRIPTION = ( + "Encode prompts using one text_encoder input across SD1.5, SDXL, and FLUX families." + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "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",), + }, + } + + def encode( + self, + text_encoder, + positive_prompt: str = "", + negative_prompt: str = "", + clip_skip=-1, + model_info=None, + ): + info = parse_jsonish_info(model_info) + family_hint = str(info.get("model_family") or info.get("family") or "Auto") + family = resolve_model_family(family_hint, clip=text_encoder) + defaults = get_family_defaults(family) + clip_skip = _normalize_clip_skip(clip_skip) + encoder = text_encoder + if is_flux_family(family): + clip_skip = -1 + + if clip_skip != -1: + try: + encoder = encoder.clone() + encoder.clip_layer(clip_skip) + except Exception: + pass + + negative_mode = "standard" + negative_for_encode = negative_prompt + + if family == "SDXL_TURBO" and negative_prompt.strip(): + negative_for_encode = "" + negative_mode = "weakened_for_turbo" + + try: + if is_flux_family(family): + guidance = defaults.get("default_guidance") + positive = _encode_flux_prompt(encoder, positive_prompt, guidance) + negative = _encode_flux_prompt(encoder, "", guidance) + negative_mode = "ignored_for_flux" + elif is_sdxl_family(family): + width = int(defaults.get("default_width", 1024)) + height = int(defaults.get("default_height", 1024)) + positive = _encode_sdxl_prompt(encoder, positive_prompt, width, height) + negative = _encode_sdxl_prompt(encoder, negative_for_encode, width, height) + else: + positive = _encode_standard_prompt(encoder, positive_prompt) + negative = _encode_standard_prompt(encoder, negative_for_encode) + except Exception as exc: + raise RuntimeError(f"[LLS] Failed to encode prompts for family {family}: {exc}") from exc + + prompt_mode = "flux" if is_flux_family(family) else "sdxl" if is_sdxl_family(family) else "clip" + prompt_info = info_to_json( + { + "model_family": family, + "checkpoint_name": info.get("checkpoint_name") or info.get("model_name") or "", + "text_encoder_type": info.get("text_encoder_type") or defaults.get("text_encoder_type", "clip"), + "text_encoder_name": info.get("text_encoder_name") or "", + "text_encoder_name_1": info.get("text_encoder_name_1") or "", + "text_encoder_name_2": info.get("text_encoder_name_2") or "", + "positive_prompt": positive_prompt, + "negative_prompt": negative_prompt, + "prompt_mode": prompt_mode, + "positive_prompt_length": len(positive_prompt), + "negative_prompt_length": len(negative_prompt), + "clip_skip": clip_skip, + "negative_mode": negative_mode, + "guidance": defaults.get("default_guidance") if is_flux_family(family) else None, + } + ) + return (positive, negative, prompt_info) + + NODE_CLASS_MAPPINGS: dict[str, type] = { "LLSSimplePromptEncode": LLSSimplePromptEncode, + "LLSUniversalPromptEncode": LLSUniversalPromptEncode, } NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { "LLSSimplePromptEncode": "LLS Simple Prompt Encode", + "LLSUniversalPromptEncode": "LLS Universal Prompt Encode", } diff --git a/model_loader/nodes.py b/model_loader/nodes.py index 36b2f22..2cf4728 100644 --- a/model_loader/nodes.py +++ b/model_loader/nodes.py @@ -39,6 +39,7 @@ from ..utils.model_info import ( MODEL_FAMILY_CHOICES, canonicalize_family, get_family_defaults, + info_to_json, infer_family_from_name, is_flux_family, is_sdxl_family, @@ -53,6 +54,8 @@ 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"] +UNIVERSAL_VAE_SOURCE_CHOICES = ["auto", "embedded", "external"] +UNIVERSAL_TEXT_ENCODER_SOURCE_CHOICES = ["auto", "embedded", "external"] _FLUX_CLIP_L_PATTERNS = ("clip_l.safetensors", "clip_l", "clip-l") _FLUX_T5_PATTERNS = ("t5xxl_fp8_e4m3fn.safetensors", "t5xxl_fp16.safetensors", "t5xxl", "t5") @@ -371,6 +374,120 @@ def _resolve_vae( return None, "none", None +def _resolve_loader_family(model_name: str, model_family: str) -> str: + if model_family == "Auto": + return infer_family_from_name(model_name, "SD1.5") + return canonicalize_family(model_family) + + +def _load_tagged_resources( + *, + model_name: str, + model_family: str, + load_mode: str, + vae_source: str, + text_encoder_source: str, + vae_name: str, + text_encoder_name_1: str, + text_encoder_name_2: str, +): + if folder_paths is None: + raise RuntimeError( + "[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 ComfyUI." + ) from _COMFY_SD_ERR + + family = _resolve_loader_family(model_name, model_family) + model_source, model_path = _resolve_model_path(model_name, family) + model, embedded_text_encoder, embedded_vae = _load_model(model_source, model_path) + if model is None: + raise RuntimeError(f"[LLS] The selected model '{model_name}' did not produce a valid diffusion model.") + + external_vae_name = _normalize_choice(vae_name, NO_VAE_PLACEHOLDER) + external_text_encoder_1 = _normalize_choice(text_encoder_name_1, NO_TEXT_ENCODER_PLACEHOLDER) + external_text_encoder_2 = _normalize_choice(text_encoder_name_2, NO_TEXT_ENCODER_PLACEHOLDER) + + text_encoder, resolved_text_encoder_source, resolved_te_1, resolved_te_2 = _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, resolved_vae_name = _resolve_vae( + family=family, + source=vae_source, + embedded_vae=embedded_vae, + external_vae_name=external_vae_name, + ) + + defaults = get_family_defaults(family) + capability_tags = _build_capability_tags(model_name, family) + resolved_text_encoder_name = resolved_te_1 or ("embedded" if text_encoder is not None else None) + resolved_text_encoder_name_1 = resolved_te_1 or ("embedded" if text_encoder is not None and not resolved_te_2 else None) + resolved_text_encoder_name_2 = resolved_te_2 + if resolved_te_1 and resolved_te_2: + resolved_text_encoder_name = ", ".join([resolved_te_1, resolved_te_2]) + resolved_vae_label = resolved_vae_name or ("embedded" if vae is not None else None) + + tag_lls_object( + model, + family=family, + model_name=model_name, + checkpoint_name=model_name, + load_mode=load_mode, + **capability_tags, + ) + tag_lls_object( + text_encoder, + family=family, + model_name=model_name, + checkpoint_name=model_name, + text_encoder_type=defaults["text_encoder_type"], + text_encoder_source=resolved_text_encoder_source, + text_encoder_name=resolved_text_encoder_name, + text_encoder_name_1=resolved_text_encoder_name_1, + text_encoder_name_2=resolved_text_encoder_name_2, + **capability_tags, + ) + tag_lls_object( + vae, + family=family, + model_name=model_name, + checkpoint_name=model_name, + vae_name=resolved_vae_label, + vae_source=resolved_vae_source, + **capability_tags, + ) + + model_info = { + "model_family": family, + "checkpoint_name": model_name, + "model_name": model_name, + "load_mode": load_mode, + "model_source": model_source, + "profile_id": capability_tags["profile_id"], + "backend_type": capability_tags["backend_type"], + "sampler_strategy": capability_tags["sampler_strategy"], + "loader_strategy": capability_tags["loader_strategy"], + "model_role": capability_tags["model_role"], + "supports_inpaint_native": capability_tags["supports_inpaint_native"], + "supports_image_edit_native": capability_tags["supports_image_edit_native"], + "preferred_edit_backend": capability_tags["preferred_edit_backend"], + "text_encoder_type": defaults["text_encoder_type"], + "text_encoder_source": resolved_text_encoder_source, + "text_encoder_name": resolved_text_encoder_name or "", + "text_encoder_name_1": resolved_text_encoder_name_1 or "", + "text_encoder_name_2": resolved_text_encoder_name_2 or "", + "vae_name": resolved_vae_label or "", + "vae_source": resolved_vae_source, + } + return model, text_encoder, vae, model_info + + class LLSSimpleCheckpointLoader: """ 统一封装的基础模型加载器。 @@ -418,89 +535,74 @@ class LLSSimpleCheckpointLoader: 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 ComfyUI." - ) from _FOLDER_PATHS_ERR - if comfy_sd is None: - raise RuntimeError( - "[LLS] comfy.sd is not available. Make sure this node runs inside ComfyUI." - ) from _COMFY_SD_ERR - - if model_family == "Auto": - family = infer_family_from_name(ckpt_name, "SD1.5") - else: - family = canonicalize_family(model_family) - - 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] The selected model '{ckpt_name}' did not produce a valid diffusion model.") - - 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) - - text_encoder, resolved_text_encoder_source, resolved_te_1, resolved_te_2 = _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, resolved_vae_name = _resolve_vae( - family=family, - source=vae_source, - embedded_vae=embedded_vae, - external_vae_name=external_vae_name, - ) - - defaults = get_family_defaults(family) - capability_tags = _build_capability_tags(ckpt_name, family) - resolved_text_encoder_name = resolved_te_1 or ("embedded" if text_encoder is not None else None) - resolved_text_encoder_name_1 = resolved_te_1 or ("embedded" if text_encoder is not None and not resolved_te_2 else None) - resolved_text_encoder_name_2 = resolved_te_2 - if resolved_te_1 and resolved_te_2: - resolved_text_encoder_name = ", ".join([resolved_te_1, resolved_te_2]) - resolved_vae_label = resolved_vae_name or ("embedded" if vae is not None else None) - - tag_lls_object( - model, - family=family, + model, text_encoder, vae, _model_info = _load_tagged_resources( model_name=ckpt_name, - checkpoint_name=ckpt_name, + model_family=model_family, load_mode=load_mode, - **capability_tags, + vae_source=vae_source, + text_encoder_source=text_encoder_source, + vae_name=external_vae_name, + text_encoder_name_1=external_text_encoder_1, + text_encoder_name_2=external_text_encoder_2, ) - tag_lls_object( - text_encoder, - family=family, - model_name=ckpt_name, - checkpoint_name=ckpt_name, - text_encoder_type=defaults["text_encoder_type"], - text_encoder_source=resolved_text_encoder_source, - text_encoder_name=resolved_text_encoder_name, - text_encoder_name_1=resolved_text_encoder_name_1, - text_encoder_name_2=resolved_text_encoder_name_2, - **capability_tags, - ) - tag_lls_object( - vae, - family=family, - model_name=ckpt_name, - checkpoint_name=ckpt_name, - vae_name=resolved_vae_label, - vae_source=resolved_vae_source, - **capability_tags, - ) - return (model, text_encoder, vae, text_encoder) +class LLSUniversalModelLoader: + CATEGORY = "LLS/Model Loader" + FUNCTION = "load" + RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING") + RETURN_NAMES = ("model", "text_encoder", "vae", "model_info") + DESCRIPTION = ( + "Unified model loader for SD1.5, SDXL, and FLUX families. " + "Internally resolves single or dual text encoders but always exposes one text_encoder output." + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model_name": (_get_model_name_choices(),), + "model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}), + "load_mode": (LOAD_MODE_CHOICES, {"default": "simple"}), + "vae_source": (UNIVERSAL_VAE_SOURCE_CHOICES, {"default": "auto"}), + "text_encoder_source": (UNIVERSAL_TEXT_ENCODER_SOURCE_CHOICES, {"default": "auto"}), + "text_encoder_1": (_get_text_encoder_choices(), {"default": AUTO_PLACEHOLDER}), + "text_encoder_2": (_get_text_encoder_choices(), {"default": AUTO_PLACEHOLDER}), + "vae_name": (_get_vae_choices(), {"default": AUTO_PLACEHOLDER}), + }, + } + + def load( + self, + model_name: str, + model_family: str, + load_mode: str, + vae_source: str, + text_encoder_source: str, + text_encoder_1: str, + text_encoder_2: str, + vae_name: str, + ): + model, text_encoder, vae, model_info = _load_tagged_resources( + model_name=model_name, + model_family=model_family, + load_mode=load_mode, + vae_source=vae_source, + text_encoder_source=text_encoder_source, + vae_name=vae_name, + text_encoder_name_1=text_encoder_1, + text_encoder_name_2=text_encoder_2, + ) + return model, text_encoder, vae, info_to_json(model_info) + + NODE_CLASS_MAPPINGS: dict[str, type] = { "LLSSimpleCheckpointLoader": LLSSimpleCheckpointLoader, + "LLSUniversalModelLoader": LLSUniversalModelLoader, } NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { "LLSSimpleCheckpointLoader": "LLS Simple Checkpoint Loader", + "LLSUniversalModelLoader": "LLS Universal Model Loader", } diff --git a/tests/test_loader_prompt_refactor.py b/tests/test_loader_prompt_refactor.py index 7ccbf7d..0732791 100644 --- a/tests/test_loader_prompt_refactor.py +++ b/tests/test_loader_prompt_refactor.py @@ -297,6 +297,35 @@ class TestLoaderPromptRefactor(unittest.TestCase): ) self.assertNotIn("optional", node_cls.INPUT_TYPES()) + def test_universal_loader_schema_exposes_single_text_encoder_output(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSUniversalModelLoader"] + required = node_cls.INPUT_TYPES()["required"] + + self.assertEqual( + node_cls.RETURN_TYPES, + ("MODEL", "CLIP", "VAE", "STRING"), + ) + self.assertEqual( + node_cls.RETURN_NAMES, + ("model", "text_encoder", "vae", "model_info"), + ) + self.assertEqual( + tuple(required.keys()), + ( + "model_name", + "model_family", + "load_mode", + "vae_source", + "text_encoder_source", + "text_encoder_1", + "text_encoder_2", + "vae_name", + ), + ) + self.assertEqual(required["model_family"][1]["default"], "Auto") + self.assertEqual(required["text_encoder_source"][1]["default"], "auto") + def test_loader_tags_sd15_resources_without_context_output(self): load_plugin_package() from lls_node_test_refactor.model_loader import nodes as loader_nodes @@ -328,6 +357,36 @@ class TestLoaderPromptRefactor(unittest.TestCase): self.assertEqual(clip._lls_text_encoder_type, "clip") self.assertEqual(vae._lls_family, "SD1.5") + def test_universal_loader_loads_sd15_into_single_text_encoder_port(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.LLSUniversalModelLoader() + model, text_encoder, vae, model_info = node.load( + "sd15.safetensors", + "SD1.5", + "simple", + "auto", + "auto", + "(auto)", + "(auto)", + "(auto)", + ) + + payload = json.loads(model_info) + self.assertEqual(model.label, "MODEL::SD15") + self.assertEqual(text_encoder.label, "CLIP::SD15") + self.assertEqual(vae.label, "VAE::SD15") + self.assertEqual(payload["model_family"], "SD1.5") + self.assertEqual(payload["checkpoint_name"], "sd15.safetensors") + self.assertEqual(payload["text_encoder_source"], "embedded") + self.assertEqual(payload["vae_source"], "embedded") + def test_loader_tags_flux_resources_and_keeps_standard_text_encoder_alias(self): load_plugin_package() from lls_node_test_refactor.model_loader import nodes as loader_nodes @@ -358,6 +417,37 @@ class TestLoaderPromptRefactor(unittest.TestCase): self.assertEqual(clip._lls_model_name, "diffusion_models/flux1-schnell.safetensors") self.assertEqual(vae._lls_vae_name, "ae.safetensors") + def test_universal_loader_loads_flux_dual_encoders_into_single_text_encoder_port(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.LLSUniversalModelLoader() + model, text_encoder, vae, model_info = node.load( + "diffusion_models/flux1-schnell.safetensors", + "FLUX_SCHNELL", + "advanced", + "external", + "external", + "clip_l.safetensors", + "t5xxl_fp16.safetensors", + "ae.safetensors", + ) + + payload = json.loads(model_info) + self.assertEqual(model.label, "MODEL::flux1-schnell.safetensors") + self.assertEqual(text_encoder.label, "CLIP::flux::clip_l.safetensors+t5xxl_fp16.safetensors") + self.assertEqual(vae.label, "VAE::ae.safetensors") + self.assertEqual(payload["model_family"], "FLUX_SCHNELL") + self.assertEqual(payload["text_encoder_name_1"], "clip_l.safetensors") + self.assertEqual(payload["text_encoder_name_2"], "t5xxl_fp16.safetensors") + self.assertEqual(payload["text_encoder_name"], "clip_l.safetensors, t5xxl_fp16.safetensors") + self.assertEqual(payload["vae_name"], "ae.safetensors") + def test_loader_raises_clear_error_when_flux_text_encoder_is_missing(self): load_plugin_package() from lls_node_test_refactor.model_loader import nodes as loader_nodes @@ -394,6 +484,18 @@ class TestLoaderPromptRefactor(unittest.TestCase): self.assertEqual(optional["clip"][0], "CLIP") self.assertNotIn("task_context", optional) + def test_universal_prompt_encode_schema_requires_single_text_encoder_input(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSUniversalPromptEncode"] + required = node_cls.INPUT_TYPES()["required"] + optional = node_cls.INPUT_TYPES()["optional"] + + self.assertEqual(node_cls.RETURN_TYPES, ("CONDITIONING", "CONDITIONING", "STRING")) + self.assertEqual(required["text_encoder"][0], "CLIP") + self.assertEqual(required["clip_skip"][1]["default"], -1) + self.assertNotIn("clip", required) + self.assertEqual(optional["model_info"][0], "STRING") + def test_prompt_encode_dispatches_sdxl_using_inferred_family_defaults(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"] @@ -451,6 +553,60 @@ class TestLoaderPromptRefactor(unittest.TestCase): self.assertEqual(positive[0][0], "cond::a robot in a forest") self.assertEqual(negative[0][0], "cond::") + def test_universal_prompt_encode_dispatches_sdxl_from_model_info(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSUniversalPromptEncode"] + node = node_cls() + clip = RecordingClip(("g", "l")) + + positive, negative, prompt_info = node.encode( + text_encoder=clip, + positive_prompt="a castle", + negative_prompt="low quality", + clip_skip=-1, + model_info=json.dumps({"model_family": "SDXL", "checkpoint_name": "sdxl_turbo.safetensors"}), + ) + + self.assertEqual(len(clip.calls), 2) + self.assertEqual(clip.calls[0]["add_dict"]["width"], 1024) + payload = json.loads(prompt_info) + self.assertEqual(payload["model_family"], "SDXL") + self.assertEqual(payload["prompt_mode"], "sdxl") + self.assertEqual(payload["checkpoint_name"], "sdxl_turbo.safetensors") + self.assertEqual(positive[0][0], "cond::a castle") + self.assertEqual(negative[0][0], "cond::low quality") + + def test_universal_prompt_encode_dispatches_flux_from_model_info(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSUniversalPromptEncode"] + node = node_cls() + clip = RecordingClip(("t5xxl",)) + + positive, negative, prompt_info = node.encode( + text_encoder=clip, + positive_prompt="a robot in a forest", + negative_prompt="ugly, blurry", + clip_skip=-1, + model_info=json.dumps( + { + "model_family": "FLUX_DEV", + "checkpoint_name": "diffusion_models/flux1-schnell.safetensors", + "text_encoder_name": "clip_l.safetensors, t5xxl_fp16.safetensors", + } + ), + ) + + self.assertEqual(len(clip.calls), 2) + self.assertEqual(clip.calls[0]["add_dict"]["guidance"], 3.5) + self.assertEqual(clip.calls[1]["tokens"]["text"], "") + payload = json.loads(prompt_info) + self.assertEqual(payload["model_family"], "FLUX_DEV") + self.assertEqual(payload["prompt_mode"], "flux") + self.assertEqual(payload["checkpoint_name"], "diffusion_models/flux1-schnell.safetensors") + self.assertEqual(payload["text_encoder_name"], "clip_l.safetensors, t5xxl_fp16.safetensors") + self.assertEqual(positive[0][0], "cond::a robot in a forest") + self.assertEqual(negative[0][0], "cond::") + def test_empty_latent_uses_model_family_inference_for_flux_shape(self): load_plugin_package() from lls_node_test_refactor.latent import nodes as latent_nodes