refactor: remove context ports from core nodes
This commit is contained in:
@@ -39,7 +39,6 @@ import types
|
||||
# ---------- 子包白名单(控制加载顺序,同时作为文档) ----------
|
||||
# 按功能分类总览章节顺序排列;新增子包追加到列表末尾即可。
|
||||
_SUBPACKAGES: list[str] = [
|
||||
"task", # 统一任务控制与上下文
|
||||
"model_loader", # 第 1 节:模型加载与管理
|
||||
"conditioning", # 第 2 节:文本编码与条件生成
|
||||
"sampling", # 第 3 节:图像采样与去噪
|
||||
|
||||
+35
-35
@@ -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] = {
|
||||
|
||||
@@ -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.
|
||||
+122
-90
@@ -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,
|
||||
|
||||
+30
-27
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+38
-93
@@ -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] = {
|
||||
|
||||
+22
-50
@@ -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)
|
||||
|
||||
|
||||
# ---------- 注册表 ----------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")],
|
||||
|
||||
+48
-65
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
+10
-10
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user