Add lora presets
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user