From a0181d8fda34330c9dab128510ec4413a8079e6b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 7 Feb 2026 23:18:12 +0900 Subject: [PATCH] feat: Z-Image-Turbo LoRA Stack V1 (rgthree-style), Issue #12 link in changelog, v2.4.0 release notes --- README.md | 1 + RELEASE_NOTES/RELEASE_NOTES_V2.4.0.md | 28 + __init__.py | 7 + js/zimageturbo_lora_dynamic_v1.js | 342 +++++++++ md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md | 784 ++++++++++++++++++++ nodes/lora/zimageturbo_v1.py | 174 +++++ 6 files changed, 1336 insertions(+) create mode 100644 RELEASE_NOTES/RELEASE_NOTES_V2.4.0.md create mode 100644 js/zimageturbo_lora_dynamic_v1.js create mode 100644 md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md create mode 100644 nodes/lora/zimageturbo_v1.py diff --git a/README.md b/README.md index b9e0049..a68ada2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASE_NOTES/RELEASE_NOTES_V2.4.0.md b/RELEASE_NOTES/RELEASE_NOTES_V2.4.0.md new file mode 100644 index 0000000..f16d0dc --- /dev/null +++ b/RELEASE_NOTES/RELEASE_NOTES_V2.4.0.md @@ -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) diff --git a/__init__.py b/__init__.py index edf4c48..3b32680 100644 --- a/__init__.py +++ b/__init__.py @@ -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, diff --git a/js/zimageturbo_lora_dynamic_v1.js b/js/zimageturbo_lora_dynamic_v1.js new file mode 100644 index 0000000..f0903b2 --- /dev/null +++ b/js/zimageturbo_lora_dynamic_v1.js @@ -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); + } + } + }; + } +}); diff --git a/md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md b/md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md new file mode 100644 index 0000000..8bfdf40 --- /dev/null +++ b/md/V2.4.0_V1_RENAME_COMPLETE_EXPLANATION.md @@ -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) diff --git a/nodes/lora/zimageturbo_v1.py b/nodes/lora/zimageturbo_v1.py new file mode 100644 index 0000000..4a13e2c --- /dev/null +++ b/nodes/lora/zimageturbo_v1.py @@ -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", +}