From 4567bbfe79c83ea00a0d46c0e1a34f9666543255 Mon Sep 17 00:00:00 2001 From: sn0w <105451515+sn0w12@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:56:59 +0200 Subject: [PATCH] Add lora presets --- .gitignore | 1 + __init__.py | 36 ++++++++++++++++++++++ src/dynamic_lora_loader.py | 25 +++++++++++++-- src/lora_presets.py | 61 ++++++++++++++++++++++++++++++++++++ web/js/loraPresets.js | 63 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/lora_presets.py create mode 100644 web/js/loraPresets.js diff --git a/.gitignore b/.gitignore index 01fd74d..993d484 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ web/images/*/* /models web/settings/visible_series.json web/settings/hidden_characters.json +web/settings/lora_presets.json web/characters/characters.db diff --git a/__init__.py b/__init__.py index 55ff32e..d6eeeb6 100644 --- a/__init__.py +++ b/__init__.py @@ -23,6 +23,7 @@ from .src.simple_ksampler import SimpleSamplerCustom from .src.filter_tags import FilterTags from .src.generate_all_character_images import GenerateCharactersNode from .src.upscaler import AutoTaggedTiledUpscaler +from .src.lora_presets import build_lora_loader_info, save_preset # Constants WEB_DIRECTORY = "./web" @@ -160,6 +161,41 @@ import_and_register_scheduler_nodes() API_PREFIX = "/sn0w" +@PromptServer.instance.routes.get(f"{API_PREFIX}/lora_loader_info") +async def lora_loader_info_endpoint(request): + return web.json_response(build_lora_loader_info(NODE_CLASS_MAPPINGS)) + + +@PromptServer.instance.routes.post(f"{API_PREFIX}/lora_presets") +async def save_lora_preset(request): + data = await request.json() + if data is None or not isinstance(data, dict): + return web.json_response({"error": "Invalid request, expected a JSON object"}, status=400) + + name = str(data.get("name", "")).strip() + path = str(data.get("path", "")).strip() + loras = data.get("loras") + if not name or not path or not isinstance(loras, list): + return web.json_response({"error": "Invalid request, 'name', 'path' and 'loras' are required"}, status=400) + + cleaned_loras = [] + for entry in loras[:26]: + if not isinstance(entry, dict): + continue + lora = str(entry.get("lora", "")).strip() + if not lora: + continue + try: + strength = float(entry.get("strength", 1.0)) + except (TypeError, ValueError): + strength = 1.0 + cleaned_loras.append({"lora": lora, "strength": strength}) + + if not save_preset(name, path, cleaned_loras): + return web.json_response({"error": "Failed to save preset"}, status=500) + return web.json_response({"status": "ok"}) + + @PromptServer.instance.routes.get(f"{API_PREFIX}/series") async def series_endpoint(request): db = CharacterDB() diff --git a/src/dynamic_lora_loader.py b/src/dynamic_lora_loader.py index 1c6f8e2..88f3b13 100644 --- a/src/dynamic_lora_loader.py +++ b/src/dynamic_lora_loader.py @@ -5,6 +5,7 @@ import os import folder_paths from ..sn0w import Logger, Utility, ConfigReader +from .lora_presets import MAX_PRESET_DEPTH, find_preset, normalize_loader_path, presets_for_path def generate_lora_node_class(lora_type, required_folders=None, combos=1): @@ -19,6 +20,8 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1): class DynamicLoraNode: logger = Logger() + LORA_PATH = required_folders + LORA_COMBOS = combos @classmethod def sort_loras(cls, loras, method="alphabetical"): @@ -70,6 +73,11 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1): filtered_sorted_loras = Utility.put_favourite_on_top("sn0w.FavouriteLoras", filtered_sorted_loras) + lora_options = ["None"] + filtered_sorted_loras + if required_folders is not None: + loader_path = normalize_loader_path(required_folders) + lora_options += [preset["name"] for preset in presets_for_path(loader_path)] + inputs = { "required": { "model": ("MODEL",), @@ -77,7 +85,7 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1): }, } if combos == 1: - inputs["required"]["lora"] = (["None"] + filtered_sorted_loras,) + inputs["required"]["lora"] = (lora_options,) inputs["required"]["lora_strength"] = ( "FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}, @@ -87,7 +95,7 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1): else: for idx in range(combos): suffix = chr(97 + idx) # 'a', 'b', 'c', etc. - inputs["required"][f"lora_{suffix}"] = (["None"] + filtered_sorted_loras,) + inputs["required"][f"lora_{suffix}"] = (lora_options,) inputs["required"][f"lora_strength_{suffix}"] = ( "FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}, @@ -101,10 +109,21 @@ def generate_lora_node_class(lora_type, required_folders=None, combos=1): CATEGORY = "sn0w/lora" OUTPUT_NODE = True - def load_lora(self, model, clip, lora, lora_strength): + def load_lora(self, model, clip, lora, lora_strength, depth=0): if lora == "None": return (model, clip) + preset = None + if required_folders is not None: + preset = find_preset(normalize_loader_path(required_folders), lora) + if preset is not None: + if depth >= MAX_PRESET_DEPTH: + self.logger.log("Preset expansion too deep, skipping", "WARNING") + return (model, clip) + for entry in preset["loras"]: + model, clip = self.load_lora(model, clip, entry["lora"], entry["strength"] * lora_strength, depth + 1) + return (model, clip) + lora_loader = LoraLoader() full_loras_list = folder_paths.get_filename_list("loras") full_lora_path = next((full_path for full_path in full_loras_list if lora in full_path), None) diff --git a/src/lora_presets.py b/src/lora_presets.py new file mode 100644 index 0000000..d30d5a9 --- /dev/null +++ b/src/lora_presets.py @@ -0,0 +1,61 @@ +import json +import os +from pathlib import Path + +from ..sn0w import Logger + +PRESETS_FILE = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "web", + "settings", + "lora_presets.json", +) +MAX_PRESET_DEPTH = 5 +logger = Logger() + + +def normalize_loader_path(folders): + return ",".join(Path(folder).as_posix().lower() for folder in folders) + + +def build_lora_loader_info(node_class_mappings): + info = {} + for name, node_class in node_class_mappings.items(): + combos = getattr(node_class, "LORA_COMBOS", 1) + folders = getattr(node_class, "LORA_PATH", None) + if combos > 1 and folders: + info[name] = {"path": normalize_loader_path(folders), "combos": combos} + return info + + +def presets_for_path(path): + return [preset for preset in load_presets() if preset.get("path") == path] + + +def find_preset(path, name): + return next((preset for preset in presets_for_path(path) if preset.get("name") == name), None) + + +def load_presets(): + if not os.path.exists(PRESETS_FILE): + return [] + try: + with open(PRESETS_FILE, "r", encoding="utf-8") as file: + data = json.load(file) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, IOError): + logger.log(f"Failed to read lora presets from {PRESETS_FILE}", "WARNING") + return [] + + +def save_preset(name, path, loras): + presets = [p for p in load_presets() if not (p.get("name") == name and p.get("path") == path)] + presets.append({"name": name, "path": path, "loras": loras}) + os.makedirs(os.path.dirname(PRESETS_FILE), exist_ok=True) + try: + with open(PRESETS_FILE, "w", encoding="utf-8") as file: + json.dump(presets, file, indent=2) + return True + except IOError: + logger.log(f"Failed to write lora presets to {PRESETS_FILE}", "ERROR") + return False \ No newline at end of file diff --git a/web/js/loraPresets.js b/web/js/loraPresets.js new file mode 100644 index 0000000..f30dbf9 --- /dev/null +++ b/web/js/loraPresets.js @@ -0,0 +1,63 @@ +import { app } from "../../../scripts/app.js"; +import { SettingUtils } from "./sn0w.js"; + +const LOADER_INFO_ROUTE = "/sn0w/lora_loader_info"; +const PRESETS_ROUTE = "/sn0w/lora_presets"; + +let loaderInfo = {}; + +const suffixForIndex = (index) => String.fromCharCode(97 + index); + +const collectPreset = (node, info) => { + const loras = []; + for (let i = 0; i < info.combos; i++) { + const suffix = suffixForIndex(i); + const loraWidget = node.widgets.find((w) => w.name === `lora_${suffix}`); + const strengthWidget = node.widgets.find((w) => w.name === `lora_strength_${suffix}`); + if (!loraWidget) break; + if (loraWidget.value && loraWidget.value !== "None") { + loras.push({ lora: loraWidget.value, strength: strengthWidget?.value ?? 1.0 }); + } + } + return loras; +}; + +const savePreset = async (node, info) => { + const name = window.prompt("Preset name:", ""); + if (!name || !name.trim()) return; + const trimmedName = name.trim(); + const loras = collectPreset(node, info); + const result = await SettingUtils.fetchApi(PRESETS_ROUTE, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: trimmedName, path: info.path, loras }), + }); + if (result?.status !== "ok") return; + + const settingUtils = new SettingUtils(); + for (const [typeName, typeInfo] of Object.entries(loaderInfo)) { + if (typeInfo.path === info.path) { + settingUtils.refreshComboInSingleNode(app, typeName); + } + } +}; + +app.registerExtension({ + name: "sn0w.LoraPresets", + async setup() { + loaderInfo = (await SettingUtils.fetchApi(LOADER_INFO_ROUTE)) || {}; + }, + beforeRegisterNodeDef(nodeType) { + const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + if (getExtraMenuOptions) getExtraMenuOptions.apply(this, arguments); + const info = loaderInfo[this.type] || loaderInfo[this.comfyClass]; + if (!info) return; + + options.unshift({ + content: "Save Current as Preset", + callback: () => savePreset(this, info), + }); + }; + }, +});