QWEN节点添加LORA功能

This commit is contained in:
Gin3601
2026-05-20 17:50:28 +08:00
parent ce6ac6b0d4
commit 1884cf31aa
6 changed files with 131 additions and 237 deletions
@@ -2,9 +2,9 @@
> **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:** Extend `LLSQwenTextToImage` and `LLSQwenImageEdit` with official/common advanced controls, optional ordered user LoRA stack input, optional turbo/lightning LoRA support, and full regression coverage while preserving the compressed single-node UX.
**Goal:** Extend `LLSQwenTextToImage` and `LLSQwenImageEdit` with official/common advanced controls, an optional standard `MODEL` input compatible with `LoraLoaderModelOnly`, optional turbo/lightning LoRA support, and full regression coverage while preserving the compressed single-node UX.
**Architecture:** Keep the current dedicated `qwen/` package and expand it in place. `qwen/discovery.py` will own advanced choice lists, ordered user LoRA choice lists, turbo LoRA filtering, auto-resolution, and turbo presets; `qwen/runtime.py` will own advanced parameter passthrough plus ordered model-only LoRA application; `qwen/nodes.py` will expose the expanded Qwen node schemas and add one compact `LLSQwenLoRAStack` helper while the main generation nodes still return only `IMAGE`.
**Architecture:** Keep the current dedicated `qwen/` package and expand it in place. `qwen/discovery.py` will own turbo LoRA filtering, auto-resolution, and turbo presets; `qwen/runtime.py` will own advanced parameter passthrough plus internal-vs-external model selection; `qwen/nodes.py` will expose the expanded Qwen node schemas with optional `MODEL` inputs while the main generation nodes still return only `IMAGE`.
**Tech Stack:** Python, unittest, ComfyUI core nodes, `comfy_extras.nodes_qwen`, `comfy_extras.nodes_flux`, `comfy_extras.nodes_model_advanced`, `comfy_extras.nodes_cfg`
@@ -13,26 +13,25 @@
## File Structure
- Modify: `qwen/discovery.py`
- Add user LoRA stack choice lists, turbo/lightning LoRA discovery, `(auto)` handling, compatible LoRA validation, and turbo preset lookup.
- Keep turbo/lightning LoRA discovery, `(auto)` handling, compatible LoRA validation, and turbo preset lookup; remove now-unneeded custom LoRA-stack helpers.
- Modify: `qwen/runtime.py`
- Add advanced parameter passthrough, ordered model-only user LoRA application, turbo preset overrides, and image-edit multi-reference handling.
- Add advanced parameter passthrough, external `MODEL` preference when connected, turbo preset overrides, and image-edit multi-reference handling.
- Modify: `qwen/nodes.py`
- Expand the two public node schemas with advanced inputs, add optional `lora_stack` inputs, and add the compact `LLSQwenLoRAStack` helper while preserving hidden resource loading and `IMAGE`-only outputs.
- Expand the two public node schemas with advanced inputs, add optional `MODEL` inputs, and remove the custom `LLSQwenLoRAStack` helper while preserving hidden resource loading and `IMAGE`-only outputs.
- Modify: `tests/test_qwen_nodes.py`
- Add schema, discovery, ordered LoRA stack, passthrough, turbo, and failure-path tests with small deterministic stubs.
- Replace custom LoRA-stack tests with schema, external `MODEL` passthrough, turbo, and failure-path tests with small deterministic stubs.
## Plan Amendment
This plan now assumes two changes beyond the original draft:
- both public Qwen generation nodes accept an optional ordered `lora_stack` input
- both public Qwen generation nodes accept an optional standard `MODEL` input
- `qwen_image_edit_2511_*` turbo/lightning support is allowed when a matching 2511 LoRA exists locally
The intended internal model application order is:
The intended model selection order is:
- base Qwen model
- ordered user LoRA stack entries
- optional turbo/lightning LoRA
- external path: use connected `MODEL`, then optionally apply turbo/lightning LoRA
- internal path: load model from `model_name`, then optionally apply turbo/lightning LoRA
### Task 1: Expand schemas and turbo LoRA discovery
@@ -38,8 +38,8 @@ Extend the existing Qwen nodes so they expose official/common advanced parameter
- Add advanced prompt/sampling controls to `LLSQwenTextToImage`
- Add advanced prompt/sampling/edit controls to `LLSQwenImageEdit`
- Add optional `image2` and `image3` to the image-edit node
- Add optional ordered user LoRA stack input to both Qwen nodes
- Add one compact helper node for building an ordered Qwen LoRA stack
- Add optional standard `MODEL` input to both Qwen nodes
- Make that `MODEL` input compatible with official `LoraLoaderModelOnly` chaining
- Add optional turbo/lightning LoRA mode to both nodes
- Auto-match compatible turbo LoRAs when requested
- Reject incompatible turbo LoRA selections explicitly
@@ -64,7 +64,7 @@ This keeps the current user experience intact:
- users still drag one node for text-to-image
- users still drag one node for image edit
- users can optionally connect one compact ordered LoRA stack input
- users can optionally connect a standard patched `MODEL`
- advanced users gain the controls they expect
- the plugin still avoids forcing Qwen through the generic SD/SDXL/FLUX abstractions
@@ -89,7 +89,7 @@ This keeps the current user experience intact:
- `sampler_name`
- `scheduler`
- `shift`
- `lora_stack` (optional input)
- `model` (optional input)
- `enable_turbo_mode`
- `turbo_lora_name`
- `turbo_strength`
@@ -100,14 +100,15 @@ This keeps the current user experience intact:
**Behavior**
- Loads the selected Qwen text-to-image model internally
- Loads the selected Qwen text-to-image model internally when `model` is not connected
- Uses the externally connected `MODEL` directly when `model` is connected
- Resolves the Qwen text encoder and VAE internally
- Encodes `prompt` as positive conditioning
- Encodes `negative_prompt` as negative conditioning
- Passes `cfg`, `sampler_name`, and `scheduler` directly into internal sampling
- Passes `shift` directly into `ModelSamplingAuraFlow`
- Creates the latent internally from `width`, `height`, and `batch_size`
- Applies any connected ordered user LoRA stack to the internal model before turbo handling
- Keeps the Qwen node compatible with official `LoraLoaderModelOnly` by accepting a patched `MODEL`
- Optionally loads a compatible turbo/lightning LoRA when turbo mode is enabled
- Returns only the final decoded `IMAGE`
@@ -132,7 +133,7 @@ This keeps the current user experience intact:
- `shift`
- `cfg_norm_strength`
- `reference_latents_method`
- `lora_stack` (optional input)
- `model` (optional input)
- `enable_turbo_mode`
- `turbo_lora_name`
- `turbo_strength`
@@ -143,7 +144,8 @@ This keeps the current user experience intact:
**Behavior**
- Loads the selected Qwen image-edit model internally
- Loads the selected Qwen image-edit model internally when `model` is not connected
- Uses the externally connected `MODEL` directly when `model` is connected
- Resolves the Qwen text encoder and VAE internally
- Uses `image` as the required primary edit input
- Optionally passes `image2` and `image3` into the official `TextEncodeQwenImageEditPlus` path
@@ -154,32 +156,10 @@ This keeps the current user experience intact:
- Passes `shift` into `ModelSamplingAuraFlow`
- Passes `cfg_norm_strength` into `CFGNorm`
- Passes `reference_latents_method` into `FluxKontextMultiReferenceLatentMethod`
- Applies any connected ordered user LoRA stack to the internal model before turbo handling
- Keeps the Qwen node compatible with official `LoraLoaderModelOnly` by accepting a patched `MODEL`
- Optionally loads a compatible turbo/lightning LoRA when turbo mode is enabled
- Returns only the final decoded `IMAGE`
### `LLSQwenLoRAStack`
**Base Inputs**
- `lora_name`
- `strength_model`
**Optional Input**
- `lora_stack`
**Output**
- `LLS_QWEN_LORA_STACK`
**Behavior**
- Builds an ordered model-only LoRA stack for the Qwen nodes
- Supports serial chaining by accepting an existing `lora_stack` and appending one more LoRA entry
- Preserves user-specified order exactly
- Keeps the main Qwen nodes compressed by moving multi-LoRA assembly into one small helper node
## Advanced Parameter Defaults
### Shared Defaults
@@ -238,28 +218,25 @@ When `enable_turbo_mode = true`:
- find a compatible turbo/lightning LoRA for the selected main model
- if `turbo_lora_name = "(auto)"`, auto-select the matching LoRA
- if `turbo_lora_name` is manually set, validate it against the selected main model
- keep user LoRA stack application order separate from turbo handling
- keep external `MODEL` chaining separate from turbo handling
- apply that LoRA to the internal model only
- use `turbo_strength` as the LoRA strength
- override runtime `steps` and `cfg` with turbo presets
- keep `sampler_name`, `scheduler`, and `shift` user-controlled
### Ordered LoRA Rules
### Standard Model Input Rules
When a `lora_stack` input is connected:
When a `model` input is connected:
- treat it as an ordered serial chain
- apply entries in the exact order they appear in the stack
- use `LoraLoaderModelOnly` semantics because these Qwen nodes keep `clip` hidden
- apply the full user stack before optional turbo/lightning LoRA application
- treat it as the authoritative diffusion model input
- do not load a second internal UNet for generation
- still use `model_name` to validate the selected Qwen family and resolve companion resources such as text encoder, VAE, and turbo profile
- apply optional turbo/lightning LoRA on top of the externally supplied model
This yields the internal order:
This yields two legal execution paths:
- base Qwen model
- user LoRA #1
- user LoRA #2
- ...
- optional turbo/lightning LoRA
- internal path: `model` disconnected -> load model from `model_name`
- external path: `model` connected -> use external `MODEL` directly
### Compatibility Strategy
@@ -358,7 +335,7 @@ Required error cases:
- required Qwen text encoder is missing
- required Qwen VAE is missing
- primary edit image is missing
- the connected user LoRA stack is malformed
- the connected external `MODEL` is missing when expected by the user workflow
- required ComfyUI runtime component is unavailable
- turbo mode is enabled but no compatible turbo LoRA exists
- turbo mode is enabled but the selected model has no supported turbo preset
@@ -380,7 +357,6 @@ Extend the existing `qwen/` package instead of creating a second feature family.
Add helpers for:
- discovering compatible turbo/lightning LoRAs
- listing user-selectable LoRAs for Qwen stack building
- resolving `(auto)` turbo LoRA matches
- validating manual turbo LoRA selections
- exposing placeholder choices when no compatible turbo LoRAs exist
@@ -390,7 +366,7 @@ Add helpers for:
Extend runtime wrappers so they accept advanced parameters and:
- pass prompt/sampler controls through to internal Qwen execution
- apply ordered model-only user LoRA stacks
- prefer external `MODEL` input when connected
- load optional turbo LoRAs
- apply turbo presets when enabled
- support multi-image edit conditioning
@@ -404,16 +380,14 @@ Expand both node schemas to expose the agreed advanced inputs while preserving:
- internal resource encapsulation
- separate text-to-image and image-edit nodes
Also add one compact helper node that outputs `LLS_QWEN_LORA_STACK`.
## Testing
Add or update tests for:
- text-to-image schema includes the new advanced inputs
- image-edit schema includes the new advanced inputs
- LoRA stack helper schema exposes serial chaining
- ordered user LoRA stack entries are applied in order
- both node schemas expose an optional `MODEL` input
- external `MODEL` input bypasses internal model loading
- negative prompt is routed into the negative conditioning path
- `cfg`, `sampler_name`, `scheduler`, and `shift` are passed through correctly
- `image2` and `image3` are routed into edit conditioning
@@ -443,7 +417,8 @@ This iteration is successful when:
- `LLSQwenTextToImage` exposes official/common advanced prompt and sampling controls
- `LLSQwenImageEdit` exposes official/common advanced prompt, sampling, and edit controls
- both nodes accept an optional ordered model-side LoRA stack input
- both nodes accept an optional standard `MODEL` input
- that `MODEL` input is compatible with official `LoraLoaderModelOnly` chaining
- both nodes still hide model/clip/vae wiring
- both nodes still output only `IMAGE`
- text-to-image turbo mode works for supported Qwen text models
-17
View File
@@ -11,7 +11,6 @@ except Exception:
TEXT_MODEL_PLACEHOLDER = "(no qwen text-to-image models found)"
EDIT_MODEL_PLACEHOLDER = "(no qwen image edit models found)"
NO_LORA_PLACEHOLDER = "(no loras found)"
AUTO_TURBO_LORA_CHOICE = "(auto)"
NO_TEXT_TURBO_LORA_PLACEHOLDER = "(no qwen text turbo loras found)"
NO_EDIT_TURBO_LORA_PLACEHOLDER = "(no qwen edit turbo loras found)"
@@ -96,22 +95,6 @@ def validate_qwen_edit_model_name(model_name: str) -> str:
return model_name
def get_qwen_lora_choices() -> list[str]:
names = _sorted_unique(_get_filename_list("loras"))
return names or [NO_LORA_PLACEHOLDER]
def validate_qwen_lora_name(lora_name: str) -> str:
if not lora_name or lora_name == NO_LORA_PLACEHOLDER:
raise RuntimeError("[LLS] Missing Qwen LoRA selection.")
available = _get_filename_list("loras")
if lora_name not in available:
raise RuntimeError(
f"[LLS] LoRA '{lora_name}' was not found in ComfyUI/models/loras/."
)
return lora_name
def is_qwen_text_turbo_lora(name: str | None) -> bool:
lowered = _lower(name)
return "qwen-image" in lowered and "lightning" in lowered and "edit" not in lowered
+18 -56
View File
@@ -14,48 +14,6 @@ except Exception:
comfy_core_nodes = None
_MAX_RESOLUTION = int(getattr(comfy_core_nodes, "MAX_RESOLUTION", 8192))
_QWEN_LORA_STACK_TYPE = "LLS_QWEN_LORA_STACK"
class LLSQwenLoRAStack:
CATEGORY = "LLS/Qwen"
FUNCTION = "build"
RETURN_TYPES = (_QWEN_LORA_STACK_TYPE,)
RETURN_NAMES = ("lora_stack",)
DESCRIPTION = (
"Builds an ordered model-side LoRA stack for the Qwen nodes. "
"Chain multiple nodes to apply multiple LoRAs serially."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_name": (discovery.get_qwen_lora_choices(),),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
},
"optional": {
"lora_stack": (_QWEN_LORA_STACK_TYPE,),
},
}
def build(self, lora_name: str, strength_model: float, lora_stack=None):
if not lora_name or lora_name == discovery.NO_LORA_PLACEHOLDER:
raise RuntimeError("[LLS] Missing Qwen LoRA selection.")
stack = []
if lora_stack is not None:
if not isinstance(lora_stack, list):
raise RuntimeError("[LLS] Qwen lora_stack must be a list.")
stack.extend(lora_stack)
stack.append(
{
"lora_name": str(lora_name),
"strength_model": float(strength_model),
}
)
return (stack,)
class LLSQwenTextToImage:
@@ -64,8 +22,8 @@ class LLSQwenTextToImage:
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
DESCRIPTION = (
"Compressed Qwen text-to-image node. Internally loads the Qwen model, text encoder, "
"and VAE, supports advanced sampling controls, optional ordered LoRA stacks, "
"Compressed Qwen text-to-image node. Internally loads Qwen resources when needed, "
"supports advanced sampling controls, optional external MODEL chaining, "
"and optional turbo/lightning LoRAs."
)
@@ -90,10 +48,13 @@ class LLSQwenTextToImage:
discovery.get_qwen_text_turbo_lora_choices(),
{"default": discovery.AUTO_TURBO_LORA_CHOICE, "advanced": True},
),
"turbo_strength": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "advanced": True}),
"turbo_strength": (
"FLOAT",
{"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "advanced": True},
),
},
"optional": {
"lora_stack": (_QWEN_LORA_STACK_TYPE,),
"model": ("MODEL",),
},
}
@@ -114,7 +75,7 @@ class LLSQwenTextToImage:
enable_turbo_mode: bool,
turbo_lora_name: str,
turbo_strength: float,
lora_stack=None,
model=None,
):
return (
runtime.run_qwen_text_to_image(
@@ -133,7 +94,7 @@ class LLSQwenTextToImage:
enable_turbo_mode=enable_turbo_mode,
turbo_lora_name=turbo_lora_name,
turbo_strength=turbo_strength,
lora_stack=lora_stack,
model=model,
),
)
@@ -144,8 +105,8 @@ class LLSQwenImageEdit:
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
DESCRIPTION = (
"Compressed Qwen image-edit node. Internally loads the Qwen edit model, text encoder, "
"and VAE, supports official multi-image edit conditioning, ordered LoRA stacks, "
"Compressed Qwen image-edit node. Internally loads Qwen companion resources, "
"supports official multi-image edit conditioning, optional external MODEL chaining, "
"and optional turbo/lightning LoRAs."
)
@@ -173,12 +134,15 @@ class LLSQwenImageEdit:
discovery.get_qwen_edit_turbo_lora_choices(),
{"default": discovery.AUTO_TURBO_LORA_CHOICE, "advanced": True},
),
"turbo_strength": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "advanced": True}),
"turbo_strength": (
"FLOAT",
{"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "advanced": True},
),
},
"optional": {
"image2": ("IMAGE",),
"image3": ("IMAGE",),
"lora_stack": (_QWEN_LORA_STACK_TYPE,),
"model": ("MODEL",),
},
}
@@ -201,7 +165,7 @@ class LLSQwenImageEdit:
enable_turbo_mode: bool = False,
turbo_lora_name: str = discovery.AUTO_TURBO_LORA_CHOICE,
turbo_strength: float = 1.0,
lora_stack=None,
model=None,
):
return (
runtime.run_qwen_image_edit(
@@ -222,19 +186,17 @@ class LLSQwenImageEdit:
enable_turbo_mode=enable_turbo_mode,
turbo_lora_name=turbo_lora_name,
turbo_strength=turbo_strength,
lora_stack=lora_stack,
model=model,
),
)
NODE_CLASS_MAPPINGS: dict[str, type] = {
"LLSQwenLoRAStack": LLSQwenLoRAStack,
"LLSQwenTextToImage": LLSQwenTextToImage,
"LLSQwenImageEdit": LLSQwenImageEdit,
}
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {
"LLSQwenLoRAStack": "LLS Qwen LoRA Stack",
"LLSQwenTextToImage": "LLS Qwen Text To Image",
"LLSQwenImageEdit": "LLS Qwen Image Edit",
}
+7 -26
View File
@@ -122,27 +122,10 @@ def _load_model_only_lora(model, lora_name: str, strength_model: float):
return lora_loader_cls().load_lora_model_only(model, lora_name, float(strength_model))[0]
def _apply_lora_stack(model, lora_stack):
if lora_stack is None:
def _resolve_generation_model(model_name: str, model):
if model is not None:
return model
if not isinstance(lora_stack, list):
raise RuntimeError("[LLS] Qwen lora_stack must be a list of LoRA entries.")
current_model = model
for index, entry in enumerate(lora_stack, start=1):
if not isinstance(entry, dict):
raise RuntimeError(f"[LLS] Invalid Qwen LoRA stack entry at position {index}.")
lora_name = discovery.validate_qwen_lora_name(str(entry.get("lora_name", "")))
try:
strength_model = float(entry.get("strength_model", 1.0))
except (TypeError, ValueError) as exc:
raise RuntimeError(
f"[LLS] Invalid strength_model in Qwen LoRA stack entry at position {index}."
) from exc
current_model = _load_model_only_lora(current_model, lora_name, strength_model)
return current_model
return _load_qwen_model(model_name)
def _prepare_turbo_request(resolver, model_name: str, steps: int, cfg: float, enable_turbo_mode: bool, turbo_lora_name: str):
@@ -264,7 +247,7 @@ def run_qwen_text_to_image(
enable_turbo_mode: bool,
turbo_lora_name: str,
turbo_strength: float,
lora_stack=None,
model=None,
):
try:
model_name, clip_name, vae_name = _resolve_qwen_resources(
@@ -279,8 +262,7 @@ def run_qwen_text_to_image(
enable_turbo_mode,
turbo_lora_name,
)
model = _load_qwen_model(model_name)
model = _apply_lora_stack(model, lora_stack)
model = _resolve_generation_model(model_name, model)
if resolved_turbo_lora is not None:
model = _load_model_only_lora(model, resolved_turbo_lora, float(turbo_strength))
model = _patch_qwen_model_sampling(model, shift)
@@ -325,7 +307,7 @@ def run_qwen_image_edit(
enable_turbo_mode: bool,
turbo_lora_name: str,
turbo_strength: float,
lora_stack=None,
model=None,
):
try:
if image is None:
@@ -343,8 +325,7 @@ def run_qwen_image_edit(
enable_turbo_mode,
turbo_lora_name,
)
model = _load_qwen_model(model_name)
model = _apply_lora_stack(model, lora_stack)
model = _resolve_generation_model(model_name, model)
if resolved_turbo_lora is not None:
model = _load_model_only_lora(model, resolved_turbo_lora, float(turbo_strength))
model = _patch_qwen_model_sampling(model, shift)
+69 -75
View File
@@ -6,7 +6,6 @@ from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[1]
QWEN_LORA_STACK_TYPE = "LLS_QWEN_LORA_STACK"
def load_plugin_package():
@@ -25,10 +24,6 @@ def load_plugin_package():
return module
def make_lora_stack(*entries):
return [{"lora_name": name, "strength_model": strength} for name, strength in entries]
class NodeOutputStub:
def __init__(self, *args):
self.args = args
@@ -55,8 +50,6 @@ class QwenFolderPathsStub:
"text_encoders": ["qwen_2.5_vl_7b_fp8_scaled.safetensors"],
"vae": ["qwen_image_vae.safetensors"],
"loras": [
"qwen-style-a.safetensors",
"qwen-style-b.safetensors",
"Qwen-Image-Lightning-8steps-V1.0.safetensors",
"Qwen-Image-2512-Lightning-4steps-V1.0-fp32.safetensors",
"Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
@@ -303,7 +296,7 @@ class TestQwenNodes(unittest.TestCase):
self.assertIn("LLSQwenTextToImage", plugin.NODE_CLASS_MAPPINGS)
self.assertIn("LLSQwenImageEdit", plugin.NODE_CLASS_MAPPINGS)
self.assertIn("LLSQwenLoRAStack", plugin.NODE_CLASS_MAPPINGS)
self.assertNotIn("LLSQwenLoRAStack", plugin.NODE_CLASS_MAPPINGS)
def test_qwen_text_node_filters_only_text_models(self):
load_plugin_package()
@@ -337,7 +330,7 @@ class TestQwenNodes(unittest.TestCase):
],
)
def test_qwen_text_node_schema_exposes_advanced_inputs_and_optional_lora_stack(self):
def test_qwen_text_node_schema_exposes_advanced_inputs_and_optional_model(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSQwenTextToImage"]
required = node_cls.INPUT_TYPES()["required"]
@@ -371,9 +364,9 @@ class TestQwenNodes(unittest.TestCase):
self.assertIn("simple", required["scheduler"][0])
self.assertEqual(required["shift"][1]["default"], 3.1)
self.assertEqual(required["enable_turbo_mode"][1]["default"], False)
self.assertEqual(optional["lora_stack"][0], QWEN_LORA_STACK_TYPE)
self.assertEqual(optional["model"][0], "MODEL")
def test_qwen_edit_node_schema_exposes_advanced_inputs_optional_images_and_lora_stack(self):
def test_qwen_edit_node_schema_exposes_advanced_inputs_optional_images_and_model(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSQwenImageEdit"]
required = node_cls.INPUT_TYPES()["required"]
@@ -403,29 +396,12 @@ class TestQwenNodes(unittest.TestCase):
)
self.assertEqual(optional["image2"][0], "IMAGE")
self.assertEqual(optional["image3"][0], "IMAGE")
self.assertEqual(optional["lora_stack"][0], QWEN_LORA_STACK_TYPE)
self.assertEqual(optional["model"][0], "MODEL")
self.assertEqual(
required["reference_latents_method"][0],
["offset", "index", "uxo/uno", "index_timestep_zero"],
)
def test_qwen_lora_stack_node_builds_ordered_stack(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSQwenLoRAStack"]
node = node_cls()
first = node.build("qwen-style-a.safetensors", 0.5)[0]
second = node.build("qwen-style-b.safetensors", 1.25, first)[0]
self.assertEqual(node_cls.RETURN_TYPES, (QWEN_LORA_STACK_TYPE,))
self.assertEqual(
second,
[
{"lora_name": "qwen-style-a.safetensors", "strength_model": 0.5},
{"lora_name": "qwen-style-b.safetensors", "strength_model": 1.25},
],
)
def test_qwen_text_node_turbo_lora_choices_filter_only_text_loras(self):
load_plugin_package()
from lls_node_test_qwen.qwen import discovery as qwen_discovery
@@ -488,7 +464,7 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=None,
model=None,
)
def test_qwen_edit_runtime_rejects_incompatible_model_name(self):
@@ -521,7 +497,7 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=None,
model=None,
)
def test_qwen_text_runtime_raises_when_qwen_text_encoder_is_missing(self):
@@ -553,7 +529,7 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=None,
model=None,
)
def test_qwen_text_runtime_raises_when_qwen_vae_is_missing(self):
@@ -585,19 +561,15 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=None,
model=None,
)
def test_qwen_text_runtime_passes_advanced_controls_and_ordered_user_loras(self):
def test_qwen_text_runtime_executes_internal_pipeline_when_model_not_connected(self):
load_plugin_package()
from lls_node_test_qwen.qwen import discovery as qwen_discovery
from lls_node_test_qwen.qwen import runtime as qwen_runtime
stub = QwenFolderPathsStub()
lora_stack = make_lora_stack(
("qwen-style-a.safetensors", 0.5),
("qwen-style-b.safetensors", 1.25),
)
with mock.patch.object(qwen_discovery, "folder_paths", stub), mock.patch.object(
qwen_runtime,
"folder_paths",
@@ -623,10 +595,11 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=lora_stack,
model=None,
)
self.assertEqual(image, "IMAGE::SAMPLED::99::33")
self.assertEqual(CoreQwenNodesStub.last_unet_call["unet_name"], "qwen_image_fp8_e4m3fn.safetensors")
self.assertEqual(CoreQwenNodesStub.last_text_encode_calls[0]["text"], "a cat")
self.assertEqual(CoreQwenNodesStub.last_text_encode_calls[1]["text"], "low quality")
self.assertEqual(SD3NodesStub.last_generate_call, {"width": 1024, "height": 1152, "batch_size": 2})
@@ -634,19 +607,53 @@ class TestQwenNodes(unittest.TestCase):
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["cfg"], 7.5)
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["sampler_name"], "heun")
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["scheduler"], "normal")
self.assertEqual(
[call["lora_name"] for call in CoreQwenNodesStub.last_lora_calls],
["qwen-style-a.safetensors", "qwen-style-b.safetensors"],
)
self.assertEqual(ModelAdvancedStub.last_call["shift"], 2.25)
def test_qwen_text_runtime_applies_user_loras_before_turbo_lora(self):
def test_qwen_text_runtime_uses_external_model_without_internal_unet_load(self):
load_plugin_package()
from lls_node_test_qwen.qwen import discovery as qwen_discovery
from lls_node_test_qwen.qwen import runtime as qwen_runtime
stub = QwenFolderPathsStub()
with mock.patch.object(qwen_discovery, "folder_paths", stub), mock.patch.object(
qwen_runtime,
"folder_paths",
stub,
), mock.patch.object(qwen_runtime, "comfy_core_nodes", CoreQwenNodesStub), mock.patch.object(
qwen_runtime,
"nodes_sd3",
SD3NodesStub,
), mock.patch.object(qwen_runtime, "nodes_model_advanced", ModelAdvancedStub):
image = qwen_runtime.run_qwen_text_to_image(
model_name="qwen_image_fp8_e4m3fn.safetensors",
prompt="a cat",
negative_prompt="low quality",
width=1024,
height=1024,
steps=20,
seed=5,
batch_size=1,
cfg=4.5,
sampler_name="euler",
scheduler="simple",
shift=3.1,
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
model="MODEL::external",
)
self.assertEqual(image, "IMAGE::SAMPLED::5::20")
self.assertIsNone(CoreQwenNodesStub.last_unet_call)
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["cfg"], 4.5)
self.assertEqual(ModelAdvancedStub.last_call["model"], "MODEL::external")
def test_qwen_text_runtime_applies_turbo_lora_on_external_model(self):
load_plugin_package()
from lls_node_test_qwen.qwen import discovery as qwen_discovery
from lls_node_test_qwen.qwen import runtime as qwen_runtime
stub = QwenFolderPathsStub()
lora_stack = make_lora_stack(("qwen-style-a.safetensors", 0.5))
with mock.patch.object(qwen_discovery, "folder_paths", stub), mock.patch.object(
qwen_runtime,
"folder_paths",
@@ -672,17 +679,16 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=True,
turbo_lora_name="(auto)",
turbo_strength=0.75,
lora_stack=lora_stack,
model="MODEL::external",
)
self.assertIsNone(CoreQwenNodesStub.last_unet_call)
self.assertEqual(CoreQwenNodesStub.last_lora_calls[0]["model"], "MODEL::external")
self.assertEqual(
[call["lora_name"] for call in CoreQwenNodesStub.last_lora_calls],
[
"qwen-style-a.safetensors",
"Qwen-Image-Lightning-8steps-V1.0.safetensors",
],
CoreQwenNodesStub.last_lora_calls[0]["lora_name"],
"Qwen-Image-Lightning-8steps-V1.0.safetensors",
)
self.assertEqual(CoreQwenNodesStub.last_lora_calls[-1]["strength_model"], 0.75)
self.assertEqual(CoreQwenNodesStub.last_lora_calls[0]["strength_model"], 0.75)
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["steps"], 8)
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["cfg"], 1.0)
@@ -714,19 +720,15 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=True,
turbo_lora_name="Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
turbo_strength=1.0,
lora_stack=None,
model=None,
)
def test_qwen_edit_runtime_passes_optional_images_controls_and_ordered_user_loras(self):
def test_qwen_edit_runtime_uses_external_model_without_internal_unet_load(self):
load_plugin_package()
from lls_node_test_qwen.qwen import discovery as qwen_discovery
from lls_node_test_qwen.qwen import runtime as qwen_runtime
stub = QwenFolderPathsStub()
lora_stack = make_lora_stack(
("qwen-style-a.safetensors", 0.5),
("qwen-style-b.safetensors", 1.25),
)
with mock.patch.object(qwen_discovery, "folder_paths", stub), mock.patch.object(
qwen_runtime,
"folder_paths",
@@ -758,28 +760,21 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=lora_stack,
model="MODEL::external-edit",
)
self.assertEqual(image, "IMAGE::SAMPLED::123::28")
self.assertIsNone(CoreQwenNodesStub.last_unet_call)
self.assertEqual(FluxNodesStub.last_scale_call, {"image": "IMAGE::input"})
self.assertEqual(QwenExtraNodesStub.last_calls[0]["prompt"], "turn the cat blue")
self.assertEqual(QwenExtraNodesStub.last_calls[0]["image1"], "SCALED::IMAGE::input")
self.assertEqual(QwenExtraNodesStub.last_calls[0]["image2"], "IMAGE::ref2")
self.assertEqual(QwenExtraNodesStub.last_calls[0]["image3"], "IMAGE::ref3")
self.assertEqual(QwenExtraNodesStub.last_calls[1]["prompt"], "low quality")
self.assertEqual(len(FluxNodesStub.last_reference_calls), 2)
self.assertEqual(FluxNodesStub.last_reference_calls[0]["reference_latents_method"], "index")
self.assertEqual(CFGNodesStub.last_call["strength"], 0.8)
self.assertEqual(CoreQwenNodesStub.last_vae_encode_call["pixels"], "SCALED::IMAGE::input")
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["cfg"], 6.5)
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["sampler_name"], "heun")
self.assertEqual(CoreQwenNodesStub.last_ksampler_call["scheduler"], "normal")
self.assertEqual(
[call["lora_name"] for call in CoreQwenNodesStub.last_lora_calls],
["qwen-style-a.safetensors", "qwen-style-b.safetensors"],
)
self.assertEqual(ModelAdvancedStub.last_call["shift"], 2.0)
self.assertEqual(ModelAdvancedStub.last_call["model"], "MODEL::external-edit")
def test_qwen_edit_runtime_supports_2511_turbo_when_matching_lora_exists(self):
load_plugin_package()
@@ -818,9 +813,10 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=True,
turbo_lora_name="(auto)",
turbo_strength=0.7,
lora_stack=None,
model="MODEL::external-edit",
)
self.assertIsNone(CoreQwenNodesStub.last_unet_call)
self.assertEqual(
CoreQwenNodesStub.last_lora_calls[-1]["lora_name"],
"Qwen-Image-Edit-2511-Lightning-4steps-V1.0.safetensors",
@@ -834,7 +830,6 @@ class TestQwenNodes(unittest.TestCase):
from lls_node_test_qwen.qwen import nodes as qwen_nodes
node = qwen_nodes.LLSQwenTextToImage()
lora_stack = make_lora_stack(("qwen-style-a.safetensors", 0.5))
with mock.patch.object(qwen_nodes.runtime, "run_qwen_text_to_image", return_value="IMAGE::node") as runtime_mock:
result = node.generate(
@@ -853,18 +848,17 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=lora_stack,
model="MODEL::external",
)
self.assertEqual(result, ("IMAGE::node",))
self.assertEqual(runtime_mock.call_args.kwargs["lora_stack"], lora_stack)
self.assertEqual(runtime_mock.call_args.kwargs["model"], "MODEL::external")
def test_qwen_edit_node_executes_runtime(self):
load_plugin_package()
from lls_node_test_qwen.qwen import nodes as qwen_nodes
node = qwen_nodes.LLSQwenImageEdit()
lora_stack = make_lora_stack(("qwen-style-a.safetensors", 0.5))
with mock.patch.object(qwen_nodes.runtime, "run_qwen_image_edit", return_value="IMAGE::node-edit") as runtime_mock:
result = node.generate(
@@ -885,11 +879,11 @@ class TestQwenNodes(unittest.TestCase):
enable_turbo_mode=False,
turbo_lora_name="(auto)",
turbo_strength=1.0,
lora_stack=lora_stack,
model="MODEL::external",
)
self.assertEqual(result, ("IMAGE::node-edit",))
self.assertEqual(runtime_mock.call_args.kwargs["lora_stack"], lora_stack)
self.assertEqual(runtime_mock.call_args.kwargs["model"], "MODEL::external")
if __name__ == "__main__":