Brings the D: clone in line with the live E: install, whose style files had diverged and were never committed. - styles/nsfw_cinematic_styles.json: replaces nsfw.json, which it is a strict superset of (all 10 original keys plus 26 more). The loader globs styles/*.json, so keeping both would define those 10 twice. - styles/realism_style_pack_45.json: 45-style realism pack, previously only on disk. - style_library_node.py: accumulated uncommitted changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""
|
|
Style Prompt Library — Improved version for the new structured JSON
|
|
with explicit subcategories.
|
|
|
|
Key improvements:
|
|
- Properly uses the "subcategory" field from the new JSON
|
|
- Better cascading Category → Subcategory → Style experience
|
|
- Loads from styles/ folder (supports multiple packs)
|
|
- Single style_guide output (for LLM prompt nodes)
|
|
- Minimal changes from original node logic
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import glob
|
|
import hashlib
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_STYLES_DIR = os.path.join(_HERE, "styles")
|
|
|
|
|
|
def _pack_files():
|
|
"""All style-pack JSON files from the styles/ folder."""
|
|
files = sorted(glob.glob(os.path.join(_STYLES_DIR, "*.json")))
|
|
if not files:
|
|
legacy = os.path.join(_HERE, "styles_library.json")
|
|
if os.path.exists(legacy):
|
|
files = [legacy]
|
|
return files
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Load library
|
|
# ---------------------------------------------------------------------------
|
|
_ENTRIES = {}
|
|
_LABEL_TO_KEY = {}
|
|
_TREE = {}
|
|
_CATEGORIES = []
|
|
_ALL_LABELS = []
|
|
_LOAD_ERROR = None
|
|
|
|
|
|
# --- Curated display order (not alphabetical) -------------------------------
|
|
# Categories in this order come first; anything not listed falls after,
|
|
# alphabetically. Reflects the user's actual working priorities.
|
|
_CATEGORY_ORDER = [
|
|
"Photography",
|
|
"Fashion",
|
|
"Sci-Fi & Futurism",
|
|
"Illustration",
|
|
"Painting",
|
|
"3D & Render",
|
|
"Art Movements",
|
|
"Anime & Manga",
|
|
"Drawing & Sketch",
|
|
"Graphic Design",
|
|
"Crafts & Materials",
|
|
"Vector & Flat",
|
|
]
|
|
# Subcategories forced to the bottom of their category regardless of size
|
|
# (e.g. the national/folk styles the user wants out of the way).
|
|
_SUBCATEGORY_DEMOTE = {"Cultural & Folk"}
|
|
|
|
|
|
def _category_sort_key(cat):
|
|
"""Curated categories first (by _CATEGORY_ORDER), then unknowns A→Z."""
|
|
try:
|
|
return (0, _CATEGORY_ORDER.index(cat))
|
|
except ValueError:
|
|
return (1, cat.lower())
|
|
|
|
|
|
def _subcategory_sort_key(sub, count):
|
|
"""Demoted subs last; otherwise fullest-first, then A→Z."""
|
|
return (1 if sub in _SUBCATEGORY_DEMOTE else 0, -count, sub.lower())
|
|
|
|
|
|
def _load_library():
|
|
global _LOAD_ERROR
|
|
files = _pack_files()
|
|
|
|
if not files:
|
|
_LOAD_ERROR = f"No JSON files found in {_STYLES_DIR}"
|
|
print(f"[StylePromptLibrary] {_LOAD_ERROR}")
|
|
return
|
|
|
|
raw = {}
|
|
for fp in files:
|
|
try:
|
|
with open(fp, "r", encoding="utf-8") as f:
|
|
pack = json.load(f)
|
|
except Exception as e:
|
|
print(f"[StylePromptLibrary] Skipped {os.path.basename(fp)}: {e}")
|
|
continue
|
|
|
|
# Support new improved format with subcategories
|
|
if "categories" in pack:
|
|
for cat_name, cat_data in pack.get("categories", {}).items():
|
|
for style in cat_data.get("styles", []):
|
|
full_name = style.get("full_name") or style.get("name", "")
|
|
if not full_name:
|
|
continue
|
|
|
|
key = full_name
|
|
subcategory = style.get("subcategory", "General")
|
|
|
|
raw[key] = {
|
|
"name": full_name,
|
|
"category": cat_name,
|
|
"subcategory": subcategory,
|
|
"content": style.get("prompt", "")
|
|
}
|
|
continue
|
|
|
|
# Fallback to old flat format
|
|
for k, v in pack.items():
|
|
raw[k] = v
|
|
|
|
if not raw:
|
|
_LOAD_ERROR = "No valid entries found in style packs."
|
|
return
|
|
|
|
print(f"[StylePromptLibrary] Loaded {len(files)} pack(s)")
|
|
|
|
# Build display labels and tree
|
|
name_counts = {}
|
|
for entry in raw.values():
|
|
name_counts[entry.get("name", "")] = name_counts.get(entry.get("name", ""), 0) + 1
|
|
|
|
tree = {}
|
|
for key, entry in raw.items():
|
|
_ENTRIES[key] = entry
|
|
label = entry.get("name", key)
|
|
if name_counts.get(label, 0) > 1:
|
|
label = f"{label} · {key}"
|
|
_LABEL_TO_KEY[label] = key
|
|
|
|
cat = entry.get("category", "Misc")
|
|
sub = entry.get("subcategory", "General")
|
|
tree.setdefault(cat, {}).setdefault(sub, []).append(label)
|
|
|
|
# Styles within a subcategory stay alphabetical (that part reads fine).
|
|
for cat in tree:
|
|
for sub in tree[cat]:
|
|
tree[cat][sub].sort(key=str.lower)
|
|
|
|
# Curated order: categories by _CATEGORY_ORDER, subcategories fullest-first
|
|
# (demoted ones sink to the bottom). Insertion order is preserved by dict.
|
|
ordered = {}
|
|
for cat in sorted(tree, key=_category_sort_key):
|
|
subs = tree[cat]
|
|
ordered_subs = sorted(subs, key=lambda s: _subcategory_sort_key(s, len(subs[s])))
|
|
ordered[cat] = {s: subs[s] for s in ordered_subs}
|
|
|
|
_TREE.update(ordered)
|
|
_CATEGORIES.extend(_TREE.keys())
|
|
_ALL_LABELS.extend(sorted(_LABEL_TO_KEY.keys(), key=str.lower))
|
|
|
|
print(f"[StylePromptLibrary] Loaded {len(_ENTRIES)} styles in {len(_CATEGORIES)} categories.")
|
|
|
|
|
|
_load_library()
|
|
|
|
# UI options
|
|
_CATEGORY_OPTIONS = ["All"] + _CATEGORIES if _CATEGORIES else ["(no styles loaded)"]
|
|
_SUBCATEGORY_OPTIONS = ["All"] + list(dict.fromkeys(s for subs in _TREE.values() for s in subs)) if _TREE else ["All"]
|
|
_STYLE_OPTIONS = _ALL_LABELS if _ALL_LABELS else ["(no styles loaded)"]
|
|
|
|
|
|
# Server routes
|
|
try:
|
|
from server import PromptServer
|
|
from aiohttp import web
|
|
|
|
@PromptServer.instance.routes.get("/style_prompt_library/tree")
|
|
async def _style_tree(request):
|
|
return web.json_response({
|
|
"categories": _CATEGORIES,
|
|
"tree": _TREE,
|
|
"all": _ALL_LABELS,
|
|
"error": _LOAD_ERROR,
|
|
})
|
|
|
|
@PromptServer.instance.routes.get("/style_prompt_library/content")
|
|
async def _style_content(request):
|
|
label = request.query.get("label", "")
|
|
key = _LABEL_TO_KEY.get(label) or label
|
|
content = _ENTRIES.get(key, {}).get("content", "") if key in _ENTRIES else ""
|
|
return web.json_response({"content": content})
|
|
except Exception as e:
|
|
print(f"[StylePromptLibrary] Could not register routes: {e}")
|
|
|
|
|
|
class StylePromptLibrary:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"category": (_CATEGORY_OPTIONS,),
|
|
"subcategory": (_SUBCATEGORY_OPTIONS,),
|
|
"style": (_STYLE_OPTIONS,),
|
|
},
|
|
"optional": {
|
|
"search": ("STRING", {"default": "", "multiline": False,
|
|
"placeholder": "type to filter styles…"}),
|
|
"enabled": ("BOOLEAN", {"default": True,
|
|
"label_on": "styles ON", "label_off": "styles OFF",
|
|
"tooltip": "Master switch. When OFF, this node outputs an empty "
|
|
"string so no style is applied — lets you A/B with one click."}),
|
|
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05,
|
|
"tooltip": "Style weight, like a LoRA. 1.0 = normal (text unchanged), "
|
|
"0 = no style at all, >1 pushes it harder. See strength_mode "
|
|
"for how the weight is applied."}),
|
|
"strength_mode": (["instruction", "emphasis"], {"default": "instruction",
|
|
"tooltip": "instruction: expresses the weight as a directive for an LLM "
|
|
"prompt node (use when this feeds your LLM node). emphasis: wraps "
|
|
"as (text:weight) attention syntax for a CLIP Text Encode (use "
|
|
"only when feeding CLIP directly)."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("style_guide",)
|
|
FUNCTION = "get_style"
|
|
CATEGORY = "Style Prompt Library"
|
|
|
|
def get_style(self, category, subcategory, style, search="",
|
|
enabled=True, strength=1.0, strength_mode="instruction"):
|
|
# Master switch — OFF means this node contributes nothing.
|
|
if not enabled:
|
|
return ("",)
|
|
|
|
if _LOAD_ERROR:
|
|
return (f"[StylePromptLibrary ERROR] {_LOAD_ERROR}",)
|
|
|
|
key = _LABEL_TO_KEY.get(style)
|
|
if key is None:
|
|
if style in _ENTRIES:
|
|
key = style
|
|
else:
|
|
return (f"[StylePromptLibrary ERROR] Unknown style: {style!r}",)
|
|
|
|
content = _ENTRIES.get(key, {}).get("content", "")
|
|
if not content:
|
|
return (f"[StylePromptLibrary ERROR] Style {key!r} has no content.",)
|
|
|
|
return (self._apply_strength(content, float(strength), strength_mode),)
|
|
|
|
@staticmethod
|
|
def _apply_strength(content, strength, mode):
|
|
"""Emulate a LoRA-like weight on a text style block.
|
|
|
|
There is no tensor here, so 'strength' is expressed one of two ways:
|
|
- emphasis: CLIP attention syntax (text:weight) — for a CLIP Text Encode.
|
|
- instruction: a worded directive — for an LLM prompt node (default).
|
|
"""
|
|
# 0 weight → no style contribution, like a LoRA at 0.
|
|
if strength <= 0.0:
|
|
return ""
|
|
# 1.0 is the identity / normal weight — leave the text untouched.
|
|
if abs(strength - 1.0) < 1e-3:
|
|
return content
|
|
|
|
if mode == "emphasis":
|
|
# Escape any literal parens so they don't break the weight syntax.
|
|
safe = content.replace("(", r"\(").replace(")", r"\)")
|
|
return f"({safe}:{strength:.2f})"
|
|
|
|
# default "instruction" mode — a directive the LLM honors when it
|
|
# weaves style_description into the final prompt.
|
|
if strength < 0.5:
|
|
phrase = "only subtly, as a faint accent"
|
|
elif strength < 0.85:
|
|
phrase = "as a light, secondary influence"
|
|
elif strength <= 1.15:
|
|
phrase = "as a clear, present treatment"
|
|
elif strength <= 1.6:
|
|
phrase = "strongly, as the dominant visual treatment"
|
|
else:
|
|
phrase = "as the overriding aesthetic that defines the entire image"
|
|
return f"Apply the following style {phrase}: {content}"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, category, subcategory, style, search="",
|
|
enabled=True, strength=1.0, strength_mode="instruction"):
|
|
mtimes = []
|
|
for fp in _pack_files():
|
|
try:
|
|
mtimes.append(os.path.getmtime(fp))
|
|
except OSError:
|
|
pass
|
|
sig = f"{style}|{enabled}|{strength}|{strength_mode}|{max(mtimes) if mtimes else 0}"
|
|
return hashlib.sha1(sig.encode("utf-8")).hexdigest()
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {"StylePromptLibrary": StylePromptLibrary}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {"StylePromptLibrary": "🎨 Style Prompt Library"} |