From 10ed2b551feaabe3f4a7222fdcb5ecf8ea70a8de Mon Sep 17 00:00:00 2001 From: LUCAS M MORGAN Date: Thu, 6 Aug 2026 12:57:20 -0500 Subject: [PATCH] Add LoRA allow-list filtering --- README.md | 57 ++++++++-------------- lora_nodes.py | 95 +++++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- tests/test_lora_nodes.py | 74 +++++++++++++++++++++++++++-- web/filtered_lora_loader.js | 69 ++++++++++++++++++++++++++- 5 files changed, 244 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 488bfe0..9543018 100644 --- a/README.md +++ b/README.md @@ -161,27 +161,10 @@ choice needs to come from a string-producing node. ### `Enviral Load LoRA` -Native-style LoRA loader that keeps ComfyUI's LoRA application behavior but -allows `lora_name` to be driven by a linked string. - -Inputs: - -- `model`: diffusion model to patch -- `clip`: CLIP model to patch -- `lora_name`: dropdown or linked string matching a LoRA path known to ComfyUI -- `strength_model`: diffusion model LoRA strength -- `strength_clip`: CLIP LoRA strength - -Outputs: - -- patched `model` -- patched `clip` - -### `Enviral Load LoRA (Filtered)` - -Native-style LoRA loader with a folder filter for workflow-specific LoRA lists. -The folder and LoRA choices are derived from ComfyUI's current registered LoRA -paths, including paths supplied through `extra_model_paths.yaml`. +LoRA loader with folder and text allow-list filtering, up to five independently +bypassable LoRA banks, and a text output containing the final visible list. The +folder and LoRA choices come from ComfyUI's registered LoRA paths, including +paths supplied through `extra_model_paths.yaml`. Inputs: @@ -189,29 +172,27 @@ Inputs: - `clip`: CLIP model to patch - `folder`: `All LoRAs` or any current LoRA folder prefix; nested folders are included in their parent filter -- `lora_name`: dropdown limited to the selected folder and its subfolders -- `strength_model`: diffusion model LoRA strength -- `strength_clip`: CLIP LoRA strength +- `allow_list`: optional STRING input intended for ComfyUI's native + `Text (Multiline)` node; each non-empty line keeps an exact displayed name or + full relative LoRA path +- `banks`: number of visible LoRA banks, from one to five +- each bank has a LoRA dropdown plus separate model and CLIP strengths; a bank + is bypassed when both strengths are zero + +Outputs: + +- patched `model` +- patched `clip` +- `lora_list`: newline-separated final dropdown options; names are relative to + the selected folder, or include full relative paths under `All LoRAs` The node uses ComfyUI's normal combo refresh behavior. Refreshing node definitions re-enumerates LoRAs and folders without maintaining a separate list or cache. Saved selections are validated against both the current LoRA registry and selected folder before execution. -### `Enviral Load LoRA (Model Only)` - -Model-only variant for workflows such as WAN where the LoRA chain patches the -diffusion model without a CLIP connection. - -Inputs: - -- `model`: diffusion model to patch -- `lora_name`: dropdown or linked string matching a LoRA path known to ComfyUI -- `strength_model`: diffusion model LoRA strength - -Output: - -- patched `model` +The earlier standard and model-only Enviral LoRA loaders remain registered for +existing workflows but are deprecated and hidden from normal node discovery. ### `Model Patch Torch Settings` diff --git a/lora_nodes.py b/lora_nodes.py index 32ab735..4597a1a 100644 --- a/lora_nodes.py +++ b/lora_nodes.py @@ -22,6 +22,38 @@ def _lora_is_in_folder(lora_name, folder): return bool(normalized_folder) and normalized_name.startswith(normalized_folder + "/") +def _lora_display_name(lora_name, folder): + normalized_name = _normalize_lora_path(lora_name) + if folder == ALL_LORA_FOLDERS: + return normalized_name + normalized_folder = _normalize_lora_path(folder) + return normalized_name[len(normalized_folder) + 1 :] + + +def _parse_lora_allow_list(allow_list): + return { + _normalize_lora_path(line).casefold() + for line in str(allow_list or "").splitlines() + if _normalize_lora_path(line) + } + + +def _filtered_lora_names(lora_names, folder, allow_list=""): + allowed = _parse_lora_allow_list(allow_list) + filtered = [] + for lora_name in lora_names: + if not _lora_is_in_folder(lora_name, folder): + continue + display_name = _lora_display_name(lora_name, folder) + if allowed and not { + _normalize_lora_path(lora_name).casefold(), + _normalize_lora_path(display_name).casefold(), + } & allowed: + continue + filtered.append((lora_name, display_name)) + return filtered + + class _EnviralLoraLoaderBase: def __init__(self): self.loaded_lora = None @@ -114,6 +146,8 @@ class _EnviralLoraLoaderBase: class EnviralLoadLora(_EnviralLoraLoaderBase): + DEPRECATED = True + @classmethod def INPUT_TYPES(cls): return { @@ -185,6 +219,8 @@ Native-style LoRA loader with a string-linkable LoRA dropdown. class EnviralLoadLoraFiltered(EnviralLoadLora): + DEPRECATED = False + @staticmethod def _bank_input_name(input_name, bank_index): if bank_index == 1: @@ -237,10 +273,26 @@ class EnviralLoadLoraFiltered(EnviralLoadLora): if not _lora_is_in_folder(lora_name, folder): raise ValueError(f"LoRA {lora_name!r} is not inside folder {folder!r}") + @classmethod + def _available_loras(cls, folder, allow_list=""): + return _filtered_lora_names(cls._lora_names(), folder, allow_list) + @classmethod def INPUT_TYPES(cls): required = super().INPUT_TYPES()["required"] - optional = {} + optional = { + "allow_list": ( + "STRING", + { + "forceInput": True, + "multiline": True, + "tooltip": ( + "Optional newline-separated LoRA allow-list. Connect ComfyUI's " + "Text (Multiline) node; entries may use displayed names or full paths." + ), + }, + ), + } for bank_index in range(2, MAX_LORA_BANKS + 1): for input_name in ("lora_name", "strength_model", "strength_clip"): input_type, options = required[input_name] @@ -274,9 +326,16 @@ class EnviralLoadLoraFiltered(EnviralLoadLora): "optional": optional, } + RETURN_TYPES = ("MODEL", "CLIP", "STRING") + RETURN_NAMES = ("model", "clip", "lora_list") + OUTPUT_TOOLTIPS = ( + "The modified diffusion model.", + "The modified CLIP model.", + "The visible LoRA options, one display name per line.", + ) FUNCTION = "load_lora_filtered" DESCRIPTION = """ -LoRA loader with a shared folder filter and independently bypassable LoRA banks. +LoRA loader with folder and text allow-list filters plus independently bypassable banks. """ SEARCH_ALIASES = [ "lora", @@ -294,9 +353,14 @@ LoRA loader with a shared folder filter and independently bypassable LoRA banks. strength_model=1.0, strength_clip=1.0, bank_count=1, + allow_list="", **kwargs, ): try: + folder = cls._resolve_folder(folder) + available_loras = { + lora_name for lora_name, _ in cls._available_loras(folder, allow_list) + } banks = cls._bank_values( lora_name, strength_model, @@ -312,9 +376,12 @@ LoRA loader with a shared folder filter and independently bypassable LoRA banks. if not active_banks: return True - folder = cls._resolve_folder(folder) for bank_index, lora_name, _, _ in active_banks: cls._validate_lora_bank(folder, lora_name, bank_index) + if lora_name not in available_loras: + raise ValueError( + f"LoRA {lora_name!r} is not included by the current allow-list" + ) except Exception as err: return str(err) return True @@ -332,8 +399,13 @@ LoRA loader with a shared folder filter and independently bypassable LoRA banks. strength_model, strength_clip, bank_count=1, + allow_list="", **kwargs, ): + folder = self._resolve_folder(folder) + available_loras = self._available_loras(folder, allow_list) + available_paths = {lora_name for lora_name, _ in available_loras} + lora_list = "\n".join(display_name for _, display_name in available_loras) banks = self._bank_values( lora_name, strength_model, @@ -345,18 +417,23 @@ LoRA loader with a shared folder filter and independently bypassable LoRA banks. bank for bank in banks if not self._is_bypassed(bank[2], bank[3]) ] if not active_banks: - return (model, clip) + return (model, clip, lora_list) - folder = self._resolve_folder(folder) for bank_index, lora_name, strength_model, strength_clip in active_banks: self._validate_lora_bank(folder, lora_name, bank_index) + if lora_name not in available_paths: + raise ValueError( + f"LoRA {lora_name!r} is not included by the current allow-list" + ) model, clip = self.load_lora( model, clip, lora_name, strength_model, strength_clip ) - return (model, clip) + return (model, clip, lora_list) class EnviralLoadLoraModelOnly(_EnviralLoraLoaderBase): + DEPRECATED = True + @classmethod def INPUT_TYPES(cls): return { @@ -421,7 +498,7 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { - "EnviralLoadLora": "Enviral Load LoRA", - "EnviralLoadLoraFiltered": "Enviral Load LoRA (Filtered)", - "EnviralLoadLoraModelOnly": "Enviral Load LoRA (Model Only)", + "EnviralLoadLora": "Enviral Load LoRA (Deprecated)", + "EnviralLoadLoraFiltered": "Enviral Load LoRA", + "EnviralLoadLoraModelOnly": "Enviral Load LoRA (Model Only, Deprecated)", } diff --git a/pyproject.toml b/pyproject.toml index feac1fa..e5bcebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "enviral-design-node-pack" -version = "0.1.24" +version = "0.1.25" description = "Small, dependency-light ComfyUI utility nodes from Enviral Design." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_lora_nodes.py b/tests/test_lora_nodes.py index 07d506c..f6e4fc0 100644 --- a/tests/test_lora_nodes.py +++ b/tests/test_lora_nodes.py @@ -92,6 +92,7 @@ class LoraNodeTests(unittest.TestCase): module.MAX_LORA_BANKS, ) self.assertIn("lora_name_5", input_types["optional"]) + self.assertTrue(input_types["optional"]["allow_list"][1]["forceInput"]) self.assertEqual( input_types["optional"]["strength_model_2"][1]["default"], 0.0 ) @@ -107,7 +108,7 @@ class LoraNodeTests(unittest.TestCase): self.assertIs( module.EnviralLoadLoraFiltered.VALIDATE_INPUTS( - "not-a-folder", + "All LoRAs", "", strength_model=0, strength_clip=0, @@ -122,7 +123,7 @@ class LoraNodeTests(unittest.TestCase): loader.load_lora_filtered( model, clip, - "not-a-folder", + "All LoRAs", "", 0, 0, @@ -131,9 +132,75 @@ class LoraNodeTests(unittest.TestCase): strength_model_2=0, strength_clip_2=0, ), - (model, clip), + (model, clip, "wan/one.safetensors"), ) + def test_allow_list_matches_display_names_and_full_paths(self): + module = load_lora_nodes( + [ + "wan/one.safetensors", + "wan/nested/two.safetensors", + "sdxl/other.safetensors", + ] + ) + + self.assertEqual( + module.EnviralLoadLoraFiltered._available_loras( + "wan", + "one.safetensors\nWAN/NESTED/TWO.SAFETENSORS\nmissing.safetensors", + ), + [ + ("wan/one.safetensors", "one.safetensors"), + ("wan/nested/two.safetensors", "nested/two.safetensors"), + ], + ) + + def test_filtered_loader_outputs_visible_lora_list(self): + module = load_lora_nodes( + [ + "wan/one.safetensors", + "wan/nested/two.safetensors", + "sdxl/other.safetensors", + ] + ) + loader = module.EnviralLoadLoraFiltered() + + self.assertEqual( + loader.load_lora_filtered( + "model", + "clip", + "wan", + "", + 0, + 0, + allow_list="nested/two.safetensors\none.safetensors", + ), + ( + "model", + "clip", + "one.safetensors\nnested/two.safetensors", + ), + ) + + def test_allow_list_rejects_active_bank_outside_list(self): + module = load_lora_nodes(["wan/one.safetensors", "wan/two.safetensors"]) + + self.assertEqual( + module.EnviralLoadLoraFiltered.VALIDATE_INPUTS( + "wan", + "wan/two.safetensors", + allow_list="one.safetensors", + ), + "LoRA 'wan/two.safetensors' is not included by the current allow-list", + ) + + def test_filtered_loader_is_the_only_non_deprecated_lora_loader(self): + module = load_lora_nodes([]) + + self.assertTrue(module.EnviralLoadLora.DEPRECATED) + self.assertFalse(module.EnviralLoadLoraFiltered.DEPRECATED) + self.assertTrue(module.EnviralLoadLoraModelOnly.DEPRECATED) + def test_filtered_loader_applies_active_banks_in_order(self): module = load_lora_nodes( ["wan/one.safetensors", "wan/three.safetensors", "sdxl/other.safetensors"] @@ -180,6 +247,7 @@ class LoraNodeTests(unittest.TestCase): ( "model:wan/one.safetensors:wan/three.safetensors", "clip:wan/one.safetensors:wan/three.safetensors", + "one.safetensors\nthree.safetensors", ), ) diff --git a/web/filtered_lora_loader.js b/web/filtered_lora_loader.js index 9edf72d..2789c8c 100644 --- a/web/filtered_lora_loader.js +++ b/web/filtered_lora_loader.js @@ -36,6 +36,15 @@ function getLoraDisplayName(loraName, folder) { return normalizedName.slice(prefix.length); } +function parseAllowList(value) { + return new Set( + String(value ?? "") + .split(/\r?\n/) + .map((line) => normalizePath(line).toLowerCase()) + .filter(Boolean), + ); +} + function getWidget(node, name) { return node.widgets?.find((widget) => widget.name === name); } @@ -52,6 +61,24 @@ function getBankCount(node) { return Math.min(MAX_BANKS, Math.max(1, count)); } +function getConnectedAllowList(node) { + const slot = node.findInputSlot?.("allow_list"); + const linkId = slot === undefined || slot < 0 ? null : node.inputs?.[slot]?.link; + const link = linkId == null ? null : app.graph?.links?.[linkId]; + const sourceNode = link ? app.graph?.getNodeById?.(link.origin_id) : null; + const sourceWidget = sourceNode?.widgets?.find((widget) => widget.name === "value"); + return { sourceWidget, value: sourceWidget?.value ?? "" }; +} + +function isAllowed(loraName, folder, allowed) { + if (allowed.size === 0) { + return true; + } + const fullName = normalizePath(loraName).toLowerCase(); + const displayName = normalizePath(getLoraDisplayName(loraName, folder)).toLowerCase(); + return allowed.has(fullName) || allowed.has(displayName); +} + function configureLoraFilter(node, folderWidget, loraWidget) { if (!Array.isArray(loraWidget.options?.values)) { return; @@ -65,7 +92,12 @@ function configureLoraFilter(node, folderWidget, loraWidget) { configurable: true, enumerable: true, get() { - return allLoras.filter((name) => isInFolder(name, folderWidget.value)); + const allowed = parseAllowList(getConnectedAllowList(node).value); + return allLoras.filter( + (name) => + isInFolder(name, folderWidget.value) && + isAllowed(name, folderWidget.value, allowed), + ); }, set(values) { if (Array.isArray(values)) { @@ -137,6 +169,26 @@ function configureFilter(node) { } function sync() { + const { sourceWidget } = getConnectedAllowList(node); + if (node.enviralAllowListSource && node.enviralAllowListSource !== sourceWidget) { + node.enviralAllowListSource.enviralLoraFilterTargets?.delete(node); + } + node.enviralAllowListSource = sourceWidget; + if (sourceWidget) { + sourceWidget.enviralLoraFilterTargets ??= new Set(); + sourceWidget.enviralLoraFilterTargets.add(node); + if (!sourceWidget.enviralLoraFilterWrapped) { + const originalCallback = sourceWidget.callback; + sourceWidget.callback = function (value) { + const result = originalCallback?.apply(this, arguments); + for (const target of sourceWidget.enviralLoraFilterTargets) { + target.enviralSyncLoraFilter?.(); + } + return result; + }; + sourceWidget.enviralLoraFilterWrapped = true; + } + } for (const syncSelection of syncSelections) { syncSelection(); } @@ -160,8 +212,21 @@ function configureFilter(node) { return result; }; - sync(); + const onConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function () { + const result = onConnectionsChange?.apply(this, arguments); + queueMicrotask(() => this.enviralSyncLoraFilter?.()); + return result; + }; + + const onRemoved = node.onRemoved; + node.onRemoved = function () { + this.enviralAllowListSource?.enviralLoraFilterTargets?.delete(this); + return onRemoved?.apply(this, arguments); + }; + node.enviralSyncLoraFilter = sync; + sync(); } app.registerExtension({