From 585b5c922a49a09cd624933a87558363f9cc5a7d Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 21 Jul 2026 09:44:28 +0800 Subject: [PATCH] feat(nodes): add Create Hook LoRA (LoraManager) node for multi-LoRA hook pipelines --- __init__.py | 5 ++ py/nodes/create_hook_lora.py | 116 ++++++++++++++++++++++++++ web/comfyui/create_hook_lora.js | 143 ++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 py/nodes/create_hook_lora.py create mode 100644 web/comfyui/create_hook_lora.js diff --git a/__init__.py b/__init__.py index 120fec08..6f07f90a 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,7 @@ try: # pragma: no cover - import fallback for pytest collection from .py.nodes.lora_cycler import LoraCyclerLM from .py.nodes.lora_info import LoraInfoLM 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 except ( ImportError @@ -62,6 +63,9 @@ except ( LoraSyntaxToPath = importlib.import_module( "py.nodes.lora_syntax_to_path" ).LoraSyntaxToPath + CreateHookLoraLM = importlib.import_module( + "py.nodes.create_hook_lora" + ).CreateHookLoraLM init_metadata_collector = importlib.import_module("py.metadata_collector").init NODE_CLASS_MAPPINGS = { @@ -83,6 +87,7 @@ NODE_CLASS_MAPPINGS = { LoraCyclerLM.NAME: LoraCyclerLM, LoraInfoLM.NAME: LoraInfoLM, LoraSyntaxToPath.NAME: LoraSyntaxToPath, + CreateHookLoraLM.NAME: CreateHookLoraLM, } WEB_DIRECTORY = "./web/comfyui" diff --git a/py/nodes/create_hook_lora.py b/py/nodes/create_hook_lora.py new file mode 100644 index 00000000..5796b4d5 --- /dev/null +++ b/py/nodes/create_hook_lora.py @@ -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"" + ) + else: + formatted_loras.append(f"") + active_loras_text = " ".join(formatted_loras) + + return (hook_group, trigger_words_text, active_loras_text) diff --git a/web/comfyui/create_hook_lora.js b/web/comfyui/create_hook_lora.js new file mode 100644 index 00000000..f081a31d --- /dev/null +++ b/web/comfyui/create_hook_lora.js @@ -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; + } + }, +});