Restore deleted files after force push: images, js, md, nodes, and other missing files

This commit is contained in:
Your Name
2025-12-30 19:24:44 +09:00
parent 7f3ef6337b
commit 7c4b3e7d92
14 changed files with 2141 additions and 138 deletions
+5 -2
View File
@@ -67,5 +67,8 @@ venv.bak/
Thumbs.db
# User-specific documentation files
technical_explanation.md
PR34_COMFYUI_V0.6.0_COMPATIBILITY.md
# Ignore root directory versions, but allow versions in md/ folder
/technical_explanation.md
PR34_COMFYUI_V0.6.0_COMPATIBILITY.md
# Ignore Japanese version in root, but allow English version in md/ folder
/ZIMAGETURBO_CONTROLNET_FIX.md
+47 -131
View File
@@ -1,8 +1,12 @@
# ComfyUI-QwenImageLoraLoader
# ComfyUI-Nunchaku QwenImage&Z-ImageTurboLoraLoader and Diffsynth ControlNet Loader
A ComfyUI custom node for loading and applying LoRA (Low-Rank Adaptation) to Nunchaku Qwen Image models. ComfyUI Nodes 2.0 compatible.
A ComfyUI custom node for loading and applying LoRA (Low-Rank Adaptation) to Nunchaku Qwen Image and Z-ImageTurbo models, and diffsynth ControlNet functionality. ComfyUI Nodes 2.0 compatible.
> Latest release: [v2.0.8 on GitHub Releases](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.0.8)
## ⚠️ **DEVELOPMENT STATUS**
**Currently under development and testing. Debug logs are being output extensively. This does not affect functionality.**
> Latest release: [v2.2.3 on GitHub Releases](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.3)
>
> ⚠️ **Note for v2.0+ users**: If you encounter `TypeError: got multiple values for argument 'guidance'` errors, see [troubleshooting section](#issue-30-typeerror-got-multiple-values-for-argument-guidance-v20) below.
@@ -13,14 +17,22 @@ This LoRA loader was extracted and modified from GavChap's fork:
- **Extraction**: LoRA functionality was extracted from the full fork to create an independent custom node
- **Integration**: Modified to work with the official ComfyUI-nunchaku plugin
## 🎉 MAJOR UPDATE: v2.2.2 - Nunchaku Z-ImageTurbo Diffsynth ControlNet Support Added!
For detailed technical explanation, see [v2.2.2 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.2)
## 🎉 MAJOR UPDATE: v2.2.0 - Nunchaku Z Image Turbo LoRA Support Added!
For detailed technical explanation, see [v2.2.0 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.0)
## 🎉 MAJOR UPDATE: v2.0 - Diffsynth ControlNet Support Added!
**As of v2.0, diffsynth ControlNet is now fully supported for Nunchaku Qwen Image models.**
**As of v2.0, diffsynth ControlNet is now fully supported for Nunchaku Qwen Image models, Z Image Turbo BF16.safetensors, and Nunchaku Z Image Turbo models.**
A new dedicated node `NunchakuQwenImageDiffsynthControlnet` enables diffsynth ControlNet functionality with Nunchaku quantized Qwen Image models.
A new dedicated node `NunchakuQI&ZITDiffsynthControlnet` enables diffsynth ControlNet functionality with Nunchaku quantized Qwen Image models, Z Image Turbo BF16.safetensors, and Nunchaku Z Image Turbo models.
### What's New in v2.0
- ✅ **New Node**: `NunchakuQwenImageDiffsynthControlnet` - Dedicated diffsynth ControlNet loader for Nunchaku Qwen Image models
- ✅ **New Node**: `NunchakuQI&ZITDiffsynthControlnet` - Dedicated diffsynth ControlNet loader for Nunchaku Qwen Image models, Z Image Turbo BF16.safetensors, and Nunchaku Z Image Turbo models
- ✅ **Full ControlNet Support**: Works with standard diffsynth ControlNet models
- ✅ **Seamless Integration**: Automatically applies ControlNet patches during model forward pass
- ✅ **Backward Compatible**: All existing LoRA functionality remains unchanged
@@ -29,62 +41,7 @@ For detailed technical explanation, see [v2.0 Release Notes](https://github.com/
## 🎉 MAJOR UPDATE: v1.60 - Simplified Installation (No Integration Required!)
**As of v1.60, manual integration with ComfyUI-nunchaku's `__init__.py` is completely unnecessary.**
The node now operates as a fully independent custom node that works out-of-the-box. Simply clone the repository into your `custom_nodes` folder and restart ComfyUI. The nodes will automatically appear in ComfyUI's node menu through ComfyUI's built-in automatic node loading mechanism.
### What Changed in v1.60
- ✅ **Removed dependency on ComfyUI-nunchaku integration** - The LoRA loader is now a standalone plugin
- ✅ **Simplified installation** - No batch scripts or manual file editing required
- ✅ **Cleaner architecture** - Node registration happens automatically
- ✅ **Backward compatible** - All existing LoRA files and workflows continue to work
For a detailed technical explanation of why integration is no longer needed, see [v1.60 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v1.60)
## Features
- **NunchakuQwenImageLoraLoader**: Load and apply single LoRA to Qwen Image models
- **NunchakuQwenImageLoraStack**: Apply multiple LoRAs with dynamic UI control
- **NunchakuQwenImageDiffsynthControlnet**: Apply diffsynth ControlNet to Nunchaku Qwen Image models (v2.0)
- **Dynamic VRAM Management**: Automatic CPU offloading based on available VRAM
- **LoRA Composition**: Efficient LoRA stacking and composition
- **ComfyUI Integration**: Seamless integration with ComfyUI workflows
## Installation
### Quick Installation (v1.60 - Simplified!)
**Prerequisites:**
- ComfyUI-nunchaku must be installed
1. Clone this repository to your ComfyUI custom_nodes directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader.git
```
2. Restart ComfyUI
**That's it!** The nodes will automatically appear in ComfyUI's node menu.
### Manual Installation (Alternative)
If you prefer to install manually or are using macOS/Linux:
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader.git
```
Then restart ComfyUI.
### Requirements
- Python 3.11+
- ComfyUI (latest version recommended)
- ComfyUI-nunchaku (required)
- CUDA-capable GPU (optional, but recommended for performance)
For installation instructions, features, and requirements, see [Installation Guide](md/installation.md).
## Upgrade Guide for v1.57 and Earlier Users
@@ -96,16 +53,29 @@ If you have v1.57 or earlier installed with integration code in ComfyUI-nunchaku
- **NunchakuQwenImageLoraLoader**: Single LoRA loader
- **NunchakuQwenImageLoraStack**: Multi LoRA stacker with dynamic UI (Legacy)
- **NunchakuQwenImageLoraStackV2**: Multi LoRA stacker with dynamic UI - ComfyUI Nodes 2.0 (Beta) compatible
- **NunchakuQwenImageDiffsynthControlnet**: Diffsynth ControlNet loader for Nunchaku Qwen Image models (v2.0)
- **NunchakuZImageTurboLoraStackV3**: Z-Image-Turbo LoRA stacker with dynamic UI - ComfyUI Nodes 2.0 (Beta) compatible
- **NunchakuQI&ZITDiffsynthControlnet**: Diffsynth ControlNet loader for Nunchaku Qwen Image models, Z Image Turbo BF16.safetensors, and Nunchaku Z Image Turbo models (v2.0)
### Basic Usage
**For Nunchaku Qwen Image models:**
1. Load your Nunchaku Qwen Image model using `Nunchaku Qwen Image DiT Loader`
2. Add either `NunchakuQwenImageLoraLoader` or `NunchakuQwenImageLoraStack` node
3. Select your LoRA file and set the strength
4. Connect to your workflow
**For Nunchaku Z-Image-Turbo models:**
1. Load your Nunchaku Z-Image-Turbo model using `Nunchaku Z-Image DiT Loader`
2. Add `Nunchaku Z-Image-Turbo LoRA Stack V3` node
3. Select your LoRA file and set the strength
4. Connect to your workflow
### Dynamic UI Control
The `NunchakuQwenImageLoraStack` node automatically adjusts the number of visible LoRA slots based on the `lora_count` parameter (1-10).
<img src="images/qilorastacker.png" alt="Dynamic UI Control" width="400">
<img src="images/zitlorav3.png" alt="Dynamic UI Control V3" width="400">
The `NunchakuQwenImageLoraStack` and `NunchakuZImageTurboLoraStackV3` nodes automatically adjust the number of visible LoRA slots based on the `lora_count` parameter (1-10).
### Diffsynth ControlNet Usage
@@ -113,7 +83,7 @@ The `NunchakuQwenImageLoraStack` node automatically adjusts the number of visibl
1. Load your diffsynth ControlNet model patch using `Model Patch Loader` from [ComfyUI-NunchakuFluxLoraStacker](https://github.com/ussoewwin/ComfyUI-NunchakuFluxLoraStacker)
2. The `Model Patch Loader` (`ModelPatchLoaderCustom`) supports CPU offload, allowing you to load ControlNet patches to CPU memory to save VRAM
3. Connect the `MODEL_PATCH` output to the `model_patch` input of `NunchakuQwenImageDiffsynthControlnet` node
3. Connect the `MODEL_PATCH` output to the `model_patch` input of `NunchakuQI&ZITDiffsynthControlnet` node
4. Connect your Nunchaku Qwen Image model, VAE, and control image
5. Set the ControlNet strength and connect to your workflow
@@ -184,52 +154,9 @@ This node is designed to work with:
### Issue #25: ComfyUI 0.4.0 Model Management Errors
- **Status**: ⚠️ **Environment Dependent** - May require ComfyUI core fixes
- **Issue**: After the ComfyUI 0.4.0 update, multiple nodes (including this one in some environments) experienced errors such as `TypeError: 'NoneType' object is not callable` and `AttributeError: 'NoneType' object has no attribute`. In our environment, we resolved these errors by modifying ComfyUI's core `model_management.py`. Note that in our environment, these errors did not occur with this node (ComfyUI-QwenImageLoraLoader). Nunchaku library and ComfyUI-Nunchaku nodes should use the latest versions. If errors persist even after applying the latest version of this node (ComfyUI-QwenImageLoraLoader), modification of ComfyUI's core `model_management.py` may be necessary.
- **Root Cause**: In ComfyUI 0.4.0, ComfyUI's core `model_management.py` lacks sufficient None checks, causing `TypeError` and `AttributeError` when accessing methods or attributes on objects that became `None` after models were unloaded or garbage collected. This problem is not a bug in individual nodes, but rather a structural issue in ComfyUI 0.4.0's model management (`model_management.py`).
- **GC Changes in ComfyUI 0.4.0**: Compared to ComfyUI 0.3.x, automatic model unloading occurs earlier, making the following flow more likely:
```
ModelPatcher → GC → weakref(None)
```
For detailed information, see [COMFYUI_0.4.0_MODEL_MANAGEMENT_ERRORS.md](md/COMFYUI_0.4.0_MODEL_MANAGEMENT_ERRORS.md).
This also explains why the occurrence of the issue varies by user environment.
- **Technical Basis**:
1. **Multiple Locations with "Missing None Checks"** - This is not a bug in individual nodes, but the core main logic crashes when accessing attributes on `None`. The added fixes like `if model is None: continue` are defensive code that ComfyUI core should have in all paths.
2. **Post-Weak-Reference GC Behavior Not Considered** - The introduction of `LoadedModel._model = weakref.ref(ModelPatcher)` in ComfyUI 0.4.0 is a breaking change. When the weak reference target is garbage collected, it returns `None`, but this is not handled. Post-processing for the breaking update is incomplete.
3. **Multiple Nodes Were Affected in a Chain Reaction** - This is not a problem with nodes, but multiple nodes were affected in a chain reaction due to core behavior changes. Model loading/unloading, memory calculation, GPU/CPU offloading, and `ModelPatcher` lifecycle are all controlled by ComfyUI core.
4. **All Fix Locations Are Core Responsibility Areas** - The locations fixed (`model_memory`, `model_offloaded_memory`, `load_models_gpu`, `free_memory`, `model_unload`, `is_dead` checks, etc.) are all ComfyUI core functions. These are not areas that node developers should touch. The fact that all fix locations are core logic leaves no explanation other than a core defect.
5. **Result of Applying Fixes** - After applying None check fixes to ComfyUI's core `model_management.py` in our environment, similar errors were resolved. This demonstrates that the problem can be solved by adding defensive code that the core should have.
- **Model Lifecycle and ModelPatcher Initialization Relationship**:
- **Fact 1: Relationship between LoadedModel and ModelPatcher** - The `LoadedModel` class (lines 502-524 in ComfyUI's `model_management.py`) holds a weak reference to `ModelPatcher`:
```python
def _set_model(self, model):
self._model = weakref.ref(model) # Weak reference to ModelPatcher
@property
def model(self):
return self._model() # Returns None when garbage collected
```
- **Fact 2: ModelPatcher Initialization** - In the `__init__` of the `ModelPatcher` class (lines 215-237 in `model_patcher.py`), the `pinned` attribute is initialized:
```python
def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False):
# ...
self.pinned = set() # Line 237: Initialized
```
- **Fact 3: Fix Content in ComfyUI Core's model_management.py** - The fix in ComfyUI's core `model_management.py` now skips `LoadedModel` instances where `model` is `None`: In `load_models_gpu()`, skips `LoadedModel` instances where `model` is `None` (lines 712, 727, 743); In `free_memory()`, excludes `LoadedModel` instances where `model` is `None` (line 646).
- **Fact 4: Problem Before Fix** - `LoadedModel` holds a weak reference to `ModelPatcher`. When garbage collected, `LoadedModel.model` returns `None`. Before the fix, methods were called on `LoadedModel` instances where `model` was `None`, causing errors.
- **Fact 5: Behavior After Fix** - By skipping `LoadedModel` instances where `model` is `None`, errors do not occur. Because errors do not occur, processing continues normally.
- **Fact 6: Why copy.deepcopy Fails** - `copy.deepcopy` fails because references to GC'd `ModelPatcher` instances remain in the dictionary being deepcopied. When these references are accessed, they return `None`, causing deepcopy to stop.
- **Fact 7: Confirmation Items** - After applying the fix, `copy.deepcopy` and `pinned` attribute errors do not occur in our environment. Nunchaku library and ComfyUI-Nunchaku nodes should use the latest versions, but this may still be insufficient. While these errors did not occur with this node (ComfyUI-QwenImageLoraLoader) in our environment, the fix to ComfyUI's core `model_management.py` may have indirectly affected it, making errors less likely to occur.
- **Important Note: Not a Problem with Nunchaku Library** - This problem is not caused by the Nunchaku library's implementation. Nunchaku's `model_config` and `ModelPatcher` itself are normal. The problem is in the upstream = ComfyUI core's `model_management.py` GC processing.
- **Speculation (Items That May Be Environment-Dependent)**: The fix allows `ModelPatcher` initialization to complete normally. As a result, the `pinned` attribute is also properly initialized. Accessing `self.pinned` in `__del__` does not cause errors.
- **Recommendations**:
1. Update Nunchaku library and ComfyUI-Nunchaku nodes to the latest version (addresses `model_config` issues)
2. Consider applying None check fixes to ComfyUI's core `model_management.py` (may address the root cause)
- **Note**: This is the first support measure. I have published the technical details of the fixes I applied to ComfyUI's core `model_management.py` in my environment. See [COMFYUI_0.4.0_UPDATE_ERROR_FIXES.md](md/COMFYUI_0.4.0_UPDATE_ERROR_FIXES.md) for details. Note that these fixes were applied in my specific environment and may not work universally in all environments. This may also resolve `copy.deepcopy` and `pinned` attribute errors.
- **Related Issues**:
- [Issue #25](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/25) - `AttributeError: 'NunchakuModelPatcher' object has no attribute 'pinned'` and deepcopy errors with `model_config`
- [Issue #33](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/33) - `AttributeError: 'NoneType' object has no attribute 'to'` in `to_safely` method (Fixed in v2.1.0)
@@ -283,24 +210,18 @@ def __init__(self, model, load_device, offload_device, size=0, weight_inplace_up
## Changelog
### v2.2.4 (latest)
- **Added**: AWQ Modulation Layer Detection and Skip Logic
- **Feature**: Default skip of LoRA application to `img_mod.1` and `txt_mod.1` modulation layers in AWQ quantized models to prevent image noise
- **Environment Variable**: `QWENIMAGE_LORA_APPLY_AWQ_MOD=1` to force-enable (not recommended due to noise risk)
- **Technical Details**: See [v2.2.4 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.4) for complete explanation
- **Documentation**: See [AWQ_MODULATION_LORA_EXPLANATION.md](AWQ_MODULATION_LORA_EXPLANATION.md) for detailed technical explanation
### v2.2.3
- **Added**: Toggle buttons and LoRA format detection features
### v2.2.3 (latest)
- **Added**: Toggle buttons to enable/disable individual LoRA slots and all LoRAs at once ([Issue #12](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/12), [Issue #36](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/36))
- ⚠️ **DEVELOPMENT STATUS**: These features are currently experimental implementations for the `NunchakuZImageTurboLoraStackV3` node only. ComfyUI Nodes 2.0 environment only. With current technical capabilities, it is not possible to fully implement all requested features in JavaScript.
- **Technical Details**: See [v2.2.3 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.3) for complete explanation
### v2.2.2
- **Added**: Nunchaku Z-ImageTurbo Diffsynth ControlNet support
- **Added**: Diffsynth ControlNet support for Nunchaku Z-ImageTurbo models
- **Technical Details**: See [v2.2.2 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.2) for complete explanation
### v2.2.0
- **MAJOR UPDATE**: Added Nunchaku Z-Image-Turbo LoRA Loader V3 Support
- **Technical Details**: See [v2.2.0 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/2.2.0) for complete explanation
- **Added**: NunchakuZImageTurboLoraStackV3 node – Z-Image-Turbo LoRA stacker with dynamic UI for official Nunchaku Z-Image loader
- **Technical Details**: See [v2.2.0 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.2.0) for complete explanation
### v2.1.1
- **Fixed**: ComfyUI v0.6.0+ compatibility – Migrated from `guidance` to `additional_t_cond` parameter in `_execute_model` method to support ComfyUI v0.6.0+ API changes ([PR #34](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/pull/34))
@@ -310,11 +231,9 @@ def __init__(self, model, load_device, offload_device, size=0, weight_inplace_up
- **Fixed**: Resolved [Issue #33](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/33) – Fixed `AttributeError: 'NoneType' object has no attribute 'to'` by adding None checks to `to_safely` and `forward` methods in `ComfyQwenImageWrapper`
- **Technical Details**: See [v2.1.0 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.1.0) for complete explanation
### v2.0.8
- **Fixed**: Resolved [Issue #30](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/30) – Fixed `TypeError: got multiple values for argument 'guidance'` error by adding final cleanup of kwargs before calling model forward
- **Technical Details**: See [v2.0.8 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.0.8) for complete explanation
### v2.0.2 - v2.0.7
### v2.0.2 - v2.0.8
- **v2.0.8 Fixed**: Resolved [Issue #30](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/30) – Fixed `TypeError: got multiple values for argument 'guidance'` error by adding final cleanup of kwargs before calling model forward
- **Technical Details**: See [v2.0.8 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.0.8) for complete explanation
- **v2.0.7 Fixed**: Enhanced [Issue #32](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/32) fix by adding exclusion processing in `forward` method in addition to `_execute_model` method to prevent duplicate argument errors
- **Technical Details**: See [v2.0.7 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.0.7) for complete explanation
- **v2.0.6 Fixed**: Excluded `ref_latents`, `transformer_options`, and `attention_mask` from kwargs to prevent duplicate argument errors
@@ -330,7 +249,7 @@ def __init__(self, model, load_device, offload_device, size=0, weight_inplace_up
### v2.0
- **MAJOR UPDATE**: Added diffsynth ControlNet support for Nunchaku Qwen Image models
- **New Node**: `NunchakuQwenImageDiffsynthControlnet` - Enables diffsynth ControlNet to work with Nunchaku quantized Qwen Image models
- **New Node**: `NunchakuQI&ZITDiffsynthControlnet` - Enables diffsynth ControlNet to work with Nunchaku quantized Qwen Image models, Z Image Turbo BF16.safetensors, and Nunchaku Z Image Turbo models
- **Features**:
- Full diffsynth ControlNet functionality for Nunchaku Qwen Image models
- Automatic patch registration and application
@@ -357,10 +276,7 @@ def __init__(self, model, load_device, offload_device, size=0, weight_inplace_up
- Complete feature parity with V1 implementation
- Dynamic UI for adjusting slot count
- Automatic node height adjustment
- **Technical Details**:
- Flexible widget management using `optional` inputs
- Clean implementation using Flux V2-style widget array reconstruction
- Complete feature reproduction in both Python backend and JavaScript frontend
- **Technical Details**: See [v1.70 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v1.70) for complete explanation
### v1.63
- **Fixed**: Addressed [Issue #21](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/issues/21) – User-configurable CPU offload setting
+19 -5
View File
@@ -20,20 +20,31 @@ QWEN_V2_NAMES = {}
try:
from .nodes.lora.qwenimage import NunchakuQwenImageLoraLoader, NunchakuQwenImageLoraStack
from .nodes.lora.qwenimage_v2 import GENERATED_NODES as QWEN_V2_NODES, GENERATED_DISPLAY_NAMES as QWEN_V2_NAMES
from .nodes.lora.zimageturbo_v2 import GENERATED_NODES as ZIMAGETURBO_V2_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V2_NAMES
from .nodes.lora.qwenimage_v3 import GENERATED_NODES as QWEN_V3_NODES, GENERATED_DISPLAY_NAMES as QWEN_V3_NAMES
# Z-Image-Turbo V2 is deprecated (unofficial loader only) - keep file but do not register to avoid confusion
# from .nodes.lora.zimageturbo_v2 import GENERATED_NODES as ZIMAGETURBO_V2_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V2_NAMES
from .nodes.lora.zimageturbo_v3 import GENERATED_NODES as ZIMAGETURBO_V3_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V3_NAMES
# Add version to classes before creating NODE_CLASS_MAPPINGS
NunchakuQwenImageLoraLoader.__version__ = __version__
NunchakuQwenImageLoraStack.__version__ = __version__
for node_class in QWEN_V2_NODES.values():
node_class.__version__ = __version__
for node_class in ZIMAGETURBO_V2_NODES.values():
for node_class in QWEN_V3_NODES.values():
node_class.__version__ = __version__
# Z-Image-Turbo V2 registration removed (unofficial loader only)
# for node_class in ZIMAGETURBO_V2_NODES.values():
# node_class.__version__ = __version__
for node_class in ZIMAGETURBO_V3_NODES.values():
node_class.__version__ = __version__
NODE_CLASS_MAPPINGS["NunchakuQwenImageLoraLoader"] = NunchakuQwenImageLoraLoader
NODE_CLASS_MAPPINGS["NunchakuQwenImageLoraStack"] = NunchakuQwenImageLoraStack
NODE_CLASS_MAPPINGS.update(QWEN_V2_NODES)
NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V2_NODES)
NODE_CLASS_MAPPINGS.update(QWEN_V3_NODES)
# Z-Image-Turbo V2 registration removed (unofficial loader only)
# NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V2_NODES)
NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V3_NODES)
except ImportError:
logger.exception("LoRA nodes import failed:")
@@ -52,12 +63,15 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"NunchakuQwenImageLoraLoader": "Nunchaku Qwen Image LoRA Loader",
"NunchakuQwenImageLoraStack": "Nunchaku Qwen Image LoRA Stack (Legacy)",
**QWEN_V2_NAMES,
**ZIMAGETURBO_V2_NAMES
**QWEN_V3_NAMES,
# Z-Image-Turbo V2 registration removed (unofficial loader only)
# **ZIMAGETURBO_V2_NAMES,
**ZIMAGETURBO_V3_NAMES
}
# Add ControlNet display name only if the node was successfully loaded
if "NunchakuQwenImageDiffsynthControlnet" in NODE_CLASS_MAPPINGS:
NODE_DISPLAY_NAME_MAPPINGS["NunchakuQwenImageDiffsynthControlnet"] = "Nunchaku Qwen Image Diffsynth Controlnet"
NODE_DISPLAY_NAME_MAPPINGS["NunchakuQwenImageDiffsynthControlnet"] = "NunchakuQI&ZITDiffsynthControlnet"
# Register JavaScript extensions
WEB_DIRECTORY = "js"
Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

+218
View File
@@ -0,0 +1,218 @@
import { app } from "../../scripts/app.js";
console.log("★★★ zimageturbo_lora_dynamic.js: Z-Image-Turbo LoRA Stack V3 ★★★");
const HIDDEN_TAG = "tschide";
app.registerExtension({
name: "nunchaku.zimageturbo_lora_dynamic_v3",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "NunchakuZImageTurboLoraStackV3") {
nodeType["@visibleLoraCount"] = { type: "number", default: 1, min: 1, max: 10, step: 1 };
}
},
nodeCreated(node) {
if (node.comfyClass !== "NunchakuZImageTurboLoraStackV3") return;
if (!node.properties) node.properties = {};
if (node.properties["visibleLoraCount"] === undefined) node.properties["visibleLoraCount"] = 1;
// Immediately hide lora_count widget if it exists
const initialLoraCountWidget = node.widgets?.find(w => w.name === "lora_count");
if (initialLoraCountWidget) {
if (!initialLoraCountWidget.origType) {
initialLoraCountWidget.origType = initialLoraCountWidget.type;
initialLoraCountWidget.origComputeSize = initialLoraCountWidget.computeSize;
}
initialLoraCountWidget.type = HIDDEN_TAG;
initialLoraCountWidget.computeSize = () => [0, -4];
}
node.cachedWidgets = {};
let cacheReady = false;
const initCache = () => {
if (cacheReady) return;
const all = [...node.widgets];
// Cache lora_count widget (required for Python backend, but hidden in UI)
const loraCountWidget = all.find(w => w.name === "lora_count");
if (loraCountWidget) {
node.cachedLoraCount = loraCountWidget;
// Store original properties for restoration if needed
if (!loraCountWidget.origType) {
loraCountWidget.origType = loraCountWidget.type;
loraCountWidget.origComputeSize = loraCountWidget.computeSize;
}
// Hide V1's lora_count widget using HIDDEN_TAG and computeSize
loraCountWidget.type = HIDDEN_TAG;
loraCountWidget.computeSize = () => [0, -4];
}
// Cache cpu_offload widget
const cpuOffloadWidget = all.find(w => w.name === "cpu_offload");
if (cpuOffloadWidget) {
node.cachedCpuOffload = cpuOffloadWidget;
}
// Cache toggle_all widget
const toggleAllWidget = all.find(w => w.name === "toggle_all");
if (toggleAllWidget) {
node.cachedToggleAll = toggleAllWidget;
}
for (let i = 1; i <= 10; i++) {
const wEnabled = all.find(w => w.name === `enabled_${i}`);
const wName = all.find(w => w.name === `lora_name_${i}`);
const wStrength = all.find(w => w.name === `lora_strength_${i}`);
if (wEnabled && wName && wStrength) {
node.cachedWidgets[i] = [wEnabled, wName, wStrength];
wEnabled.type = "toggle";
wName.type = "combo";
wStrength.type = "number";
if (wEnabled.computeSize) delete wEnabled.computeSize;
if (wName.computeSize) delete wName.computeSize;
if (wStrength.computeSize) delete wStrength.computeSize;
}
}
cacheReady = true;
};
const ensureControlWidget = () => {
const name = "🔢 LoRA Count";
// Remove old button widgets
for (let i = node.widgets.length - 1; i >= 0; i--) {
const w = node.widgets[i];
if (w.name === "🔢 Set LoRA Count" || w.type === "button") {
node.widgets.splice(i, 1);
}
}
let w = node.widgets.find(x => x.name === name);
if (!w) {
const values = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
w = node.addWidget("combo", name, "1", (v) => {
const num = parseInt(v);
if (!isNaN(num)) {
node.properties["visibleLoraCount"] = num;
if (node.cachedLoraCount) {
node.cachedLoraCount.value = num;
}
node.updateLoraSlots();
}
}, { values });
}
w.value = node.properties["visibleLoraCount"].toString();
if (node.cachedLoraCount) {
node.cachedLoraCount.value = node.properties["visibleLoraCount"];
}
return w;
};
const ensureToggleAllWidget = () => {
if (!node.cachedToggleAll) return null;
if (!node.cachedToggleAll.origCallback) {
node.cachedToggleAll.origCallback = node.cachedToggleAll.callback;
}
node.cachedToggleAll.callback = (value) => {
if (node.cachedToggleAll.origCallback) {
node.cachedToggleAll.origCallback(value);
}
const count = parseInt(node.properties["visibleLoraCount"] || 1);
for (let i = 1; i <= count; i++) {
const pair = node.cachedWidgets[i];
if (pair && pair[0]) {
pair[0].value = value;
}
}
};
return node.cachedToggleAll;
};
// Plain logic restored
node.updateLoraSlots = function () {
if (!cacheReady) initCache();
const count = parseInt(this.properties["visibleLoraCount"] || 1);
const controlWidget = ensureControlWidget();
this.widgets = [controlWidget];
if (node.cachedLoraCount) {
node.cachedLoraCount.type = HIDDEN_TAG;
node.cachedLoraCount.computeSize = () => [0, -4];
node.cachedLoraCount.value = count;
this.widgets.push(node.cachedLoraCount);
}
const toggleAllWidget = ensureToggleAllWidget();
if (toggleAllWidget) {
this.widgets.push(toggleAllWidget);
}
if (node.cachedCpuOffload) {
this.widgets.push(node.cachedCpuOffload);
}
for (let i = 1; i <= count; i++) {
const pair = this.cachedWidgets[i];
if (pair && pair.length >= 3) {
const wEnabled = pair[0];
const wName = pair[1];
const wStrength = pair[2];
// Restore standard widgets
wEnabled.type = "toggle";
wName.type = "combo";
wStrength.type = "number";
// Clear custom compute sizes if any
if (wEnabled.computeSize) delete wEnabled.computeSize;
if (wName.computeSize) delete wName.computeSize;
if (wStrength.computeSize) delete wStrength.computeSize;
this.widgets.push(wEnabled);
this.widgets.push(wName);
this.widgets.push(wStrength);
}
}
const HEADER_H = 60;
// 3 lines per slot (Toggle ~20 + Name ~26 + Strength ~26 + margins)
const SLOT_H = 80;
const TOGGLE_ALL_H = toggleAllWidget ? 30 : 0;
const CPU_OFFLOAD_H = node.cachedCpuOffload ? 30 : 0;
const PADDING = 20;
const targetH = HEADER_H + TOGGLE_ALL_H + CPU_OFFLOAD_H + (count * SLOT_H) + PADDING;
this.setSize([this.size[0], targetH]);
if (app.canvas) app.canvas.setDirty(true, true);
};
node.onPropertyChanged = function (property, value) {
if (property === "visibleLoraCount") {
const w = this.widgets.find(x => x.name === "🔢 LoRA Count");
if (w) w.value = value.toString();
this.updateLoraSlots();
}
};
const origOnConfigure = node.onConfigure;
node.onConfigure = function () {
if (origOnConfigure) origOnConfigure.apply(this, arguments);
setTimeout(() => node.updateLoraSlots(), 100);
};
setTimeout(() => {
initCache();
node.updateLoraSlots();
}, 100);
}
});
@@ -0,0 +1,49 @@
# Issue #25: ComfyUI 0.4.0 Model Management Errors
- **Issue**: After the ComfyUI 0.4.0 update, multiple nodes (including this one in some environments) experienced errors such as `TypeError: 'NoneType' object is not callable` and `AttributeError: 'NoneType' object has no attribute`. In our environment, we resolved these errors by modifying ComfyUI's core `model_management.py`. Note that in our environment, these errors did not occur with this node (ComfyUI-QwenImageLoraLoader). Nunchaku library and ComfyUI-Nunchaku nodes should use the latest versions. If errors persist even after applying the latest version of this node (ComfyUI-QwenImageLoraLoader), modification of ComfyUI's core `model_management.py` may be necessary.
- **Root Cause**: In ComfyUI 0.4.0, ComfyUI's core `model_management.py` lacks sufficient None checks, causing `TypeError` and `AttributeError` when accessing methods or attributes on objects that became `None` after models were unloaded or garbage collected. This problem is not a bug in individual nodes, but rather a structural issue in ComfyUI 0.4.0's model management (`model_management.py`).
- **GC Changes in ComfyUI 0.4.0**: Compared to ComfyUI 0.3.x, automatic model unloading occurs earlier, making the following flow more likely:
```
ModelPatcher → GC → weakref(None)
```
This also explains why the occurrence of the issue varies by user environment.
- **Technical Basis**:
1. **Multiple Locations with "Missing None Checks"** - This is not a bug in individual nodes, but the core main logic crashes when accessing attributes on `None`. The added fixes like `if model is None: continue` are defensive code that ComfyUI core should have in all paths.
2. **Post-Weak-Reference GC Behavior Not Considered** - The introduction of `LoadedModel._model = weakref.ref(ModelPatcher)` in ComfyUI 0.4.0 is a breaking change. When the weak reference target is garbage collected, it returns `None`, but this is not handled. Post-processing for the breaking update is incomplete.
3. **Multiple Nodes Were Affected in a Chain Reaction** - This is not a problem with nodes, but multiple nodes were affected in a chain reaction due to core behavior changes. Model loading/unloading, memory calculation, GPU/CPU offloading, and `ModelPatcher` lifecycle are all controlled by ComfyUI core.
4. **All Fix Locations Are Core Responsibility Areas** - The locations fixed (`model_memory`, `model_offloaded_memory`, `load_models_gpu`, `free_memory`, `model_unload`, `is_dead` checks, etc.) are all ComfyUI core functions. These are not areas that node developers should touch. The fact that all fix locations are core logic leaves no explanation other than a core defect.
5. **Result of Applying Fixes** - After applying None check fixes to ComfyUI's core `model_management.py` in our environment, similar errors were resolved. This demonstrates that the problem can be solved by adding defensive code that the core should have.
- **Model Lifecycle and ModelPatcher Initialization Relationship**:
- **Fact 1: Relationship between LoadedModel and ModelPatcher** - The `LoadedModel` class (lines 502-524 in ComfyUI's `model_management.py`) holds a weak reference to `ModelPatcher`:
```python
def _set_model(self, model):
self._model = weakref.ref(model) # Weak reference to ModelPatcher
@property
def model(self):
return self._model() # Returns None when garbage collected
```
- **Fact 2: ModelPatcher Initialization** - In the `__init__` of the `ModelPatcher` class (lines 215-237 in `model_patcher.py`), the `pinned` attribute is initialized:
```python
def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False):
# ...
self.pinned = set() # Line 237: Initialized
```
- **Fact 3: Fix Content in ComfyUI Core's model_management.py** - The fix in ComfyUI's core `model_management.py` now skips `LoadedModel` instances where `model` is `None`: In `load_models_gpu()`, skips `LoadedModel` instances where `model` is `None` (lines 712, 727, 743); In `free_memory()`, excludes `LoadedModel` instances where `model` is `None` (line 646).
- **Fact 4: Problem Before Fix** - `LoadedModel` holds a weak reference to `ModelPatcher`. When garbage collected, `LoadedModel.model` returns `None`. Before the fix, methods were called on `LoadedModel` instances where `model` was `None`, causing errors.
- **Fact 5: Behavior After Fix** - By skipping `LoadedModel` instances where `model` is `None`, errors do not occur. Because errors do not occur, processing continues normally.
- **Fact 6: Why copy.deepcopy Fails** - `copy.deepcopy` fails because references to GC'd `ModelPatcher` instances remain in the dictionary being deepcopied. When these references are accessed, they return `None`, causing deepcopy to stop.
- **Fact 7: Confirmation Items** - After applying the fix, `copy.deepcopy` and `pinned` attribute errors do not occur in our environment. Nunchaku library and ComfyUI-Nunchaku nodes should use the latest versions, but this may still be insufficient. While these errors did not occur with this node (ComfyUI-QwenImageLoraLoader) in our environment, the fix to ComfyUI's core `model_management.py` may have indirectly affected it, making errors less likely to occur.
- **Important Note: Not a Problem with Nunchaku Library** - This problem is not caused by the Nunchaku library's implementation. Nunchaku's `model_config` and `ModelPatcher` itself are normal. The problem is in the upstream = ComfyUI core's `model_management.py` GC processing.
- **Speculation (Items That May Be Environment-Dependent)**: The fix allows `ModelPatcher` initialization to complete normally. As a result, the `pinned` attribute is also properly initialized. Accessing `self.pinned` in `__del__` does not cause errors.
- **Recommendations**:
1. Update Nunchaku library and ComfyUI-Nunchaku nodes to the latest version (addresses `model_config` issues)
2. Consider applying None check fixes to ComfyUI's core `model_management.py` (may address the root cause)
- **Note**: This is the first support measure. I have published the technical details of the fixes I applied to ComfyUI's core `model_management.py` in my environment. See [COMFYUI_0.4.0_UPDATE_ERROR_FIXES.md](md/COMFYUI_0.4.0_UPDATE_ERROR_FIXES.md) for details. Note that these fixes were applied in my specific environment and may not work universally in all environments. This may also resolve `copy.deepcopy` and `pinned` attribute errors.
@@ -0,0 +1,230 @@
# Diffsynth (ControlNet) Official NextDiT Support - Complete Technical Documentation
## Reason for Development
ComfyUI-QwenImageLoraLoader had implemented Diffsynth (ControlNet) support for the unofficial Z-Image loader (`ComfyUI-nunchaku-unofficial-z-image-turbo-loader`), but it did not work with ComfyUI-nunchaku's official Z-Image loader (ComfyUI Lumina2 / NextDiT).
**Root Causes**:
1. **Parameter Passing Issues Due to Forward Signature Differences**
- Unofficial: `NunchakuZImageTransformer2DModel.forward(x, t, cap_feats=..., **kwargs)` where `transformer_options` and `control` are received as explicit parameters via monkey-patch
- Official: `NextDiT.forward(x, timesteps, context, num_tokens, attention_mask=None, **kwargs)` where `transformer_options` is read via `**kwargs` (`kwargs.get("transformer_options", {})`)
2. **transformer_options Transmission Path**
- ComfyUI's ModelPatcher registers Diffsynth patches in `transformer_options["patches"]["double_block"]`. NextDiT reads this `patches["double_block"]` in its internal `_forward()` method and applies patches after each transformer block. However, if `transformer_options` is not passed via `**kwargs`, the patches do not reach NextDiT and Diffsynth does not work.
3. **Limitations of Existing Implementation**
- `ComfyZImageTurboWrapper._execute_model()` dynamically detected the forward signature using `inspect.signature()`, but when `transformer_options` does **not exist as an explicit parameter** (i.e., when passed via `**kwargs`), it could be missed in the conditional branch.
4. **Maintaining Compatibility with Unofficial Loader**
- Processing for the unofficial loader (via `customized_forward` or direct forward calls) was already working, so the design needed to add official support without breaking the unofficial loader's behavior.
## Modified Files
### `ComfyUI-QwenImageLoraLoader/wrappers/zimageturbo.py`
**Modification Location: NextDiT Branch in `_execute_model()` Method (lines 442-472)**
**Problem Before Fix**:
```python
# Branch 2: ComfyUI Lumina2 / NextDiT signature
if "context" in forward_params and "num_tokens" in forward_params:
# ... (processing of x, context, num_tokens) ...
forward_kwargs = {}
if "attention_mask" in forward_params:
forward_kwargs["attention_mask"] = attention_mask_value
if "transformer_options" in forward_params:
forward_kwargs["transformer_options"] = transformer_options
if "control" in forward_params:
forward_kwargs["control"] = control
return self.model(
x_tensor,
timestep,
context=context,
num_tokens=num_tokens_value,
**forward_kwargs,
**kwargs,
)
```
**Problem**: When `transformer_options` does not exist as an explicit parameter (not in `forward_params`), it was not added to `forward_kwargs`, and might not be included in `**kwargs` either. Since NextDiT reads it via `**kwargs.get("transformer_options", {})`, if it is not passed, Diffsynth patches do not reach NextDiT.
**After Fix**:
```python
# Branch 2: ComfyUI Lumina2 / NextDiT signature
# forward(x, timesteps, context, num_tokens, attention_mask=None, **kwargs)
if "context" in forward_params and "num_tokens" in forward_params:
# ... (processing of x, context, num_tokens) ...
# NextDiT forward is: forward(x, timesteps, context, num_tokens, attention_mask=None, **kwargs)
# It consumes `transformer_options` via **kwargs (not a named parameter),
# so we must pass it when the function supports VAR_KEYWORD.
supports_kwargs = any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in forward_sig.parameters.values()
)
forward_kwargs = {}
if "attention_mask" in forward_params:
forward_kwargs["attention_mask"] = attention_mask_value
if supports_kwargs:
# Ensure patches (e.g. double_block from DiffSynth/ControlNet) reach NextDiT
forward_kwargs["transformer_options"] = transformer_options
# Safe to forward; NextDiT ignores it if unused.
if control is not None:
forward_kwargs["control"] = control
else:
if "transformer_options" in forward_params:
forward_kwargs["transformer_options"] = transformer_options
if "control" in forward_params:
forward_kwargs["control"] = control
# NOTE: NextDiT expects `context` and `num_tokens` as required args
return self.model(
x_tensor,
timestep,
context=context,
num_tokens=num_tokens_value,
**forward_kwargs,
**kwargs,
)
```
**Technical Meaning**:
1. **VAR_KEYWORD Detection**
- `inspect.Parameter.VAR_KEYWORD` represents `**kwargs` in Python function signatures. `any(p.kind == inspect.Parameter.VAR_KEYWORD for p in forward_sig.parameters.values())` checks whether the forward method supports `**kwargs`.
2. **When `supports_kwargs` is True**
- When `**kwargs` is supported (like NextDiT), `transformer_options` and `control` are always added to `forward_kwargs`. This ensures they are passed via `**forward_kwargs`. Since NextDiT reads via `kwargs.get("transformer_options", {})`, this allows Diffsynth patches to reach NextDiT.
3. **When `supports_kwargs` is False (Backward Compatibility)**
- When `**kwargs` is not supported (rare, but for safety), it falls back to the traditional approach of adding only if they exist as explicit parameters.
4. **Handling of `control`**
- Similarly, when `supports_kwargs` is True, `control` is added only when `control is not None` (safe because NextDiT does not error even if unused). When False, explicit parameter checks are performed.
## Official NextDiT Implementation Verification
The `NextDiT` class in `ComfyUI/comfy/ldm/lumina/model.py`:
```python
def forward(self, x, timesteps, context, num_tokens, attention_mask=None, **kwargs):
return comfy.patcher_extension.WrapperExecutor.new_class_executor(
self._forward,
self,
comfy.patcher_extension.get_all_wrappers(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, kwargs.get("transformer_options", {}))
).execute(x, timesteps, context, num_tokens, attention_mask, **kwargs)
def _forward(self, x, timesteps, context, num_tokens, attention_mask=None, transformer_options={}, **kwargs):
# ...
patches = transformer_options.get("patches", {})
# ...
transformer_options["total_blocks"] = len(self.layers)
transformer_options["block_type"] = "double"
for i, layer in enumerate(self.layers):
transformer_options["block_index"] = i
img = layer(img, mask, freqs_cis, adaln_input, transformer_options=transformer_options)
if "double_block" in patches:
for p in patches["double_block"]:
out = p({...}) # Apply Diffsynth patch
```
**Key Point**: NextDiT's `forward()` receives `**kwargs` and extracts the dictionary via `kwargs.get("transformer_options", {})`. In `_forward()`, it reads `double_block` patches via `patches = transformer_options.get("patches", {})` and applies them after each block. Therefore, **ensuring `transformer_options` is passed via `**kwargs` is a necessary condition for Diffsynth to work**.
## Differences and Compatibility with Unofficial Loader
**Unofficial Loader (`ComfyUI-nunchaku-unofficial-z-image-turbo-loader`)**:
- Monkey-patches `NunchakuZImageTransformer2DModel.forward` to add `transformer_options` and `control` as **explicit parameters**
- Signature: `_patched_forward(self, x, t, cap_feats=None, *args, control=None, transformer_options=None, **kwargs)`
- In this case, it is processed in Branch 1 (when `cap_feats` exists) of `ComfyZImageTurboWrapper._execute_model()`, and works correctly with the existing implementation
**Official Loader (NextDiT)**:
- Signature: `forward(x, timesteps, context, num_tokens, attention_mask=None, **kwargs)`
- `transformer_options` is received via `**kwargs` (not an explicit parameter)
- With this fix, Branch 2 checks `supports_kwargs` and ensures it is passed when `**kwargs` is supported
**Compatibility Mechanism**:
- Branch 1 (Unofficial): When `cap_feats` exists, processed with existing implementation (checks explicit parameters via `inspect.signature`)
- Branch 2 (Official): When `context` and `num_tokens` exist, checks `VAR_KEYWORD` and ensures passing via `**kwargs`
This allows **Diffsynth to work with both unofficial and official loaders**, and the existing unofficial users are not affected by the addition of official support.
## Technical Design Philosophy
### 1. Dynamic Detection via VAR_KEYWORD
**Problem**: Need to determine at runtime whether `transformer_options` is an explicit parameter or passed via `**kwargs`
**Solution**:
- Detects `**kwargs` support using `inspect.Parameter.VAR_KEYWORD`, and when supported, always adds to `forward_kwargs`
**Benefits**:
- Supports both explicit parameters and `**kwargs`
- Flexible to future signature changes
- No need for type checks or isinstance checks (signature-based detection)
### 2. Safe Parameter Transmission
**Policy**:
- When `supports_kwargs` is True: Always add `transformer_options` and `control` (necessary because NextDiT reads from `**kwargs`)
- When `supports_kwargs` is False: Fall back to explicit parameter checks (backward compatibility)
**Result**:
- Diffsynth works reliably with official NextDiT
- Unofficial loader behavior maintained with existing implementation (no impact)
- Can adapt to future new loaders
### 3. Maintaining Compatibility with Unofficial Loader
**Policy**:
- Do not change Branch 1 (unofficial) at all
- Add fixes only to Branch 2 (official)
- Both branches operate independently, so they do not affect each other
**Result**:
- No impact on existing unofficial users
- Official users can use Diffsynth without additional configuration
- Implementation with minimal code complexity
## Operation Flow
### When Using Official NextDiT + Diffsynth
1. Diffsynth node execution: `NunchakuQwenImageDiffsynthControlnet.diffsynth_controlnet_nunchaku()` registers patches via `set_model_double_block_patch()`
2. ComfyUI ModelPatcher: Registers patches in `transformer_options["patches"]["double_block"]`
3. `ComfyZImageTurboWrapper.forward()`: Receives `transformer_options` and passes it to `_execute_model()`
4. `_execute_model()`: Detects NextDiT's forward signature using `inspect.signature()`
5. Branch 2: Confirms existence of `context` and `num_tokens`, processes as NextDiT format
6. VAR_KEYWORD detection: Checks `**kwargs` support via `supports_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD ...)`
7. Parameter addition: When `supports_kwargs` is True, adds via `forward_kwargs["transformer_options"] = transformer_options`
8. NextDiT.forward() call: `self.model(x_tensor, timestep, context=context, num_tokens=num_tokens_value, **forward_kwargs, **kwargs)`
9. NextDiT._forward(): Extracts `transformer_options` via `kwargs.get("transformer_options", {})`
10. Patch application: Extracts `double_block` patches via `patches = transformer_options.get("patches", {})` and applies after each block
### When Using Unofficial Loader + Diffsynth
1. Diffsynth node execution: Similarly registers patches
2. `ComfyZImageTurboWrapper.forward()`: Receives `transformer_options` and passes it to `_execute_model()`
3. `_execute_model()`: Detects unofficial forward signature using `inspect.signature()`
4. Branch 1: Confirms existence of `cap_feats`, processes as unofficial format
5. Explicit parameter check: Checks explicit parameters via `if "transformer_options" in forward_params`
6. Parameter addition: When it exists as an explicit parameter, adds via `forward_kwargs["transformer_options"] = transformer_options`
7. `_patched_forward()` call: Monkey-patched forward receives `transformer_options` as an explicit parameter
8. Patch application: Applies patches after each block via `_apply_double_block_patches()`
## Conclusion
The official NextDiT support for Diffsynth (ControlNet) has achieved the following:
1. **Official Loader Support**: Diffsynth (ControlNet) works with NextDiT format models
2. **Reliable Parameter Transmission**: `VAR_KEYWORD` detection ensures `transformer_options` is reliably passed via `**kwargs`
3. **Compatibility with Unofficial Loader**: Unofficial loader behavior is maintained with existing implementation, no impact
4. **Minimal Code Changes**: Existing unofficial branch unchanged, fixes added only to official branch
With this design, users can use either the official or unofficial loader, and Diffsynth (ControlNet) will work appropriately with both.
+311
View File
@@ -0,0 +1,311 @@
# Official Nunchaku Z-Image LoRA Loader V3 - Complete Technical Documentation
## Reason for Development
With the release of the official Z-Image loader by ComfyUI-nunchaku, the following issues emerged:
1. **Model Structure Differences**
- Unofficial loader: `NunchakuZImageTransformer2DModel` (diffusers format, `to_qkv` / `feed_forward.net.0.proj`)
- Official loader: `NextDiT` (ComfyUI Lumina2 format, `attention.qkv` / `feed_forward.w1/w2/w3` or `w13`)
2. **Forward Signature Differences**
- Unofficial: `forward(x_list: List[torch.Tensor], t_zimage, cap_feats=...)`
- Official: `forward(x: torch.Tensor, timesteps, context, num_tokens, attention_mask=...)`
3. **LoRA Mapping Mismatch**
- The existing v2 loader was designed for the unofficial structure (`to_qkv` / `net.0.proj`), and failed to resolve keys for the official structure (`qkv` / `w13`)
4. **Backward Compatibility Maintenance**
- Need to provide both v2 (unofficial-only) and v3 (official-only) simultaneously, allowing users to choose
## Newly Created Files
### 1. `ComfyUI-QwenImageLoraLoader/nodes/lora/zimageturbo_v3.py`
**Purpose**: LoRA stack node dedicated to the official Nunchaku Z-Image loader (`NextDiT`)
**Main Implementation**:
The class name is `NunchakuZImageTurboLoraStackV2`, but it is actually dedicated to V3 (registration name changed via GENERATED_NODES)
**Key Logic (lines 169-191)**:
```python
elif model_wrapper_type_name == "NextDiT" and model_wrapper_module == "comfy.ldm.lumina.model":
logger.info("🔧 Official loader detected (NextDiT), wrapping with ComfyZImageTurboWrapper")
transformer = model_wrapper
logger.info(f"📦 Creating ComfyZImageTurboWrapper for NextDiT with cpu_offload='{cpu_offload}'")
wrapped_model = ComfyZImageTurboWrapper(
transformer,
getattr(transformer, 'config', {}),
None,
{},
cpu_offload,
4.0,
)
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("V3 is for official Nunchaku Z-Image DiT Loader only. For unofficial loader, please use V2.")
raise TypeError(f"This LoRA loader (V3) only works with official Nunchaku Z-Image loader, but got {model_wrapper_type_name}.")
```
**Technical Meaning**:
- When `NextDiT` is detected, wrap it with `ComfyZImageTurboWrapper`
- By directly replacing `diffusion_model`, maintain compatibility with existing ComfyUI workflows
- Error messages clearly indicate the distinction between v2/v3 usage
**Registration (lines 226-232)**:
```python
GENERATED_NODES = {
"NunchakuZImageTurboLoraStackV3": NunchakuZImageTurboLoraStackV2
}
GENERATED_DISPLAY_NAMES = {
"NunchakuZImageTurboLoraStackV3": "Nunchaku Z-Image-Turbo LoRA Stack V3"
}
```
### 2. `ComfyUI-QwenImageLoraLoader/js/zimageturbo_lora_dynamic_v3.js`
**Purpose**: Dynamic UI for v3 node (variable display of LoRA slot count)
**Main Implementation**:
- Uses the same UI logic as v2 (line 8: `name: "nunchaku.zimageturbo_lora_dynamic_v3"` to avoid conflicts with v2)
- Line 17: Filters for v3-only with `if (node.comfyClass !== "NunchakuZImageTurboLoraStackV3")`
**Technical Meaning**:
- Separates JavaScript extensions for v2 and v3, avoiding name collisions
- UI logic is reused from v2 (no changes needed)
## Modified Files
### 1. `ComfyUI-QwenImageLoraLoader/wrappers/zimageturbo.py`
**Modification 1: NextDiT Support in forward Method (lines 425-458)**
```python
if "context" in forward_params and "num_tokens" in forward_params:
if isinstance(x, list):
x_stack = torch.stack([t for t in x if t is not None], dim=0)
if x_stack.ndim == 5 and x_stack.shape[2] == 1:
x_stack = x_stack.squeeze(2)
x_tensor = x_stack
else:
x_tensor = x
if num_tokens_value is None and isinstance(context, torch.Tensor) and context.ndim >= 2:
num_tokens_value = context.shape[1]
forward_kwargs = {}
if "attention_mask" in forward_params:
forward_kwargs["attention_mask"] = attention_mask_value
if "transformer_options" in forward_params:
forward_kwargs["transformer_options"] = transformer_options
if "control" in forward_params:
forward_kwargs["control"] = control
return self.model(
x_tensor,
timestep,
context=context,
num_tokens=num_tokens_value,
**forward_kwargs,
**kwargs,
)
```
**Technical Meaning**:
- Dynamically detects forward signature using `inspect.signature`
- When `context` and `num_tokens` exist, processes as NextDiT format
- If `num_tokens` is not specified, automatically derives from `context.shape[1]`
- Passes `attention_mask`, `transformer_options`, and `control` as keyword arguments to maintain compatibility with extension features like ControlNet
**Modification 2: Extraction of `num_tokens` and `attention_mask` from kwargs (lines 241-242)**
```python
ref_latents_value = kwargs.pop("ref_latents", None)
num_tokens_value = kwargs.pop("num_tokens", None)
attention_mask_value = kwargs.pop("attention_mask", None)
```
**Technical Meaning**:
- ComfyUI passes `num_tokens` and `attention_mask` via kwargs, so they are extracted beforehand
- `kwargs.pop` prevents duplicate parameter passing
### 2. `ComfyUI-QwenImageLoraLoader/nunchaku_code/lora_qwen.py`
**Modification 1: NextDiT Mapping Definitions (lines 30-54)**
```python
ZIMAGE_NEXTDIT_UNPATCHED_KEY_MAPPING = [
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]([qkv])$"), r"\1.\2.attention.qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]out(?:[._]0)?$"), r"\1.\2.attention.out", "regular", None),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w1$"), r"\1.\2.feed_forward.w1", "regular", None),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"), r"\1.\2.feed_forward.w2", "regular", None),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w3$"), r"\1.\2.feed_forward.w3", "regular", None),
]
ZIMAGE_NEXTDIT_NUNCHAKU_PATCHED_KEY_MAPPING = [
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]([qkv])$"), r"\1.\2.attention.qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]out(?:[._]0)?$"), r"\1.\2.attention.out", "regular", None),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"), r"\1.\2.feed_forward.w13", "glu", lambda m: m.group(3)),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"), r"\1.\2.feed_forward.w2", "regular", None),
]
```
**Technical Meaning**:
- UNPATCHED: Mapping for vanilla NextDiT (with `w1/w2/w3` separated)
- NUNCHAKU_PATCHED: Mapping for when ComfyUI-nunchaku replaces `feed_forward` with `w13` (w1+w3 fused)
- The `glu` group specification causes `_fuse_glu_lora` to fuse and apply w1 and w3
**Modification 2: Model Structure Detection and Automatic Mapping Switching (lines 984-1014)**
```python
global _ACTIVE_KEY_MAPPING
prev_mapping = _ACTIVE_KEY_MAPPING
try:
nextdit_markers = (
"layers.0.attention.qkv",
"layers.0.attention.out",
"layers.0.feed_forward.w1",
"layers.0.feed_forward.w2",
"layers.0.feed_forward.w3",
"layers.0.feed_forward.w13",
)
is_nextdit_style = any(_get_module_by_name(model, p) is not None for p in nextdit_markers)
if is_nextdit_style:
has_w13 = _get_module_by_name(model, "layers.0.feed_forward.w13") is not None
if has_w13:
_ACTIVE_KEY_MAPPING = ZIMAGE_NEXTDIT_NUNCHAKU_PATCHED_KEY_MAPPING + KEY_MAPPING
else:
_ACTIVE_KEY_MAPPING = ZIMAGE_NEXTDIT_UNPATCHED_KEY_MAPPING + KEY_MAPPING
else:
_ACTIVE_KEY_MAPPING = None
except Exception:
_ACTIVE_KEY_MAPPING = None
```
**Technical Meaning**:
- Dynamically detects model structure using `_get_module_by_name`
- Determines nunchaku-patched/unpatched by the presence of `layers.0.feed_forward.w13`
- Sets appropriate mapping to `_ACTIVE_KEY_MAPPING`, which is prioritized in `_classify_and_map_key` (line 249)
**Modification 3: Active Mapping Priority in _classify_and_map_key (line 249)**
```python
mapping_to_use = _ACTIVE_KEY_MAPPING if _ACTIVE_KEY_MAPPING is not None else KEY_MAPPING
```
**Technical Meaning**:
- When `_ACTIVE_KEY_MAPPING` is set (NextDiT detected), it is prioritized
- When not set, uses the traditional `KEY_MAPPING` (for unofficial/v2)
- This allows v2 and v3 to share the same `compose_loras_v2` function while switching only the mapping
### 3. `ComfyUI-QwenImageLoraLoader/__init__.py`
**Modification: V3 Node Import and Registration (lines 24, 33-34, 40, 60)**
```python
from .nodes.lora.zimageturbo_v3 import GENERATED_NODES as ZIMAGETURBO_V3_NODES, GENERATED_DISPLAY_NAMES as ZIMAGETURBO_V3_NAMES
for node_class in ZIMAGETURBO_V3_NODES.values():
node_class.__version__ = __version__
NODE_CLASS_MAPPINGS.update(ZIMAGETURBO_V3_NODES)
NODE_DISPLAY_NAME_MAPPINGS = {
**ZIMAGETURBO_V3_NAMES
}
```
**Technical Meaning**:
- Registers v3 node into ComfyUI's node system
- Provides both v2 and v3 simultaneously, allowing users to choose
## Technical Design Philosophy
### 1. Automatic Mapping Switching
**Problem**: Module names differ between unofficial (`to_qkv`/`net.0.proj`) and official (`qkv`/`w13`)
**Solution**:
- Uses a global variable `_ACTIVE_KEY_MAPPING` to switch mappings at runtime
- Detects model structure within `compose_loras_v2` and sets appropriate mapping
- Prioritizes `_ACTIVE_KEY_MAPPING` in `_classify_and_map_key`
**Benefits**:
- v2/v3 share the same `compose_loras_v2` function
- Minimizes code duplication
- To support new model structures, only mapping definitions need to be added
### 2. Dynamic Forward Signature Detection
**Problem**: Forward signatures differ between Z-Image (`cap_feats`) and NextDiT (`context`/`num_tokens`)
**Solution**:
- Dynamically retrieves forward parameters using `inspect.signature`
- Branches to Z-Image if `cap_feats` exists, or NextDiT if `context` and `num_tokens` exist
**Benefits**:
- Single `ComfyZImageTurboWrapper` handles both formats
- No need for type checks or isinstance checks (high flexibility)
### 3. GLU Fusion Handling
**Problem**: ComfyUI-nunchaku fuses `feed_forward.w1/w3` into `w13`
**Solution**:
- `ZIMAGE_NEXTDIT_NUNCHAKU_PATCHED_KEY_MAPPING` maps LoRA's `w1`/`w3` to `w13`
- Specifies `glu` as the group, and applies via fusion using `_fuse_glu_lora`
**Technical Details**:
- `_fuse_glu_lora` is a function that mathematically fuses w1 and w3 LoRAs and applies them to w13
- This allows LoRA files in `w1`/`w3` format to be correctly applied to patched models (`w13`)
### 4. Backward Compatibility Maintenance
**Policy**:
- Provides v2 (unofficial-only) and v3 (official-only) as separate nodes
- v2 code remains completely unchanged, v3 added as new file
- Both use the same `compose_loras_v2`, but work correctly via automatic mapping switching
**Result**:
- No impact on existing v2 users
- v3 clearly separated as official-loader-only
- Users can choose nodes according to their loader
## Operation Flow
### When Using V3 Node (Official Loader)
1. Node execution: `NunchakuZImageTurboLoraStackV3.load_lora_stack()`
2. Model detection: Checks `model_wrapper_type_name == "NextDiT"`
3. Wrapper creation: Wraps `NextDiT` with `ComfyZImageTurboWrapper`
4. LoRA composition: Calls `compose_loras_v2()`
5. Mapping switch: Detects `layers.0.feed_forward.w13` using `_get_module_by_name()`
6. Mapping application: Sets `ZIMAGE_NEXTDIT_NUNCHAKU_PATCHED_KEY_MAPPING` to `_ACTIVE_KEY_MAPPING`
7. Key resolution: Uses `_ACTIVE_KEY_MAPPING` in `_classify_and_map_key()`
8. LoRA application: Applies `w1`/`w3` → `w13` (GLU fusion), `to_q/k/v` → `qkv` (QKV fusion)
### During Forward Execution (NextDiT)
1. Signature detection: Checks `context` and `num_tokens` using `inspect.signature()`
2. Branching: Processes as NextDiT format
3. Argument preparation: Unifies `x` to tensor format, derives `num_tokens`
4. Call: `model.forward(x, timestep, context=context, num_tokens=num_tokens, ...)`
## Conclusion
The development of the official-only v3 has achieved the following:
1. **Official Loader Support**: Enables LoRA application to `NextDiT` format models
2. **Automatic Mapping Switching**: Detects model structure and automatically selects appropriate mapping
3. **Backward Compatibility**: v2 (unofficial) and v3 (official) work simultaneously
4. **Code Reuse**: Extended existing `compose_loras_v2` and `ComfyZImageTurboWrapper`, minimizing duplication
With this design, users can use either unofficial or official loaders, and simply selecting the appropriate LoRA loader node will make everything work.
+294
View File
@@ -0,0 +1,294 @@
# Detailed Explanation of Z-Image-Turbo ControlNet Support Fix in ComfyUI-QwenImageLoraLoader
## Reason for Additional Fix
The unofficial Z-Image-Turbo loader (`ComfyUI-nunchaku-unofficial-z-image-turbo-loader`) monkey-patched `NunchakuZImageTransformer2DModel.forward` to accept `transformer_options` and `control` parameters.
However, the `ComfyZImageTurboWrapper` class in ComfyUI-QwenImageLoraLoader was not correctly passing these parameters to the underlying `NunchakuZImageTransformer2DModel.forward`.
`ComfyZImageTurboWrapper` is a wrapper that separates LoRA composition and forward pass, passing arguments received in the `forward` method to `NunchakuZImageTransformer2DModel.forward` via the `_execute_model` method. We needed to add `transformer_options` and `control` to this transmission path.
## Modified File
`ComfyUI-QwenImageLoraLoader/wrappers/zimageturbo.py`
## Fix Details
### Fix 1: forward Method Signature (Lines 76-85)
#### Before
```python
def forward(
self,
x,
timestep,
context=None,
y=None,
guidance=None,
**kwargs,
):
```
#### After
```python
def forward(
self,
x,
timestep,
context=None,
y=None,
guidance=None,
control=None,
transformer_options={},
**kwargs,
):
```
#### Explanation
Added `control` and `transformer_options` to the `forward` method signature to receive them from ComfyUI's ModelPatcher.
### Fix 2: transformer_options Merge Processing in forward Method (Lines 93-102)
#### Fix Content
```python
# Remove guidance, transformer_options, and attention_mask from kwargs
if "guidance" in kwargs:
kwargs.pop("guidance")
if "transformer_options" in kwargs:
if isinstance(transformer_options, dict) and isinstance(kwargs["transformer_options"], dict):
transformer_options = {**transformer_options, **kwargs.pop("transformer_options")}
else:
kwargs.pop("transformer_options")
if "attention_mask" in kwargs:
kwargs.pop("attention_mask")
```
#### Explanation
ComfyUI's ModelPatcher can pass parameters either as explicit arguments or via `**kwargs`. To handle both cases correctly:
1. If `transformer_options` is passed both as an explicit parameter and in `kwargs`, merge them with the explicit parameter taking precedence (dictionary spread operator `{**transformer_options, **kwargs["transformer_options"]}` ensures explicit parameter values override kwargs values)
2. Remove `guidance` and `attention_mask` from `kwargs` to prevent duplicate parameter errors, as these are handled separately
3. This merging strategy ensures compatibility with different ComfyUI versions that may pass parameters in different ways
### Fix 3: Argument Passing to _execute_model Method (Lines 219, 221)
#### Fix Content
Modified calls to `_execute_model` within the `forward` method to pass `control` and `transformer_options`:
```python
with cache_context(self._cache_context):
out = self._execute_model(x, timestep, context, y, guidance, control, transformer_options, **kwargs)
else:
out = self._execute_model(x, timestep, context, y, guidance, control, transformer_options, **kwargs)
```
#### Explanation
By passing `control` and `transformer_options` to `_execute_model`, they can be transmitted to the underlying `NunchakuZImageTransformer2DModel.forward`.
### Fix 4: _execute_model Method Signature and Parameter Cleanup (Line 239-247)
#### Before
```python
def _execute_model(self, x, timestep, context, y, guidance, **kwargs):
```
#### After
```python
def _execute_model(self, x, timestep, context, y, guidance, control, transformer_options, **kwargs):
"""Helper function to run the Z-Image-Turbo model's forward pass."""
# Get ref_latents from kwargs before removing it
ref_latents_value = kwargs.pop("ref_latents", None)
# Remove guidance, transformer_options, and attention_mask from kwargs
kwargs.pop("guidance", None)
kwargs.pop("transformer_options", None)
kwargs.pop("attention_mask", None)
...
```
#### Explanation
1. Added `control` and `transformer_options` as explicit parameters to the `_execute_model` signature to match the `forward` method's parameter list
2. Extract `ref_latents` from `kwargs` early (line 242) before cleaning `kwargs`, as `ref_latents` is needed later in both `customized_forward` and direct forward paths
3. Remove `guidance`, `transformer_options`, and `attention_mask` from `kwargs` to prevent duplicate argument errors when passing `**kwargs` to downstream methods
4. This cleanup ensures that these parameters are passed explicitly where needed, rather than through `**kwargs`, providing better control and preventing conflicts
### Fix 5: Transmission When Using customized_forward (Lines 282-300)
#### Fix Content
Processing when `customized_forward` is set (when using the custom forward from the unofficial ZIT loader):
```python
if self.customized_forward:
with torch.inference_mode():
forward_kwargs_cleaned = {k: v for k, v in self.forward_kwargs.items() if k not in ("guidance", "ref_latents", "transformer_options", "attention_mask")}
transformer_options_cleaned = dict(transformer_options) if transformer_options else {}
transformer_options_cleaned.pop("guidance", None)
transformer_options_cleaned.pop("ref_latents", None)
return self.customized_forward(
self.model,
hidden_states=x,
encoder_hidden_states=context,
timestep=timestep,
guidance=guidance if self.config.get("guidance_embed", False) else None,
ref_latents=ref_latents_value,
control=control,
transformer_options=transformer_options_cleaned,
**forward_kwargs_cleaned,
**kwargs,
)
```
#### Explanation
Pass `control` and `transformer_options` when calling `customized_forward`. Remove `guidance` and `ref_latents` from `transformer_options` (to avoid duplication).
**Additional Details:**
1. **forward_kwargs cleaning (line 134)**: The `self.forward_kwargs` dictionary may contain parameters that conflict with explicit arguments passed to `customized_forward`. We filter out `guidance`, `ref_latents`, `transformer_options`, and `attention_mask` to prevent duplicate argument errors.
2. **transformer_options cleaning (lines 135-137)**: Create a copy of `transformer_options` to avoid modifying the original dictionary, then remove `guidance` and `ref_latents` if they exist. This prevents duplicate parameter passing, as `guidance` is passed explicitly based on `self.config.get("guidance_embed", False)`, and `ref_latents` is extracted from `kwargs` earlier and passed as `ref_latents_value`.
3. **ref_latents handling**: The `ref_latents_value` was extracted from `kwargs` in `_execute_model` (line 242) before cleaning `kwargs`. It is passed explicitly to `customized_forward` rather than through `transformer_options`, providing clearer separation of concerns.
4. **guidance conditional passing**: `guidance` is only passed to `customized_forward` if `self.config.get("guidance_embed", False)` is `True`, allowing for conditional guidance embedding support.
### Fix 6: Transmission When Directly Calling forward (Lines 389-409)
#### Fix Content
Processing when `customized_forward` is not set (when directly calling `NunchakuZImageTransformer2DModel.forward`):
```python
# Call Z-Image-Turbo forward with correct signature
# Pass control and transformer_options to allow Model Patcher (double_block patches) to work
# Check if the model's forward method accepts control parameter
import inspect
forward_sig = inspect.signature(self.model.forward)
forward_params = set(forward_sig.parameters.keys())
zimage_kwargs_clean = {k: v for k, v in zimage_kwargs.items() if k not in ('control', 'transformer_options')}
# Build kwargs based on what the forward method accepts
forward_kwargs = zimage_kwargs_clean.copy()
if 'control' in forward_params:
forward_kwargs['control'] = control
if 'transformer_options' in forward_params:
forward_kwargs['transformer_options'] = transformer_options
model_output = self.model(
x_list,
t_zimage,
cap_feats=cap_feats,
**forward_kwargs
)
```
#### Explanation
- Check the signature of `self.model.forward` using `inspect.signature`
- Add `control` and `transformer_options` to `forward_kwargs` only if they are accepted
- This allows the `_patched_forward` (monkey-patched forward method) from the unofficial ZIT loader to correctly receive `transformer_options` and `control`
**Additional Details:**
1. **zimage_kwargs preparation (lines 365-371, not shown in snippet)**: Before the dynamic parameter check, `zimage_kwargs` is prepared with Z-Image-Turbo specific parameters like `patch_size`, `f_patch_size`, and `return_dict=False`. These are extracted from `kwargs` and prepared separately.
2. **zimage_kwargs_clean (line 171)**: Remove `control` and `transformer_options` from `zimage_kwargs` if they were accidentally included, ensuring they are only passed through the dynamic parameter checking mechanism.
3. **Dynamic parameter inspection (lines 391-393)**:
- `inspect.signature(self.model.forward)` retrieves the signature of the model's forward method
- `forward_params = set(forward_sig.parameters.keys())` creates a set of parameter names for fast lookup
- This checks the actual signature at runtime, not the static type definition
4. **Conditional parameter addition (lines 399-402)**: Only add `control` and `transformer_options` to `forward_kwargs` if they are accepted by the forward method. This is crucial because:
- The original `NunchakuZImageTransformer2DModel.forward` does not accept these parameters
- The unofficial ZIT loader monkey-patches the forward method to add these parameters
- This check ensures compatibility whether the monkey-patch is applied or not
5. **Model call structure (lines 404-409)**: The model is called with positional arguments (`x_list`, `t_zimage`, `cap_feats`) followed by `**forward_kwargs`, which includes `control` and `transformer_options` if accepted. This matches the expected signature pattern for Z-Image-Turbo models.
6. **Why inspect.signature is necessary**: Without dynamic parameter checking, passing `control` or `transformer_options` to a forward method that doesn't accept them would raise a `TypeError: forward() got an unexpected keyword argument`. Using `inspect.signature` allows the code to adapt to the actual method signature at runtime, supporting both patched and unpatched versions of the model.
## Key Design Decisions
### Dynamic Parameter Checking with inspect.signature
Using `inspect.signature` to dynamically check whether the `forward` method accepts `control` and `transformer_options`. This ensures:
1. Backward compatibility is maintained (no errors in older versions that don't accept these parameters)
2. Works appropriately regardless of whether the monkey patch from the unofficial ZIT loader is applied
3. Easy to adapt to future extensions
### Integration with Unofficial ZIT Loader
This fix enables the following integration flow:
1. ComfyUI's ModelPatcher passes `transformer_options` and `control` to `ComfyZImageTurboWrapper.forward`
2. `ComfyZImageTurboWrapper.forward` passes these to `_execute_model`
3. `_execute_model` checks with `inspect.signature` before passing to `NunchakuZImageTransformer2DModel.forward`
4. The `_patched_forward` (monkey-patched forward) from the unofficial ZIT loader receives `transformer_options` and `control` and applies the `double_block` patch
## Summary
Modified the `ComfyZImageTurboWrapper` class in ComfyUI-QwenImageLoraLoader to correctly pass `control` and `transformer_options` parameters.
Main changes:
1. Added `control` and `transformer_options` to the `forward` method signature
2. Added `control` and `transformer_options` to the `_execute_model` method signature
3. Implemented using `inspect.signature` to pass these parameters only if the underlying `forward` method accepts them
This enables the ControlNet support monkey patch added in the unofficial ZIT loader to work correctly, achieving compatibility between ControlNet and LoRA.
## Technical Flow Diagram
The complete parameter transmission flow:
```
ComfyUI ModelPatcher
↓ (passes control, transformer_options)
ComfyZImageTurboWrapper.forward()
↓ (merges transformer_options if in kwargs, removes from kwargs)
↓ (passes control, transformer_options as explicit parameters)
ComfyZImageTurboWrapper._execute_model()
↓ (extracts ref_latents from kwargs, cleans kwargs)
↓
├─→ customized_forward path (if self.customized_forward is set)
│ ↓ (cleans forward_kwargs and transformer_options)
│ ↓ (passes control, transformer_options explicitly)
│ Custom Forward Function (from unofficial ZIT loader)
│ ↓ (receives control, transformer_options)
│ NunchakuZImageTransformer2DModel.forward() (patched)
│ ↓ (applies double_block patches using transformer_options and control)
│
└─→ Direct forward path (if customized_forward is None)
↓ (prepares zimage_kwargs, uses inspect.signature)
↓ (conditionally adds control, transformer_options to forward_kwargs)
NunchakuZImageTransformer2DModel.forward() (patched or unpatched)
↓ (if patched: receives control, transformer_options, applies double_block patches)
↓ (if unpatched: ignores extra parameters, works normally)
```
## Parameter Cleanup Strategy
The fix implements a comprehensive parameter cleanup strategy to prevent duplicate argument errors:
1. **forward method level (lines 93-102)**: Merge `transformer_options` from kwargs with explicit parameter, remove conflicting parameters from kwargs
2. **_execute_model level (lines 241-247)**: Extract `ref_latents` early, remove `guidance`, `transformer_options`, and `attention_mask` from kwargs
3. **customized_forward path (lines 284-287)**: Clean `forward_kwargs` and `transformer_options` to remove duplicates before passing to customized forward
4. **Direct forward path (lines 395-402)**: Use dynamic parameter checking to only pass `control` and `transformer_options` if accepted
This multi-level cleanup ensures that parameters are passed explicitly where needed and not duplicated through `**kwargs`, preventing runtime errors and ensuring correct parameter precedence.
+61
View File
@@ -0,0 +1,61 @@
# Installation and Features
## 🎉 MAJOR UPDATE: v1.60 - Simplified Installation (No Integration Required!)
**As of v1.60, manual integration with ComfyUI-nunchaku's `__init__.py` is completely unnecessary.**
The node now operates as a fully independent custom node that works out-of-the-box. Simply clone the repository into your `custom_nodes` folder and restart ComfyUI. The nodes will automatically appear in ComfyUI's node menu through ComfyUI's built-in automatic node loading mechanism.
### What Changed in v1.60
- ✅ **Removed dependency on ComfyUI-nunchaku integration** - The LoRA loader is now a standalone plugin
- ✅ **Simplified installation** - No batch scripts or manual file editing required
- ✅ **Cleaner architecture** - Node registration happens automatically
- ✅ **Backward compatible** - All existing LoRA files and workflows continue to work
For a detailed technical explanation of why integration is no longer needed, see [v1.60 Release Notes](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v1.60)
## Features
- **NunchakuQwenImageLoraLoader**: Load and apply single LoRA to Qwen Image models
- **NunchakuQwenImageLoraStack**: Apply multiple LoRAs with dynamic UI control
- **NunchakuQwenImageDiffsynthControlnet**: Apply diffsynth ControlNet to Nunchaku Qwen Image models (v2.0)
- **Dynamic VRAM Management**: Automatic CPU offloading based on available VRAM
- **LoRA Composition**: Efficient LoRA stacking and composition
- **ComfyUI Integration**: Seamless integration with ComfyUI workflows
## Installation
### Quick Installation (v1.60 - Simplified!)
**Prerequisites:**
- ComfyUI-nunchaku must be installed
1. Clone this repository to your ComfyUI custom_nodes directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader.git
```
2. Restart ComfyUI
**That's it!** The nodes will automatically appear in ComfyUI's node menu.
### Manual Installation (Alternative)
If you prefer to install manually or are using macOS/Linux:
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader.git
```
Then restart ComfyUI.
## Requirements
- Python 3.11+
- ComfyUI (latest version recommended)
- ComfyUI-nunchaku (required)
- CUDA-capable GPU (optional, but recommended for performance)
+366
View File
@@ -0,0 +1,366 @@
# Comprehensive Technical Report: Z-Image (Nunchaku) LoRA Integration
**Version**: 2.0.8 Integration Analysis
**Date**: 2025-12-25
**Scope**: Enabling full LoRA support for CompVis/Nunchaku-based Z-Image models in ComfyUI.
---
## 1. Context & Objective
The objective was to integrate LoRA support for the **Z-Image** model running on the **Nunchaku** inference backend within `ComfyUI-QwenImageLoraLoader`. This was challenging because Nunchaku optimizations fundamentally change how the model is structured and executed compared to standard `diffusers` pipelines.
Unlike Qwen/Flux models which were already supported, Z-Image introduced two critical hurdles:
1. **Tensor Shape Mismatch**: The Nunchaku engine enforces strict 5D input requirements `(B, F, C, H, W)` that conflicted with ComfyUI's standard latent formats.
2. **MLP Layer Incompatibility**: The model uses a highly optimized `SwiGLU` activation in its Feed-Forward (MLP) blocks. Standard LoRA injection logic failed to recognize these blocks, resulting in "silent failures" where LoRA weigths were loaded but not applied to the majority of model parameters.
---
## 2. Technical Architecture Analysis
### 2.1 The Nunchaku Z-Image Block
The core processing unit `ZImageTransformerBlock` deviates from standard implementations.
* **Path**: `nunchaku.models.transformers.transformer_zimage.ZImageTransformerBlock`
* **Structure**:
* **Attention**: Standard Self-Attention (Q, K, V).
* **Feed-Forward (MLP)**:
* Uses `diffusers.models.activations.SwiGLU`.
* Historically, Diffusers models separate `w1` (Gate) and `w3` (Up) linear layers.
* Nunchaku's implementation **fuses** `w1` and `w3` into a single projection layer.
**Block Structure Details:**
1. **Attention Block**:
- Standard multi-head self-attention mechanism.
- Q, K, V projections are fused into a single `to_qkv` linear layer for efficiency.
- This fusion is standard in many optimized transformer implementations and doesn't require special LoRA handling.
2. **Feed-Forward Block Structure**:
- Path in model: `layers[i].feed_forward.net` where `net` is a `nn.Sequential`:
- `net[0]`: `SwiGLU` container module containing `proj: Linear(dim_in, dim_out * 2)`
- `net[1]`: Activation function (usually identity or SiLU)
- `net[2]`: `Linear(dim_out, dim_out * expansion_factor)` (the `w2` layer)
- The `SwiGLU` at `net[0]` replaces the original separate `w1` and `w3` layers.
- Original structure would be: `w1: Linear(dim_in, dim_out)`, `w3: Linear(dim_in, dim_out)`, `w2: Linear(dim_out, dim_out * expansion)`.
- Fused structure: `proj: Linear(dim_in, dim_out * 2)` (inside SwiGLU), `w2: Linear(dim_out, dim_out * expansion)`.
### 2.2 SwiGLU Mechanics
The `SwiGLU` class acts as a customized Container, not a raw `nn.Linear` layer.
```python
class SwiGLU(nn.Module):
def __init__(self, dim_in, dim_out):
self.proj = nn.Linear(dim_in, dim_out * 2) # Fused w1+w3
self.activation = nn.SiLU()
def forward(self, x):
x, gate = self.proj(x).chunk(2, dim=-1)
return x * self.activation(gate)
```
**Implication**: You cannot apply LoRA to `SwiGLU` directly. You must apply it to `SwiGLU.proj`. Furthermore, because `SwiGLU.proj` outputs `dim_out * 2`, the LoRA weights for `w1` and `w3` must be similarly fused before application.
**Detailed Technical Analysis:**
1. **Why Direct LoRA Application Fails**:
- Standard LoRA injection mechanisms search for `nn.Linear` or `nn.Conv2d` modules in the model's module tree.
- `SwiGLU` is a container module (`nn.Module`) that wraps a `nn.Linear` layer, but the injection logic doesn't recursively search inside container modules.
- When the injection logic encounters `SwiGLU`, it sees an unsupported module type and skips it, logging `[MISS] Module found but unsupported type: <class 'diffusers.models.activations.SwiGLU'>`.
2. **The Fused Projection Layer**:
- `SwiGLU.proj` is a `nn.Linear(dim_in, dim_out * 2)` layer that outputs twice the dimension of the intended output.
- In the forward pass, this output is split using `chunk(2, dim=-1)`, creating two tensors of shape `(..., dim_out)`.
- The first half becomes the "value" (`x`), and the second half becomes the "gate" (`gate`).
- The gate is passed through `SiLU` activation, then multiplied element-wise with the value: `x * SiLU(gate)`.
3. **LoRA Weight Structure**:
- Standard Z-Image LoRA files contain separate weights for `w1` (gate projection) and `w3` (value/up projection).
- Each has its own LoRA matrices: `w1_A`, `w1_B`, `w3_A`, `w3_B` (and optionally `w1_alpha`, `w3_alpha`).
- The base model has these fused into a single `proj` layer, so LoRA weights must be fused to match this structure.
4. **Mathematical Relationship**:
- If the original model had separate `w1: Linear(dim_in, dim_out)` and `w3: Linear(dim_in, dim_out)`, the fused version is `proj: Linear(dim_in, dim_out * 2)`.
- The fused weight matrix is: `W_fused = [W1; W3]` (vertical concatenation along the output dimension), where `W1` and `W3` are the weight matrices of `w1` and `w3` respectively.
- This means `W_fused.shape == (dim_out * 2, dim_in)`, matching the `proj` layer's weight shape.
- During forward pass: `proj_output = W_fused @ x`, which produces shape `(dim_out * 2, batch_size)`. The `chunk(2, dim=-1)` splits this into two `(dim_out, batch_size)` tensors, the first being the value and the second being the gate.
- For LoRA application, the same fusion principle applies: if separate LoRAs modify `w1` and `w3`, their weight deltas must be fused before application to the fused `proj` layer.
---
## 3. Deep Dive: Debugging & Resolution Steps
### Phase 1: The Tensor Shape Crisis (`wrappers/zimageturbo.py`)
**Symptom**: `RuntimeError` regarding batch size mismatches during generation.
**Cause**:
ComfyUI often processes latents as a 4D tensor `(Batch * Frames, C, H, W)`. Nunchaku's `forward` pass explicitly expects 5D input to handle temporal data:
```python
# Nunchaku Requirement
x.shape == (batch_size, num_frames, channels, height, width)
```
**Fix Strategy**:
1. **Batch Synchronization**: We explicitly pass `batch_size=B` to the inference call, ensuring `x` and `cap_feats` align.
2. **Dimensional Gymnastics**: The wrapper now intelligently reshapes usage.
* Input: `[B, C, H, W]` (Single frame inference contexts)
* Transform: `unsqueeze(1)` -> `[B, 1, C, H, W]`
* Inference: Nunchaku processes the 5D tensor.
* Output: `squeeze(1)` -> `[B, C, H, W]` to return to ComfyUI format.
**Implementation Details (`wrappers/zimageturbo.py`, lines 251-280):**
The `_execute_model` method handles tensor format conversion and device management:
1. **List Format Handling (lines 253-266)**:
- If `x` is already a list (from model base conversion), filters out `None` elements.
- Each tensor in the list is moved to the model's device if needed: `img.to(model_device)`.
- Validates that the filtered list is not empty, raising `ValueError` if empty.
2. **Tensor Format Handling (lines 267-275)**:
- If `x` is still a tensor, moves it to model device first.
- Checks for 5D input: `input_is_5d = x.ndim == 5`.
- If 5D, squeezes dimension 2: `x = x.squeeze(2)` to convert from `(B, F, C, H, W)` format.
3. **Context Device Management (lines 277-280)**:
- Moves `context` tensor to model device if it's a tensor (not a list).
- Ensures all tensors are on the same device before model execution.
4. **List Format Conversion for Direct Forward (lines 308-337)**:
- Z-Image-Turbo's forward method expects `x` as a list of tensors with shape `(C, F, H, W)` where F is the frame dimension.
- Conversion process:
- If `x` is already a list, processes each element individually (lines 309-322).
- Filters out `None` elements and validates `img.numel() > 0` to avoid empty tensors (line 313).
- Handles batch dimension: if input is `(1, C, H, W)` (batch size 1), squeezes dimension 0 to get `(C, H, W)` (lines 315-316).
- Ensures frame dimension: if tensor is `(C, H, W)` (3D), unsqueezes dimension 1 to get `(C, 1, H, W)` (lines 318-320).
- Appends each processed tensor to `x_list` for batch processing.
- If `x` is a tensor (not a list), splits it by batch dimension (lines 323-333).
- **Empty list validation** (lines 335-337): Raises `ValueError` if `x_list` is empty, as Z-Image-Turbo cannot process empty batches.
- **Length mismatch detection** (lines 381-386): Checks if `len(x_list) != len(cap_feats)` and warns, as `zip()` will truncate to the shorter length, potentially causing batch size mismatches.
### Phase 2: The "Weak LoRA" Mystery (MLP Injection Failure)
**Symptom**: LoRA was loaded, but generated images changed very slightly. Users reported "it feels like strength 0.1 even at 1.0".
**Investigation**:
Using detailed `[DEBUG]` logging inspection of the `apply_lora` function in `lora_qwen.py`.
#### Findings
1. **Success**: Attention layers (`to_q`, `to_k`, `to_v`) were recognized and mapped correctly to `to_qkv`.
2. **Failure**: MLP layers (`w1`, `w2`, `w3`) showed erratic behavior.
* *Attempt 1*: Regex targeted `ff.net`. result: **0 matches**.
* *Correction 1*: Variable name is `feed_forward`, not `ff`.
* *Attempt 2*: Regex targeted `feed_forward.net`. result: **Match found, Injection Failed**.
**Investigation Process Details:**
The debugging process involved inspecting the `_classify_and_map_key` function (`nunchaku_code/lora_qwen.py`, lines 170-222) and module resolution:
1. **Key Mapping Process**:
- LoRA keys from the safetensors file are parsed using regex patterns in `KEY_MAPPING` (line 27).
- The regex `r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"` matches keys like `layers.0.feed_forward.w1` or `layers_0_feed_forward_w3`.
- The template `r"\1.\2.feed_forward.net.0.proj"` maps to `layers.0.feed_forward.net.0.proj`.
- The group type `"glu"` triggers GLU fusion logic in `compose_loras_v2`.
- Before pattern matching, `_classify_and_map_key` (line 170) removes common prefixes like `"transformer."`, `"diffusion_model."`, or `"lora_unet_"` (lines 176-182).
- The function uses `_RE_LORA_SUFFIX` regex (line 120: `r"\.(?P<tag>lora(?:[._](?:A|B|down|up)))(?:\.[^.]+)*\.weight$"`) to extract the base key and identify A/B/alpha components (lines 188-195).
- If the suffix matches "lora_A" or ends with ".A" or contains "down", `ab` is set to "A". If it matches "lora_B" or ends with ".B" or contains "up", `ab` is set to "B" (lines 192-195).
2. **Module Resolution**:
- `_resolve_module_name` (line 254) attempts to find the module using `_get_module_by_name` (line 230).
- The path `layers.0.feed_forward.net.0.proj` is traversed step by step: `layers` -> `0` (index) -> `feed_forward` -> `net` -> `0` (index) -> `proj`.
- `_get_module_by_name` uses a two-step resolution strategy (lines 237-251):
- **First**: Try `hasattr(module, part)` then `getattr(module, part)`. This works for regular attributes and also for numeric keys in `nn.Sequential`/`nn.ModuleDict` (which Python exposes as attributes for keys like "0", "1", "2").
- **Second**: If `hasattr` fails and `part.isdigit()` is true, check if the module is indexable (`_is_indexable_module` checks for `nn.ModuleList`, `nn.Sequential`, `list`, `tuple`). If so, use integer indexing: `module[int(part)]`.
- The `hasattr`-first approach is necessary because `nn.Sequential` stores modules as attributes with numeric names, so `hasattr(sequential, "0")` returns `True` even though it's not a traditional attribute. `nn.ModuleList` does not expose numeric keys via `hasattr`, requiring the indexing fallback.
- Fallback mappings (lines 269-277): If direct resolution fails, `_resolve_module_name` tries alternative paths:
- `.ff.net.0.proj` -> `.mlp_fc1` (for models using `mlp_fc1` instead of `feed_forward.net.0.proj`)
- `.ff.net.2` -> `.mlp_fc2`
- This allows compatibility with different model architectures that use different naming conventions.
3. **Failure Point**:
- When the regex matched `feed_forward.net.0` (without `.proj`), the resolved module was `SwiGLU` container.
- `_apply_lora_to_module` (line 873) checks `isinstance(module, nn.Linear)`, which fails for `SwiGLU`.
- The function also checks for `proj_down`/`proj_up` attributes (Nunchaku LoRA-ready modules), which `SwiGLU` also lacks.
- Result: `ValueError: Unsupported module type <class 'diffusers.models.activations.SwiGLU'>` or similar error, logged as `[MISS]`.
#### The SwiGLU Barrier
The log showed:
```text
[MISS] Module found but unsupported type: <class 'diffusers.models.activations.SwiGLU'>
```
The standard injection logic looks for `nn.Linear`, `nn.Conv2d`, or specific `lora.Linear` layers. It saw `SwiGLU` and skipped it, having no knowledge of how to inject weights into a container.
### Phase 3: The GLU Fusion Solution (`lora_qwen.py`)
To solve this, we architected a specific path for "GLU Fusion".
#### 1. Regex Retargeting
We modified `KEY_MAPPING` to "pierce" the container.
* **Old**: `...feed_forward.net.0` (Targets the container)
* **New**: `...feed_forward.net.0.proj` (Targets the inner Linear layer)
**Regex Pattern Details (`nunchaku_code/lora_qwen.py`, line 33):**
```python
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"),
r"\1.\2.feed_forward.net.0.proj", "glu", lambda m: m.group(3))
```
- **Pattern**: `r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"`
- `^(layers)`: Matches "layers" at start
- `[._]`: Matches either dot or underscore separator
- `(\d+)`: Captures layer number (e.g., "0", "1")
- `[._]feed_forward[._]`: Matches "feed_forward" with any separator
- `(w1|w3)`: Captures either "w1" or "w3"
- **Template**: `r"\1.\2.feed_forward.net.0.proj"`
- `\1`: Layer group name ("layers")
- `\2`: Layer number
- Adds `.feed_forward.net.0.proj` to target the inner Linear layer
- **Group Type**: `"glu"` indicates GLU fusion is needed
- **Component Function**: `lambda m: m.group(3)` extracts "w1" or "w3" for fusion logic
#### 2. Weight Fusion Logic (`_fuse_glu_lora`)
The Z-Image LoRA files (standard format) provide `w1` (Gate) and `w3` (Up) as separate weight matrices. The Model has them fused. We must replicate this fusion in the LoRA weights *before* injection.
```python
# Concept
w_fused_A = cat([w1_A, w3_A], dim=0) # Stack ranks? No, usually features.
w_fused_B = cat([w1_B, w3_B], dim=1) # Output dimension doubles
```
*Note: The actual implementation in `_fuse_glu_lora` ensures correct dimensional concatenation to match `dim_out * 2`.*
**Implementation Details (`nunchaku_code/lora_qwen.py`, lines 640-695):**
The `_fuse_glu_lora` function (line 640) performs the actual fusion:
1. **Input Validation (lines 653-667)**:
- Requires both `w1_A` and `w3_A` in `glu_weights`, returns `(None, None, None)` if missing.
- Validates `A_w1.shape[0] == A_w3.shape[0]` (input features must match), logs warning on mismatch.
2. **A Matrix Fusion (line 674)**:
- Concatenates along dimension 0: `A_fused = torch.cat([A_w1, A_w3], dim=0)`.
- Result shape: `(r1 + r3, in_features)` where `r1` and `r3` are the ranks of `w1` and `w3` respectively.
3. **B Matrix Fusion (lines 682-684)**:
- Creates block-diagonal matrix: `B_fused = torch.zeros(out1 + out3, r1 + r3, ...)`.
- Assigns `B_fused[:out1, :r1] = B_w1` and `B_fused[out1:, r1:] = B_w3`.
- Result shape: `(out1 + out3, r1 + r3)` matching the fused `proj` layer's output dimension.
4. **Integration in compose_loras_v2 (line 1078-1079)**:
- GLU fusion is detected by checking `"w1_A" in lw or "w3_A" in lw` in the grouped LoRA weights.
- The fused weights are then applied via `_apply_lora_to_module` (line 1152) to the `feed_forward.net.0.proj` module (a standard `nn.Linear` layer).
5. **Weight Aggregation and Scaling (lines 1107-1152)**:
- Multiple LoRAs can target the same module. Weights are aggregated in `aggregated_weights[module_key].append({...})` during the first loop (line 1108).
- In the second loop (lines 1114-1152), for each module, all LoRA parts from different LoRAs are combined:
- **Scaling calculation** (line 1129): `scale = strength * (scale_alpha / max(1.0, float(r_lora)))` where `scale_alpha = alpha.item() if alpha is not None else float(r_lora)`.
- If `alpha` is `None`, it defaults to the rank (`r_lora`), making the scale simply `strength`. If `alpha` is provided, it acts as a normalization factor: higher alpha increases the effect per rank unit.
- **Special handling for AdaNorm layers** (lines 1131-1134): If the module name contains `.norm1.linear` or `.norm1_context.linear`, the B matrix is reordered using `reorder_adanorm_lora_up(B, splits=6)` to match AdaNorm's split structure. Similar handling for single transformer blocks with `.norm.linear` (splits=3).
- **Device and dtype synchronization** (lines 1136-1141): Determines target dtype and device from the module's weight (or `proj_down` for Nunchaku modules), ensuring all tensors match before concatenation.
- `all_A.append(A.to(dtype=target_dtype, device=target_device))` (line 1143) - A matrices are moved to target device/dtype without scaling.
- `all_B_scaled.append((B * scale).to(...))` (line 1144) - B matrices are scaled before concatenation. Scaling is applied here rather than after concatenation to allow per-LoRA strength control.
- **Final concatenation** (lines 1149-1150): `final_A = torch.cat(all_A, dim=0)` concatenates A matrices along the rank dimension, increasing total rank. `final_B = torch.cat(all_B_scaled, dim=1)` concatenates scaled B matrices along the rank dimension, matching the concatenated A shape.
- The concatenated weights are applied to the module via `_apply_lora_to_module` (line 1152).
6. **Module Application (`_apply_lora_to_module`, lines 873-934)**:
- For standard `nn.Linear` modules (which includes `feed_forward.net.0.proj`):
- Validates tensor dimensions: `A.ndim == 2` and `B.ndim == 2`, `A.shape[1] == module.in_features`, `B.shape[0] == module.out_features` (lines 876-882).
- Backs up original weight to CPU in `_lora_slots` dictionary: `module.weight.detach().cpu().clone()` (line 920). This backup enables LoRA reset functionality.
- Calculates weight delta: `delta = B @ A` where `B.shape == (out_features, rank)` and `A.shape == (rank, in_features)` (line 925). The matrix multiplication produces `delta.shape == (out_features, in_features)`, matching the module weight shape.
- Applies delta in-place: `module.weight.data.add_(delta.to(dtype=module.weight.dtype, device=module.weight.device))` (line 930). The `add_` operation modifies weights directly, avoiding temporary tensor allocation.
- For Nunchaku LoRA-ready modules (with `proj_down`/`proj_up` attributes):
- Unpacks existing low-rank weights using `unpack_lowrank_weight` (lines 887-888).
- Determines base rank and concatenation axis based on weight layout (lines 890-897).
- Concatenates new LoRA A matrices along the rank dimension and B matrices along the output dimension (lines 893, 899).
- Repacks using `pack_lowrank_weight` and updates `module.rank` (lines 901-903).
- Tracks appended rank in `_lora_slots` for reset support (line 908).
#### 3. Output Projection (`w2`)
The `w2` layer (Down projection) is a standard linear layer appearing at `feed_forward.net.2`. It requires no special fusion, just correct mapping.
**Mapping Details (`nunchaku_code/lora_qwen.py`, line 35):**
The `w2` layer uses standard regex mapping without fusion:
```python
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"),
r"\1.\2.feed_forward.net.2", "regular", None)
```
- Maps `layers.X.feed_forward.w2` directly to `layers.X.feed_forward.net.2`
- Group type `"regular"` means standard LoRA application (no fusion)
- Component function is `None` (no component extraction needed)
- The resolved module is a standard `nn.Linear`, handled by `_apply_lora_to_module` (lines 911-930)
---
## 4. Final Configuration
### Supported Layer Mappings
| LoRA Key (Source) | Model Module (Target) | Logic |
| :--- | :--- | :--- |
| `layers.*.attention.to_q/k/v` | `attention.to_qkv` | **QKV Fusion**: Standard concatenation. |
| `layers.*.feed_forward.w1/w3` | `feed_forward.net.0.proj` | **GLU Fusion (NEW)**: Fuses Gate+Up, injects into SwiGLU inner linear. |
| `layers.*.feed_forward.w2` | `feed_forward.net.2` | **Direct**: Standard linear injection. |
---
## 5. Verification Results
**Test Condition**: Z-Image Model + `MistyPokemon_ZIT.safetensors` (Strength 1.0).
**Logs (v2.0.8)**:
```text
[APPLY] LoRA applied to: layers.0.attention.to_qkv (Success)
[APPLY] LoRA applied to: layers.0.feed_forward.net.0.proj (Success - GLU Fused)
[APPLY] LoRA applied to: layers.0.feed_forward.net.2 (Success - Linear)
...
Applied LoRA compositions to 150 modules.
```
**Outcome**:
Changes in generated images are now distinct and accurate to the requested LoRA style, confirming that the MLP layers (which contain ~60% of model parameters) are finally participating in the inference.
**Verification Process:**
1. **Log Analysis**:
- The `[APPLY]` log messages confirm successful application to `feed_forward.net.0.proj`.
- The total count "Applied LoRA compositions to 150 modules" includes all transformer layers (attention + MLP).
- Each layer has attention (`to_qkv`) and MLP (`net.0.proj`, `net.2`) modules, so 150 modules ≈ 50 layers × 3 modules per layer.
2. **Before Fix Behavior**:
- Only attention layers were modified, representing ~40% of parameters.
- MLP layers (60% of parameters) were unchanged, causing weak LoRA effects.
- Users needed to set strength > 1.0 to see noticeable changes, and even then results were inaccurate.
3. **After Fix Behavior**:
- Both attention and MLP layers are modified.
- Strength 1.0 produces accurate LoRA style transfer.
- Image generation shows distinct style changes matching the LoRA training data.
4. **Performance Impact**:
- LoRA fusion adds minimal computational overhead during composition (one-time operation).
- Block-diagonal B matrix structure maintains efficient matrix multiplication: `B @ A` computation remains O(rank × features).
- Memory overhead: fused LoRA weights have shape `(r1 + r3, in_features)` for A and `(out1 + out3, r1 + r3)` for B, compared to separate `(r1, in)`, `(r3, in)`, `(out1, r1)`, `(out3, r3)`.
- The memory increase is negligible compared to model weights (LoRA rank is typically 4-128, while model dimensions are 1024-4096).
---
## 6. How to Maintain
* **New Architectures**: If Nunchaku adds new model types, check if they use `SwiGLU`. If so, ensure the regex targets `.proj`.
* **Updates to Diffusers**: If `diffusers` changes the `SwiGLU` internal name (unlikely, but possible) from `.proj` to `.linear`, `KEY_MAPPING` must be updated.
**Maintenance Guidelines:**
1. **Adding Support for New Model Types**:
- Inspect the model architecture using `model.named_modules()` to find the path to SwiGLU modules.
- Verify the path format (e.g., `layers.X.feed_forward.net.0` contains a `SwiGLU` with `.proj` attribute).
- Add regex pattern to `KEY_MAPPING` if the path format differs from existing patterns.
- Test with a known LoRA file to verify successful application (check logs for `[APPLY]` messages).
2. **Debugging Failed LoRA Applications**:
- Enable debug logging: `logger.setLevel(logging.DEBUG)`.
- Check `[MISS]` log messages to see which modules were not found or unsupported.
- Use `_resolve_module_name` manually to verify module path resolution.
- Inspect `_classify_and_map_key` output to verify regex pattern matching.
3. **Key Mapping Updates**:
- If model architecture changes, update regex patterns in `KEY_MAPPING` (line 27).
- Ensure template strings correctly map LoRA keys to model module paths.
- Component functions (lambda) should extract necessary information for fusion logic.
- Group types (`"glu"`, `"qkv"`, `"regular"`) determine which fusion function to call.
4. **Fusion Logic Maintenance**:
- `_fuse_glu_lora` assumes `w1` (gate) and `w3` (up) weights are present. If model uses different naming, update the detection logic (line 1078).
- Block-diagonal B matrix structure assumes `out1 == out3`. If this changes, fusion logic needs adjustment.
- Alpha handling assumes same alpha for w1/w3. If models require different alphas, fusion logic must be updated.
+266
View File
@@ -0,0 +1,266 @@
"""
This module provides the :class:`NunchakuQwenImageLoraStackV3` node
for applying LoRA weights to Nunchaku Qwen Image models within ComfyUI.
"""
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__)
class NunchakuQwenImageLoraStackV2:
"""
Node for loading and applying multiple LoRAs to a Nunchaku Qwen Image model with dynamic UI.
V3 adds per-slot enable toggles and an all-toggle for ComfyUI Nodes 2.0 UI.
"""
@classmethod
def IS_CHANGED(cls, model, lora_count, cpu_offload="disable", toggle_all=True, **kwargs):
"""
Detect changes to trigger node re-execution.
Returns a hash of relevant parameters to detect changes.
"""
import hashlib
m = hashlib.sha256()
m.update(str(model).encode())
m.update(str(lora_count).encode())
m.update(cpu_offload.encode())
m.update(str(toggle_all).encode())
# Hash all LoRA parameters
for i in range(1, 11):
m.update(kwargs.get(f"lora_name_{i}", "").encode())
m.update(str(kwargs.get(f"lora_strength_{i}", 0)).encode())
m.update(str(kwargs.get(f"enabled_{i}", True)).encode())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
@classmethod
def INPUT_TYPES(s):
loras = ["None"] + folder_paths.get_filename_list("loras")
inputs = {
"required": {
"model": (
"MODEL",
{"tooltip": "The diffusion model to apply LoRAs to."},
),
"lora_count": (
"INT",
{
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of LoRA slots to process.",
},
),
"cpu_offload": (
["auto", "enable", "disable"],
{
"default": "disable",
"tooltip": "CPU offload setting. 'auto' enables offload when VRAM is low, 'enable' forces offload, 'disable' disables offload.",
},
),
"toggle_all": (
"BOOLEAN",
{
"default": True,
"tooltip": "Enable/disable all LoRAs at once.",
},
),
},
"optional": {},
}
# Add all LoRA inputs (up to 10 slots) as optional
for i in range(1, 11):
inputs["optional"][f"enabled_{i}"] = (
"BOOLEAN",
{
"default": True,
"tooltip": f"Enable/disable LoRA {i}.",
},
)
inputs["optional"][f"lora_name_{i}"] = (
loras,
{"tooltip": f"The file name of LoRA {i}. Select 'None' to skip this slot."},
)
inputs["optional"][f"lora_strength_{i}"] = (
"FLOAT",
{
"default": 1.0,
"min": -100.0,
"max": 100.0,
"step": 0.01,
"tooltip": f"Strength for LoRA {i}.",
},
)
return inputs
RETURN_TYPES = ("MODEL",)
OUTPUT_TOOLTIPS = ("The modified diffusion model with all LoRAs applied.",)
FUNCTION = "load_lora_stack"
TITLE = "Nunchaku Qwen Image LoRA Stack V3"
CATEGORY = "Nunchaku"
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control."
def load_lora_stack(self, model, lora_count, cpu_offload="disable", toggle_all=True, **kwargs):
loras_to_apply = []
# Log toggle_all state
logger.info(f"[LoRA Stack Status] toggle_all: {toggle_all}")
logger.info(f"[LoRA Stack Status] Processing {lora_count} LoRA slot(s):")
# Process only the number of LoRAs specified by lora_count
for i in range(1, lora_count + 1):
lora_name = kwargs.get(f"lora_name_{i}")
lora_strength = kwargs.get(f"lora_strength_{i}", 1.0)
enabled_individual = kwargs.get(f"enabled_{i}", True)
enabled = toggle_all and enabled_individual
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"toggle_all={toggle_all}")
status_parts.append(f"enabled_{i}={enabled_individual}")
status_parts.append(f"final_enabled={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,)
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
# Debug logging
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"🔍 Model wrapper module: {model_wrapper_module}")
logger.info(f"🔍 Type repr: {repr(type(model_wrapper))}")
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)")
logger.info(f"📦 Current CPU offload setting: '{model_wrapper.cpu_offload_setting}'")
if model_wrapper.cpu_offload_setting != cpu_offload:
logger.info(f"🔄 Updating CPU offload setting from '{model_wrapper.cpu_offload_setting}' to '{cpu_offload}'")
model_wrapper.cpu_offload_setting = cpu_offload
transformer = model_wrapper.model
elif model_wrapper_type_name == "NunchakuQwenImageTransformer2DModel" or model_wrapper_type_name.endswith(
"NunchakuQwenImageTransformer2DModel"
):
logger.info("🔧 Wrapping NunchakuQwenImageTransformer2DModel with ComfyQwenImageWrapper")
logger.info(f"📦 Creating ComfyQwenImageWrapper with cpu_offload='{cpu_offload}'")
wrapped_model = ComfyQwenImageWrapper(
model_wrapper,
getattr(model_wrapper, "config", {}),
None, # customized_forward
{}, # forward_kwargs
cpu_offload, # cpu_offload_setting
4.0, # vram_margin_gb
)
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}.")
# 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,)
GENERATED_NODES = {
"NunchakuQwenImageLoraStackV3": NunchakuQwenImageLoraStackV2,
}
GENERATED_DISPLAY_NAMES = {
"NunchakuQwenImageLoraStackV3": "Nunchaku Qwen Image LoRA Stack V3",
}
+275
View File
@@ -0,0 +1,275 @@
"""
This module provides the :class:`NunchakuZImageTurboLoraStackV2` node
for applying LoRA weights to Nunchaku Z-Image-Turbo models within ComfyUI.
"""
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] zimageturbo.py exists: {os.path.exists(os.path.join(lora_loader_dir, 'wrappers', 'zimageturbo.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__)
class NunchakuZImageTurboLoraStackV2:
"""
Node for loading and applying multiple LoRAs to a Nunchaku Z-Image-Turbo model with dynamic UI.
V3 is for official Nunchaku Z-Image loader only. For unofficial loader, use V2.
"""
@classmethod
def IS_CHANGED(cls, model, lora_count, cpu_offload="disable", toggle_all=True, **kwargs):
"""
Detect changes to trigger node re-execution.
Returns a hash of relevant parameters to detect changes.
"""
import hashlib
m = hashlib.sha256()
m.update(str(model).encode())
m.update(str(lora_count).encode())
m.update(cpu_offload.encode())
m.update(str(toggle_all).encode())
# Hash all LoRA parameters
for i in range(1, 11):
m.update(kwargs.get(f"lora_name_{i}", "").encode())
m.update(str(kwargs.get(f"lora_strength_{i}", 0)).encode())
m.update(str(kwargs.get(f"enabled_{i}", True)).encode())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
@classmethod
def INPUT_TYPES(s):
loras = ["None"] + folder_paths.get_filename_list("loras")
inputs = {
"required": {
"model": (
"MODEL",
{"tooltip": "The diffusion model to apply LoRAs to."},
),
"lora_count": (
"INT",
{
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of LoRA slots to process.",
},
),
"cpu_offload": (
["auto", "enable", "disable"],
{
"default": "disable",
"tooltip": "CPU offload setting. 'auto' enables offload when VRAM is low, 'enable' forces offload, 'disable' disables offload.",
},
),
"toggle_all": (
"BOOLEAN",
{
"default": True,
"tooltip": "Enable/disable all LoRAs at once.",
},
),
},
"optional": {},
}
# Add all LoRA inputs (up to 10 slots) as optional
for i in range(1, 11):
inputs["optional"][f"enabled_{i}"] = (
"BOOLEAN",
{
"default": True,
"tooltip": f"Enable/disable LoRA {i}.",
},
)
inputs["optional"][f"lora_name_{i}"] = (
loras,
{"tooltip": f"The file name of LoRA {i}. Select 'None' to skip this slot."},
)
inputs["optional"][f"lora_strength_{i}"] = (
"FLOAT",
{
"default": 1.0,
"min": -100.0,
"max": 100.0,
"step": 0.01,
"tooltip": f"Strength for LoRA {i}.",
},
)
return inputs
RETURN_TYPES = ("MODEL",)
OUTPUT_TOOLTIPS = ("The modified diffusion model with all LoRAs applied.",)
FUNCTION = "load_lora_stack"
TITLE = "Nunchaku Z-Image-Turbo LoRA Stack V3"
CATEGORY = "Nunchaku"
DESCRIPTION = "Apply multiple LoRAs to a diffusion model in a single node with dynamic UI control. V3 is for official Nunchaku Z-Image loader only. For unofficial loader, use V2."
def load_lora_stack(self, model, lora_count, cpu_offload="disable", toggle_all=True, **kwargs):
loras_to_apply = []
# Log toggle_all state
logger.info(f"[LoRA Stack Status] toggle_all: {toggle_all}")
logger.info(f"[LoRA Stack Status] Processing {lora_count} LoRA slot(s):")
# Process only the number of LoRAs specified by lora_count
for i in range(1, lora_count + 1):
lora_name = kwargs.get(f"lora_name_{i}")
lora_strength = kwargs.get(f"lora_strength_{i}", 1.0)
enabled_individual = kwargs.get(f"enabled_{i}", True)
# Check if this LoRA is enabled (considering both toggle_all and individual enabled_<i>)
enabled = toggle_all and enabled_individual
# Log each LoRA slot status
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"toggle_all={toggle_all}")
status_parts.append(f"enabled_{i}={enabled_individual}")
status_parts.append(f"final_enabled={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)}")
# Log summary
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,)
model_wrapper = model.model.diffusion_model
# Dynamic import with explicit path manipulation
import sys
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.zimageturbo",
os.path.join(lora_loader_dir, "wrappers", "zimageturbo.py")
)
wrappers_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wrappers_module)
ComfyZImageTurboWrapper = wrappers_module.ComfyZImageTurboWrapper
from nunchaku import NunchakuZImageTransformer2DModel
# Debug logging
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"🔍 Model wrapper module: {model_wrapper_module}")
logger.info(f"🔍 Type repr: {repr(type(model_wrapper))}")
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
# ComfyZImageTurboWrapper has a 'model' attribute that contains the transformer
# and a 'loras' list attribute
if hasattr(model_wrapper, 'model') and hasattr(model_wrapper, 'loras'):
# Already wrapped, proceed normally
logger.info("✅ Model is already wrapped (detected via attributes)")
logger.info(f"📦 Current CPU offload setting: '{model_wrapper.cpu_offload_setting}'")
# Update CPU offload setting if different
if model_wrapper.cpu_offload_setting != cpu_offload:
logger.info(f"🔄 Updating CPU offload setting from '{model_wrapper.cpu_offload_setting}' to '{cpu_offload}'")
model_wrapper.cpu_offload_setting = cpu_offload
transformer = model_wrapper.model
elif model_wrapper_type_name == "NextDiT" and model_wrapper_module == "comfy.ldm.lumina.model":
# Official Nunchaku loader uses ComfyUI Lumina2 / NextDiT signature.
# Our ComfyZImageTurboWrapper now supports both NextDiT and Z-Image signatures.
logger.info("🔧 Official loader detected (NextDiT), wrapping with ComfyZImageTurboWrapper")
transformer = model_wrapper
logger.info(f"📦 Creating ComfyZImageTurboWrapper for NextDiT with cpu_offload='{cpu_offload}'")
wrapped_model = ComfyZImageTurboWrapper(
transformer,
getattr(transformer, 'config', {}),
None, # customized_forward
{}, # forward_kwargs
cpu_offload, # cpu_offload_setting
4.0, # vram_margin_gb
)
# Replace the model's diffusion_model with our wrapper
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("V3 is for official Nunchaku Z-Image DiT Loader only. For unofficial loader, please use V2.")
raise TypeError(f"This LoRA loader (V3) only works with official Nunchaku Z-Image loader, but got {model_wrapper_type_name}.")
# Flux-style deepcopy
# Save config before deepcopy to avoid __setstate__ errors
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:
# Restore config and model
if saved_config is not None:
model.model.model_config = saved_config
model_wrapper.model = transformer
ret_model_wrapper = ret_model.model.diffusion_model
# Restore config in copied model if it was saved
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,)
GENERATED_NODES = {
"NunchakuZImageTurboLoraStackV3": NunchakuZImageTurboLoraStackV2
}
GENERATED_DISPLAY_NAMES = {
"NunchakuZImageTurboLoraStackV3": "Nunchaku Z-Image-Turbo LoRA Stack V3"
}