Add WanVideo Lora Select node and related functionality. Fixes #266

- Implemented the WanVideo Lora Select node in Python with input handling for low memory loading and LORA syntax processing.
- Updated the JavaScript side to register the new node and manage its widget interactions.
- Enhanced constants files to include the new node type and its corresponding ID.
- Modified existing Lora Loader and Stacker references to accommodate the new node in various workflows and UI components.
- Added example workflow JSON for the new node to demonstrate its usage.
This commit is contained in:
Will Miao
2025-06-30 15:10:34 +08:00
parent 71762d788f
commit fc4327087b
11 changed files with 245 additions and 11 deletions

View File

@@ -4,6 +4,7 @@ from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage
from .py.nodes.debug_metadata import DebugMetadata
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
# Import metadata collector to install hooks on startup
from .py.metadata_collector import init as init_metadata_collector
@@ -12,7 +13,8 @@ NODE_CLASS_MAPPINGS = {
TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker,
SaveImage.NAME: SaveImage,
DebugMetadata.NAME: DebugMetadata
DebugMetadata.NAME: DebugMetadata,
WanVideoLoraSelect.NAME: WanVideoLoraSelect
}
WEB_DIRECTORY = "./web/comfyui"

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,4 @@
from comfy.comfy_types import IO # type: ignore
from ..services.lora_scanner import LoraScanner
from ..config import config
import asyncio
import os
from .utils import FlexibleOptionalInputType, any_type, get_lora_info, extract_lora_name, get_loras_list

View File

@@ -0,0 +1,92 @@
from comfy.comfy_types import IO # type: ignore
import asyncio
import folder_paths # type: ignore
from .utils import FlexibleOptionalInputType, any_type, get_lora_info, extract_lora_name, get_loras_list
import logging
logger = logging.getLogger(__name__)
class WanVideoLoraSelect:
NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load the LORA model with less VRAM usage, slower loading"}),
"text": (IO.STRING, {
"multiline": True,
"dynamicPrompts": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>"
}),
},
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras"
def process_loras(self, text, low_mem_load=False, **kwargs):
loras_list = []
all_trigger_words = []
active_loras = []
# Process existing prev_lora if available
prev_lora = kwargs.get('prev_lora', None)
if prev_lora is not None:
loras_list.extend(prev_lora)
# Get blocks if available
blocks = kwargs.get('blocks', {})
selected_blocks = blocks.get("selected_blocks", {})
layer_filter = blocks.get("layer_filter", "")
# Process loras from kwargs with support for both old and new formats
loras_from_widget = get_loras_list(kwargs)
for lora in loras_from_widget:
if not lora.get('active', False):
continue
lora_name = lora['name']
model_strength = float(lora['strength'])
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(get_lora_info(lora_name))
# Create lora item for WanVideo format
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"strength": model_strength,
"name": lora_path.split(".")[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,
}
# Add to list and collect active loras
loras_list.append(lora_item)
active_loras.append((lora_name, model_strength, clip_strength))
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# Format trigger_words for output
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format active_loras for output
formatted_loras = []
for name, model_strength, clip_strength in active_loras:
if abs(model_strength - clip_strength) > 0.001:
# Different model and clip strengths
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}:{str(clip_strength).strip()}>")
else:
# Same strength for both
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}>")
active_loras_text = " ".join(formatted_loras)
return (loras_list, trigger_words_text, active_loras_text)

View File

@@ -10,7 +10,8 @@ NSFW_LEVELS = {
# Node type constants
NODE_TYPES = {
"Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2
"Lora Stacker (LoraManager)": 2,
"WanVideo Lora Select (LoraManager)": 3
}
# Default ComfyUI node color when bgcolor is null

View File

@@ -108,19 +108,22 @@ export const NSFW_LEVELS = {
// Node type constants
export const NODE_TYPES = {
LORA_LOADER: 1,
LORA_STACKER: 2
LORA_STACKER: 2,
WAN_VIDEO_LORA_SELECT: 3
};
// Node type names to IDs mapping
export const NODE_TYPE_NAMES = {
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT
};
// Node type icons
export const NODE_TYPE_ICONS = {
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
[NODE_TYPES.LORA_STACKER]: "fas fa-s"
[NODE_TYPES.LORA_STACKER]: "fas fa-s",
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w"
};
// Default ComfyUI node color when bgcolor is null

View File

@@ -337,7 +337,7 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
// Success case - check node count
if (registryData.data.node_count === 0) {
// No nodes found - show warning
showToast('No Lora Loader or Lora Stacker nodes found in workflow', 'warning');
showToast('No supported target nodes found in workflow', 'warning');
return false;
} else if (registryData.data.node_count > 1) {
// Multiple nodes - show selector

View File

@@ -76,7 +76,9 @@ app.registerExtension({
// Standard mode - update a specific node
const node = app.graph.getNodeById(+id);
if (!node || (node.comfyClass !== "Lora Loader (LoraManager)" && node.comfyClass !== "Lora Stacker (LoraManager)")) {
if (!node || (node.comfyClass !== "Lora Loader (LoraManager)" &&
node.comfyClass !== "Lora Stacker (LoraManager)" &&
node.comfyClass !== "WanVideo Lora Select (LoraManager)")) {
console.warn("Node not found or not a LoraLoader:", id);
return;
}
@@ -87,7 +89,7 @@ app.registerExtension({
// Helper method to update a single node's lora code
updateNodeLoraCode(node, loraCode, mode) {
// Update the input widget with new lora code
const inputWidget = node.widgets[0];
const inputWidget = node.inputWidget;
if (!inputWidget) return;
// Get the current lora code
@@ -182,6 +184,7 @@ app.registerExtension({
// Update input widget callback
const inputWidget = this.widgets[0];
this.inputWidget = inputWidget;
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;

View File

@@ -105,6 +105,7 @@ app.registerExtension({
// Update input widget callback
const inputWidget = this.widgets[0];
this.inputWidget = inputWidget;
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;

View File

@@ -52,7 +52,9 @@ app.registerExtension({
// Find all Lora nodes
const loraNodes = [];
for (const node of workflow.nodes.values()) {
if (node.type === "Lora Loader (LoraManager)" || node.type === "Lora Stacker (LoraManager)") {
if (node.type === "Lora Loader (LoraManager)" ||
node.type === "Lora Stacker (LoraManager)" ||
node.type === "WanVideo Lora Select (LoraManager)") {
loraNodes.push({
node_id: node.id,
bgcolor: node.bgcolor || null,

View File

@@ -0,0 +1,131 @@
import { app } from "../../scripts/app.js";
import {
LORA_PATTERN,
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Reset pattern index before using
LORA_PATTERN.lastIndex = 0;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const modelStrength = Number(match[2]);
// Extract clip strength if provided, otherwise use model strength
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : modelStrength,
active: existingLora ? existingLora.active : true,
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
});
}
return result;
}
app.registerExtension({
name: "LoraManager.WanVideoLoraSelect",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "WanVideo Lora Select (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", async function() {
// Enable widget serialization
this.serialize_widgets = true;
// Add optional inputs
this.addInput("prev_lora", 'WANVIDLORA', {
"shape": 7 // 7 is the shape of the optional input
});
this.addInput("blocks", 'SELECTEDBLOCKS', {
"shape": 7 // 7 is the shape of the optional input
});
// Restore saved value if exists
let existingLoras = [];
if (this.widgets_values && this.widgets_values.length > 0) {
// 0 for low_mem_load, 1 for text widget, 2 for loras widget
const savedValue = this.widgets_values[2];
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(this.widgets[1].value, existingLoras);
// Add flag to prevent callback loops
let isUpdating = false;
const result = addLorasWidget(this, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = this.widgets[1];
const currentLoras = value.map(l => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength) => {
return currentLoras.includes(name) ? match : '';
});
// Clean up multiple spaces and trim
newText = newText.replace(/\s+/g, ' ').trim();
inputWidget.value = newText;
// Update this node's direct trigger toggles with its own active loras
const activeLoraNames = new Set();
value.forEach(lora => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
});
this.lorasWidget = result.widget;
// Update input widget callback
const inputWidget = this.widgets[1];
this.inputWidget = inputWidget;
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
// Update this node's direct trigger toggles with its own active loras
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
};
});
}
},
});