From 1884cf31aaee4f332eb5f26361076216a0d157d7 Mon Sep 17 00:00:00 2001 From: Gin3601 Date: Wed, 20 May 2026 17:50:28 +0800 Subject: [PATCH] =?UTF-8?q?QWEN=E8=8A=82=E7=82=B9=E6=B7=BB=E5=8A=A0LORA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-05-20-qwen-advanced-params.md | 21 ++- .../2026-05-20-qwen-advanced-params-design.md | 79 ++++------ qwen/discovery.py | 17 --- qwen/nodes.py | 74 +++------ qwen/runtime.py | 33 +--- tests/test_qwen_nodes.py | 144 +++++++++--------- 6 files changed, 131 insertions(+), 237 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-qwen-advanced-params.md b/docs/superpowers/plans/2026-05-20-qwen-advanced-params.md index 69ab4ba..f91d7fe 100644 --- a/docs/superpowers/plans/2026-05-20-qwen-advanced-params.md +++ b/docs/superpowers/plans/2026-05-20-qwen-advanced-params.md @@ -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 diff --git a/docs/superpowers/specs/2026-05-20-qwen-advanced-params-design.md b/docs/superpowers/specs/2026-05-20-qwen-advanced-params-design.md index 5607777..437361c 100644 --- a/docs/superpowers/specs/2026-05-20-qwen-advanced-params-design.md +++ b/docs/superpowers/specs/2026-05-20-qwen-advanced-params-design.md @@ -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 diff --git a/qwen/discovery.py b/qwen/discovery.py index 1912000..d3d8e4f 100644 --- a/qwen/discovery.py +++ b/qwen/discovery.py @@ -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 diff --git a/qwen/nodes.py b/qwen/nodes.py index 3d60f16..6229f0c 100644 --- a/qwen/nodes.py +++ b/qwen/nodes.py @@ -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", } diff --git a/qwen/runtime.py b/qwen/runtime.py index 7663e3a..c4061cf 100644 --- a/qwen/runtime.py +++ b/qwen/runtime.py @@ -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) diff --git a/tests/test_qwen_nodes.py b/tests/test_qwen_nodes.py index b78e28d..558b95b 100644 --- a/tests/test_qwen_nodes.py +++ b/tests/test_qwen_nodes.py @@ -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__":