feat: Z-Image-Turbo LoRA Stack V1 (rgthree-style), Issue #12 link in changelog, v2.4.0 release notes
This commit is contained in:
@@ -213,6 +213,7 @@ ComfyUI\python_embeded\python.exe -m pip install --upgrade diffusers
|
||||
- **Added**: Nunchaku Qwen Image LoRA Stack V1 with rgthree-style UI - Clean, minimalist interface inspired by Power Lora Loader (rgthree-comfy). Toggle, LoRA name, and strength per row.
|
||||
- **Merged**: [PR #49](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49) - feat(qwen_lora): add Nunchaku Qwen Image LoRA Stack V4 with rgthree-style UI (proposed by [avan06](https://github.com/avan06))
|
||||
- **Note**: Does not work properly with ComfyUI Nodes 2.0. Use the standard (LiteGraph) canvas.
|
||||
- **Related Issues**: [Issue #12](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/12) - Request for better LoRA option (rgthree-style UI)
|
||||
- **Technical Details**: See [v2.4.0 Release Notes](RELEASE_NOTES/RELEASE_NOTES_V2.4.0.md) for complete explanation
|
||||
|
||||
### v2.3.9
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Release Notes v2.4.0: Nunchaku Qwen Image LoRA Stack V1
|
||||
|
||||
**Full technical documentation**: [md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md](../md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- **Added**: Nunchaku Qwen Image LoRA Stack V1 (rgthree-style UI) — merged from [PR #49](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49)
|
||||
- **Renamed**: V4 → V1 (node was introduced as V4 in PR; renamed to V1 as the first rgthree-style variant)
|
||||
- **Breaking change**: Workflows using `NunchakuQwenImageLoraStackV4` must switch to `Nunchaku Qwen Image LoRA Stack V1` and reconfigure
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| Item | V4 (Before) | V1 (After) |
|
||||
|------|-------------|------------|
|
||||
| Python | `qwenimage_v4.py` | `qwenimage_v1.py` |
|
||||
| JS | `z_qwen_lora_dynamic_v4.js` | `z_qwen_lora_dynamic_v1.js` |
|
||||
| Node ID | `NunchakuQwenImageLoraStackV4` | `NunchakuQwenImageLoraStackV1` |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [PR #49](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49)
|
||||
- [Power Lora Loader (rgthree-comfy)](https://github.com/rgthree/rgthree-comfy)
|
||||
@@ -20,6 +20,8 @@ QWEN_V2_NODES = {}
|
||||
QWEN_V2_NAMES = {}
|
||||
QWEN_V3_NODES = {}
|
||||
QWEN_V3_NAMES = {}
|
||||
ZIMAGETURBO_V1_NODES = {}
|
||||
ZIMAGETURBO_V1_NAMES = {}
|
||||
ZIMAGETURBO_V2_NODES = {}
|
||||
ZIMAGETURBO_V2_NAMES = {}
|
||||
ZIMAGETURBO_V4_NODES = {}
|
||||
@@ -41,6 +43,7 @@ try:
|
||||
from .nodes.lora.qwenimage_v2 import GENERATED_NODES as QWEN_V2_NODES, GENERATED_DISPLAY_NAMES as QWEN_V2_NAMES
|
||||
from .nodes.lora.qwenimage_v3 import GENERATED_NODES as QWEN_V3_NODES, GENERATED_DISPLAY_NAMES as QWEN_V3_NAMES
|
||||
from .nodes.lora.qwenimage_v1 import GENERATED_NODES as QWEN_V1_NODES, GENERATED_DISPLAY_NAMES as QWEN_V1_NAMES
|
||||
from .nodes.lora.zimageturbo_v1 import GENERATED_NODES as ZIMAGETURBO_V1_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V1_NAMES
|
||||
from .nodes.lora.zimageturbo_v2 import GENERATED_NODES as ZIMAGETURBO_V2_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V2_NAMES
|
||||
# Z-Image-Turbo V3 is deprecated - removed from registration
|
||||
# from .nodes.lora.zimageturbo_v3 import GENERATED_NODES as ZIMAGETURBO_V3_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V3_NAMES
|
||||
@@ -55,6 +58,8 @@ try:
|
||||
node_class.__version__ = __version__
|
||||
for node_class in QWEN_V1_NODES.values():
|
||||
node_class.__version__ = __version__
|
||||
for node_class in ZIMAGETURBO_V1_NODES.values():
|
||||
node_class.__version__ = __version__
|
||||
for node_class in ZIMAGETURBO_V2_NODES.values():
|
||||
node_class.__version__ = __version__
|
||||
# Z-Image-Turbo V3 registration removed
|
||||
@@ -68,6 +73,7 @@ try:
|
||||
NODE_CLASS_MAPPINGS.update(QWEN_V2_NODES)
|
||||
NODE_CLASS_MAPPINGS.update(QWEN_V3_NODES)
|
||||
NODE_CLASS_MAPPINGS.update(QWEN_V1_NODES)
|
||||
NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V1_NODES)
|
||||
NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V2_NODES)
|
||||
# Z-Image-Turbo V3 registration removed
|
||||
# NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V3_NODES)
|
||||
@@ -81,6 +87,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**QWEN_V2_NAMES,
|
||||
**QWEN_V3_NAMES,
|
||||
**QWEN_V1_NAMES,
|
||||
**ZIMAGETURBO_V1_NAMES,
|
||||
**ZIMAGETURBO_V2_NAMES,
|
||||
# Z-Image-Turbo V3 registration removed
|
||||
# **ZIMAGETURBO_V3_NAMES,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
console.log("★★★ zimageturbo_lora_dynamic_v1.js: Z-Image-Turbo 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;
|
||||
|
||||
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]
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// --- Check global stack enabled state ---
|
||||
const globalOn = node.widgets.find(w => w.name === "stack_enabled")?.value !== false;
|
||||
const effectiveOn = this.value.enabled && globalOn;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// 1. Background - using theme colors
|
||||
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
|
||||
const toggleWidth = 20;
|
||||
const toggleHeight = 12;
|
||||
const toggleX = x + 8;
|
||||
const toggleY = midY - (toggleHeight / 2);
|
||||
|
||||
// Track background
|
||||
ctx.fillStyle = colors.outline;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(toggleX, toggleY, toggleWidth, toggleHeight, [toggleHeight / 2]);
|
||||
ctx.fill();
|
||||
|
||||
// Knob
|
||||
ctx.fillStyle = globalOn ? colors.text : colors.secondary; // Knob follows global state
|
||||
ctx.beginPath();
|
||||
// Position knob with smaller offsets to stay within the compact track
|
||||
const knobX = this.value.enabled ? toggleX + toggleWidth - 6 : toggleX + 6;
|
||||
ctx.arc(knobX, midY, 4, 0, Math.PI * 2); // Radius reduced to 4
|
||||
ctx.fill();
|
||||
this.hitAreas.toggle = [toggleX, toggleX + toggleWidth];
|
||||
|
||||
// 3. LoRA Name (auto-calculate remaining width)
|
||||
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");
|
||||
|
||||
// Text clipping
|
||||
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 control
|
||||
const rightX = x + widgetWidth - 5;
|
||||
const valueCenter = rightX - 35; // Center point for the numeric value
|
||||
const arrowGap = 22; // Distance from center to arrows
|
||||
|
||||
ctx.textAlign = "center";
|
||||
|
||||
// ▶ symbol
|
||||
ctx.fillText("▶", valueCenter + arrowGap, midY + 4);
|
||||
this.hitAreas.strengthInc = [valueCenter + arrowGap - 10, valueCenter + arrowGap + 10];
|
||||
|
||||
// Numeric value
|
||||
ctx.fillStyle = effectiveOn ? colors.text : colors.secondary;
|
||||
const valStr = this.value.lora_strength.toFixed(2);
|
||||
ctx.fillText(valStr, valueCenter, midY + 4);
|
||||
this.hitAreas.strengthVal = [valueCenter - 18, valueCenter + 18];
|
||||
|
||||
// ◀ symbol
|
||||
ctx.fillText("◀", valueCenter - arrowGap, midY + 4);
|
||||
this.hitAreas.strengthDec = [valueCenter - arrowGap - 10, valueCenter - arrowGap + 10];
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
mouse(event, pos, node) {
|
||||
if (event.type !== "pointerdown") return false;
|
||||
if (event.button === 2) return false; // Right-click is handled by the node
|
||||
|
||||
// --- Block interaction if global stack enabled is off ---
|
||||
const globalOn = node.widgets.find(w => w.name === "stack_enabled")?.value !== false;
|
||||
if (!globalOn) return false;
|
||||
|
||||
const widgetX = pos[0];
|
||||
// Determine hit area
|
||||
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]) {
|
||||
// Use ComfyUI built-in prompt
|
||||
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;
|
||||
}
|
||||
|
||||
async chooseLora(event) {
|
||||
// Get ComfyUI LoRA list
|
||||
const resp = await api.fetchApi("/object_info");
|
||||
const data = await resp.json();
|
||||
const loras = data?.LoraLoader?.input?.required?.lora_name?.[0] || [];
|
||||
|
||||
// Use LiteGraph's ContextMenu
|
||||
const menu = new LiteGraph.ContextMenu(loras, {
|
||||
event: event,
|
||||
callback: (value) => {
|
||||
this.value.lora_name = value;
|
||||
this.parentNode.setDirtyCanvas(true, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
serializeValue() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "nunchaku.zimageturbo_lora_dynamic_v1",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "NunchakuZImageTurboLoraStackV1") return;
|
||||
|
||||
// Save original onNodeCreated
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
// Inform ComfyUI to include widget values in the prompt sent to the backend
|
||||
this.serialize_widgets = true;
|
||||
this.loraWidgets = [];
|
||||
|
||||
// Monitor stack_enabled switch
|
||||
const stackEnabledWidget = this.widgets.find(w => w.name === "stack_enabled");
|
||||
if (stackEnabledWidget) {
|
||||
stackEnabledWidget.callback = () => { this.setDirtyCanvas(true); };
|
||||
}
|
||||
|
||||
// Add "Add Lora" button
|
||||
const btn = this.addWidget("button", "➕ Add Lora", null, (val, canvas, node, pos, event) => {
|
||||
this.addLoraRowWithChooser(event);
|
||||
});
|
||||
|
||||
// Prevent the button from being serialized and sent to the backend
|
||||
btn.serialize = false;
|
||||
|
||||
const HEADER_H = 60;
|
||||
const INPUT_WIDGETS_H = stackEnabledWidget ? 50 : 0;
|
||||
const PADDING = 20;
|
||||
const targetH = HEADER_H + INPUT_WIDGETS_H + PADDING;
|
||||
this.setSize([this.size[0], targetH]);
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
// Show LoRA menu before adding the row
|
||||
nodeType.prototype.addLoraRowWithChooser = async function (event) {
|
||||
// Fetch LoRA list from ComfyUI object info
|
||||
const resp = await api.fetchApi("/object_info");
|
||||
const data = await resp.json();
|
||||
const loras = data?.LoraLoader?.input?.required?.lora_name?.[0] || [];
|
||||
|
||||
// Show the selection menu
|
||||
new LiteGraph.ContextMenu(loras, {
|
||||
event: event,
|
||||
callback: (value) => {
|
||||
// Add the row only after a LoRA is selected
|
||||
this.addLoraRow({ enabled: true, lora_name: value, lora_strength: 1.0 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Add a new LoRA row
|
||||
nodeType.prototype.addLoraRow = function (data = null) {
|
||||
const name = `lora_${this.loraWidgets.length + 1}`;
|
||||
const w = new NunchakuLoraWidget(name, this);
|
||||
// Ensure we have a default value if no data is provided
|
||||
w.value = data || { enabled: true, lora_name: "None", lora_strength: 1.0 };
|
||||
|
||||
// Insert above the button
|
||||
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;
|
||||
};
|
||||
|
||||
// Right-click menu logic
|
||||
// Override getSlotInPosition to detect if a widget was clicked
|
||||
nodeType.prototype.getSlotInPosition = function (canvasX, canvasY) {
|
||||
const slot = LGraphNode.prototype.getSlotInPosition.apply(this, arguments);
|
||||
if (slot) return slot;
|
||||
|
||||
// If no slot was clicked, check if the click was on our LoraWidget
|
||||
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 dummy slot info, which triggers LiteGraph to call getSlotMenuOptions
|
||||
return { widget: widget, output: { type: "LORA_WIDGET" } };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Show custom menu when the dummy slot is detected
|
||||
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;
|
||||
// Ensure move range stays between LoraWidgets (prevent moving past or onto model/cpu_offload widgets)
|
||||
if (this.widgets[targetIdx] instanceof NunchakuLoraWidget) {
|
||||
this.widgets.splice(idx, 1);
|
||||
this.widgets.splice(targetIdx, 0, widget);
|
||||
this.setDirtyCanvas(true);
|
||||
}
|
||||
};
|
||||
|
||||
// serialized data loading
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (info) {
|
||||
onConfigure?.apply(this, arguments);
|
||||
if (info.widgets_values) {
|
||||
// Clear initialized widgets
|
||||
this.widgets = this.widgets.filter(w => !(w instanceof NunchakuLoraWidget));
|
||||
this.loraWidgets = [];
|
||||
|
||||
// Recover LoRA rows from serialized data
|
||||
// ComfyUI's serialized data may exist in array format
|
||||
info.widgets_values.forEach((val, idx) => {
|
||||
if (val && typeof val === 'object' && val.lora_name !== undefined) {
|
||||
this.addLoraRow(val);
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure the "Add Lora" button stays at the bottom.
|
||||
if (this.addLoraBtn) {
|
||||
const btnIdx = this.widgets.indexOf(this.addLoraBtn);
|
||||
this.widgets.splice(btnIdx, 1);
|
||||
this.widgets.push(this.addLoraBtn);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
# v2.4.0 Complete Modification Explanation: V4 to V1 Rename and Nunchaku Qwen Image LoRA Stack V1
|
||||
|
||||
**Credits**: The Nunchaku Qwen Image LoRA Stack V1 node (rgthree-style UI) was contributed via [PR #49](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49) by [@avan06](https://github.com/avan06). This document explains the merged contribution, the V4→V1 rename, and the implementation details.
|
||||
|
||||
---
|
||||
|
||||
## 1. Node Functionality Overview
|
||||
|
||||
### 1.1 What This Node Does
|
||||
|
||||
The **Nunchaku Qwen Image LoRA Stack V1** node applies multiple LoRA (Low-Rank Adaptation) weights to a Nunchaku Qwen Image diffusion model in a single node. Unlike the single-LoRA loader, this node allows the user to stack multiple LoRAs with individual strength control per LoRA.
|
||||
|
||||
**Key capabilities**:
|
||||
- Apply multiple LoRAs in one node (dynamic row count)
|
||||
- Per-LoRA enable/disable toggle
|
||||
- Per-LoRA strength adjustment (0.00–2.00, step 0.05)
|
||||
- Master switch (`stack_enabled`) to disable entire stack
|
||||
- CPU offload control (auto/enable/disable)
|
||||
- AWQ modulation layer support (`apply_awq_mod`) for quantized models
|
||||
- Workflow serialization (saved workflows retain LoRA configuration)
|
||||
|
||||
**UI design**: rgthree-style compact layout inspired by [Power Lora Loader (rgthree-comfy)](https://github.com/rgthree/rgthree-comfy). Each LoRA row shows: Toggle | LoRA Name | ◀ Strength ▶ in a single horizontal bar.
|
||||
|
||||
**Limitation**: Does not work properly with ComfyUI Nodes 2.0. Use the standard (LiteGraph) canvas.
|
||||
|
||||
---
|
||||
|
||||
## 2. Python Backend: Full Code and Explanation
|
||||
|
||||
**File**: `nodes/lora/qwenimage_v1.py`
|
||||
|
||||
### 2.1 Imports and Path Setup (Lines 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__)
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `copy`: Used for `copy.deepcopy(model)` to clone the model when adding LoRAs.
|
||||
- `lora_loader_dir`: Resolves the ComfyUI-QwenImageLoraLoader root so that `wrappers.qwenimage` can be imported. ComfyUI custom nodes may not have the parent in `sys.path`.
|
||||
- `folder_paths`: ComfyUI module for resolving paths to models/loras (e.g. `folder_paths.get_full_path_or_raise("loras", lora_name)`).
|
||||
- `logger`: Used for status and debug output.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 FlexibleOptionalInputType and any_type (Lines 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, {})
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- ComfyUI validates input types using `key in optional` and `optional[key]`. By default, only declared optional inputs are accepted.
|
||||
- `FlexibleOptionalInputType` makes **any** key appear to be a valid optional input: `__contains__` always returns `True`, and `__getitem__` returns `(any_type, {})`.
|
||||
- `any_type` is a string subclass where `!=` always returns `False`, so it matches any type during validation.
|
||||
- **Purpose**: The JS frontend adds dynamic widgets `lora_1`, `lora_2`, … at runtime. The Python node does not know their names in advance. By declaring `"optional": FlexibleOptionalInputType()`, any `lora_N` key passed from the frontend is accepted and forwarded to `load_lora_stack` via `**kwargs`.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 RETURN_TYPES, FUNCTION, and Metadata (Lines 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."
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `RETURN_TYPES`: Node returns a single `MODEL` output.
|
||||
- `FUNCTION`: The method to call when the node runs.
|
||||
- `TITLE` / `CATEGORY` / `DESCRIPTION`: ComfyUI node browser and tooltip metadata.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Node Class and INPUT_TYPES (Lines 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
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `model`: Input diffusion model from a Nunchaku Qwen Image DiT Loader.
|
||||
- `cpu_offload`: Controls whether the model runs on CPU when VRAM is low.
|
||||
- `apply_awq_mod`: Enables manual planar injection for AWQ quantized modulation layers; fixes noise with quantized models.
|
||||
- `stack_enabled`: Master switch; when `False`, no LoRAs are applied regardless of per-row toggles.
|
||||
- `optional`: Accepts any dynamic `lora_N` inputs from the frontend.
|
||||
- `hidden`: ComfyUI standard; `prompt` and `extra_pnginfo` are used for workflow serialization.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 load_lora_stack — Part 1: Parse Dynamic LoRA Data (Lines 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,)
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `kwargs` contains `lora_1`, `lora_2`, … from the JS widgets. Each value is `{enabled, lora_name, lora_strength}`.
|
||||
- `lora_keys` is sorted by numeric suffix so rows are processed in order.
|
||||
- For each slot:
|
||||
- `enabled` is `True` only if both `stack_enabled` and the row’s `enabled` are `True`.
|
||||
- A LoRA is applied only when: `enabled`, `lora_name` is set (not `"None"`), and `abs(lora_strength) > 1e-5`.
|
||||
- If no LoRAs qualify, the model is returned unchanged.
|
||||
|
||||
---
|
||||
|
||||
### 2.6 load_lora_stack — Part 2: Model Wrapping and ComfyQwenImageWrapper (Lines 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}.")
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `model.model.diffusion_model` is the actual transformer (e.g. `NunchakuQwenImageTransformer2DModel`).
|
||||
- If it is already wrapped (`model` and `loras` attributes exist), only `cpu_offload` and `apply_awq_mod` are updated.
|
||||
- Otherwise, it is wrapped with `ComfyQwenImageWrapper`, which:
|
||||
- Holds a `loras` list of `(path, strength)` tuples.
|
||||
- Composes LoRAs into the model via `compose_loras_v2` when the model runs.
|
||||
- The node only works with Nunchaku Qwen Image models; other types raise `TypeError`.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 load_lora_stack — Part 3: Flux-Style Deepcopy and LoRA Append (Lines 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,)
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- ComfyUI expects each node to return a *new* model object. The original must not be mutated.
|
||||
- `model_config` is cleared before `deepcopy` because it may contain non-picklable data. It is restored afterwards.
|
||||
- `model_wrapper.model` is temporarily set to `None` so the transformer is not deep-copied; only the wrapper structure is copied.
|
||||
- After `deepcopy`, `ret_model_wrapper` receives the copied `loras` list, and the new LoRAs from this node are appended.
|
||||
- `folder_paths.get_full_path_or_raise("loras", lora_name)` resolves the LoRA file path and raises if not found.
|
||||
|
||||
---
|
||||
|
||||
### 2.8 GENERATED_NODES and GENERATED_DISPLAY_NAMES (Lines 241–248)
|
||||
|
||||
```python
|
||||
GENERATED_NODES = {
|
||||
"NunchakuQwenImageLoraStackV1": NunchakuQwenImageLoraStackV1,
|
||||
}
|
||||
|
||||
GENERATED_DISPLAY_NAMES = {
|
||||
"NunchakuQwenImageLoraStackV1": "Nunchaku Qwen Image LoRA Stack V1",
|
||||
}
|
||||
```
|
||||
|
||||
**Explanation**: These dicts are imported by `__init__.py` and merged into `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` for ComfyUI registration.
|
||||
|
||||
---
|
||||
|
||||
## 3. JavaScript Frontend: Full Code and Explanation
|
||||
|
||||
**File**: `js/z_qwen_lora_dynamic_v1.js`
|
||||
|
||||
### 3.1 Imports and Theme Colors (Lines 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;
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `app`: ComfyUI app; used for `app.canvas.prompt`, `app.registerExtension`.
|
||||
- `api`: Used for `api.fetchApi("/object_info")` to get the LoRA list.
|
||||
- `getColors()`: Returns theme-aware colors so the widget looks correct in Light and Dark mode.
|
||||
- `WIDGET_HEIGHT`, `HORIZ_MARGIN`: Layout constants for each LoRA row.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 NunchakuLoraWidget Class — Constructor (Lines 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]
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `name`: e.g. `lora_1`, `lora_2`; must match what Python expects in `kwargs`.
|
||||
- `type: "custom"`: ComfyUI/LiteGraph custom widget; requires `draw` and optionally `mouse`, `serializeValue`.
|
||||
- `value`: State sent to the backend: `{enabled, lora_name, lora_strength}`.
|
||||
- `last_y`: Vertical position of the row; used for right-click hit testing.
|
||||
- `hitAreas`: `[xMin, xMax]` for each interactive region (toggle, lora name, ◀, value, ▶). Updated in `draw`, used in `mouse`.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 NunchakuLoraWidget.draw — Rendering (Lines 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();
|
||||
}
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- Draws one row: rounded rect background, toggle switch, LoRA name, strength with ◀/▶.
|
||||
- `effectiveOn`: dims the row when the stack or row is disabled.
|
||||
- `hitAreas` are set to the drawn coordinates for later click handling.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 NunchakuLoraWidget.mouse — Click Handling (Lines 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;
|
||||
}
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- Left-click only; right-click is left for the node’s slot menu.
|
||||
- If `stack_enabled` is off, no interaction.
|
||||
- Uses `hitAreas` to decide: toggle, LoRA picker, decrement, increment, or numeric prompt.
|
||||
- Strength is clamped implicitly by UI; prompt allows any float.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 chooseLora and serializeValue (Lines 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;
|
||||
}
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `chooseLora`: Fetches LoRA list from `/object_info`, shows a context menu, updates `lora_name` on selection.
|
||||
- `serializeValue`: Returns `{enabled, lora_name, lora_strength}` so ComfyUI includes it in the prompt sent to the backend.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Extension Registration and beforeRegisterNodeDef (Lines 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);
|
||||
};
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- Only runs for `NunchakuQwenImageLoraStackV1`.
|
||||
- `serialize_widgets = true`: Include widget values in the prompt for the backend.
|
||||
- "➕ Add Lora" button: calls `addLoraRowWithChooser` (shows LoRA menu, then adds row).
|
||||
- `btn.serialize = false`: The button itself is not serialized; only the LoRA widgets are.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 addLoraRowWithChooser, addLoraRow (Lines 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;
|
||||
};
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `addLoraRowWithChooser`: Shows LoRA menu, then adds a row with the selected LoRA.
|
||||
- `addLoraRow`: Creates `NunchakuLoraWidget` with name `lora_1`, `lora_2`, …; inserts it above the Add Lora button; updates size and canvas.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Right-Click Menu: getSlotInPosition, getSlotMenuOptions, moveLora (Lines 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);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- `getSlotInPosition`: If the click is not on a real slot, check if it’s on a `NunchakuLoraWidget` row. If so, return a dummy slot so the context menu is shown.
|
||||
- `getSlotMenuOptions`: For that dummy slot, show Toggle On/Off, Move Up, Move Down, Remove.
|
||||
- `moveLora`: Reorders widgets only if the target is another LoRA row (avoids moving model/cpu_offload widgets).
|
||||
|
||||
---
|
||||
|
||||
### 3.9 onConfigure — Deserialization (Lines 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- When loading a workflow, `info.widgets_values` contains the serialized widget values.
|
||||
- Existing LoRA widgets are removed, then recreated from `widgets_values` via `addLoraRow(val)`.
|
||||
- The Add Lora button is moved back to the end (if `this.addLoraBtn` is set; currently the button is created as local `btn` and not assigned to `this.addLoraBtn`, so this block does not run — the button order depends on `addWidget` placement).
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Flow: Frontend to Backend
|
||||
|
||||
1. User adds LoRA rows in the UI; each `NunchakuLoraWidget` has `value = {enabled, lora_name, lora_strength}`.
|
||||
2. When the user queues a prompt, ComfyUI serializes widgets with `serialize_widgets = true`. Each widget’s `serializeValue()` returns its `value`.
|
||||
3. The serialized prompt includes `lora_1`, `lora_2`, … as widget inputs.
|
||||
4. Python’s `INPUT_TYPES` uses `FlexibleOptionalInputType()` so these keys are accepted.
|
||||
5. `load_lora_stack` receives them in `**kwargs`, filters by `lora_` prefix, sorts, and applies only enabled LoRAs with valid names and non-zero strength.
|
||||
|
||||
---
|
||||
|
||||
## 5. V4 to V1 Rename Summary
|
||||
|
||||
| Item | V4 (Before) | V1 (After) |
|
||||
|------|-------------|------------|
|
||||
| Python file | `qwenimage_v4.py` | `qwenimage_v1.py` |
|
||||
| JS file | `z_qwen_lora_dynamic_v4.js` | `z_qwen_lora_dynamic_v1.js` |
|
||||
| Class name | `NunchakuQwenImageLoraStackV4` | `NunchakuQwenImageLoraStackV1` |
|
||||
| Node ID | `NunchakuQwenImageLoraStackV4` | `NunchakuQwenImageLoraStackV1` |
|
||||
| Display name | `Nunchaku Qwen Image LoRA Stack V4` | `Nunchaku Qwen Image LoRA Stack V1` |
|
||||
| Extension name | `nunchaku.qwen_lora_dynamic_v4` | `nunchaku.qwen_lora_dynamic_v1` |
|
||||
| __init__ variable | `QWEN_V4_NODES`, `QWEN_V4_NAMES` | `QWEN_V1_NODES`, `QWEN_V1_NAMES` |
|
||||
|
||||
**Breaking change**: Workflows using `NunchakuQwenImageLoraStackV4` must be updated to use `Nunchaku Qwen Image LoRA Stack V1` and reconfigure.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- **PR #49**: [feat(qwen_lora): add Nunchaku Qwen Image LoRA Stack V4 with rgthree-style UI](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/49)
|
||||
- **Inspiration**: [Power Lora Loader (rgthree-comfy)](https://github.com/rgthree/rgthree-comfy)
|
||||
|
||||
---
|
||||
|
||||
## 7. Source Files (Full Code)
|
||||
|
||||
The complete, unmodified source code can be found in:
|
||||
|
||||
- **Python node**: `nodes/lora/qwenimage_v1.py` (247 lines)
|
||||
- **JavaScript UI**: `js/z_qwen_lora_dynamic_v1.js` (342 lines)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
This module provides the :class:`NunchakuZImageTurboLoraStackV1` node
|
||||
for applying LoRA weights to Nunchaku Z-Image-Turbo models within ComfyUI.
|
||||
rgthree-style UI with dynamic LoRA rows. Uses compose_loras_v2 for perfect mapping.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Fix import path for this module
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
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)
|
||||
|
||||
import folder_paths
|
||||
import comfy
|
||||
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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, {})
|
||||
|
||||
|
||||
class NunchakuZImageTurboLoraStackV1:
|
||||
"""
|
||||
Node for loading and applying multiple LoRAs to a Nunchaku Z-Image-Turbo model with dynamic UI.
|
||||
rgthree-style layout. Uses compose_loras_v2 for perfect mapping. Official Nunchaku Z-Image loader only.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
inputs = {
|
||||
"required": {
|
||||
"model": ("MODEL", {"tooltip": "The diffusion model to apply LoRAs to."}),
|
||||
"clip": ("CLIP", {"tooltip": "The CLIP model (unchanged, for standard interface compatibility)."}),
|
||||
"stack_enabled": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Master Switch: Enable or disable the entire LoRA stack processing.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(),
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP")
|
||||
OUTPUT_TOOLTIPS = ("The modified diffusion model with all LoRAs applied.", "The CLIP model (unchanged).")
|
||||
FUNCTION = "load_lora_stack"
|
||||
TITLE = "Nunchaku Z-Image-Turbo LoRA Stack V1"
|
||||
CATEGORY = "Nunchaku"
|
||||
DESCRIPTION = "Apply multiple LoRAs to Z-Image-Turbo with rgthree-style dynamic UI. Uses compose_loras_v2."
|
||||
|
||||
def load_lora_stack(self, model, clip, stack_enabled=True, **kwargs):
|
||||
lora_keys = [key for key in kwargs.keys() if key.startswith("lora_")]
|
||||
lora_keys.sort(key=lambda x: int(x.split("_")[-1]))
|
||||
|
||||
lora_count = len(lora_keys)
|
||||
loras_to_apply = []
|
||||
|
||||
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)
|
||||
enabled = stack_enabled and value.get("enabled", True)
|
||||
|
||||
status_parts = [f"Slot {i}:"]
|
||||
if lora_name and lora_name != "None":
|
||||
status_parts.extend([f"'{lora_name}'", f"strength={lora_strength}"])
|
||||
else:
|
||||
status_parts.append("(no LoRA selected)")
|
||||
|
||||
status_parts.extend([f"stack_enabled={stack_enabled}", 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, clip)
|
||||
|
||||
model_wrapper = model.model.diffusion_model
|
||||
model_wrapper_type_name = type(model_wrapper).__name__
|
||||
model_wrapper_module = type(model_wrapper).__module__
|
||||
|
||||
if model_wrapper_type_name == "NextDiT" and model_wrapper_module == "comfy.ldm.lumina.model":
|
||||
transformer = model_wrapper
|
||||
elif hasattr(model_wrapper, "model"):
|
||||
transformer = model_wrapper.model
|
||||
else:
|
||||
logger.error(f"❌ Unsupported model type: {model_wrapper_type_name} from {model_wrapper_module}")
|
||||
logger.error("V1 requires NextDiT model. Falling back to standard loader.")
|
||||
from comfy.sd import load_lora_for_models
|
||||
ret_model, ret_clip = model, clip
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
ret_model, ret_clip = load_lora_for_models(ret_model, ret_clip, lora, lora_strength, lora_strength)
|
||||
return (ret_model, ret_clip)
|
||||
|
||||
try:
|
||||
if lora_loader_dir not in sys.path:
|
||||
sys.path.insert(0, lora_loader_dir)
|
||||
from nunchaku_code.lora_qwen import compose_loras_v2
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import compose_loras_v2: {e}")
|
||||
from comfy.sd import load_lora_for_models
|
||||
ret_model, ret_clip = model, clip
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
ret_model, ret_clip = load_lora_for_models(ret_model, ret_clip, lora, lora_strength, lora_strength)
|
||||
return (ret_model, ret_clip)
|
||||
|
||||
lora_configs = []
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
lora_configs.append((lora_path, lora_strength))
|
||||
|
||||
logger.info(f"Applying {len(lora_configs)} LoRA(s) using compose_loras_v2...")
|
||||
try:
|
||||
compose_loras_v2(transformer, lora_configs, apply_awq_mod=False)
|
||||
logger.info(f"✅ Successfully applied {len(lora_configs)} LoRA(s)")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to apply LoRAs: {e}")
|
||||
from comfy.sd import load_lora_for_models
|
||||
ret_model, ret_clip = model, clip
|
||||
for lora_name, lora_strength in loras_to_apply:
|
||||
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
ret_model, ret_clip = load_lora_for_models(ret_model, ret_clip, lora, lora_strength, lora_strength)
|
||||
return (ret_model, ret_clip)
|
||||
|
||||
return (model, clip)
|
||||
|
||||
|
||||
GENERATED_NODES = {
|
||||
"NunchakuZImageTurboLoraStackV1": NunchakuZImageTurboLoraStackV1,
|
||||
}
|
||||
|
||||
GENERATED_DISPLAY_NAMES = {
|
||||
"NunchakuZImageTurboLoraStackV1": "Nunchaku Z-Image-Turbo LoRA Stack V1",
|
||||
}
|
||||
Reference in New Issue
Block a user