Add Chinese translation for v2.4.0 release notes
Add zhmd/v2.4.0.md — full faithful translation of v2.4.0 GitHub Release body. Includes correct EN|中文 switcher table (EN→releases/tag, 中文 plain text). Fix zhmd/README.md EN switcher link to point to English README Fix zhmd/README.md v2.4.0 link to point to Chinese release notes Was linking to English GitHub Release (releases/tag/v2.4.0); now points to zhmd/v2.4.0.md.
This commit is contained in:
+1
-1
@@ -250,7 +250,7 @@ ComfyUI\python_embeded\python.exe -m pip install --upgrade diffusers
|
||||
- **已合并**: [PR #49](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49) - feat(qwen_lora): 添加 Nunchaku Qwen Image LoRA Stack V4,rgthree 风格 UI (由 [avan06](https://github.com/avan06) 提出)
|
||||
- **注意**: 无法与 ComfyUI Nodes 2.0 正常工作。请使用标准 (LiteGraph) 画布。
|
||||
- **相关问题**: [问题 #12](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/12) - 请求更好的 LoRA 选项 (rgthree 风格 UI), [问题 #36](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/36) - 请求启用/禁用 LoRA 功能
|
||||
- **技术详情**: 请参阅 [v2.4.0 发行说明](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.4.0) 获取完整说明
|
||||
- **技术详情**: 请参阅 [v2.4.0 发行说明](v2.4.0.md) 获取完整说明
|
||||
|
||||
### 先前版本 (v2.3.0 到 v2.3.9)
|
||||
|
||||
|
||||
+801
@@ -0,0 +1,801 @@
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#e5e7eb" width="88" height="36"><a href="https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.4.0"><font color="#4b5563"><b>EN</b></font></a></td>
|
||||
<td align="center" bgcolor="#d4465e" width="88" height="36"><font color="#ffffff"><b>中文</b></font></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 概述
|
||||
|
||||
本文档完整说明 **ComfyUI-QwenImageLoraLoader** 中 Nunchaku Qwen Image LoRA Stack V1 节点的实现,以及 V4 到 V1 的重命名变更。对应 GitHub 发行 [v2.4.0](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.4.0) 的英文说明:
|
||||
|
||||
**v2.4.0 完整修改说明:V4 到 V1 重命名与 Nunchaku Qwen Image LoRA Stack V1**
|
||||
|
||||
**致谢**:Nunchaku Qwen Image LoRA Stack V1 节点(rgthree 风格 UI)由 [@avan06](https://github.com/avan06) 通过 [PR #49](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49) 贡献。本文档解释已合并的贡献内容、V4→V1 重命名以及实现细节。
|
||||
|
||||
---
|
||||
|
||||
## 1. 节点功能概述
|
||||
|
||||
### 1.1 此节点的作用
|
||||
|
||||
**Nunchaku Qwen Image LoRA Stack V1** 节点可在单个节点中将多个 LoRA(低秩适配)权重应用于 Nunchaku Qwen Image 扩散模型。与单 LoRA 加载器不同,此节点允许用户堆叠多个 LoRA,并为每个 LoRA 单独控制强度。
|
||||
|
||||
**主要功能**:
|
||||
- 在一个节点中应用多个 LoRA(动态行数)
|
||||
- 每个 LoRA 的启用/禁用切换
|
||||
- 每个 LoRA 的强度调整(0.00–2.00,步进 0.05)
|
||||
- 总开关(`stack_enabled`)用于禁用整个堆栈
|
||||
- CPU 卸载控制(auto/enable/disable)
|
||||
- AWQ 调制层支持(`apply_awq_mod`)用于量化模型
|
||||
- 工作流序列化(保存的工作流保留 LoRA 配置)
|
||||
|
||||
**UI 设计**:受 [Power Lora Loader (rgthree-comfy)](https://github.com/rgthree/rgthree-comfy) 启发的 rgthree 风格紧凑布局。每个 LoRA 行显示:切换开关 | LoRA 名称 | ◀ 强度 ▶ 于单个水平栏中。
|
||||
|
||||
**限制**:无法与 ComfyUI Nodes 2.0 正常配合。请使用标准(LiteGraph)画布。
|
||||
|
||||
---
|
||||
|
||||
## 2. Python 后端:完整代码与说明
|
||||
|
||||
**文件**:`nodes/lora/qwenimage_v1.py`
|
||||
|
||||
### 2.1 导入与路径设置(第 1–30 行)
|
||||
|
||||
```python
|
||||
"""
|
||||
This module provides the :class:`NunchakuQwenImageLoraStackV1` node
|
||||
for applying LoRA weights to Nunchaku Qwen Image models within ComfyUI.
|
||||
The interface completely mimics the Power Lora Loader from rgthree-comfy(https://github.com/rgthree/rgthree-comfy ),
|
||||
supporting dynamic additions and custom widgets.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Fix import path for this module
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Go up 2 levels: nodes/lora -> nodes -> ComfyUI-QwenImageLoraLoader
|
||||
lora_loader_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if lora_loader_dir not in sys.path:
|
||||
sys.path.insert(0, lora_loader_dir)
|
||||
print(f"[DEBUG] Added to sys.path: {lora_loader_dir}")
|
||||
print(f"[DEBUG] wrappers dir exists: {os.path.exists(os.path.join(lora_loader_dir, 'wrappers'))}")
|
||||
print(f"[DEBUG] qwenimage.py exists: {os.path.exists(os.path.join(lora_loader_dir, 'wrappers', 'qwenimage.py'))}")
|
||||
|
||||
import folder_paths
|
||||
|
||||
# Get log level from environment variable (default to INFO)
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `copy`:用于 `copy.deepcopy(model)`,在添加 LoRA 时克隆模型。
|
||||
- `lora_loader_dir`:解析 ComfyUI-QwenImageLoraLoader 根目录,以便能够导入 `wrappers.qwenimage`。ComfyUI 自定义节点可能未将父目录加入 `sys.path`。
|
||||
- `folder_paths`:ComfyUI 模块,用于解析模型/LoRA 路径(例如 `folder_paths.get_full_path_or_raise("loras", lora_name)`)。
|
||||
- `logger`:用于状态和调试输出。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 FlexibleOptionalInputType 与 any_type(第 32–40 行)
|
||||
|
||||
```python
|
||||
class any_type(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
def __getitem__(self, key):
|
||||
return (any_type, {})
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- ComfyUI 使用 `key in optional` 和 `optional[key]` 验证输入类型。默认情况下,仅接受已声明的可选输入。
|
||||
- `FlexibleOptionalInputType` 使**任何**键都表现为有效的可选输入:`__contains__` 始终返回 `True`,`__getitem__` 返回 `(any_type, {})`。
|
||||
- `any_type` 是字符串子类,其 `!=` 始终返回 `False`,因此在验证期间匹配任何类型。
|
||||
- **目的**:JS 前端在运行时添加动态控件 `lora_1`、`lora_2`、…。Python 节点事先不知道它们的名称。通过声明 `"optional": FlexibleOptionalInputType()`,从前端传入的任何 `lora_N` 键都会被接受,并通过 `**kwargs` 转发给 `load_lora_stack`。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 RETURN_TYPES、FUNCTION 与元数据(第 88–94 行)
|
||||
|
||||
```python
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
OUTPUT_TOOLTIPS = ("The modified diffusion model with all LoRAs applied.",)
|
||||
FUNCTION = "load_lora_stack"
|
||||
TITLE = "Nunchaku Qwen Image LoRA Stack V1"
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control."
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `RETURN_TYPES`:节点返回单个 `MODEL` 输出。
|
||||
- `FUNCTION`:节点运行时调用的方法。
|
||||
- `TITLE` / `CATEGORY` / `DESCRIPTION`:ComfyUI 节点浏览器和工具提示元数据。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 节点类与 INPUT_TYPES(第 42–86 行)
|
||||
|
||||
```python
|
||||
class NunchakuQwenImageLoraStackV1:
|
||||
"""
|
||||
Node for loading and applying multiple LoRAs to a Nunchaku Qwen Image model with dynamic UI.
|
||||
Built upon V3, pays full homage to the clean and minimalist design of the
|
||||
Power Lora Loader from the rgthree-comfy project.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
inputs = {
|
||||
"required": {
|
||||
"model": (
|
||||
"MODEL",
|
||||
{"tooltip": "The diffusion model to apply LoRAs to."},
|
||||
),
|
||||
"cpu_offload": (
|
||||
["auto", "enable", "disable"],
|
||||
{
|
||||
"default": "disable",
|
||||
"tooltip": "CPU offload setting. 'auto' enables offload when VRAM is low, 'enable' forces offload, 'disable' disables offload.",
|
||||
},
|
||||
),
|
||||
"apply_awq_mod": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Enable manual planar injection for AWQ modulation layers. Fixes noise issues in quantized models. Default is True.",
|
||||
},
|
||||
),
|
||||
"stack_enabled": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Master Switch: Enable or disable the entire LoRA stack processing.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(),
|
||||
# Use 'hidden' here to let JS pass dynamic LoRA data
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
return inputs
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `model`:来自 Nunchaku Qwen Image DiT Loader 的输入扩散模型。
|
||||
- `cpu_offload`:控制当 VRAM 较低时模型是否在 CPU 上运行。
|
||||
- `apply_awq_mod`:为 AWQ 量化调制层启用手动平面注入;修复量化模型的噪声问题。
|
||||
- `stack_enabled`:总开关;当为 `False` 时,无论每行的切换状态如何,都不应用任何 LoRA。
|
||||
- `optional`:接受来自前端的任何动态 `lora_N` 输入。
|
||||
- `hidden`:ComfyUI 标准;`prompt` 和 `extra_pnginfo` 用于工作流序列化。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 load_lora_stack — 第 1 部分:解析动态 LoRA 数据(第 95–144 行)
|
||||
|
||||
```python
|
||||
def load_lora_stack(self, model, cpu_offload="disable", apply_awq_mod=True, stack_enabled=True, **kwargs):
|
||||
# Dynamic widgets passed from JS will appear in kwargs
|
||||
# Named lora_1, lora_2... with values as dictionaries: {'enabled': bool, 'lora_name': str, 'lora_strength': float}
|
||||
lora_keys = [key for key in kwargs.keys() if key.startswith("lora_")]
|
||||
lora_keys.sort(key=lambda x: int(x.split('_')[-1])) # Ensure numerical sorting
|
||||
|
||||
lora_count = len(lora_keys)
|
||||
|
||||
loras_to_apply = []
|
||||
|
||||
# Log stack_enabled state
|
||||
logger.info(f"[LoRA Stack Status] apply_awq_mod: {apply_awq_mod}")
|
||||
logger.info(f"[LoRA Stack Status] stack_enabled: {stack_enabled}")
|
||||
logger.info(f"[LoRA Stack Status] Processing {lora_count} LoRA slot(s):")
|
||||
|
||||
for i, key in enumerate(lora_keys):
|
||||
value = kwargs[key]
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
lora_name = value.get("lora_name", "None")
|
||||
lora_strength = value.get("lora_strength", 1.0)
|
||||
# stack_enabled acts as a Master Switch.
|
||||
# If stack_enabled is False, all LoRAs are disabled.
|
||||
# If stack_enabled is True, we respect the individual 'enabled' state.
|
||||
enabled = stack_enabled and value.get("enabled", True)
|
||||
|
||||
status_parts = []
|
||||
status_parts.append(f"Slot {i}:")
|
||||
if lora_name and lora_name != "None":
|
||||
status_parts.append(f"'{lora_name}'")
|
||||
status_parts.append(f"strength={lora_strength}")
|
||||
else:
|
||||
status_parts.append("(no LoRA selected)")
|
||||
|
||||
status_parts.append(f"stack_enabled={stack_enabled}")
|
||||
status_parts.append(f"enabled_{i}={enabled}")
|
||||
|
||||
if enabled and lora_name and lora_name != "None" and abs(lora_strength) > 1e-5:
|
||||
status_parts.append("→ APPLIED ✓")
|
||||
loras_to_apply.append((lora_name, lora_strength))
|
||||
else:
|
||||
status_parts.append("→ SKIPPED ✗")
|
||||
|
||||
logger.info(f"[LoRA Stack Status] {' | '.join(status_parts)}")
|
||||
|
||||
logger.info(f"[LoRA Stack Status] Summary: {len(loras_to_apply)} LoRA(s) will be applied out of {lora_count} slot(s)")
|
||||
|
||||
if not loras_to_apply:
|
||||
return (model,)
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `kwargs` 包含来自 JS 控件的 `lora_1`、`lora_2`、…。每个值都是 `{enabled, lora_name, lora_strength}`。
|
||||
- `lora_keys` 按数字后缀排序,因此按顺序处理各行。
|
||||
- 对于每个槽位:
|
||||
- 仅当 `stack_enabled` 和该行的 `enabled` 均为 `True` 时,`enabled` 才为 `True`。
|
||||
- 仅在以下条件下应用 LoRA:`enabled`、已设置 `lora_name`(不是 `"None"`)、且 `abs(lora_strength) > 1e-5`。
|
||||
- 如果没有 LoRA 符合条件,则原样返回模型。
|
||||
|
||||
---
|
||||
|
||||
### 2.6 load_lora_stack — 第 2 部分:模型包装与 ComfyQwenImageWrapper(第 146–211 行)
|
||||
|
||||
```python
|
||||
model_wrapper = model.model.diffusion_model
|
||||
|
||||
# Dynamic import with explicit path manipulation
|
||||
import importlib.util
|
||||
|
||||
lora_loader_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
if lora_loader_dir not in sys.path:
|
||||
sys.path.insert(0, lora_loader_dir)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"wrappers.qwenimage",
|
||||
os.path.join(lora_loader_dir, "wrappers", "qwenimage.py"),
|
||||
)
|
||||
wrappers_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(wrappers_module)
|
||||
ComfyQwenImageWrapper = wrappers_module.ComfyQwenImageWrapper
|
||||
|
||||
from nunchaku import NunchakuQwenImageTransformer2DModel
|
||||
|
||||
model_wrapper_type_name = type(model_wrapper).__name__
|
||||
model_wrapper_module = type(model_wrapper).__module__
|
||||
logger.info(f"🔍 Model wrapper type: '{model_wrapper_type_name}'")
|
||||
logger.info(f"🔍 Has 'model' attr? {hasattr(model_wrapper, 'model')}")
|
||||
logger.info(f"🔍 Has 'loras' attr? {hasattr(model_wrapper, 'loras')}")
|
||||
|
||||
# Check if it's already wrapped
|
||||
if hasattr(model_wrapper, "model") and hasattr(model_wrapper, "loras"):
|
||||
logger.info("✅ Model is already wrapped (detected via attributes)")
|
||||
if model_wrapper.cpu_offload_setting != cpu_offload:
|
||||
model_wrapper.cpu_offload_setting = cpu_offload
|
||||
if hasattr(model_wrapper, "apply_awq_mod") and model_wrapper.apply_awq_mod != apply_awq_mod:
|
||||
model_wrapper.apply_awq_mod = apply_awq_mod
|
||||
transformer = model_wrapper.model
|
||||
elif model_wrapper_type_name == "NunchakuQwenImageTransformer2DModel" or model_wrapper_type_name.endswith(
|
||||
"NunchakuQwenImageTransformer2DModel"
|
||||
):
|
||||
logger.info("🔧 Wrapping NunchakuQwenImageTransformer2DModel with ComfyQwenImageWrapper")
|
||||
wrapped_model = ComfyQwenImageWrapper(
|
||||
model_wrapper,
|
||||
getattr(model_wrapper, "config", {}),
|
||||
None, # customized_forward
|
||||
{}, # forward_kwargs
|
||||
cpu_offload, # cpu_offload_setting
|
||||
4.0, # vram_margin_gb
|
||||
apply_awq_mod=apply_awq_mod,
|
||||
)
|
||||
model.model.diffusion_model = wrapped_model
|
||||
model_wrapper = wrapped_model
|
||||
transformer = model_wrapper.model
|
||||
else:
|
||||
logger.error(f"❌ Model type mismatch! Type: {model_wrapper_type_name}, Module: {model_wrapper_module}")
|
||||
logger.error("Please use 'Nunchaku Qwen Image DiT Loader'.")
|
||||
raise TypeError(f"This LoRA loader only works with Nunchaku Qwen Image models, but got {model_wrapper_type_name}.")
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `model.model.diffusion_model` 是实际的 transformer(例如 `NunchakuQwenImageTransformer2DModel`)。
|
||||
- 如果已经被包装(存在 `model` 和 `loras` 属性),则仅更新 `cpu_offload` 和 `apply_awq_mod`。
|
||||
- 否则,使用 `ComfyQwenImageWrapper` 进行包装,该包装器:
|
||||
- 保存 `(path, strength)` 元组的 `loras` 列表。
|
||||
- 在模型运行时通过 `compose_loras_v2` 将 LoRA 组合到模型中。
|
||||
- 此节点仅适用于 Nunchaku Qwen Image 模型;其他类型会引发 `TypeError`。
|
||||
|
||||
---
|
||||
|
||||
### 2.7 load_lora_stack — 第 3 部分:Flux 风格深拷贝与 LoRA 追加(第 212–237 行)
|
||||
|
||||
```python
|
||||
# Flux-style deepcopy
|
||||
saved_config = None
|
||||
if hasattr(model, "model") and hasattr(model.model, "model_config"):
|
||||
saved_config = model.model.model_config
|
||||
model.model.model_config = None
|
||||
|
||||
model_wrapper.model = None
|
||||
try:
|
||||
ret_model = copy.deepcopy(model)
|
||||
finally:
|
||||
if saved_config is not None:
|
||||
model.model.model_config = saved_config
|
||||
model_wrapper.model = transformer
|
||||
|
||||
ret_model_wrapper = ret_model.model.diffusion_model
|
||||
if saved_config is not None:
|
||||
ret_model.model.model_config = saved_config
|
||||
ret_model_wrapper.model = transformer
|
||||
|
||||
ret_model_wrapper.loras = model_wrapper.loras.copy()
|
||||
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
logger.debug(f"LoRA added to stack: {lora_name} (strength={lora_strength})")
|
||||
|
||||
logger.info(f"Total LoRAs in stack: {len(ret_model_wrapper.loras)}")
|
||||
return (ret_model,)
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- ComfyUI 期望每个节点返回一个*新*的模型对象。不得修改原始对象。
|
||||
- 在 `deepcopy` 之前清除 `model_config`,因为它可能包含不可 pickle 的数据。之后将其恢复。
|
||||
- `model_wrapper.model` 被临时设为 `None`,以便不深度拷贝 transformer;仅拷贝包装器结构。
|
||||
- 在 `deepcopy` 之后,`ret_model_wrapper` 接收拷贝的 `loras` 列表,并将此节点的新 LoRA 追加进去。
|
||||
- `folder_paths.get_full_path_or_raise("loras", lora_name)` 解析 LoRA 文件路径,如果找不到则引发错误。
|
||||
|
||||
---
|
||||
|
||||
### 2.8 GENERATED_NODES 与 GENERATED_DISPLAY_NAMES(第 241–248 行)
|
||||
|
||||
```python
|
||||
GENERATED_NODES = {
|
||||
"NunchakuQwenImageLoraStackV1": NunchakuQwenImageLoraStackV1,
|
||||
}
|
||||
|
||||
GENERATED_DISPLAY_NAMES = {
|
||||
"NunchakuQwenImageLoraStackV1": "Nunchaku Qwen Image LoRA Stack V1",
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:这些字典由 `__init__.py` 导入,并合并到 `NODE_CLASS_MAPPINGS` 和 `NODE_DISPLAY_NAME_MAPPINGS` 中,用于 ComfyUI 注册。
|
||||
|
||||
---
|
||||
|
||||
## 3. JavaScript 前端:完整代码与说明
|
||||
|
||||
**文件**:`js/z_qwen_lora_dynamic_v1.js`
|
||||
|
||||
### 3.1 导入与主题颜色(第 1–16 行)
|
||||
|
||||
```javascript
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
console.log("★★★ z_qwen_lora_dynamic_v1.js: Qwen Image LoRA Stack V1 ★★★");
|
||||
|
||||
// Use LiteGraph built-in colors to ensure Light/Dark Mode compatibility
|
||||
const getColors = () => ({
|
||||
bg: LiteGraph.WIDGET_BGCOLOR,
|
||||
text: LiteGraph.WIDGET_TEXT_COLOR,
|
||||
secondary: LiteGraph.WIDGET_SECONDARY_TEXT_COLOR,
|
||||
outline: LiteGraph.WIDGET_OUTLINE_COLOR,
|
||||
active: LiteGraph.NODE_SELECTED_TITLE_COLOR,
|
||||
});
|
||||
|
||||
const WIDGET_HEIGHT = 24;
|
||||
const HORIZ_MARGIN = 15;
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `app`:ComfyUI 应用;用于 `app.canvas.prompt`、`app.registerExtension`。
|
||||
- `api`:用于 `api.fetchApi("/object_info")` 获取 LoRA 列表。
|
||||
- `getColors()`:返回主题感知颜色,使控件在亮模式和暗模式下均显示正确。
|
||||
- `WIDGET_HEIGHT`、`HORIZ_MARGIN`:每个 LoRA 行的布局常量。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 NunchakuLoraWidget 类 — 构造函数(第 18–34 行)
|
||||
|
||||
```javascript
|
||||
class NunchakuLoraWidget {
|
||||
constructor(name, parentNode) {
|
||||
this.name = name;
|
||||
this.type = "custom";
|
||||
this.parentNode = parentNode;
|
||||
this.value = { enabled: true, lora_name: "None", lora_strength: 1.0 };
|
||||
this.last_y = 0;
|
||||
|
||||
// Define hit areas
|
||||
this.hitAreas = {
|
||||
toggle: [0, 0],
|
||||
lora: [0, 0],
|
||||
strengthDec: [0, 0],
|
||||
strengthInc: [0, 0],
|
||||
strengthVal: [0, 0]
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `name`:例如 `lora_1`、`lora_2`;必须与 Python 在 `kwargs` 中预期的名称匹配。
|
||||
- `type: "custom"`:ComfyUI/LiteGraph 自定义控件;需要 `draw` 以及可选的 `mouse`、`serializeValue`。
|
||||
- `value`:发送给后端的状态:`{enabled, lora_name, lora_strength}`。
|
||||
- `last_y`:该行的垂直位置;用于右键点击命中测试。
|
||||
- `hitAreas`:每个交互区域(切换开关、LoRA 名称、◀、值、▶)的 `[xMin, xMax]`。在 `draw` 中更新,在 `mouse` 中使用。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 NunchakuLoraWidget.draw — 渲染(第 36–120 行)
|
||||
|
||||
```javascript
|
||||
draw(ctx, node, width, posY, height) {
|
||||
const colors = getColors();
|
||||
const x = HORIZ_MARGIN;
|
||||
const midY = posY + height / 2;
|
||||
this.last_y = posY;
|
||||
const widgetWidth = width - HORIZ_MARGIN * 2;
|
||||
|
||||
const globalOn = node.widgets.find(w => w.name === "stack_enabled")?.value !== false;
|
||||
const effectiveOn = this.value.enabled && globalOn;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// 1. Background
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.strokeStyle = colors.outline;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, posY + 2, widgetWidth, height - 4, [4]);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// 2. Toggle switch - pill-shaped track
|
||||
const toggleWidth = 20, toggleHeight = 12;
|
||||
const toggleX = x + 8, toggleY = midY - (toggleHeight / 2);
|
||||
ctx.fillStyle = colors.outline;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(toggleX, toggleY, toggleWidth, toggleHeight, [toggleHeight / 2]);
|
||||
ctx.fill();
|
||||
// Knob - circle, left=off right=on
|
||||
ctx.fillStyle = globalOn ? colors.text : colors.secondary;
|
||||
const knobX = this.value.enabled ? toggleX + toggleWidth - 6 : toggleX + 6;
|
||||
ctx.arc(knobX, midY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
this.hitAreas.toggle = [toggleX, toggleX + toggleWidth];
|
||||
|
||||
// 3. LoRA Name - left-aligned, clipped if too long
|
||||
const strengthAreaWidth = 70;
|
||||
const labelX = toggleX + toggleWidth + 10;
|
||||
const labelWidth = widgetWidth - (labelX - x) - strengthAreaWidth - 5;
|
||||
ctx.fillStyle = effectiveOn ? colors.text : colors.secondary;
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "left";
|
||||
let displayLabel = (this.value.lora_name || "None");
|
||||
const metrics = ctx.measureText(displayLabel);
|
||||
if (metrics.width > labelWidth) {
|
||||
const avgCharWidth = 7;
|
||||
const maxChars = Math.floor(labelWidth / avgCharWidth);
|
||||
if (displayLabel.length > maxChars) {
|
||||
displayLabel = displayLabel.substring(0, maxChars - 3) + "...";
|
||||
}
|
||||
}
|
||||
ctx.fillText(displayLabel, labelX, midY + 4);
|
||||
this.hitAreas.lora = [labelX, labelX + labelWidth];
|
||||
|
||||
// 4. Strength: ◀ value ▶
|
||||
const rightX = x + widgetWidth - 5;
|
||||
const valueCenter = rightX - 35, arrowGap = 22;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("▶", valueCenter + arrowGap, midY + 4);
|
||||
this.hitAreas.strengthInc = [valueCenter + arrowGap - 10, valueCenter + arrowGap + 10];
|
||||
ctx.fillStyle = effectiveOn ? colors.text : colors.secondary;
|
||||
ctx.fillText(this.value.lora_strength.toFixed(2), valueCenter, midY + 4);
|
||||
this.hitAreas.strengthVal = [valueCenter - 18, valueCenter + 18];
|
||||
ctx.fillText("◀", valueCenter - arrowGap, midY + 4);
|
||||
this.hitAreas.strengthDec = [valueCenter - arrowGap - 10, valueCenter - arrowGap + 10];
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 绘制一行:圆角矩形背景、切换开关、LoRA 名称、带 ◀/▶ 的强度。
|
||||
- `effectiveOn`:当堆栈或行被禁用时,使该行变暗。
|
||||
- `hitAreas` 被设置为绘制坐标,供后续点击处理使用。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 NunchakuLoraWidget.mouse — 点击处理(第 123–156 行)
|
||||
|
||||
```javascript
|
||||
mouse(event, pos, node) {
|
||||
if (event.type !== "pointerdown") return false;
|
||||
if (event.button === 2) return false; // Right-click -> node context menu
|
||||
|
||||
const globalOn = node.widgets.find(w => w.name === "stack_enabled")?.value !== false;
|
||||
if (!globalOn) return false;
|
||||
|
||||
const widgetX = pos[0];
|
||||
if (widgetX >= this.hitAreas.toggle[0] && widgetX <= this.hitAreas.toggle[1]) {
|
||||
this.value.enabled = !this.value.enabled;
|
||||
} else if (widgetX >= this.hitAreas.lora[0] && widgetX <= this.hitAreas.lora[1]) {
|
||||
this.chooseLora(event);
|
||||
} else if (widgetX >= this.hitAreas.strengthDec[0] && widgetX <= this.hitAreas.strengthDec[1]) {
|
||||
this.value.lora_strength = Math.round((this.value.lora_strength - 0.05) * 100) / 100;
|
||||
} else if (widgetX >= this.hitAreas.strengthInc[0] && widgetX <= this.hitAreas.strengthInc[1]) {
|
||||
this.value.lora_strength = Math.round((this.value.lora_strength + 0.05) * 100) / 100;
|
||||
} else if (widgetX >= this.hitAreas.strengthVal[0] && widgetX <= this.hitAreas.strengthVal[1]) {
|
||||
app.canvas.prompt("Lora Strength", this.value.lora_strength, (v) => {
|
||||
const val = parseFloat(v);
|
||||
if (!isNaN(val)) this.value.lora_strength = val;
|
||||
node.setDirtyCanvas(true);
|
||||
}, event);
|
||||
} else return false;
|
||||
|
||||
this.parentNode.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 仅左键点击;右键保留给节点的插槽菜单。
|
||||
- 如果 `stack_enabled` 关闭,则无交互。
|
||||
- 使用 `hitAreas` 决定:切换、LoRA 选择器、减量、增量或数字提示。
|
||||
- 强度由 UI 隐式限制;提示允许任何浮点数。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 chooseLora 与 serializeValue(第 158–176 行)
|
||||
|
||||
```javascript
|
||||
async chooseLora(event) {
|
||||
const resp = await api.fetchApi("/object_info");
|
||||
const data = await resp.json();
|
||||
const loras = data?.LoraLoader?.input?.required?.lora_name?.[0] || [];
|
||||
|
||||
new LiteGraph.ContextMenu(loras, {
|
||||
event: event,
|
||||
callback: (value) => {
|
||||
this.value.lora_name = value;
|
||||
this.parentNode.setDirtyCanvas(true, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
serializeValue() {
|
||||
return this.value;
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `chooseLora`:从 `/object_info` 获取 LoRA 列表,显示上下文菜单,在选择时更新 `lora_name`。
|
||||
- `serializeValue`:返回 `{enabled, lora_name, lora_strength}`,以便 ComfyUI 将其包含在发送给后端的 prompt 中。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 扩展注册与 beforeRegisterNodeDef(第 178–214 行)
|
||||
|
||||
```javascript
|
||||
app.registerExtension({
|
||||
name: "nunchaku.qwen_lora_dynamic_v1",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "NunchakuQwenImageLoraStackV1") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
this.serialize_widgets = true;
|
||||
this.loraWidgets = [];
|
||||
|
||||
const stackEnabledWidget = this.widgets.find(w => w.name === "stack_enabled");
|
||||
if (stackEnabledWidget) {
|
||||
stackEnabledWidget.callback = () => { this.setDirtyCanvas(true); };
|
||||
}
|
||||
|
||||
const btn = this.addWidget("button", "➕ Add Lora", null, (val, canvas, node, pos, event) => {
|
||||
this.addLoraRowWithChooser(event);
|
||||
});
|
||||
btn.serialize = false;
|
||||
|
||||
const HEADER_H = 60, INPUT_WIDGETS_H = stackEnabledWidget ? 50 : 0, PADDING = 20;
|
||||
this.setSize([this.size[0], HEADER_H + INPUT_WIDGETS_H + PADDING]);
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
};
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 仅对 `NunchakuQwenImageLoraStackV1` 运行。
|
||||
- `serialize_widgets = true`:将控件值包含在发送给后端的 prompt 中。
|
||||
- "➕ Add Lora" 按钮:调用 `addLoraRowWithChooser`(显示 LoRA 菜单,然后添加行)。
|
||||
- `btn.serialize = false`:按钮本身不会被序列化;只有 LoRA 控件会被序列化。
|
||||
|
||||
---
|
||||
|
||||
### 3.7 addLoraRowWithChooser、addLoraRow(第 216–247 行)
|
||||
|
||||
```javascript
|
||||
nodeType.prototype.addLoraRowWithChooser = async function (event) {
|
||||
const resp = await api.fetchApi("/object_info");
|
||||
const data = await resp.json();
|
||||
const loras = data?.LoraLoader?.input?.required?.lora_name?.[0] || [];
|
||||
new LiteGraph.ContextMenu(loras, {
|
||||
event: event,
|
||||
callback: (value) => {
|
||||
this.addLoraRow({ enabled: true, lora_name: value, lora_strength: 1.0 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
nodeType.prototype.addLoraRow = function (data = null) {
|
||||
const name = `lora_${this.loraWidgets.length + 1}`;
|
||||
const w = new NunchakuLoraWidget(name, this);
|
||||
w.value = data || { enabled: true, lora_name: "None", lora_strength: 1.0 };
|
||||
|
||||
const btnIdx = this.widgets.findIndex(x => x.name === "➕ Add Lora");
|
||||
this.widgets.splice(btnIdx, 0, w);
|
||||
this.loraWidgets.push(w);
|
||||
|
||||
this.setSize([this.size[0], this.computeSize()[1]]);
|
||||
this.setDirtyCanvas(true, true);
|
||||
return w;
|
||||
};
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `addLoraRowWithChooser`:显示 LoRA 菜单,然后使用选中的 LoRA 添加一行。
|
||||
- `addLoraRow`:创建名为 `lora_1`、`lora_2`、… 的 `NunchakuLoraWidget`;将其插入到 Add Lora 按钮上方;更新大小和画布。
|
||||
|
||||
---
|
||||
|
||||
### 3.8 右键菜单:getSlotInPosition、getSlotMenuOptions、moveLora(第 249–315 行)
|
||||
|
||||
```javascript
|
||||
nodeType.prototype.getSlotInPosition = function (canvasX, canvasY) {
|
||||
const slot = LGraphNode.prototype.getSlotInPosition.apply(this, arguments);
|
||||
if (slot) return slot;
|
||||
|
||||
const widget = this.widgets.find(w => {
|
||||
return w instanceof NunchakuLoraWidget &&
|
||||
canvasY > (this.pos[1] + w.last_y) &&
|
||||
canvasY < (this.pos[1] + w.last_y + WIDGET_HEIGHT);
|
||||
});
|
||||
|
||||
if (widget) return { widget: widget, output: { type: "LORA_WIDGET" } };
|
||||
return null;
|
||||
};
|
||||
|
||||
nodeType.prototype.getSlotMenuOptions = function (slot) {
|
||||
if (slot && slot.widget instanceof NunchakuLoraWidget) {
|
||||
const widget = slot.widget;
|
||||
return [
|
||||
{
|
||||
content: widget.value.enabled ? "⚫ Toggle Off" : "🟢 Toggle On",
|
||||
callback: () => { widget.value.enabled = !widget.value.enabled; this.setDirtyCanvas(true); }
|
||||
},
|
||||
{ content: "⬆️ Move Up", callback: () => this.moveLora(widget, -1) },
|
||||
{ content: "⬇️ Move Down", callback: () => this.moveLora(widget, 1) },
|
||||
{
|
||||
content: "🗑️ Remove",
|
||||
callback: () => {
|
||||
const idx = this.widgets.indexOf(widget);
|
||||
this.widgets.splice(idx, 1);
|
||||
this.loraWidgets = this.loraWidgets.filter(lw => lw !== widget);
|
||||
this.setSize([this.size[0], this.computeSize()[1]]);
|
||||
this.setDirtyCanvas(true);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
return LGraphNode.prototype.getSlotMenuOptions?.apply(this, arguments);
|
||||
};
|
||||
|
||||
nodeType.prototype.moveLora = function (widget, dir) {
|
||||
const idx = this.widgets.indexOf(widget);
|
||||
const targetIdx = idx + dir;
|
||||
if (this.widgets[targetIdx] instanceof NunchakuLoraWidget) {
|
||||
this.widgets.splice(idx, 1);
|
||||
this.widgets.splice(targetIdx, 0, widget);
|
||||
this.setDirtyCanvas(true);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `getSlotInPosition`:如果点击不在真实插槽上,则检查是否位于 `NunchakuLoraWidget` 行上。如果是,则返回一个虚拟插槽以显示上下文菜单。
|
||||
- `getSlotMenuOptions`:对于该虚拟插槽,显示 Toggle On/Off、Move Up、Move Down、Remove。
|
||||
- `moveLora`:仅当目标为另一 LoRA 行时才重新排序控件(避免移动 model/cpu_offload 控件)。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 onConfigure — 反序列化(第 317–338 行)
|
||||
|
||||
```javascript
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (info) {
|
||||
onConfigure?.apply(this, arguments);
|
||||
if (info.widgets_values) {
|
||||
this.widgets = this.widgets.filter(w => !(w instanceof NunchakuLoraWidget));
|
||||
this.loraWidgets = [];
|
||||
|
||||
info.widgets_values.forEach((val, idx) => {
|
||||
if (val && typeof val === 'object' && val.lora_name !== undefined) {
|
||||
this.addLoraRow(val);
|
||||
}
|
||||
});
|
||||
|
||||
// Move Add Lora button to bottom (note: addLoraBtn must be set in onNodeCreated for this to run)
|
||||
if (this.addLoraBtn) {
|
||||
const btnIdx = this.widgets.indexOf(this.addLoraBtn);
|
||||
this.widgets.splice(btnIdx, 1);
|
||||
this.widgets.push(this.addLoraBtn);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 加载工作流时,`info.widgets_values` 包含序列化的控件值。
|
||||
- 移除现有的 LoRA 控件,然后通过 `addLoraRow(val)` 从 `widgets_values` 重新创建。
|
||||
- Add Lora 按钮被移回末尾(如果设置了 `this.addLoraBtn`;当前按钮创建为局部变量 `btn` 且未赋值给 `this.addLoraBtn`,因此此块不会运行——按钮顺序取决于 `addWidget` 的放置位置)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流:前端到后端
|
||||
|
||||
1. 用户在 UI 中添加 LoRA 行;每个 `NunchakuLoraWidget` 都有 `value = {enabled, lora_name, lora_strength}`。
|
||||
2. 当用户对 prompt 排队时,ComfyUI 通过 `serialize_widgets = true` 序列化控件。每个控件的 `serializeValue()` 返回其 `value`。
|
||||
3. 序列化的 prompt 包含 `lora_1`、`lora_2`、… 作为控件输入。
|
||||
4. Python 的 `INPUT_TYPES` 使用 `FlexibleOptionalInputType()`,因此这些键被接受。
|
||||
5. `load_lora_stack` 在 `**kwargs` 中接收它们,按 `lora_` 前缀过滤、排序,并仅应用启用的 LoRA(具有有效名称且强度非零)。
|
||||
|
||||
---
|
||||
|
||||
## 5. V4 到 V1 重命名总结
|
||||
|
||||
| 项目 | V4(之前) | V1(之后) |
|
||||
|------|-----------|-----------|
|
||||
| Python 文件 | `qwenimage_v4.py` | `qwenimage_v1.py` |
|
||||
| JS 文件 | `z_qwen_lora_dynamic_v4.js` | `z_qwen_lora_dynamic_v1.js` |
|
||||
| 类名 | `NunchakuQwenImageLoraStackV4` | `NunchakuQwenImageLoraStackV1` |
|
||||
| 节点 ID | `NunchakuQwenImageLoraStackV4` | `NunchakuQwenImageLoraStackV1` |
|
||||
| 显示名称 | `Nunchaku Qwen Image LoRA Stack V4` | `Nunchaku Qwen Image LoRA Stack V1` |
|
||||
| 扩展名称 | `nunchaku.qwen_lora_dynamic_v4` | `nunchaku.qwen_lora_dynamic_v1` |
|
||||
| `__init__` 变量 | `QWEN_V4_NODES`、`QWEN_V4_NAMES` | `QWEN_V1_NODES`、`QWEN_V1_NAMES` |
|
||||
|
||||
**破坏性变更**:使用 `NunchakuQwenImageLoraStackV4` 的工作流必须更新为使用 `Nunchaku Qwen Image LoRA Stack V1` 并重新配置。
|
||||
|
||||
---
|
||||
|
||||
## 6. 参考
|
||||
|
||||
- **PR #49**:[feat(qwen_lora): add Nunchaku Qwen Image LoRA Stack V4 with rgthree-style UI](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49)
|
||||
- **灵感来源**:[Power Lora Loader (rgthree-comfy)](https://github.com/rgthree/rgthree-comfy)
|
||||
|
||||
---
|
||||
|
||||
## 7. 源文件(完整代码)
|
||||
|
||||
完整、未修改的源代码可在以下位置找到:
|
||||
|
||||
- **Python 节点**:`nodes/lora/qwenimage_v1.py`(247 行)
|
||||
- **JavaScript UI**:`js/z_qwen_lora_dynamic_v1.js`(342 行)
|
||||
|
||||
---
|
||||
|
||||
## 发行说明
|
||||
|
||||
[发行说明](v2.4.0.md)
|
||||
Reference in New Issue
Block a user