Add LoRA allow-list filtering

This commit is contained in:
LUCAS M MORGAN
2026-08-06 12:57:20 -05:00
parent 2358251811
commit 10ed2b551f
5 changed files with 244 additions and 53 deletions
+19 -38
View File
@@ -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`
+86 -9
View File
@@ -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)",
}
+1 -1
View File
@@ -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"
+71 -3
View File
@@ -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",
),
)
+67 -2
View File
@@ -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({