Add lora presets

This commit is contained in:
sn0w
2026-09-07 14:56:59 +02:00
parent dd091e2ab6
commit 4567bbfe79
5 changed files with 183 additions and 3 deletions
+1
View File
@@ -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
+36
View File
@@ -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()
+22 -3
View File
@@ -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)
+61
View File
@@ -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
+63
View File
@@ -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),
});
};
},
});