QWEN节点添加LORA功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user