Files

280 lines
9.2 KiB
Python

from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple
_HERE = os.path.dirname(os.path.realpath(__file__))
_STYLES_PATH = os.path.join(_HERE, "styles", "styles_v1.json")
_STYLE_PACKS_DIR = os.path.join(_HERE, "styles", "packs")
def _norm_space(s: str) -> str:
return " ".join((s or "").replace("\r", " ").replace("\n", " ").split())
def _split_phrases(s: str, sep: str) -> List[str]:
s = _norm_space(s)
if not s:
return []
parts = [p.strip() for p in s.split(sep)]
return [p for p in parts if p]
def _dedupe_phrases(phrases: List[str]) -> List[str]:
# Stable de-dupe (case-insensitive) while preserving first occurrence.
seen = set()
out: List[str] = []
for p in phrases:
k = p.casefold()
if k in seen:
continue
seen.add(k)
out.append(p)
return out
@dataclass(frozen=True)
class StyleTemplate:
id: str
name: str
category: str
prefix: str
suffix: str
flux_prefix: str = ""
flux_suffix: str = ""
tags: Tuple[str, ...] = ()
_STYLE_CACHE_SIG: Optional[Tuple[Tuple[str, float, int], ...]] = None
_STYLE_CACHE_STYLES: Tuple[StyleTemplate, ...] = ()
_STYLE_CACHE_BY_ID: Dict[str, StyleTemplate] = {}
_STYLE_CACHE_CHOICES: List[str] = []
def _file_sig(path: str) -> Tuple[str, float, int]:
try:
st = os.stat(path)
return (path, float(st.st_mtime), int(st.st_size))
except OSError:
return (path, -1.0, -1)
def _iter_pack_paths() -> List[str]:
if not os.path.isdir(_STYLE_PACKS_DIR):
return []
out: List[str] = []
for name in sorted(os.listdir(_STYLE_PACKS_DIR)):
if name.lower().endswith(".json"):
out.append(os.path.join(_STYLE_PACKS_DIR, name))
return out
def _style_sources_sig(path: str = _STYLES_PATH) -> Tuple[Tuple[str, float, int], ...]:
pack_paths = _iter_pack_paths()
# Include the legacy file in the signature even when packs exist, since we
# may fall back to it if packs are empty/broken.
sources = (pack_paths + [path]) if pack_paths else [path]
return tuple(_file_sig(p) for p in sources)
def _load_styles_file(path: str) -> List[Dict[str, Any]]:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f) or {}
if not isinstance(data, dict):
return []
styles = data.get("styles", []) or []
if not isinstance(styles, list):
return []
return [s for s in styles if isinstance(s, dict)]
def _load_style_sources(path: str = _STYLES_PATH) -> List[Dict[str, Any]]:
# Supports either a single monolithic JSON (styles_v1.json) or multiple packs
# in styles/packs/*.json. If packs exist, they are merged (in filename order).
pack_paths = _iter_pack_paths()
if not pack_paths:
try:
return _load_styles_file(path)
except Exception as e:
print(f"PromptStyler: ERROR: unable to read styles file: {path} ({e})")
return []
merged: List[Dict[str, Any]] = []
for p in pack_paths:
try:
part = _load_styles_file(p)
except Exception as e:
print(f"PromptStyler: WARN: unable to read style pack: {p} ({e})")
continue
if not part:
# Empty packs are allowed; keep going.
continue
merged.extend(part)
# If packs exist but none were usable, fall back to the legacy file so the node still loads.
if not merged:
try:
legacy = _load_styles_file(path)
except Exception as e:
print(
f"PromptStyler: ERROR: no usable style packs found and unable to read legacy file: {path} ({e})"
)
return []
if legacy:
print(f"PromptStyler: WARN: no styles loaded from packs; falling back to legacy file: {path}")
return legacy
return merged
def _get_style_library(path: str = _STYLES_PATH) -> Tuple[Tuple[StyleTemplate, ...], Dict[str, StyleTemplate], List[str]]:
global _STYLE_CACHE_SIG, _STYLE_CACHE_STYLES, _STYLE_CACHE_BY_ID, _STYLE_CACHE_CHOICES
sig = _style_sources_sig(path)
if sig == _STYLE_CACHE_SIG:
return _STYLE_CACHE_STYLES, _STYLE_CACHE_BY_ID, _STYLE_CACHE_CHOICES
raw_styles = _load_style_sources(path)
styles: List[StyleTemplate] = []
for raw in raw_styles:
sid = raw.get("id")
name = raw.get("name")
if sid is None or name is None:
continue
default = raw.get("default", {}) or {}
if not isinstance(default, dict):
default = {}
models = raw.get("models", {}) or {}
if not isinstance(models, dict):
models = {}
flux = models.get("flux_2_klein", {}) or {}
if not isinstance(flux, dict):
flux = {}
tmpl = StyleTemplate(
id=str(sid),
name=str(name),
category=str(raw.get("category", "Uncategorized")),
prefix=str(default.get("prefix", "")),
suffix=str(default.get("suffix", "")),
flux_prefix=str(flux.get("prefix", "") or ""),
flux_suffix=str(flux.get("suffix", "") or ""),
tags=tuple(raw.get("tags", []) or []),
)
styles.append(tmpl)
by_id = {s.id: s for s in styles}
choices = _choices_for_styles(styles) if styles else ["(no styles found) | (no styles) | __none__"]
_STYLE_CACHE_SIG = sig
_STYLE_CACHE_STYLES = tuple(styles)
_STYLE_CACHE_BY_ID = by_id
_STYLE_CACHE_CHOICES = choices
return _STYLE_CACHE_STYLES, _STYLE_CACHE_BY_ID, _STYLE_CACHE_CHOICES
def load_styles(path: str = _STYLES_PATH) -> Tuple[StyleTemplate, ...]:
styles, _by_id, _choices = _get_style_library(path)
return styles
def _choices_for_styles(styles: Sequence[StyleTemplate]) -> List[str]:
# One big dropdown, but sorted + categorized to make it browsable.
# Format is stable and easy to parse: "Category | Name | id"
styles_sorted = sorted(styles, key=lambda s: (s.category.casefold(), s.name.casefold(), s.id))
return [f"{s.category} | {s.name} | {s.id}" for s in styles_sorted]
def _style_by_choice(styles: Sequence[StyleTemplate], choice: str) -> Optional[StyleTemplate]:
if not choice:
return None
# Choice format: "Category | Name | id"
sid = choice.rsplit("|", 1)[-1].strip()
for s in styles:
if s.id == sid:
return s
return None
class PromptStylerConditioning:
"""
One-node UX: pick ONE style (from a categorized dropdown), apply it to the
user's prompt, and output CONDITIONING for KSampler "positive".
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
styles, _by_id, choices = _get_style_library()
return {
"required": {
# Prompt is the main input; CLIP is just a wire from your model loader.
"prompt": ("STRING", {"multiline": True, "default": ""}),
"apply_style": ("BOOLEAN", {"default": True}),
"style": (choices,),
"template_variant": (["default", "flux_2_klein"],),
"style_id_override": ("STRING", {"multiline": False, "default": ""}),
"text_encoder": ("CLIP",),
}
}
RETURN_TYPES = ("CONDITIONING", "STRING")
RETURN_NAMES = ("positive", "styled_prompt")
FUNCTION = "encode"
CATEGORY = "PromptStyler"
def encode(
self,
prompt: str,
apply_style: bool,
style: str,
template_variant: str,
style_id_override: str,
text_encoder,
):
if text_encoder is None:
raise RuntimeError("ERROR: text_encoder input is invalid: None")
prompt = prompt or ""
if not apply_style:
styled_prompt = prompt
else:
styles, by_id, _choices = _get_style_library()
style_id_override = (style_id_override or "").strip()
if style_id_override:
chosen = by_id.get(style_id_override)
if chosen is None:
raise ValueError(f"Unknown style_id_override: {style_id_override}")
else:
chosen = _style_by_choice(styles, style)
if chosen is None:
raise ValueError("No style selected.")
if template_variant == "flux_2_klein" and (chosen.flux_prefix.strip() or chosen.flux_suffix.strip()):
styled_prompt = _norm_space(" ".join([prompt, chosen.flux_prefix, chosen.flux_suffix]))
else:
sep = ", "
prefix_phrases = _split_phrases(chosen.prefix, sep=sep)
prompt_phrases = _split_phrases(prompt, sep=sep)
suffix_phrases = _split_phrases(chosen.suffix, sep=sep)
phrases = _dedupe_phrases(prefix_phrases + prompt_phrases + suffix_phrases)
styled_prompt = sep.join([p for p in phrases if p])
tokens = text_encoder.tokenize(styled_prompt)
conditioning = text_encoder.encode_from_tokens_scheduled(tokens)
return (conditioning, styled_prompt)
NODE_CLASS_MAPPINGS = {
"PromptStylerConditioning": PromptStylerConditioning,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptStylerConditioning": "PromptStyler: Prompt -> Conditioning (Style Picker)",
}