Files
2026-03-22 04:21:59 +05:00

272 lines
8.3 KiB
Python

"""
ComfyUI custom nodes for Prompt Database.
"""
import json as _json
import re as _re
import folder_paths
def _clean_name(name):
"""Strip the 📖 LoRA marker suffix added by the JS frontend."""
return _re.sub(r'\s*📖$', '', name).strip()
import comfy.sd
import comfy.utils
from . import db
class PromptDBManager:
"""
Management node — provides a UI button to open the prompt database manager.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ()
FUNCTION = "manage"
CATEGORY = "Prompt Database"
OUTPUT_NODE = True
def manage(self, unique_id=None):
return {}
class PromptSelector:
"""
Selector node — pick a category and prompt.
Outputs:
- prompt_text (STRING): the selected prompt text (no LoRA tags)
- db_selection (DB_SELECTION): the category name (for PromptCombiner)
- lora_stack (LORA_STACK): LoRAs attached to the selected prompt
"""
@classmethod
def INPUT_TYPES(cls):
categories = db.get_categories()
if not categories:
categories = ["(no categories)"]
all_prompts = db.get_prompts()
prompt_names = list({p["name"] for p in all_prompts})
if not prompt_names:
prompt_names = ["(no prompts)"]
return {
"required": {
"category": (categories, {"default": categories[0]}),
"prompt_name": (prompt_names, {"default": prompt_names[0]}),
},
}
RETURN_TYPES = ("STRING", "DB_SELECTION", "LORA_STACK")
RETURN_NAMES = ("prompt_text", "db_selection", "lora_stack")
FUNCTION = "select_prompt"
CATEGORY = "Prompt Database"
def select_prompt(self, category, prompt_name):
prompt_name = _clean_name(prompt_name)
prompt = db.get_prompt_by_name(prompt_name, category)
if not prompt:
prompt = db.get_prompt_by_name(prompt_name)
if not prompt:
return ("", category, [])
text = prompt["text"]
loras = prompt.get("loras", [])
# Normalize lora entries
lora_stack = []
for l in loras:
lora_stack.append({
"name": l.get("name", ""),
"strength": float(l.get("strength", 1.0)),
})
return (text, category, lora_stack)
@classmethod
def IS_CHANGED(cls, category, prompt_name):
prompt_name = _clean_name(prompt_name)
prompt = db.get_prompt_by_name(prompt_name, category)
if prompt:
return prompt["text"] + str(prompt.get("loras", []))
return ""
class PromptCombiner:
"""
Combiner node — accepts multiple DB_SELECTION inputs (category names),
shows a prompt dropdown per category inside the node,
and combines selected prompts into one STRING + merged LORA_STACK.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"separator": (
["comma_space", "newline", "space", "none"],
{"default": "comma_space"},
),
"prompt_selections": (
"STRING",
{"default": "{}", "multiline": False},
),
},
"optional": {
"selection_1": ("DB_SELECTION", {"forceInput": True}),
},
}
RETURN_TYPES = ("STRING", "LORA_STACK")
RETURN_NAMES = ("combined_prompt", "lora_stack")
FUNCTION = "combine"
CATEGORY = "Prompt Database"
OUTPUT_NODE = True
def combine(self, separator="comma_space", prompt_selections="{}", **kwargs):
sep_map = {
"comma_space": ", ",
"newline": "\n",
"space": " ",
"none": "",
}
sep = sep_map.get(separator, ", ")
try:
selections = _json.loads(prompt_selections)
except _json.JSONDecodeError:
selections = {}
texts = []
merged_loras = []
seen_loras = set()
selection_keys = [k for k in kwargs.keys() if k.startswith("selection_")]
selection_keys.sort(key=lambda k: int(k.split("_")[1]))
for key in selection_keys:
category = kwargs[key]
if not category:
continue
prompt_name = _clean_name(selections.get(key, ""))
if not prompt_name:
continue
prompt = db.get_prompt_by_name(prompt_name, category)
if prompt:
texts.append(prompt["text"])
# Collect LoRAs (deduplicate by name)
for l in prompt.get("loras", []):
lname = l.get("name", "")
if lname and lname not in seen_loras:
seen_loras.add(lname)
merged_loras.append({
"name": lname,
"strength": float(l.get("strength", 1.0)),
})
combined = sep.join(texts)
return (combined, merged_loras)
@classmethod
def IS_CHANGED(cls, separator, prompt_selections="{}", **kwargs):
# Build a fingerprint that includes actual prompt content,
# so edits to prompt text invalidate the cache.
try:
selections = _json.loads(prompt_selections)
except _json.JSONDecodeError:
selections = {}
parts = [separator, prompt_selections]
for key in sorted(kwargs.keys()):
if not key.startswith("selection_"):
continue
category = kwargs[key]
prompt_name = _clean_name(selections.get(key, ""))
if category and prompt_name:
prompt = db.get_prompt_by_name(prompt_name, category)
if prompt:
parts.append(prompt["text"])
parts.append(str(prompt.get("loras", [])))
return "|".join(parts)
class ApplyLoraStack:
"""
Applies LoRAs from a LORA_STACK to a model and clip.
The lora_config hidden widget stores per-LoRA overrides
(enable/disable + custom strength).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"lora_config": (
"STRING",
{"default": "{}", "multiline": False},
),
},
"optional": {
"lora_stack": ("LORA_STACK",),
},
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("model", "clip")
FUNCTION = "apply_loras"
CATEGORY = "Prompt Database"
def apply_loras(self, model, clip, lora_config="{}", lora_stack=None):
if not lora_stack:
return (model, clip)
# Parse overrides
try:
config = _json.loads(lora_config)
except _json.JSONDecodeError:
config = {}
for lora_entry in lora_stack:
name = lora_entry.get("name", "")
default_strength = lora_entry.get("strength", 1.0)
if not name:
continue
# Apply per-LoRA overrides from config
override = config.get(name, {})
if not override.get("enabled", True):
print(f"[PromptDB] LoRA skipped (disabled): {name}")
continue
strength = float(override.get("strength", default_strength))
# Load LoRA file
lora_path = folder_paths.get_full_path("loras", name)
if not lora_path:
print(f"[PromptDB] LoRA file not found: {name}")
continue
try:
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(
model, clip, lora, strength, strength
)
print(f"[PromptDB] LoRA applied: {name} (strength={strength:.2f})")
except Exception as e:
print(f"[PromptDB] Error loading LoRA {name}: {e}")
return (model, clip)
@classmethod
def IS_CHANGED(cls, model, clip, lora_config="{}", lora_stack=None):
return str(lora_stack) + lora_config