From 475cc3a24621248bc73be52771bb110cbd35d60b Mon Sep 17 00:00:00 2001 From: Gin3601 <2405559452@qq.com> Date: Wed, 20 May 2026 13:35:19 +0800 Subject: [PATCH] refactor: remove context ports from core nodes --- __init__.py | 1 - conditioning/nodes.py | 70 +-- ...6-05-20-contextless-routing-final-state.md | 89 ++++ image/nodes.py | 212 ++++---- latent/nodes.py | 57 +- model_loader/nodes.py | 131 ++--- sampling/nodes.py | 72 +-- tests/test_conditioning_compat.py | 32 +- tests/test_loader_prompt_refactor.py | 485 ++++++++---------- tests/test_upscale_switcher.py | 10 +- upscale/nodes.py | 113 ++-- utils/model_info.py | 83 +++ utils/nodes.py | 20 +- 13 files changed, 705 insertions(+), 670 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-20-contextless-routing-final-state.md diff --git a/__init__.py b/__init__.py index e1db808..144eba4 100644 --- a/__init__.py +++ b/__init__.py @@ -39,7 +39,6 @@ import types # ---------- 子包白名单(控制加载顺序,同时作为文档) ---------- # 按功能分类总览章节顺序排列;新增子包追加到列表末尾即可。 _SUBPACKAGES: list[str] = [ - "task", # 统一任务控制与上下文 "model_loader", # 第 1 节:模型加载与管理 "conditioning", # 第 2 节:文本编码与条件生成 "sampling", # 第 3 节:图像采样与去噪 diff --git a/conditioning/nodes.py b/conditioning/nodes.py index e806575..9403789 100644 --- a/conditioning/nodes.py +++ b/conditioning/nodes.py @@ -8,12 +8,13 @@ CATEGORY = "LLS/Conditioning" from __future__ import annotations from ..utils.model_info import ( - LLS_TEXT_ENCODER_TYPE, + MODEL_FAMILY_CHOICES, + get_family_defaults, + info_to_json, is_flux_family, is_sdxl_family, - parse_model_info, + resolve_model_family, ) -from ..utils.task_context import LLS_TASK_CONTEXT_TYPE, parse_task_context, update_task_context _CLIP_SKIP_CHOICES = [None] + list(range(-1, -25, -1)) @@ -120,19 +121,18 @@ def _encode_flux_prompt(clip, prompt: str, guidance): class LLSSimplePromptEncode: """ 家族感知的提示词编码节点。 - - - `text_encoder` 为新接口。 - - `clip` 保留作旧工作流兼容入口。 - - `task_context` 为统一控制对象;未连接时会退回 SD1.5 默认逻辑。 + - `text_encoder` 与 `clip` 都使用原生 CLIP 端口类型。 + - `model_family` 默认 Auto,从 CLIP tokenizer 或对象标记推断家族。 + - 输出 prompt_info JSON,而不是 task_context 透传对象。 """ CATEGORY = "LLS/Conditioning" FUNCTION = "encode" - RETURN_TYPES = ("CONDITIONING", "CONDITIONING", LLS_TASK_CONTEXT_TYPE) - RETURN_NAMES = ("positive", "negative", "task_context") + RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "STRING") + RETURN_NAMES = ("positive", "negative", "prompt_info") DESCRIPTION = ( - "Encode prompts into CONDITIONING tensors. Uses task_context when available to " - "dispatch between SD1.5, SDXL, SDXL Turbo, and FLUX text-encoding paths." + "Encode prompts into CONDITIONING tensors. Dispatches between SD1.5, SDXL, " + "SDXL Turbo, and FLUX text-encoding paths using native CLIP inputs." ) @classmethod @@ -142,21 +142,21 @@ class LLSSimplePromptEncode: "positive_prompt": ("STRING", {"default": "", "multiline": True}), "negative_prompt": ("STRING", {"default": "", "multiline": True}), "clip_skip": (_CLIP_SKIP_CHOICES, {"default": -1}), + "model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}), }, "optional": { - "text_encoder": (LLS_TEXT_ENCODER_TYPE,), + "text_encoder": ("CLIP",), "clip": ("CLIP",), - "task_context": (LLS_TASK_CONTEXT_TYPE,), }, } def encode( self, - text_encoder=None, positive_prompt: str = "", negative_prompt: str = "", clip_skip=-1, - task_context=None, + model_family: str = "Auto", + text_encoder=None, clip=None, ): encoder = text_encoder if text_encoder is not None else clip @@ -166,11 +166,10 @@ class LLSSimplePromptEncode: "or the legacy 'clip' input." ) - context = parse_task_context(task_context) - info = parse_model_info(context) - family = context.get("resolved_model_family") or info["family"] + family = resolve_model_family(model_family, clip=encoder) + defaults = get_family_defaults(family) clip_skip = _normalize_clip_skip(clip_skip) - if not context.get("supports_clip_skip", True): + if is_flux_family(family): clip_skip = -1 if clip_skip != -1: @@ -189,13 +188,13 @@ class LLSSimplePromptEncode: try: if is_flux_family(family): - guidance = info.get("guidance", info.get("default_guidance")) + 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(info.get("default_width", 1024)) - height = int(info.get("default_height", 1024)) + 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: @@ -205,20 +204,21 @@ class LLSSimplePromptEncode: 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" - next_context = update_task_context( - context, - resolved_model_family=family, - text_encoder_type=info.get("text_encoder_type", "clip"), - 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, - source="LLS Simple Prompt Encode", + prompt_info = info_to_json( + { + "model_family": family, + "text_encoder_type": defaults.get("text_encoder_type", "clip"), + "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, next_context) + return (positive, negative, prompt_info) NODE_CLASS_MAPPINGS: dict[str, type] = { diff --git a/docs/superpowers/plans/2026-05-20-contextless-routing-final-state.md b/docs/superpowers/plans/2026-05-20-contextless-routing-final-state.md new file mode 100644 index 0000000..6c1f388 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-contextless-routing-final-state.md @@ -0,0 +1,89 @@ +# Contextless Routing Final State Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove `task_context` and `model_info` execution ports from the core LLS nodes, infer family/task from `MODEL` / `CLIP` / `LATENT`, and keep old workflows compatible through standard ComfyUI ports plus local manual overrides. + +**Architecture:** Store only lightweight `_lls_*` tags on native ComfyUI objects, centralize family/task inference in `utils/model_info.py`, and make each node emit JSON info strings instead of passing context objects. Keep the core node graph native: `MODEL`, `CLIP`, `VAE`, `LATENT`, `IMAGE`, and `CONDITIONING`. + +**Tech Stack:** Python, ComfyUI custom nodes, `unittest` + +--- + +### Task 1: Shared inference helpers + +**Files:** +- Modify: `utils/model_info.py` +- Test: `tests/test_loader_prompt_refactor.py` + +- [ ] **Step 1: Keep the failing spec as the red test** + +Run: `python3 -B -m unittest tests.test_loader_prompt_refactor` +Expected: FAIL on loader / prompt / latent / sampler signatures + +- [ ] **Step 2: Add shared helpers for object tagging and family resolution** + +Implement helpers in `utils/model_info.py` for: +- safely tagging `model`, `clip`, `vae` +- resolving effective family from `model_family`, `model`, `clip` +- building JSON-safe info payloads for prompt / latent / sample / decode / upscale + +- [ ] **Step 3: Re-run targeted inference tests** + +Run: `python3 -B -m unittest tests.test_model_info_inference` +Expected: PASS + +### Task 2: Core node API refactor + +**Files:** +- Modify: `model_loader/nodes.py` +- Modify: `conditioning/nodes.py` +- Modify: `latent/nodes.py` +- Modify: `sampling/nodes.py` +- Modify: `image/nodes.py` +- Modify: `upscale/nodes.py` +- Modify: `utils/nodes.py` +- Test: `tests/test_loader_prompt_refactor.py` +- Test: `tests/test_conditioning_compat.py` +- Test: `tests/test_upscale_switcher.py` + +- [ ] **Step 1: Refactor loader outputs and tagging** + +Change `LLSSimpleCheckpointLoader` to return `(MODEL, CLIP, VAE, CLIP)`, remove optional `task_context`, and tag returned native objects with `_lls_family`, `_lls_model_name`, `_lls_text_encoder_type`, and `_lls_vae_name`. + +- [ ] **Step 2: Refactor encode / latent / sample / image nodes** + +Change each node to: +- accept `model_family="Auto"` as a local override +- infer family from `MODEL` / `CLIP` +- infer task mode from `LATENT["source"]` +- return JSON strings (`prompt_info`, `latent_info`, `sample_info`, `decode_info`) + +- [ ] **Step 3: Refactor metadata and config nodes** + +Update `LLSSaveImage` and `LLSGenerationConfig` to use native objects plus JSON info strings instead of `task_context/model_info`. + +- [ ] **Step 4: Run focused tests** + +Run: `python3 -B -m unittest tests.test_loader_prompt_refactor tests.test_conditioning_compat tests.test_upscale_switcher` +Expected: PASS + +### Task 3: Registration cleanup and full verification + +**Files:** +- Modify: `__init__.py` +- Test: `tests/test_loader_prompt_refactor.py` +- Test: `tests/` + +- [ ] **Step 1: Unregister legacy task nodes** + +Remove the `task` subpackage from `__init__.py` so `LLSTaskController` and `LLSTaskInspector` are not exposed in contextless mode. + +- [ ] **Step 2: Run the full suite** + +Run: `python3 -B -m unittest discover -s tests` +Expected: PASS + +- [ ] **Step 3: Merge only after green** + +If and only if the full suite is green, merge `refactor/contextless-routing` back into `main` and remove the project-local worktree. diff --git a/image/nodes.py b/image/nodes.py index d0c02a0..272d40b 100644 --- a/image/nodes.py +++ b/image/nodes.py @@ -11,11 +11,17 @@ from datetime import datetime import importlib from ..utils.model_info import ( + MODEL_FAMILY_CHOICES, + canonicalize_family, + get_family_defaults, get_latent_spec, + info_to_json, parse_jsonish_info, - parse_model_info, + resolve_model_family, + resolve_model_name, + resolve_text_encoder_names, + resolve_vae_name, ) -from ..utils.task_context import LLS_TASK_CONTEXT_TYPE, parse_task_context, task_context_to_json, update_task_context try: @@ -109,6 +115,30 @@ def _resize_image_to(image, target_width: int, target_height: int, resize_mode: raise RuntimeError(f"[LLS] Failed to resize IMAGE for VAE encode: {exc}") from exc +def _infer_family_from_latent_or_vae(samples, vae=None) -> str: + tagged_family = getattr(vae, "_lls_family", None) + if tagged_family and tagged_family not in {"Auto", "auto", ""}: + return canonicalize_family(tagged_family) + + if isinstance(samples, dict): + try: + ratio = int(samples.get("downscale_ratio_spacial", 0)) + if ratio >= 16: + return "FLUX_DEV" + except Exception: + pass + latent_tensor = samples.get("samples") + latent_shape = tuple(getattr(latent_tensor, "shape", ())) + if len(latent_shape) >= 2: + try: + if int(latent_shape[1]) >= 16: + return "FLUX_DEV" + except Exception: + pass + + return "SD1.5" + + class LLSSimpleVAEEncode: """ 把 IMAGE 编码为 LATENT,供 img2img 直接接入现有 KSampler。 @@ -117,8 +147,8 @@ class LLSSimpleVAEEncode: CATEGORY = "LLS-node" FUNCTION = "encode" - RETURN_TYPES = ("LATENT", "INT", "INT", LLS_TASK_CONTEXT_TYPE) - RETURN_NAMES = ("latent", "width", "height", "task_context") + RETURN_TYPES = ("LATENT", "INT", "INT", "STRING") + RETURN_NAMES = ("latent", "width", "height", "latent_info") DESCRIPTION = "Encode an IMAGE into a LATENT tensor for img2img workflows." @classmethod @@ -131,9 +161,11 @@ class LLSSimpleVAEEncode: "size_source": (_SIZE_SOURCES, {"default": "input_image"}), "width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}), "height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}), + "model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}), }, "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), + "model": ("MODEL",), + "clip": ("CLIP",), }, } @@ -145,7 +177,9 @@ class LLSSimpleVAEEncode: size_source: str, width: int, height: int, - task_context=None, + model_family: str = "Auto", + model=None, + clip=None, ): if image is None: raise RuntimeError("[LLS] IMAGE input is required for LLS Simple VAE Encode.") @@ -154,9 +188,9 @@ class LLSSimpleVAEEncode: "[LLS] Missing VAE. Connect the Loader VAE output or choose an external VAE in the loader." ) - context = parse_task_context(task_context) - info = parse_model_info(context) - latent_spec = get_latent_spec(info) + family = resolve_model_family(model_family, model=model, clip=clip) + defaults = get_family_defaults(family) + latent_spec = get_latent_spec(defaults) alignment = max(8, int(latent_spec["downscale_ratio"])) batch_size, source_width, source_height = _get_image_dimensions(image) @@ -167,8 +201,8 @@ class LLSSimpleVAEEncode: source_width=source_width, source_height=source_height, model_info={ - "default_width": context.get("recommended_width", info["default_width"]), - "default_height": context.get("recommended_height", info["default_height"]), + "default_width": defaults["default_width"], + "default_height": defaults["default_height"], }, ) requested_width = _round_to_multiple(requested_width, alignment) @@ -209,24 +243,24 @@ class LLSSimpleVAEEncode: "downscale_ratio_spacial": int(latent_spec["downscale_ratio"]), "source": "image_encode", } - next_context = update_task_context( - context, - task_mode="img2img" if context.get("task_mode") in (None, "", "txt2img") else context.get("task_mode"), - latent_source="image_encode", - input_image_width=source_width, - input_image_height=source_height, - final_width=final_width, - final_height=final_height, - batch_size=batch_size, - resize_mode=resize_mode, - size_source=size_source, - vae_source=context.get("vae_source") or info.get("vae_source", "auto"), - vae_name=context.get("vae_name") or info.get("vae_name", "unknown"), - latent_channels=latent_channels, - downscale_ratio=int(latent_spec["downscale_ratio"]), - source="LLS Simple VAE Encode", + latent_info = info_to_json( + { + "model_family": family, + "task_mode": "img2img", + "latent_source": "image_encode", + "input_image_width": source_width, + "input_image_height": source_height, + "width": final_width, + "height": final_height, + "batch_size": batch_size, + "resize_mode": resize_mode, + "size_source": size_source, + "vae_name": resolve_vae_name(vae, fallback="unknown"), + "latent_channels": latent_channels, + "downscale_ratio": int(latent_spec["downscale_ratio"]), + } ) - return (latent_payload, final_width, final_height, next_context) + return (latent_payload, final_width, final_height, latent_info) class LLSSimpleVAEDecode: @@ -237,8 +271,8 @@ class LLSSimpleVAEDecode: CATEGORY = "LLS/Image" FUNCTION = "decode" - RETURN_TYPES = ("IMAGE", LLS_TASK_CONTEXT_TYPE) - RETURN_NAMES = ("image", "task_context") + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("image", "decode_info") DESCRIPTION = "Decode a LATENT tensor into a pixel IMAGE using the VAE." @classmethod @@ -248,18 +282,13 @@ class LLSSimpleVAEDecode: "samples": ("LATENT",), "vae": ("VAE",), }, - "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), - }, } - def decode(self, samples, vae, task_context=None): - context = parse_task_context(task_context) - info = parse_model_info(context) - family = context.get("resolved_model_family") or info["family"] + def decode(self, samples, vae): + family = _infer_family_from_latent_or_vae(samples, vae) if vae is None: - if info.get("is_flux"): + if family.startswith("FLUX"): raise RuntimeError( "[LLS] Missing FLUX AE/VAE. Place ae.safetensors in ComfyUI/models/vae/ " "or set Loader.vae_source to 'external'." @@ -288,22 +317,21 @@ class LLSSimpleVAEDecode: if len(getattr(image, "shape", ())) == 5: image = image.reshape(-1, image.shape[-3], image.shape[-2], image.shape[-1]) - latent_spec = get_latent_spec(info) - downscale_ratio = int(samples.get("downscale_ratio_spacial", latent_spec["downscale_ratio"])) + defaults = get_family_defaults(family) + downscale_ratio = int(samples.get("downscale_ratio_spacial", defaults["downscale_ratio"])) height = latent_tensor.shape[2] * downscale_ratio width = latent_tensor.shape[3] * downscale_ratio - next_context = update_task_context( - context, - resolved_model_family=family, - final_width=width, - final_height=height, - batch_size=latent_tensor.shape[0], - vae_name=context.get("vae_name") or info.get("vae_name", "unknown"), - decoded=True, - decode_stage="vae_decode", - source="LLS Simple VAE Decode", + decode_info = info_to_json( + { + "model_family": family, + "width": width, + "height": height, + "batch_size": latent_tensor.shape[0], + "vae_name": resolve_vae_name(vae, fallback="unknown"), + "decode_stage": "vae_decode", + } ) - return (image, next_context) + return (image, decode_info) class LLSSaveImage: @@ -326,8 +354,9 @@ class LLSSaveImage: "save_metadata": ("BOOLEAN", {"default": True}), }, "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), - "model_info": (LLS_TASK_CONTEXT_TYPE,), + "model": ("MODEL",), + "clip": ("CLIP",), + "vae": ("VAE",), "prompt_info": ("STRING", {"forceInput": True}), "latent_info": ("STRING", {"forceInput": True}), "sample_info": ("STRING", {"forceInput": True}), @@ -343,53 +372,55 @@ class LLSSaveImage: def _build_metadata( self, image, - task_context=None, - model_info=None, + model=None, + clip=None, + vae=None, prompt_info: str | None = None, latent_info: str | None = None, sample_info: str | None = None, decode_info: str | None = None, upscale_info: str | None = None, ) -> dict: - context = parse_task_context(task_context or model_info) - model = parse_model_info(context) prompt = parse_jsonish_info(prompt_info) latent = parse_jsonish_info(latent_info) sample = parse_jsonish_info(sample_info) decode = parse_jsonish_info(decode_info) upscale = parse_jsonish_info(upscale_info) - text_encoder_name = context.get("text_encoder_name") or model.get("text_encoder_name") - if context.get("text_encoder_name_2") or model.get("text_encoder_name_2"): - joined = [ - name - for name in ( - context.get("text_encoder_name_1") or model.get("text_encoder_name_1"), - context.get("text_encoder_name_2") or model.get("text_encoder_name_2"), - ) - if name - ] - text_encoder_name = ", ".join(joined) + family = prompt.get("model_family") or latent.get("model_family") or decode.get("model_family") + if not family: + family = resolve_model_family("Auto", model=model, clip=clip) + + text_encoder_name = resolve_text_encoder_names(clip).get("text_encoder_name", "") + checkpoint_name = resolve_model_name( + model=model, + clip=clip, + fallback=prompt.get("checkpoint_name") or latent.get("checkpoint_name") or decode.get("checkpoint_name") or "", + ) + vae_name = resolve_vae_name( + vae, + fallback=decode.get("vae_name") or latent.get("vae_name") or "", + ) metadata = { - "positive_prompt": context.get("positive_prompt") or prompt.get("positive_prompt", ""), - "negative_prompt": context.get("negative_prompt") or prompt.get("negative_prompt", ""), - "seed": context.get("seed", sample.get("seed")), - "model_family": context.get("resolved_model_family") or context.get("model_family") or model.get("family"), - "checkpoint_name": context.get("checkpoint_name") or model.get("checkpoint_name"), - "vae_name": context.get("vae_name") or decode.get("vae_name") or model.get("vae_name"), + "positive_prompt": prompt.get("positive_prompt", ""), + "negative_prompt": prompt.get("negative_prompt", ""), + "seed": sample.get("seed"), + "model_family": family, + "checkpoint_name": checkpoint_name, + "vae_name": vae_name, "text_encoder_name": text_encoder_name, - "steps": context.get("steps", sample.get("steps")), - "cfg": context.get("cfg", sample.get("cfg")), - "guidance": context.get("guidance", sample.get("guidance")), - "sampler_name": context.get("sampler_name", sample.get("sampler_name")), - "scheduler": context.get("scheduler", sample.get("scheduler")), - "denoise": context.get("denoise", sample.get("denoise")), - "width": context.get("final_width") or decode.get("width") or latent.get("width") or image.shape[2], - "height": context.get("final_height") or decode.get("height") or latent.get("height") or image.shape[1], - "batch_size": context.get("batch_size") or latent.get("batch_size") or decode.get("batch_size") or image.shape[0], - "upscale_mode": context.get("upscale_mode") or upscale.get("mode"), - "scale": context.get("upscale_scale") or upscale.get("scale"), + "steps": sample.get("steps"), + "cfg": sample.get("cfg"), + "guidance": sample.get("guidance"), + "sampler_name": sample.get("sampler_name"), + "scheduler": sample.get("scheduler"), + "denoise": sample.get("denoise"), + "width": decode.get("width") or latent.get("width") or image.shape[2], + "height": decode.get("height") or latent.get("height") or image.shape[1], + "batch_size": decode.get("batch_size") or latent.get("batch_size") or image.shape[0], + "upscale_mode": upscale.get("mode") or upscale.get("upscale_mode"), + "scale": upscale.get("scale") or upscale.get("upscale_scale"), "timestamp": datetime.now().isoformat(timespec="seconds"), } return metadata @@ -399,8 +430,9 @@ class LLSSaveImage: image, filename_prefix: str = "LLS", save_metadata: bool = True, - task_context=None, - model_info=None, + model=None, + clip=None, + vae=None, prompt_info: str | None = None, latent_info: str | None = None, sample_info: str | None = None, @@ -417,15 +449,15 @@ class LLSSaveImage: if save_metadata: merged_extra_pnginfo["lls_metadata"] = self._build_metadata( image=image, - task_context=task_context, - model_info=model_info, + model=model, + clip=clip, + vae=vae, prompt_info=prompt_info, latent_info=latent_info, sample_info=sample_info, decode_info=decode_info, upscale_info=upscale_info, ) - merged_extra_pnginfo["lls_task_context"] = task_context_to_json(task_context or model_info) return saver.save_images( image, filename_prefix=filename_prefix, diff --git a/latent/nodes.py b/latent/nodes.py index 473b931..fccfffa 100644 --- a/latent/nodes.py +++ b/latent/nodes.py @@ -21,11 +21,13 @@ except Exception: model_management = None from ..utils.model_info import ( + MODEL_FAMILY_CHOICES, SIZE_PRESET_AUTO, + get_family_defaults, get_latent_spec, - parse_model_info, + info_to_json, + resolve_model_family, ) -from ..utils.task_context import LLS_TASK_CONTEXT_TYPE, parse_task_context, update_task_context _SIZE_PRESETS = [ @@ -53,17 +55,17 @@ class LLSSimpleEmptyLatent: """ 生成空白 Latent,作为 txt2img 推理起点。 - - `Family Default` 时按照 task_context 推荐尺寸自动取默认值。 + - `Family Default` 时按照模型家族默认尺寸自动取值。 - `Custom` 时才使用手填 width / height。 """ CATEGORY = "LLS/Latent" FUNCTION = "create_empty_latent" - RETURN_TYPES = ("LATENT", "INT", "INT", LLS_TASK_CONTEXT_TYPE) - RETURN_NAMES = ("latent", "width", "height", "task_context") + RETURN_TYPES = ("LATENT", "INT", "INT", "STRING") + RETURN_NAMES = ("latent", "width", "height", "latent_info") DESCRIPTION = ( "Create an empty latent image as the starting point for txt2img. " - "Uses task_context recommendations when size_preset is 'Family Default'." + "Uses inferred family defaults when size_preset is 'Family Default'." ) @classmethod @@ -74,9 +76,10 @@ 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}), + "model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}), }, "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), + "model": ("MODEL",), }, } @@ -86,22 +89,23 @@ class LLSSimpleEmptyLatent: width: int, height: int, batch_size: int, - task_context=None, + model_family: str = "Auto", + model=None, ): if torch is None: raise RuntimeError( "[LLS] PyTorch is not available. Make sure this node runs inside a ComfyUI environment." ) from _TORCH_ERR - context = parse_task_context(task_context) - info = parse_model_info(context) - latent_spec = get_latent_spec(info) + family = resolve_model_family(model_family, model=model) + defaults = get_family_defaults(family) + latent_spec = get_latent_spec(defaults) latent_channels = latent_spec["latent_channels"] downscale_ratio = latent_spec["downscale_ratio"] if size_preset == SIZE_PRESET_AUTO: - width = int(context.get("recommended_width", info["default_width"])) - height = int(context.get("recommended_height", info["default_height"])) + width = int(defaults["default_width"]) + height = int(defaults["default_height"]) elif size_preset != "Custom": try: width, height = [int(part) for part in size_preset.split("x", 1)] @@ -129,25 +133,24 @@ class LLSSimpleEmptyLatent: dtype=dtype, ) - next_context = update_task_context( - context, - resolved_model_family=context.get("resolved_model_family") or info["family"], - task_mode="txt2img" if context.get("task_mode") in (None, "", "img2img") else context.get("task_mode"), - latent_source="empty_latent", - final_width=width, - final_height=height, - batch_size=batch_size, - latent_channels=latent_channels, - downscale_ratio=downscale_ratio, - size_preset=size_preset, - source="LLS Simple Empty Latent", + latent_info = info_to_json( + { + "model_family": family, + "task_mode": "txt2img", + "latent_source": "empty_latent", + "width": width, + "height": height, + "batch_size": batch_size, + "latent_channels": latent_channels, + "downscale_ratio": downscale_ratio, + "size_preset": size_preset, + } ) - return ( {"samples": latent, "downscale_ratio_spacial": downscale_ratio, "source": "empty_latent"}, width, height, - next_context, + latent_info, ) diff --git a/model_loader/nodes.py b/model_loader/nodes.py index f952f61..d5b5a90 100644 --- a/model_loader/nodes.py +++ b/model_loader/nodes.py @@ -35,18 +35,14 @@ else: _CORE_NODES_ERR = None from ..utils.model_info import ( - LLS_TEXT_ENCODER_TYPE, MODEL_FAMILY_CHOICES, - build_model_info, canonicalize_family, - family_requires_guidance, get_family_defaults, - get_latent_spec, infer_family_from_name, is_flux_family, is_sdxl_family, + tag_lls_object, ) -from ..utils.task_context import LLS_TASK_CONTEXT_TYPE, merge_task_context, parse_task_context, update_task_context AUTO_PLACEHOLDER = "(auto)" @@ -355,20 +351,21 @@ class LLSSimpleCheckpointLoader: """ 统一封装的基础模型加载器。 - 兼容输出结构为 model / clip / vae / task_context / text_encoder。 + 输出结构固定为 model / clip / vae / text_encoder。 - `clip` 保留旧工作流需要的原生 CLIP 端口类型和位置。 - - `text_encoder` 为新接口别名,内部承载同一个 ComfyUI 文本编码对象。 + - `text_encoder` 是同一个原生 CLIP 对象的兼容别名。 + - 轻量级模型信息写入原生对象 `_lls_*` 属性,不再额外输出 task_context。 """ CATEGORY = "LLS/Model Loader" FUNCTION = "load_checkpoint" - RETURN_TYPES = ("MODEL", "CLIP", "VAE", LLS_TASK_CONTEXT_TYPE, LLS_TEXT_ENCODER_TYPE) - RETURN_NAMES = ("model", "clip", "vae", "task_context", "text_encoder") + RETURN_TYPES = ("MODEL", "CLIP", "VAE", "CLIP") + RETURN_NAMES = ("model", "clip", "vae", "text_encoder") DESCRIPTION = ( "Load SD1.5, SDXL, SDXL Turbo, and FLUX families with family-aware resource dispatch. " "Supports FLUX checkpoint-all-in-one mode and separated diffusion/text-encoder/VAE mode. " - "Preserves the legacy CLIP output for old workflows and writes the resolved loader state " - "back into a unified task_context object." + "Preserves the legacy CLIP output for old workflows and writes lightweight metadata " + "into native MODEL/CLIP/VAE objects." ) @classmethod @@ -384,9 +381,6 @@ class LLSSimpleCheckpointLoader: "external_text_encoder_1": (_get_text_encoder_choices(), {"default": AUTO_PLACEHOLDER}), "external_text_encoder_2": (_get_text_encoder_choices(), {"default": AUTO_PLACEHOLDER}), }, - "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), - }, } def load_checkpoint( @@ -399,7 +393,6 @@ class LLSSimpleCheckpointLoader: external_vae_name: str, external_text_encoder_1: str, external_text_encoder_2: str, - task_context=None, ): if folder_paths is None: raise RuntimeError( @@ -410,20 +403,10 @@ class LLSSimpleCheckpointLoader: "[LLS] comfy.sd is not available. Make sure this node runs inside ComfyUI." ) from _COMFY_SD_ERR - context = parse_task_context(task_context) - requested_family = str(context.get("model_family") or "auto") - if model_family != "Auto": - requested_family = model_family if model_family == "Auto": - fallback_family = context.get("resolved_model_family") or context.get("family") or context.get("model_family") or "SD1.5" - family = infer_family_from_name(ckpt_name, fallback_family) + family = infer_family_from_name(ckpt_name, "SD1.5") else: - family = canonicalize_family(requested_family) - - if vae_source == "auto" and context.get("use_external_vae"): - vae_source = "external" - if text_encoder_source == "auto" and context.get("use_external_text_encoder"): - text_encoder_source = "external" + 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) @@ -449,79 +432,41 @@ class LLSSimpleCheckpointLoader: ) defaults = get_family_defaults(family) - latent_spec = get_latent_spec(defaults) - model_info = build_model_info( + 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, - checkpoint_name=ckpt_name, model_name=ckpt_name, + checkpoint_name=ckpt_name, load_mode=load_mode, - vae_source=resolved_vae_source, - text_encoder_source=resolved_text_encoder_source, + ) + tag_lls_object( + text_encoder, + family=family, + model_name=ckpt_name, + checkpoint_name=ckpt_name, text_encoder_type=defaults["text_encoder_type"], - has_embedded_vae=embedded_vae is not None, - has_embedded_text_encoder=embedded_text_encoder is not None, - required_text_encoders=defaults["required_text_encoders"], - required_vae=defaults["required_vae"], - is_turbo=defaults["is_turbo"], - is_flux=defaults["is_flux"], - default_width=defaults["default_width"], - default_height=defaults["default_height"], - default_steps=defaults["default_steps"], - default_cfg=defaults["default_cfg"], - default_guidance=defaults["default_guidance"], - default_sampler=defaults["default_sampler"], - default_scheduler=defaults["default_scheduler"], - default_denoise=defaults["default_denoise"], - guidance_embed=family_requires_guidance(family), - latent_channels=latent_spec["latent_channels"], - downscale_ratio=latent_spec["downscale_ratio"], - model_source=model_source, - vae_name=resolved_vae_name or ("embedded" if embedded_vae is not None else None), - text_encoder_name=resolved_te_1, - text_encoder_name_1=resolved_te_1, - text_encoder_name_2=resolved_te_2, - ) - - model_caps = { - "supports_img2img": vae is not None, - "supports_inpaint": vae is not None, - "supports_controlnet": family in {"SD1.5", "SDXL", "SDXL_TURBO"}, - "supports_clip_skip": not is_flux_family(family), - "supports_flux_guidance": is_flux_family(family), - "vae_type": "ae" if is_flux_family(family) else "vae", - "text_encoder_type": model_info["text_encoder_type"], - "latent_channels": latent_spec["latent_channels"], - "downscale_ratio": latent_spec["downscale_ratio"], - "recommended_width": model_info["default_width"], - "recommended_height": model_info["default_height"], - "recommended_steps": model_info["default_steps"], - "recommended_cfg": model_info["default_cfg"], - "recommended_denoise": model_info["default_denoise"], - "recommended_guidance": model_info["default_guidance"], - "recommended_sampler": model_info["default_sampler"], - "recommended_scheduler": model_info["default_scheduler"], - "source": "LLS Simple Checkpoint Loader", - } - next_context = merge_task_context(context, model_info) - next_context = update_task_context( - next_context, - model_family=requested_family, - resolved_model_family=family, - checkpoint_name=ckpt_name, - model_name=ckpt_name, - load_mode=load_mode, - vae_source=resolved_vae_source, text_encoder_source=resolved_text_encoder_source, - vae_name=resolved_vae_name or ("embedded" if embedded_vae is not None else None), - text_encoder_name=resolved_te_1, - text_encoder_name_1=resolved_te_1, - text_encoder_name_2=resolved_te_2, - use_external_vae=resolved_vae_source == "external", - use_external_text_encoder=resolved_text_encoder_source == "external", - **model_caps, + 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, + ) + tag_lls_object( + vae, + family=family, + model_name=ckpt_name, + checkpoint_name=ckpt_name, + vae_name=resolved_vae_label, + vae_source=resolved_vae_source, ) - return (model, text_encoder, vae, next_context, text_encoder) + return (model, text_encoder, vae, text_encoder) NODE_CLASS_MAPPINGS: dict[str, type] = { diff --git a/sampling/nodes.py b/sampling/nodes.py index 815231c..ef7cbd4 100644 --- a/sampling/nodes.py +++ b/sampling/nodes.py @@ -11,12 +11,14 @@ import random from ..utils.model_info import ( FAMILY_DEFAULT_PRESET, + MODEL_FAMILY_CHOICES, + get_family_defaults, get_sampling_preset, + infer_task_mode_from_latent, info_to_json, is_flux_family, - parse_model_info, + resolve_model_family, ) -from ..utils.task_context import LLS_TASK_CONTEXT_TYPE, parse_task_context, update_task_context # ---------- 防御性导入 ---------- @@ -197,7 +199,6 @@ def _common_ksampler( ) out = latent.copy() - out.pop("downscale_ratio_spacial", None) out["samples"] = sampled return out @@ -213,8 +214,8 @@ class LLSSimpleKSampler: CATEGORY = "LLS/Sampling" FUNCTION = "sample" - RETURN_TYPES = ("LATENT", "STRING", LLS_TASK_CONTEXT_TYPE) - RETURN_NAMES = ("latent", "sample_info", "task_context") + RETURN_TYPES = ("LATENT", "STRING") + RETURN_NAMES = ("latent", "sample_info") DESCRIPTION = ( "Basic KSampler node. Reuses ComfyUI's native sampling pipeline. " "quality_preset overrides steps/cfg/denoise when not 'Manual'. " @@ -229,6 +230,7 @@ class LLSSimpleKSampler: "positive": ("CONDITIONING",), "negative": ("CONDITIONING",), "latent_image": ("LATENT",), + "model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}), "quality_preset": (_QUALITY_PRESETS, {"default": FAMILY_DEFAULT_PRESET}), "seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFFFFFFFFFF}), "steps": ("INT", {"default": 20, "min": 1, "max": 10000}), @@ -241,9 +243,6 @@ class LLSSimpleKSampler: {"default": 3.5, "widgetType": "FLOAT", "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.1}, ), }, - "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), - }, } @classmethod @@ -265,6 +264,7 @@ class LLSSimpleKSampler: positive, negative, latent_image, + model_family: str, quality_preset: str, seed: int, steps: int, @@ -273,7 +273,6 @@ class LLSSimpleKSampler: scheduler: str, denoise: float, flux_guidance, - task_context=None, ): if comfy_sample is None: raise RuntimeError( @@ -286,30 +285,21 @@ class LLSSimpleKSampler: "Make sure this node runs inside a ComfyUI environment." ) from _COMFY_SAMPLERS_ERR - context = parse_task_context(task_context) - info = parse_model_info(context) - family = context.get("resolved_model_family") or info["family"] - latent_source = latent_image.get("source") or context.get("latent_source") - default_flux_guidance = context.get("recommended_guidance") - if default_flux_guidance is None: - default_flux_guidance = info.get("guidance", info.get("default_guidance")) - effective_task_mode = context.get("task_mode") or "txt2img" - if latent_source == "empty_latent": - effective_task_mode = "txt2img" - elif latent_source == "image_encode": - effective_task_mode = "img2img" + family = resolve_model_family(model_family, model=model) + defaults = get_family_defaults(family) + default_flux_guidance = defaults.get("default_guidance") + effective_task_mode = infer_task_mode_from_latent(latent_image) if quality_preset == FAMILY_DEFAULT_PRESET: - steps = int(context.get("recommended_steps", steps)) - cfg = float(context.get("recommended_cfg", cfg)) - sampler_name = str(context.get("recommended_sampler", sampler_name)) - scheduler = str(context.get("recommended_scheduler", scheduler)) - denoise = float(context.get("recommended_denoise", denoise)) - context_guidance = default_flux_guidance - if context_guidance is not None: - flux_guidance = float(context_guidance) + steps = int(defaults["default_steps"]) + cfg = float(defaults["default_cfg"]) + sampler_name = str(defaults["default_sampler"]) + scheduler = str(defaults["default_scheduler"]) + denoise = float(defaults["default_denoise"]) + if default_flux_guidance is not None: + flux_guidance = float(default_flux_guidance) else: - preset = get_sampling_preset(info, quality_preset) + preset = get_sampling_preset(defaults, quality_preset) if preset is not None: steps = int(preset["steps"]) cfg = float(preset["cfg"]) @@ -321,7 +311,7 @@ class LLSSimpleKSampler: flux_guidance = _normalize_flux_guidance(flux_guidance, fallback=default_flux_guidance) - if is_flux_family(family) and context.get("supports_flux_guidance", True): + if is_flux_family(family): guidance_value = flux_guidance else: guidance_value = None @@ -333,9 +323,6 @@ class LLSSimpleKSampler: except Exception: pass - if quality_preset not in {"Manual", FAMILY_DEFAULT_PRESET} and effective_task_mode == "img2img": - denoise = float(context.get("recommended_denoise", denoise)) - # seed = -1 时随机生成 actual_seed = seed if seed == -1: @@ -374,22 +361,7 @@ class LLSSimpleKSampler: "task_mode": effective_task_mode, } ) - next_context = update_task_context( - context, - resolved_model_family=family, - task_mode=effective_task_mode, - sampler_name=sampler_name, - scheduler=scheduler, - steps=steps, - cfg=cfg, - seed=actual_seed, - denoise=denoise, - guidance=guidance_value, - sampled=True, - source="LLS Simple KSampler", - ) - - return (result_latent, sample_info, next_context) + return (result_latent, sample_info) # ---------- 注册表 ---------- diff --git a/tests/test_conditioning_compat.py b/tests/test_conditioning_compat.py index db1f194..35476f2 100644 --- a/tests/test_conditioning_compat.py +++ b/tests/test_conditioning_compat.py @@ -1,4 +1,5 @@ import importlib.util +import json import pathlib import sys import unittest @@ -67,11 +68,12 @@ class TestConditioningCompatibility(unittest.TestCase): node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"] node = node_cls() - positive, negative, task_context = node.encode( - LegacyClipStub(), - "a cat", - "low quality", - -2, + positive, negative, prompt_info = node.encode( + clip=LegacyClipStub(), + positive_prompt="a cat", + negative_prompt="low quality", + clip_skip=-2, + model_family="Auto", ) self.assertEqual( @@ -82,19 +84,22 @@ class TestConditioningCompatibility(unittest.TestCase): negative, [["cond::low quality", {"pooled_output": "pooled::low quality"}]], ) - self.assertEqual(task_context["clip_skip"], -2) - self.assertEqual(task_context["prompt_mode"], "clip") + payload = json.loads(prompt_info) + self.assertEqual(payload["clip_skip"], -2) + self.assertEqual(payload["prompt_mode"], "clip") + self.assertEqual(payload["model_family"], "SD1.5") def test_simple_prompt_encode_normalizes_none_clip_skip(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"] node = node_cls() - positive, negative, task_context = node.encode( - LegacyClipStub(), - "a cat", - "low quality", - None, + positive, negative, prompt_info = node.encode( + clip=LegacyClipStub(), + positive_prompt="a cat", + negative_prompt="low quality", + clip_skip=None, + model_family="Auto", ) self.assertEqual( @@ -105,7 +110,8 @@ class TestConditioningCompatibility(unittest.TestCase): negative, [["cond::low quality", {"pooled_output": "pooled::low quality"}]], ) - self.assertEqual(task_context["clip_skip"], -1) + payload = json.loads(prompt_info) + self.assertEqual(payload["clip_skip"], -1) def test_universal_backend_falls_back_for_legacy_clip(self): load_plugin_package() diff --git a/tests/test_loader_prompt_refactor.py b/tests/test_loader_prompt_refactor.py index 844a849..505df73 100644 --- a/tests/test_loader_prompt_refactor.py +++ b/tests/test_loader_prompt_refactor.py @@ -22,6 +22,14 @@ def load_plugin_package(): return module +class TaggedValue: + def __init__(self, label): + self.label = label + + def __repr__(self): + return self.label + + class FolderPathsStub: def __init__(self): self._files = { @@ -41,6 +49,7 @@ class FolderPathsStub: "clip_g.safetensors", "t5xxl_fp16.safetensors", ], + "upscale_models": [], } def get_filename_list(self, category): @@ -69,15 +78,15 @@ class ComfySDStub: embedding_directory=None, ): if ckpt_path.endswith("sd15.safetensors"): - return ("MODEL::SD15", "CLIP::SD15", "VAE::SD15") + return (TaggedValue("MODEL::SD15"), TaggedValue("CLIP::SD15"), TaggedValue("VAE::SD15")) if ckpt_path.endswith("sdxl_turbo.safetensors"): - return ("MODEL::SDXL_TURBO", None, None) + return (TaggedValue("MODEL::SDXL_TURBO"), None, None) if ckpt_path.endswith("flux-dev-checkpoint.safetensors"): - return ("MODEL::FLUX_DEV", None, None) + return (TaggedValue("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}" + return TaggedValue(f"MODEL::{pathlib.Path(unet_path).name}") class CoreNodesStub: @@ -87,15 +96,15 @@ class CoreNodesStub: return ["ae.safetensors"] def load_vae(self, vae_name): - return (f"VAE::{vae_name}",) + return (TaggedValue(f"VAE::{vae_name}"),) class CLIPLoader: def load_clip(self, clip_name, type="stable_diffusion", device="default"): - return (f"CLIP::{type}::{clip_name}",) + return (TaggedValue(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}",) + return (TaggedValue(f"CLIP::{type}::{clip_name1}+{clip_name2}"),) class SaveImage: def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): @@ -115,12 +124,13 @@ class CoreNodesStub: class RecordingClip: - def __init__(self, calls=None): + def __init__(self, token_keys, calls=None): + self.token_keys = tuple(token_keys) self.calls = [] if calls is None else calls self.layer = None def clone(self): - cloned = RecordingClip(self.calls) + cloned = RecordingClip(self.token_keys, self.calls) cloned.layer = self.layer return cloned @@ -128,12 +138,10 @@ class RecordingClip: self.layer = layer def tokenize(self, text): - return { - "text": text, - "g": [f"g::{text}"], - "l": [f"l::{text}"], - "t5xxl": [f"t5::{text}"], - } + tokens = {"text": text} + for key in self.token_keys: + tokens[key] = [f"{key}::{text}"] + return tokens def encode_from_tokens_scheduled(self, tokens, add_dict=None): self.calls.append( @@ -147,10 +155,11 @@ class RecordingClip: class FakeVAE: - def __init__(self, encode_channels=4, encode_ratio=8): + def __init__(self, encode_channels=4, encode_ratio=8, vae_name="vae.safetensors"): self.encode_channels = encode_channels self.encode_ratio = encode_ratio self.encoded_shapes = [] + self._lls_vae_name = vae_name def encode(self, pixels): self.encoded_shapes.append(tuple(pixels.shape)) @@ -220,62 +229,24 @@ class FakeComfyUtils: class TestLoaderPromptRefactor(unittest.TestCase): - def test_task_controller_generates_context_with_recommended_defaults(self): + def test_task_nodes_are_not_registered_in_contextless_mode(self): plugin = load_plugin_package() - node_cls = plugin.NODE_CLASS_MAPPINGS["LLSTaskController"] - node = node_cls() - required = node_cls.INPUT_TYPES()["required"] - self.assertEqual(node_cls.CATEGORY, "LLS-node") - self.assertEqual(node_cls.RETURN_TYPES, ("LLS_TASK_CONTEXT",)) - self.assertEqual( - tuple(required.keys()), - ( - "task_mode", - "model_family", - "workflow_preset", - "quality_preset", - "enable_upscale", - "enable_controlnet", - "enable_reference", - "use_external_vae", - "use_external_text_encoder", - ), - ) + self.assertNotIn("LLSTaskController", plugin.NODE_CLASS_MAPPINGS) + self.assertNotIn("LLSTaskInspector", plugin.NODE_CLASS_MAPPINGS) - (task_context,) = node.execute( - "img2img", - "SDXL", - "standard", - "balanced", - True, - False, - False, - False, - False, - ) - - self.assertEqual(task_context["task_mode"], "img2img") - self.assertEqual(task_context["model_family"], "SDXL") - self.assertEqual(task_context["recommended_width"], 1024) - self.assertEqual(task_context["recommended_height"], 1024) - self.assertEqual(task_context["recommended_cfg"], 7.0) - self.assertEqual(task_context["recommended_denoise"], 0.5) - self.assertEqual(task_context["source"], "LLS Task Controller") - - def test_loader_schema_exposes_task_context_and_source_controls(self): + def test_loader_schema_uses_standard_ports_only(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleCheckpointLoader"] required = node_cls.INPUT_TYPES()["required"] - optional = node_cls.INPUT_TYPES()["optional"] self.assertEqual( node_cls.RETURN_TYPES, - ("MODEL", "CLIP", "VAE", "LLS_TASK_CONTEXT", "LLS_TEXT_ENCODER"), + ("MODEL", "CLIP", "VAE", "CLIP"), ) self.assertEqual( node_cls.RETURN_NAMES, - ("model", "clip", "vae", "task_context", "text_encoder"), + ("model", "clip", "vae", "text_encoder"), ) self.assertEqual( tuple(required.keys()), @@ -290,14 +261,9 @@ class TestLoaderPromptRefactor(unittest.TestCase): "external_text_encoder_2", ), ) - family_choices = required["model_family"][0] - self.assertIn("SD1.5", family_choices) - self.assertIn("SDXL_TURBO", family_choices) - self.assertIn("FLUX_SCHNELL", family_choices) - self.assertIn("FLUX_DEV", family_choices) - self.assertEqual(optional["task_context"][0], "LLS_TASK_CONTEXT") + self.assertNotIn("optional", node_cls.INPUT_TYPES()) - def test_loader_returns_updated_task_context_for_sd15_embedded_resources(self): + 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 @@ -307,7 +273,7 @@ class TestLoaderPromptRefactor(unittest.TestCase): ComfySDStub(), ), mock.patch.object(loader_nodes, "comfy_core_nodes", CoreNodesStub()): node = loader_nodes.LLSSimpleCheckpointLoader() - model, clip, vae, task_context, text_encoder = node.load_checkpoint( + model, clip, vae, text_encoder = node.load_checkpoint( "sd15.safetensors", "SD1.5", "simple", @@ -316,25 +282,19 @@ class TestLoaderPromptRefactor(unittest.TestCase): "(auto)", "(auto)", "(auto)", - task_context={"task_mode": "txt2img", "model_family": "SD1.5"}, ) - self.assertEqual(model, "MODEL::SD15") - self.assertEqual(clip, "CLIP::SD15") - self.assertEqual(text_encoder, "CLIP::SD15") - self.assertEqual(text_encoder, clip) - self.assertEqual(vae, "VAE::SD15") - self.assertEqual(task_context["resolved_model_family"], "SD1.5") - self.assertEqual(task_context["checkpoint_name"], "sd15.safetensors") - self.assertEqual(task_context["text_encoder_type"], "clip") - self.assertEqual(task_context["vae_source"], "embedded") - self.assertEqual(task_context["latent_channels"], 4) - self.assertTrue(task_context["supports_img2img"]) - self.assertTrue(task_context["supports_clip_skip"]) - self.assertFalse(task_context["supports_flux_guidance"]) - self.assertEqual(task_context["recommended_width"], 512) + self.assertEqual(model.label, "MODEL::SD15") + self.assertEqual(clip.label, "CLIP::SD15") + self.assertIs(text_encoder, clip) + self.assertEqual(vae.label, "VAE::SD15") + self.assertEqual(model._lls_family, "SD1.5") + self.assertEqual(model._lls_model_name, "sd15.safetensors") + self.assertEqual(clip._lls_family, "SD1.5") + self.assertEqual(clip._lls_text_encoder_type, "clip") + self.assertEqual(vae._lls_family, "SD1.5") - def test_loader_builds_flux_resources_and_updates_task_context(self): + 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 @@ -344,7 +304,7 @@ class TestLoaderPromptRefactor(unittest.TestCase): ComfySDStub(), ), mock.patch.object(loader_nodes, "comfy_core_nodes", CoreNodesStub()): node = loader_nodes.LLSSimpleCheckpointLoader() - model, clip, vae, task_context, text_encoder = node.load_checkpoint( + model, clip, vae, text_encoder = node.load_checkpoint( "diffusion_models/flux1-schnell.safetensors", "FLUX_SCHNELL", "advanced", @@ -353,22 +313,16 @@ class TestLoaderPromptRefactor(unittest.TestCase): "ae.safetensors", "clip_l.safetensors", "t5xxl_fp16.safetensors", - task_context={"task_mode": "txt2img", "model_family": "FLUX"}, ) - self.assertEqual(model, "MODEL::flux1-schnell.safetensors") - self.assertEqual(clip, "CLIP::flux::clip_l.safetensors+t5xxl_fp16.safetensors") - self.assertEqual(text_encoder, "CLIP::flux::clip_l.safetensors+t5xxl_fp16.safetensors") - self.assertEqual(text_encoder, clip) - self.assertEqual(vae, "VAE::ae.safetensors") - self.assertEqual(task_context["resolved_model_family"], "FLUX_SCHNELL") - self.assertEqual(task_context["text_encoder_type"], "flux_clip_l_t5xxl") - self.assertEqual(task_context["vae_source"], "external") - self.assertEqual(task_context["text_encoder_source"], "external") - self.assertEqual(task_context["latent_channels"], 128) - self.assertTrue(task_context["supports_flux_guidance"]) - self.assertFalse(task_context["supports_clip_skip"]) - self.assertEqual(task_context["recommended_cfg"], 1.0) + self.assertEqual(model.label, "MODEL::flux1-schnell.safetensors") + self.assertEqual(clip.label, "CLIP::flux::clip_l.safetensors+t5xxl_fp16.safetensors") + self.assertIs(text_encoder, clip) + self.assertEqual(vae.label, "VAE::ae.safetensors") + self.assertEqual(model._lls_family, "FLUX_SCHNELL") + self.assertEqual(clip._lls_text_encoder_type, "flux_clip_l_t5xxl") + self.assertEqual(clip._lls_model_name, "diffusion_models/flux1-schnell.safetensors") + self.assertEqual(vae._lls_vae_name, "ae.safetensors") def test_loader_raises_clear_error_when_flux_text_encoder_is_missing(self): load_plugin_package() @@ -392,134 +346,115 @@ class TestLoaderPromptRefactor(unittest.TestCase): "ae.safetensors", "clip_l.safetensors", "(auto)", - task_context={"task_mode": "txt2img", "model_family": "FLUX"}, ) - def test_prompt_encode_schema_uses_task_context_port(self): + def test_prompt_encode_schema_uses_standard_clip_ports_and_prompt_info(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"] + required = node_cls.INPUT_TYPES()["required"] optional = node_cls.INPUT_TYPES()["optional"] - self.assertEqual(optional["text_encoder"][0], "LLS_TEXT_ENCODER") - self.assertEqual(optional["task_context"][0], "LLS_TASK_CONTEXT") + self.assertEqual(node_cls.RETURN_TYPES, ("CONDITIONING", "CONDITIONING", "STRING")) + self.assertEqual(required["model_family"][1]["default"], "Auto") + self.assertEqual(optional["text_encoder"][0], "CLIP") + self.assertEqual(optional["clip"][0], "CLIP") + self.assertNotIn("task_context", optional) - def test_prompt_encode_dispatches_sdxl_using_task_context_dimensions(self): + def test_prompt_encode_dispatches_sdxl_using_inferred_family_defaults(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimplePromptEncode"] node = node_cls() - clip = RecordingClip() - task_context = { - "model_family": "SDXL", - "resolved_model_family": "SDXL", - "text_encoder_type": "sdxl_dual_clip", - "recommended_width": 1216, - "recommended_height": 832, - "supports_clip_skip": True, - } + clip = RecordingClip(("g", "l")) - positive, negative, next_context = node.encode( - clip, - "a castle", - "low quality", - -1, - task_context=task_context, + positive, negative, prompt_info = node.encode( + clip=clip, + positive_prompt="a castle", + negative_prompt="low quality", + clip_skip=-1, + model_family="Auto", ) 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, + "width": 1024, + "height": 1024, "crop_w": 0, "crop_h": 0, - "target_width": 1216, - "target_height": 832, + "target_width": 1024, + "target_height": 1024, }, ) + payload = json.loads(prompt_info) + self.assertEqual(payload["model_family"], "SDXL") + self.assertEqual(payload["prompt_mode"], "sdxl") + self.assertEqual(payload["clip_skip"], -1) self.assertEqual(positive[0][0], "cond::a castle") self.assertEqual(negative[0][0], "cond::low quality") - self.assertEqual(next_context["resolved_model_family"], "SDXL") - self.assertEqual(next_context["prompt_mode"], "sdxl") - self.assertEqual(next_context["positive_prompt_length"], len("a castle")) - self.assertEqual(next_context["negative_prompt_length"], len("low quality")) 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() - task_context = { - "model_family": "FLUX", - "resolved_model_family": "FLUX_DEV", - "guidance": 3.5, - "supports_flux_guidance": True, - "text_encoder_type": "flux_clip_l_t5xxl", - } + clip = RecordingClip(("t5xxl",)) - positive, negative, next_context = node.encode( - clip, - "a robot in a forest", - "ugly, blurry", - -1, - task_context=task_context, + positive, negative, prompt_info = node.encode( + clip=clip, + positive_prompt="a robot in a forest", + negative_prompt="ugly, blurry", + clip_skip=-1, + model_family="Auto", ) 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"], "") + payload = json.loads(prompt_info) + self.assertEqual(payload["model_family"], "FLUX_DEV") + self.assertEqual(payload["negative_mode"], "ignored_for_flux") + self.assertEqual(payload["prompt_mode"], "flux") self.assertEqual(positive[0][0], "cond::a robot in a forest") self.assertEqual(negative[0][0], "cond::") - self.assertEqual(next_context["negative_mode"], "ignored_for_flux") - self.assertEqual(next_context["prompt_mode"], "flux") - def test_empty_latent_uses_task_context_recommended_size_and_flux_shape(self): + 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 node = latent_nodes.LLSSimpleEmptyLatent() - task_context = { - "model_family": "FLUX", - "resolved_model_family": "FLUX_SCHNELL", - "latent_channels": 128, - "downscale_ratio": 16, - "recommended_width": 1024, - "recommended_height": 1024, - } + model = TaggedValue("MODEL::flux1-schnell.safetensors") + model._lls_family = "FLUX_SCHNELL" with mock.patch.object(latent_nodes, "torch", FakeTorch()): - latent, width, height, next_context = node.create_empty_latent( + latent, width, height, latent_info = node.create_empty_latent( "Family Default", 512, 512, 2, - task_context=task_context, + model_family="Auto", + model=model, ) + payload = json.loads(latent_info) self.assertEqual((width, height), (1024, 1024)) self.assertEqual(tuple(latent["samples"].shape), (2, 128, 64, 64)) self.assertEqual(latent["downscale_ratio_spacial"], 16) - self.assertEqual(next_context["resolved_model_family"], "FLUX_SCHNELL") - self.assertEqual(next_context["latent_source"], "empty_latent") - self.assertEqual(next_context["final_width"], 1024) - self.assertEqual(next_context["batch_size"], 2) + self.assertEqual(payload["model_family"], "FLUX_SCHNELL") + self.assertEqual(payload["task_mode"], "txt2img") + self.assertEqual(payload["batch_size"], 2) - def test_ksampler_reads_task_context_and_updates_it_for_img2img(self): + def test_ksampler_reads_model_and_latent_inference_for_img2img(self): load_plugin_package() from lls_node_test_refactor.sampling import nodes as sampling_nodes recorded = {} + model = TaggedValue("MODEL::SDXL_TURBO") + model._lls_family = "SDXL_TURBO" def fake_common_ksampler(**kwargs): recorded.update(kwargs) return kwargs["latent"] - fake_node_helpers = types.SimpleNamespace( - conditioning_set_values=lambda conditioning, values: [{"conditioning": conditioning, **values}] - ) with mock.patch.object(sampling_nodes, "comfy_sample", object()), mock.patch.object( sampling_nodes, "comfy_samplers", @@ -528,17 +463,14 @@ class TestLoaderPromptRefactor(unittest.TestCase): sampling_nodes, "_common_ksampler", side_effect=fake_common_ksampler, - ), mock.patch.object( - sampling_nodes, - "node_helpers", - fake_node_helpers, ): node = sampling_nodes.LLSSimpleKSampler() - latent, sample_info, task_context = node.sample( - model="model", + latent, sample_info = node.sample( + model=model, positive=[["pos", {}]], negative=[["neg", {}]], - latent_image={"samples": FakeTensor((1, 4, 64, 64))}, + latent_image={"samples": FakeTensor((1, 4, 64, 64)), "source": "image_encode"}, + model_family="Auto", quality_preset="Family Default", seed=123, steps=30, @@ -547,30 +479,16 @@ class TestLoaderPromptRefactor(unittest.TestCase): scheduler="karras", denoise=1.0, flux_guidance=9.0, - task_context={ - "model_family": "SDXL", - "resolved_model_family": "SDXL_TURBO", - "task_mode": "img2img", - "latent_source": "image_encode", - "recommended_steps": 4, - "recommended_cfg": 1.0, - "recommended_denoise": 0.5, - "recommended_sampler": "euler", - "recommended_scheduler": "normal", - "supports_flux_guidance": False, - }, ) sample_data = json.loads(sample_info) self.assertEqual(recorded["steps"], 4) self.assertEqual(recorded["cfg"], 1.0) - self.assertEqual(recorded["denoise"], 0.5) + self.assertEqual(recorded["denoise"], 1.0) self.assertEqual(sample_data["family"], "SDXL_TURBO") + self.assertEqual(sample_data["task_mode"], "img2img") self.assertEqual(sample_data["sampler_name"], "euler") self.assertEqual(latent["samples"].shape, (1, 4, 64, 64)) - self.assertTrue(task_context["sampled"]) - self.assertEqual(task_context["task_mode"], "img2img") - self.assertEqual(task_context["denoise"], 0.5) def test_ksampler_accepts_blank_flux_guidance_for_legacy_workflows(self): load_plugin_package() @@ -583,6 +501,8 @@ class TestLoaderPromptRefactor(unittest.TestCase): self.assertTrue(sampling_nodes.LLSSimpleKSampler.VALIDATE_INPUTS(flux_guidance="")) recorded = {} + model = TaggedValue("MODEL::FLUX_DEV") + model._lls_family = "FLUX_DEV" def fake_common_ksampler(**kwargs): recorded.update(kwargs) @@ -605,11 +525,12 @@ class TestLoaderPromptRefactor(unittest.TestCase): fake_node_helpers, ): node = sampling_nodes.LLSSimpleKSampler() - latent, sample_info, task_context = node.sample( - model="model", + latent, sample_info = node.sample( + model=model, positive=[["pos", {}]], negative=[["neg", {}]], latent_image={"samples": FakeTensor((1, 128, 64, 64)), "source": "empty_latent"}, + model_family="Auto", quality_preset="Manual", seed=123, steps=20, @@ -618,85 +539,79 @@ class TestLoaderPromptRefactor(unittest.TestCase): scheduler="simple", denoise=1.0, flux_guidance="", - task_context={ - "resolved_model_family": "FLUX_DEV", - "supports_flux_guidance": True, - "recommended_guidance": 3.5, - }, ) sample_data = json.loads(sample_info) self.assertEqual(recorded["positive"][0]["guidance"], 3.5) self.assertEqual(recorded["negative"][0]["guidance"], 3.5) self.assertEqual(sample_data["guidance"], 3.5) - self.assertEqual(task_context["guidance"], 3.5) self.assertEqual(latent["samples"].shape, (1, 128, 64, 64)) - def test_vae_decode_updates_task_context_for_flux(self): + def test_vae_decode_returns_decode_info_for_flux(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleVAEDecode"] node = node_cls() - image, task_context = node.decode( - {"samples": FakeTensor((1, 128, 64, 64))}, - FakeVAE(), - task_context={"resolved_model_family": "FLUX_DEV", "downscale_ratio": 16, "vae_name": "ae.safetensors"}, + image, decode_info = node.decode( + {"samples": FakeTensor((1, 128, 64, 64)), "downscale_ratio_spacial": 16}, + FakeVAE(vae_name="ae.safetensors"), ) + payload = json.loads(decode_info) self.assertEqual(tuple(image.shape), (1, 64, 64, 3)) - self.assertEqual(task_context["final_width"], 1024) - self.assertEqual(task_context["vae_name"], "ae.safetensors") - self.assertTrue(task_context["decoded"]) - self.assertEqual(task_context["decode_stage"], "vae_decode") + self.assertEqual(payload["width"], 1024) + self.assertEqual(payload["height"], 1024) + self.assertEqual(payload["vae_name"], "ae.safetensors") + self.assertEqual(payload["decode_stage"], "vae_decode") - def test_vae_encode_schema_exposes_img2img_inputs_and_task_context_port(self): + def test_vae_encode_schema_exposes_img2img_inputs_without_context_port(self): plugin = load_plugin_package() node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleVAEEncode"] required = node_cls.INPUT_TYPES()["required"] optional = node_cls.INPUT_TYPES()["optional"] - self.assertEqual(node_cls.RETURN_TYPES, ("LATENT", "INT", "INT", "LLS_TASK_CONTEXT")) + self.assertEqual(node_cls.RETURN_TYPES, ("LATENT", "INT", "INT", "STRING")) self.assertEqual(required["image"][0], "IMAGE") self.assertEqual(required["vae"][0], "VAE") self.assertEqual(required["resize_mode"][0], ["keep_aspect", "crop_center", "stretch", "none"]) self.assertEqual(required["size_source"][0], ["input_image", "custom", "model_recommended"]) - self.assertEqual(optional["task_context"][0], "LLS_TASK_CONTEXT") + self.assertEqual(required["model_family"][1]["default"], "Auto") + self.assertEqual(optional["model"][0], "MODEL") + self.assertEqual(optional["clip"][0], "CLIP") + self.assertNotIn("task_context", optional) - def test_vae_encode_uses_task_context_recommended_size_for_img2img(self): + def test_vae_encode_uses_inferred_model_recommended_size_for_img2img(self): load_plugin_package() from lls_node_test_refactor.image import nodes as image_nodes comfy_utils = FakeComfyUtils() vae = FakeVAE() node = image_nodes.LLSSimpleVAEEncode() + model = TaggedValue("MODEL::SDXL") + model._lls_family = "SDXL" + vae._lls_vae_name = "embedded" with mock.patch.object(image_nodes, "comfy_utils", comfy_utils): - latent, width, height, task_context = node.encode( + latent, width, height, latent_info = node.encode( image=FakeTensor((1, 768, 1536, 3)), vae=vae, resize_mode="keep_aspect", size_source="model_recommended", width=512, height=512, - task_context={ - "model_family": "SDXL", - "resolved_model_family": "SDXL", - "recommended_width": 1024, - "recommended_height": 1024, - "vae_source": "embedded", - }, + model_family="Auto", + model=model, ) + payload = json.loads(latent_info) self.assertEqual((width, height), (1024, 512)) self.assertEqual(tuple(latent["samples"].shape), (1, 4, 64, 128)) self.assertEqual(latent["downscale_ratio_spacial"], 8) self.assertEqual(vae.encoded_shapes, [(1, 512, 1024, 3)]) - self.assertEqual(task_context["latent_source"], "image_encode") - self.assertEqual(task_context["final_width"], 1024) - self.assertEqual(task_context["final_height"], 512) - self.assertEqual(task_context["resize_mode"], "keep_aspect") - self.assertEqual(task_context["vae_source"], "embedded") - self.assertEqual(task_context["input_image_width"], 1536) + self.assertEqual(payload["task_mode"], "img2img") + self.assertEqual(payload["latent_source"], "image_encode") + self.assertEqual(payload["model_family"], "SDXL") + self.assertEqual(payload["vae_name"], "embedded") self.assertEqual( comfy_utils.calls, [ @@ -716,87 +631,65 @@ class TestLoaderPromptRefactor(unittest.TestCase): vae = FakeVAE() node = image_nodes.LLSSimpleVAEEncode() - latent, width, height, task_context = node.encode( + latent, width, height, latent_info = node.encode( image=FakeTensor((1, 512, 768, 3)), vae=vae, resize_mode="none", size_source="input_image", width=1024, height=1024, - task_context={"model_family": "SD1.5", "resolved_model_family": "SD1.5", "vae_source": "embedded"}, + model_family="SD1.5", ) + payload = json.loads(latent_info) self.assertEqual((width, height), (768, 512)) self.assertEqual(tuple(latent["samples"].shape), (1, 4, 64, 96)) self.assertEqual(latent["downscale_ratio_spacial"], 8) self.assertEqual(vae.encoded_shapes, [(1, 512, 768, 3)]) - self.assertEqual(task_context["latent_source"], "image_encode") - self.assertEqual(task_context["size_source"], "input_image") - self.assertEqual(task_context["resize_mode"], "none") + self.assertEqual(payload["latent_source"], "image_encode") + self.assertEqual(payload["size_source"], "input_image") + self.assertEqual(payload["resize_mode"], "none") - def test_upscale_switcher_passthroughs_when_task_context_disables_upscale(self): - load_plugin_package() - from lls_node_test_refactor.upscale import nodes as upscale_nodes - - recorder_calls = [] - - def fake_pytorch(self, image, scale, interpolation): - recorder_calls.append(("pytorch", scale, interpolation)) - return ("INTERP_RESULT",) - - with mock.patch.object( - upscale_nodes.LLSUpscaleSwitcher, - "_upscale_with_pytorch", - new=fake_pytorch, - ): - node = upscale_nodes.LLSUpscaleSwitcher() - image, task_context = node.upscale( - image="image", - mode="upscale_model", - scale=2.0, - interpolation="bilinear", - model_name=upscale_nodes.NO_UPSCALE_MODEL_PLACEHOLDER, - tile=512, - overlap=32, - task_context={"resolved_model_family": "SDXL", "enable_upscale": False}, - ) - - self.assertEqual(image, "image") - self.assertEqual(task_context["upscaled"], False) - self.assertEqual(task_context["upscale_mode"], "disabled_by_task_context") - self.assertEqual(recorder_calls, []) - - def test_save_image_merges_metadata_from_task_context(self): + def test_save_image_merges_metadata_from_native_objects_and_info_strings(self): load_plugin_package() from lls_node_test_refactor.image import nodes as image_nodes + model = TaggedValue("MODEL::SD15") + model._lls_family = "SD1.5" + model._lls_model_name = "sd15.safetensors" + clip = TaggedValue("CLIP::SD15") + clip._lls_text_encoder_name = "clip_l.safetensors" + vae = TaggedValue("VAE::SD15") + vae._lls_vae_name = "VAE::SD15" + with mock.patch.object(image_nodes, "comfy_core_nodes", CoreNodesStub()): node = image_nodes.LLSSaveImage() result = node.save( image=FakeTensor((1, 512, 512, 3)), filename_prefix="LLS", save_metadata=True, - task_context={ - "model_family": "SD1.5", - "resolved_model_family": "SD1.5", - "checkpoint_name": "sd15.safetensors", - "vae_name": "VAE::SD15", - "text_encoder_name_1": "clip_l.safetensors", - "positive_prompt": "cat", - "negative_prompt": "bad", - "seed": 42, - "steps": 20, - "cfg": 7.0, - "guidance": None, - "sampler_name": "euler", - "scheduler": "normal", - "denoise": 1.0, - "final_width": 512, - "final_height": 512, - "batch_size": 1, - "upscale_mode": "none", - "upscale_scale": 1.0, - }, + model=model, + clip=clip, + vae=vae, + prompt_info=json.dumps( + { + "positive_prompt": "cat", + "negative_prompt": "bad", + "model_family": "SD1.5", + } + ), + sample_info=json.dumps( + { + "seed": 42, + "steps": 20, + "cfg": 7.0, + "guidance": None, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + } + ), + upscale_info=json.dumps({"mode": "none", "scale": 1.0}), ) metadata = result["ui"]["images"][0]["extra_pnginfo"]["lls_metadata"] @@ -805,6 +698,34 @@ class TestLoaderPromptRefactor(unittest.TestCase): self.assertEqual(metadata["steps"], 20) self.assertEqual(metadata["upscale_mode"], "none") + def test_generation_config_uses_model_inference_without_context(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSGenerationConfig"] + node = node_cls() + required = node_cls.INPUT_TYPES()["required"] + optional = node_cls.INPUT_TYPES()["optional"] + model = TaggedValue("MODEL::FLUX_DEV") + model._lls_family = "FLUX_DEV" + + self.assertEqual(required["model_family"][1]["default"], "Auto") + self.assertEqual(optional["model"][0], "MODEL") + self.assertEqual(optional["clip"][0], "CLIP") + + width, height, steps, cfg, guidance, sampler_name, scheduler, denoise, config_info = node.execute( + quality_preset="Family Default", + size_preset="Family Default", + model_family="Auto", + model=model, + ) + + payload = json.loads(config_info) + self.assertEqual((width, height), (1024, 1024)) + self.assertEqual((steps, cfg, guidance), (20, 1.0, 3.5)) + self.assertEqual(sampler_name, "euler") + self.assertEqual(scheduler, "simple") + self.assertEqual(denoise, 1.0) + self.assertEqual(payload["family"], "FLUX_DEV") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_upscale_switcher.py b/tests/test_upscale_switcher.py index cd3c4f2..7c9df5e 100644 --- a/tests/test_upscale_switcher.py +++ b/tests/test_upscale_switcher.py @@ -1,4 +1,5 @@ import importlib.util +import json import pathlib import sys import unittest @@ -62,7 +63,7 @@ class TestUpscaleSwitcher(unittest.TestCase): "_upscale_with_model", new=RecordingUpscaleSwitcher._upscale_with_model, ): - result = upscale_nodes.LLSUpscaleSwitcher.upscale( + image, upscale_info = upscale_nodes.LLSUpscaleSwitcher.upscale( recorder, image="image", mode="upscale_model", @@ -71,10 +72,11 @@ class TestUpscaleSwitcher(unittest.TestCase): model_name=upscale_nodes.NO_UPSCALE_MODEL_PLACEHOLDER, tile=512, overlap=32, - ) + ) - self.assertEqual(result[0], "PYTORCH_RESULT") - self.assertEqual(result[1]["upscale_mode"], "interpolation") + payload = json.loads(upscale_info) + self.assertEqual(image, "PYTORCH_RESULT") + self.assertEqual(payload["mode"], "interpolation") self.assertEqual( recorder.calls, [("pytorch", "image", 2.0, "bilinear")], diff --git a/upscale/nodes.py b/upscale/nodes.py index 38d2722..04638fd 100644 --- a/upscale/nodes.py +++ b/upscale/nodes.py @@ -11,8 +11,7 @@ from __future__ import annotations from typing import Any -from ..utils.model_info import parse_model_info -from ..utils.task_context import LLS_TASK_CONTEXT_TYPE, parse_task_context, update_task_context +from ..utils.model_info import info_to_json # ---------- 从根模块导入共享工具函数 ---------- @@ -217,8 +216,8 @@ class LLSUpscaleSwitcher: CATEGORY = "LLS/Upscale" FUNCTION = "upscale" - RETURN_TYPES = ("IMAGE", LLS_TASK_CONTEXT_TYPE) - RETURN_NAMES = ("image", "task_context") + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("image", "upscale_info") DESCRIPTION = "Switch between ComfyUI upscale models and PyTorch interpolation." @classmethod @@ -235,53 +234,37 @@ class LLSUpscaleSwitcher: "tile": ("INT", {"default": 512, "min": 128, "max": 2048, "step": 64}), "overlap": ("INT", {"default": 32, "min": 0, "max": 256, "step": 8}), }, - "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), - }, } - def upscale(self, image, mode: str, scale: float, interpolation: str, model_name: str, tile: int, overlap: int, task_context=None): - context = parse_task_context(task_context) - info = parse_model_info(context) + def upscale(self, image, mode: str, scale: float, interpolation: str, model_name: str, tile: int, overlap: int): requested_mode = mode warning = None if mode not in UPSCALE_MODE_CHOICES: raise RuntimeError(f"Unsupported mode '{mode}'. Expected one of {UPSCALE_MODE_CHOICES}.") - if not context.get("enable_upscale", True) and mode != "none": - return ( - image, - update_task_context( - context, - upscaled=False, - upscale_mode="disabled_by_task_context", - upscale_scale=1.0, - source="LLS Upscale Switcher", - ), - ) if mode == "none": return ( image, - update_task_context( - context, - upscaled=False, - upscale_mode="none", - requested_upscale_mode=requested_mode, - upscale_scale=1.0, - source="LLS Upscale Switcher", + info_to_json( + { + "mode": "none", + "requested_mode": requested_mode, + "scale": 1.0, + "upscaled": False, + } ), ) if mode in {"interpolation", "pytorch"}: result = self._upscale_with_pytorch(image=image, scale=scale, interpolation=interpolation)[0] return ( result, - update_task_context( - context, - upscaled=True, - upscale_mode="interpolation", - requested_upscale_mode=requested_mode, - upscale_scale=scale, - interpolation=interpolation, - source="LLS Upscale Switcher", + info_to_json( + { + "mode": "interpolation", + "requested_mode": requested_mode, + "scale": scale, + "interpolation": interpolation, + "upscaled": True, + } ), ) if mode == "latent_upscale": @@ -289,15 +272,15 @@ class LLSUpscaleSwitcher: result = self._upscale_with_pytorch(image=image, scale=scale, interpolation=interpolation)[0] return ( result, - update_task_context( - context, - upscaled=True, - upscale_mode="interpolation", - requested_upscale_mode=requested_mode, - upscale_scale=scale, - interpolation=interpolation, - upscale_warning=warning, - source="LLS Upscale Switcher", + info_to_json( + { + "mode": "interpolation", + "requested_mode": requested_mode, + "scale": scale, + "interpolation": interpolation, + "warning": warning, + "upscaled": True, + } ), ) if _is_missing_upscale_model_selection(model_name): @@ -309,32 +292,32 @@ class LLSUpscaleSwitcher: result = self._upscale_with_pytorch(image=image, scale=scale, interpolation=interpolation)[0] return ( result, - update_task_context( - context, - upscaled=True, - upscale_mode="interpolation", - requested_upscale_mode=requested_mode, - upscale_scale=scale, - interpolation=interpolation, - upscale_warning=warning, - source="LLS Upscale Switcher", + info_to_json( + { + "mode": "interpolation", + "requested_mode": requested_mode, + "scale": scale, + "interpolation": interpolation, + "warning": warning, + "upscaled": True, + } ), ) result = self._upscale_with_model(image=image, model_name=model_name, tile=tile, overlap=overlap)[0] actual_mode = "tile_upscale" if requested_mode == "tile_upscale" else "upscale_model" return ( result, - update_task_context( - context, - upscaled=True, - upscale_mode=actual_mode, - requested_upscale_mode=requested_mode, - upscale_scale=scale, - model_name=model_name, - tile=tile, - overlap=overlap, - upscale_warning=warning, - source="LLS Upscale Switcher", + info_to_json( + { + "mode": actual_mode, + "requested_mode": requested_mode, + "scale": scale, + "model_name": model_name, + "tile": tile, + "overlap": overlap, + "warning": warning, + "upscaled": True, + } ), ) diff --git a/utils/model_info.py b/utils/model_info.py index 38f1afb..ce69c94 100644 --- a/utils/model_info.py +++ b/utils/model_info.py @@ -369,6 +369,9 @@ def get_sampling_preset(model_info: dict[str, Any] | str | None, quality_preset: def infer_family_from_clip(clip) -> str: """从 CLIP 对象的 tokenizer 结构自动推断模型家族。""" + lls_family = getattr(clip, "_lls_family", None) + if lls_family and lls_family not in ("auto", "Auto", ""): + return canonicalize_family(lls_family) try: tokens = clip.tokenize("") except Exception: @@ -411,3 +414,83 @@ def infer_task_mode_from_latent(latent: dict) -> str: if source == "image_encode": return "img2img" return "txt2img" + + +def tag_lls_object(obj, **kwargs: Any): + """为原生 ComfyUI 对象附加轻量级 `_lls_*` 元信息。""" + if obj is None: + return obj + for key, value in kwargs.items(): + if value is None: + continue + attr_name = key if key.startswith("_lls_") else f"_lls_{key}" + try: + setattr(obj, attr_name, value) + except Exception: + continue + return obj + + +def get_lls_attr(obj, name: str, default: Any = None) -> Any: + if obj is None: + return default + attr_name = name if name.startswith("_lls_") else f"_lls_{name}" + return getattr(obj, attr_name, default) + + +def resolve_model_family( + model_family: str | None = None, + *, + model=None, + clip=None, + fallback: str = "SD1.5", +) -> str: + normalized = str(model_family or "").strip() + if normalized and normalized not in {"Auto", "auto"}: + return canonicalize_family(normalized) + + if model is not None: + inferred_from_model = infer_family_from_model(model) + if inferred_from_model: + return canonicalize_family(inferred_from_model) + + if clip is not None: + inferred_from_clip = infer_family_from_clip(clip) + if inferred_from_clip: + return canonicalize_family(inferred_from_clip) + + return canonicalize_family(fallback) + + +def resolve_model_name(model=None, clip=None, fallback: str = "") -> str: + return str( + get_lls_attr(model, "model_name") + or get_lls_attr(clip, "model_name") + or fallback + or "" + ) + + +def resolve_vae_name(vae=None, fallback: str = "") -> str: + return str(get_lls_attr(vae, "vae_name") or fallback or "") + + +def resolve_text_encoder_names(clip=None) -> dict[str, str]: + name = get_lls_attr(clip, "text_encoder_name") or "" + name_1 = get_lls_attr(clip, "text_encoder_name_1") or "" + name_2 = get_lls_attr(clip, "text_encoder_name_2") or "" + + if not name and name_1 and name_2: + name = ", ".join(part for part in (name_1, name_2) if part) + elif not name: + name = name_1 or name_2 or "" + + return { + "text_encoder_name": str(name), + "text_encoder_name_1": str(name_1), + "text_encoder_name_2": str(name_2), + } + + +def build_info_payload(**kwargs: Any) -> dict[str, Any]: + return {key: value for key, value in kwargs.items() if value is not None} diff --git a/utils/nodes.py b/utils/nodes.py index 71b471f..ea1d5f1 100644 --- a/utils/nodes.py +++ b/utils/nodes.py @@ -34,13 +34,13 @@ from __future__ import annotations from .model_info import ( FAMILY_DEFAULT_PRESET, + MODEL_FAMILY_CHOICES, SIZE_PRESET_AUTO, get_family_defaults, get_sampling_preset, info_to_json, - parse_model_info, + resolve_model_family, ) -from .task_context import LLS_TASK_CONTEXT_TYPE, parse_task_context # ---------- 节点类示例(基础类型,纯 Python 无外部依赖) ---------- @@ -168,20 +168,20 @@ class LLSGenerationConfig: def INPUT_TYPES(cls): return { "required": { + "model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}), "quality_preset": (cls._QUALITY_PRESETS, {"default": FAMILY_DEFAULT_PRESET}), "size_preset": (cls._SIZE_PRESETS, {"default": SIZE_PRESET_AUTO}), }, "optional": { - "task_context": (LLS_TASK_CONTEXT_TYPE,), - "model_info": (LLS_TASK_CONTEXT_TYPE,), + "model": ("MODEL",), + "clip": ("CLIP",), }, } - def execute(self, quality_preset: str, size_preset: str, task_context=None, model_info=None): - context = parse_task_context(task_context or model_info) - info = parse_model_info(context) - defaults = get_family_defaults(info["family"]) - preset = get_sampling_preset(info, quality_preset) or { + def execute(self, model_family: str, quality_preset: str, size_preset: str, model=None, clip=None): + family = resolve_model_family(model_family, model=model, clip=clip) + defaults = get_family_defaults(family) + preset = get_sampling_preset(defaults, quality_preset) or { "steps": defaults["default_steps"], "cfg": defaults["default_cfg"], "guidance": defaults["default_guidance"], @@ -203,7 +203,7 @@ class LLSGenerationConfig: guidance = 0.0 if guidance is None else float(guidance) config_info = info_to_json( { - "family": context.get("resolved_model_family") or info["family"], + "family": family, "width": width, "height": height, "steps": int(preset["steps"]),