Files
lunaaispace-engandClaude Opus 5 dde1c21540 Expand style packs and sync live-install changes
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>
2026-08-01 10:41:59 +03:00

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"}