mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
feat(nodes): add Create Hook LoRA (LoraManager) node for multi-LoRA hook pipelines
This commit is contained in:
@@ -17,6 +17,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||||
from .py.nodes.lora_info import LoraInfoLM
|
from .py.nodes.lora_info import LoraInfoLM
|
||||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||||
|
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -62,6 +63,9 @@ except (
|
|||||||
LoraSyntaxToPath = importlib.import_module(
|
LoraSyntaxToPath = importlib.import_module(
|
||||||
"py.nodes.lora_syntax_to_path"
|
"py.nodes.lora_syntax_to_path"
|
||||||
).LoraSyntaxToPath
|
).LoraSyntaxToPath
|
||||||
|
CreateHookLoraLM = importlib.import_module(
|
||||||
|
"py.nodes.create_hook_lora"
|
||||||
|
).CreateHookLoraLM
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -83,6 +87,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||||
LoraInfoLM.NAME: LoraInfoLM,
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||||
|
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
116
py/nodes/create_hook_lora.py
Normal file
116
py/nodes/create_hook_lora.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Create Hook LoRA (LoraManager) — multi-LoRA hook node compatible with ComfyUI's built-in hook pipeline.
|
||||||
|
|
||||||
|
Produces ``("HOOKS",)`` output that chains seamlessly with downstream hook consumers
|
||||||
|
(ConditioningSetProperties, SetHookKeyframes, CombineHooks, SetClipHooks, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import comfy.hooks # type: ignore
|
||||||
|
import comfy.utils # type: ignore
|
||||||
|
|
||||||
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from .utils import (
|
||||||
|
FlexibleOptionalInputType,
|
||||||
|
any_type,
|
||||||
|
apply_lora_syntax_format,
|
||||||
|
get_loras_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHookLoraLM:
|
||||||
|
NAME = "Create Hook LoRA (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/hooks"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"text": (
|
||||||
|
"AUTOCOMPLETE_TEXT_LORAS",
|
||||||
|
{
|
||||||
|
"placeholder": "Search LoRAs to add...",
|
||||||
|
"tooltip": (
|
||||||
|
"Search and select LoRAs. Each LoRA gets its own "
|
||||||
|
"model/clip strength. Hooks chain with prev_hooks."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||||
|
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||||
|
FUNCTION = "create_hook"
|
||||||
|
|
||||||
|
def create_hook(self, text: str, **kwargs):
|
||||||
|
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
|
||||||
|
|
||||||
|
Each active LoRA from the widget is loaded and wrapped in a WeightHook
|
||||||
|
via :func:`comfy.hooks.create_hook_lora`. All hooks are combined into a
|
||||||
|
single group and returned alongside trigger words and a human-readable
|
||||||
|
summary of the active LoRAs.
|
||||||
|
"""
|
||||||
|
del text # used by the frontend widget only
|
||||||
|
|
||||||
|
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
|
||||||
|
|
||||||
|
hook_group = prev_hooks.clone() if prev_hooks is not None else comfy.hooks.HookGroup()
|
||||||
|
|
||||||
|
all_trigger_words: list[str] = []
|
||||||
|
active_loras: list[tuple[str, float, float]] = []
|
||||||
|
|
||||||
|
for lora in get_loras_list(kwargs):
|
||||||
|
if not lora.get("active", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_name = apply_lora_syntax_format(lora["name"])
|
||||||
|
model_strength = float(lora["strength"])
|
||||||
|
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||||
|
|
||||||
|
# Skip useless no-op entries (both strengths are zero)
|
||||||
|
if model_strength == 0.0 and clip_strength == 0.0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
if not lora_path or not os.path.isfile(lora_path):
|
||||||
|
logger.warning("LoRA '%s' not found — skipping", lora_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||||
|
|
||||||
|
lora_hooks = comfy.hooks.create_hook_lora(
|
||||||
|
lora=lora_weights,
|
||||||
|
strength_model=model_strength,
|
||||||
|
strength_clip=clip_strength,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load LoRA '%s' — skipping", lora_name)
|
||||||
|
continue
|
||||||
|
hook_group = hook_group.clone_and_combine(lora_hooks)
|
||||||
|
|
||||||
|
active_loras.append((lora_name, model_strength, clip_strength))
|
||||||
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
|
# Format trigger words (group mode separator)
|
||||||
|
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
|
||||||
|
# Format active LoRAs summary
|
||||||
|
formatted_loras = []
|
||||||
|
for name, model_s, clip_s in active_loras:
|
||||||
|
if abs(model_s - clip_s) > 0.001:
|
||||||
|
formatted_loras.append(
|
||||||
|
f"<lora:{name}:{model_s}:{clip_s}>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
formatted_loras.append(f"<lora:{name}:{model_s}>")
|
||||||
|
active_loras_text = " ".join(formatted_loras)
|
||||||
|
|
||||||
|
return (hook_group, trigger_words_text, active_loras_text)
|
||||||
143
web/comfyui/create_hook_lora.js
Normal file
143
web/comfyui/create_hook_lora.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import {
|
||||||
|
getActiveLorasFromNode,
|
||||||
|
updateConnectedTriggerWords,
|
||||||
|
chainCallback,
|
||||||
|
mergeLoras,
|
||||||
|
getWidgetByName,
|
||||||
|
getWidgetSerializedValue,
|
||||||
|
} from "./utils.js";
|
||||||
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
|
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
||||||
|
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.CreateHookLora",
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
|
if (nodeType.comfyClass === "Create Hook LoRA (LoraManager)") {
|
||||||
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||||
|
// Enable widget serialization so loras widget state is persisted
|
||||||
|
this.serialize_widgets = true;
|
||||||
|
|
||||||
|
this.addInput("prev_hooks", "HOOKS", {
|
||||||
|
shape: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flags to prevent callback loops between text widget ↔ loras widget
|
||||||
|
let isUpdating = false;
|
||||||
|
let isSyncingInput = false;
|
||||||
|
|
||||||
|
// Get the text input widget (AUTOCOMPLETE_TEXT_LORAS type, created by Vue widgets)
|
||||||
|
const inputWidget = getWidgetByName(this, "text");
|
||||||
|
if (!inputWidget) {
|
||||||
|
console.warn(
|
||||||
|
"LoRA Manager: text widget not found for Create Hook LoRA"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inputWidget = inputWidget;
|
||||||
|
|
||||||
|
const scheduleInputSync = debounce((lorasValue) => {
|
||||||
|
if (isSyncingInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncingInput = true;
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextText = applyLoraValuesToText(
|
||||||
|
inputWidget.value,
|
||||||
|
lorasValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inputWidget.value !== nextText) {
|
||||||
|
inputWidget.value = nextText;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
isSyncingInput = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the LoRA list widget
|
||||||
|
const result = addLorasWidget(
|
||||||
|
this,
|
||||||
|
"loras",
|
||||||
|
{
|
||||||
|
onSelectionChange: (selection) => {
|
||||||
|
applySelectionHighlight(this, selection);
|
||||||
|
updateConnectedLoraInfoNodes(this, selection);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(value) => {
|
||||||
|
// Prevent recursive calls
|
||||||
|
if (isUpdating) return;
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update connected trigger word toggles with active LoRA names
|
||||||
|
const activeLoraNames = new Set();
|
||||||
|
value.forEach((lora) => {
|
||||||
|
if (lora.active) {
|
||||||
|
activeLoraNames.add(lora.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateConnectedTriggerWords(this, activeLoraNames);
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleInputSync(value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.lorasWidget = result.widget;
|
||||||
|
|
||||||
|
// Set up callback for the text input widget to trigger merge logic
|
||||||
|
inputWidget.callback = (value) => {
|
||||||
|
if (isUpdating) return;
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentLoras = this.lorasWidget?.value || [];
|
||||||
|
const mergedLoras = mergeLoras(value, currentLoras);
|
||||||
|
if (this.lorasWidget) {
|
||||||
|
this.lorasWidget.value = mergedLoras;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update connected trigger word toggles
|
||||||
|
const activeLoraNames = getActiveLorasFromNode(this);
|
||||||
|
updateConnectedTriggerWords(this, activeLoraNames);
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadedGraphNode(node) {
|
||||||
|
if (node.comfyClass === "Create Hook LoRA (LoraManager)") {
|
||||||
|
// Restore saved loras widget values on workflow load
|
||||||
|
let existingLoras = [];
|
||||||
|
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||||
|
const savedValue = getWidgetSerializedValue(node, "loras");
|
||||||
|
existingLoras = savedValue || [];
|
||||||
|
}
|
||||||
|
// Merge the loras data from text widget with saved values
|
||||||
|
const inputWidget =
|
||||||
|
node.inputWidget || getWidgetByName(node, "text");
|
||||||
|
if (!inputWidget) {
|
||||||
|
console.warn(
|
||||||
|
"LoRA Manager: text widget not found while restoring Create Hook LoRA"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mergedLoras = mergeLoras(inputWidget.value, existingLoras);
|
||||||
|
node.lorasWidget.value = mergedLoras;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user