Add LoRA allow-list filtering
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user