860 lines
28 KiB
Python
860 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
|
|
|
|
ROOT = os.path.dirname(os.path.realpath(__file__))
|
|
LEGACY_STYLES_PATH = os.path.join(ROOT, "styles", "styles_v1.json")
|
|
STYLE_PACKS_DIR = os.path.join(ROOT, "styles", "packs")
|
|
|
|
LOAD_POLICY_LENIENT = "lenient"
|
|
LOAD_POLICY_STRICT = "strict"
|
|
DEFAULT_VARIANT = "default"
|
|
DEFAULT_PHRASE_SEPARATOR = ", "
|
|
NO_STYLES_CHOICE = "(no styles found) | (no styles) | __none__"
|
|
Z_SEP = ", "
|
|
|
|
# Shared prompt fragments reused by the generator and user-style tooling.
|
|
CATEGORY_BASE_PREFIX = {
|
|
"Cinema": (
|
|
"cinematic film still",
|
|
"professional cinematography",
|
|
"cinematic framing",
|
|
"carefully composed shot",
|
|
"atmospheric depth",
|
|
"realistic lighting",
|
|
),
|
|
"Photography": (
|
|
"professional photography",
|
|
"realistic lighting",
|
|
"natural color rendition",
|
|
"coherent shadows",
|
|
"true-to-life textures",
|
|
),
|
|
"Product": (
|
|
"commercial product photography",
|
|
"controlled studio lighting",
|
|
"minimal background",
|
|
"controlled reflections",
|
|
"clean composition",
|
|
),
|
|
"Fashion": (
|
|
"fashion editorial photography",
|
|
"magazine look",
|
|
"high-end styling",
|
|
"clean composition",
|
|
"stylized studio lighting",
|
|
),
|
|
"Architecture/Interior": (
|
|
"architectural photography",
|
|
"straight verticals",
|
|
"clean geometry",
|
|
"wide field of view",
|
|
"realistic materials",
|
|
"balanced natural light",
|
|
),
|
|
"Nature": (
|
|
"nature photography",
|
|
"natural light",
|
|
"atmospheric depth",
|
|
"fine detail",
|
|
"realistic textures",
|
|
"environmental realism",
|
|
),
|
|
"3D/CG": (
|
|
"3d render",
|
|
"high quality render",
|
|
"clean geometry",
|
|
"global illumination",
|
|
"studio lighting",
|
|
"soft shadows",
|
|
),
|
|
"Graphic Design": (
|
|
"graphic design",
|
|
"poster-like composition",
|
|
"clear hierarchy",
|
|
"bold shapes",
|
|
"limited palette",
|
|
"print-ready layout",
|
|
),
|
|
"Illustration": (
|
|
"detailed illustration",
|
|
"cohesive style",
|
|
"readable shapes",
|
|
"intentional composition",
|
|
"handcrafted look",
|
|
),
|
|
"Anime/Manga": (
|
|
"anime/manga illustration",
|
|
"clean linework",
|
|
"cel shading",
|
|
"expressive character design",
|
|
"vibrant palette",
|
|
),
|
|
"Pixel Art": ("pixel art", "16-bit", "limited palette", "pixel-perfect"),
|
|
"Vector": ("vector illustration", "flat colors", "clean shapes", "crisp edges", "smooth curves"),
|
|
"Technical": ("technical illustration", "clean lines", "diagrammatic layout", "precise geometry", "labels and callouts"),
|
|
"Printmaking": ("printmaking", "ink on paper", "handcrafted texture", "carved lines", "high contrast"),
|
|
"Experimental": ("experimental aesthetic", "bold composition", "graphic lighting", "textural detail"),
|
|
"Color Grade": ("intentional color grade", "filmic tone mapping"),
|
|
"Fine Art": ("fine art", "museum-quality", "traditional techniques", "gallery piece", "handcrafted"),
|
|
"Mixed Media": ("mixed media artwork", "layered textures", "handcrafted marks", "tactile surface"),
|
|
"Street Art": ("street art mural", "spray paint texture", "urban wall", "bold graphic shapes"),
|
|
}
|
|
|
|
CATEGORY_BASE_SUFFIX = {
|
|
"Cinema": (
|
|
"filmic color science",
|
|
"smooth highlight rolloff",
|
|
"deep blacks",
|
|
"subtle film grain",
|
|
"soft halation",
|
|
"high dynamic range",
|
|
),
|
|
"Photography": (
|
|
"sharp focus",
|
|
"high detail",
|
|
"balanced exposure",
|
|
"fine texture detail",
|
|
"natural contrast",
|
|
"clean highlights",
|
|
),
|
|
"Product": (
|
|
"crisp edges",
|
|
"clean specular highlights",
|
|
"premium look",
|
|
"high detail",
|
|
"minimal clutter",
|
|
),
|
|
"Fashion": (
|
|
"editorial color grade",
|
|
"sharp focus",
|
|
"skin texture",
|
|
"catchlights",
|
|
"high detail",
|
|
),
|
|
"Architecture/Interior": (
|
|
"crisp lines",
|
|
"balanced exposure",
|
|
"clean shadows",
|
|
"high detail",
|
|
"material realism",
|
|
),
|
|
"Nature": (
|
|
"natural color",
|
|
"clean contrast",
|
|
"high detail",
|
|
"fine microtexture",
|
|
"realistic atmosphere",
|
|
),
|
|
"3D/CG": (
|
|
"clean render",
|
|
"high detail",
|
|
"sharp edges",
|
|
"high resolution",
|
|
"clean shading",
|
|
),
|
|
"Graphic Design": (
|
|
"crisp edges",
|
|
"high contrast",
|
|
"clean alignment",
|
|
"balanced negative space",
|
|
"print texture",
|
|
),
|
|
"Illustration": ("cohesive palette", "clean composition", "high detail", "believable texture"),
|
|
"Anime/Manga": ("high clarity", "crisp edges", "clean gradients", "sharp eyes"),
|
|
"Pixel Art": ("crisp pixels", "dithered shading", "readable silhouette", "sprite-like clarity"),
|
|
"Vector": ("clean layout", "consistent stroke weight", "crisp edges", "smooth curves"),
|
|
"Technical": ("high legibility", "crisp lines", "precise layout", "flat lighting"),
|
|
"Printmaking": ("paper texture", "ink texture", "rough edges", "high contrast"),
|
|
"Experimental": ("intentional design", "high contrast", "tactile texture", "coherent palette"),
|
|
"Color Grade": ("smooth highlight rolloff", "clean shadows", "color separation", "balanced contrast"),
|
|
"Fine Art": ("high detail", "handcrafted surface texture", "gallery lighting", "balanced composition"),
|
|
"Mixed Media": ("high detail", "tactile texture", "paper grain", "layered depth", "cohesive composition"),
|
|
"Street Art": ("bold color", "high contrast", "wall texture", "urban grit", "spray paint overspray"),
|
|
}
|
|
|
|
|
|
class StyleLibraryError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StyleTemplate:
|
|
id: str
|
|
name: str
|
|
category: str
|
|
prefix: str
|
|
suffix: str
|
|
tags: Tuple[str, ...]
|
|
variants: Dict[str, Tuple[str, str]]
|
|
|
|
def variant_prompt(self, variant: str) -> Tuple[str, str, str]:
|
|
if variant == DEFAULT_VARIANT:
|
|
return DEFAULT_VARIANT, self.prefix, self.suffix
|
|
|
|
prefix, suffix = self.variants.get(variant, ("", ""))
|
|
if prefix.strip() or suffix.strip():
|
|
return variant, prefix, suffix
|
|
|
|
return DEFAULT_VARIANT, self.prefix, self.suffix
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StyleLibrary:
|
|
styles: Tuple[StyleTemplate, ...]
|
|
by_id: Dict[str, StyleTemplate]
|
|
choices: List[str]
|
|
variants: Tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SearchCandidate:
|
|
style: StyleTemplate
|
|
score: int
|
|
matched_fields: Tuple[str, ...]
|
|
|
|
|
|
_CACHE_SIGS: Dict[str, Tuple[Tuple[str, float, int], ...]] = {}
|
|
_CACHE_LIBRARIES: Dict[str, StyleLibrary] = {}
|
|
|
|
|
|
def norm_space(value: str) -> str:
|
|
return " ".join((value or "").replace("\r", " ").replace("\n", " ").split())
|
|
|
|
|
|
def split_phrases(value: str, sep: str = DEFAULT_PHRASE_SEPARATOR) -> List[str]:
|
|
value = norm_space(value)
|
|
if not value:
|
|
return []
|
|
return [part for part in (p.strip() for p in value.split(sep)) if part]
|
|
|
|
|
|
def dedupe_phrases(phrases: Sequence[str]) -> List[str]:
|
|
seen = set()
|
|
out: List[str] = []
|
|
for phrase in phrases:
|
|
key = phrase.casefold()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(phrase)
|
|
return out
|
|
|
|
|
|
def z_join(parts: Sequence[str]) -> str:
|
|
return Z_SEP.join(dedupe_phrases([p.strip() for p in parts if (p or "").strip()]))
|
|
|
|
|
|
def flux_join_sentences(parts: Sequence[str]) -> str:
|
|
parts2 = [p.strip().rstrip(".") for p in parts if (p or "").strip()]
|
|
return ". ".join(parts2).strip() + ("." if parts2 else "")
|
|
|
|
|
|
def style_to_dict(style: StyleTemplate) -> Dict[str, Any]:
|
|
models = {
|
|
name: {"prefix": prefix, "suffix": suffix}
|
|
for name, (prefix, suffix) in sorted(style.variants.items())
|
|
if prefix or suffix
|
|
}
|
|
return {
|
|
"id": style.id,
|
|
"name": style.name,
|
|
"category": style.category,
|
|
"default": {"prefix": style.prefix, "suffix": style.suffix},
|
|
"models": models,
|
|
"tags": list(style.tags),
|
|
}
|
|
|
|
|
|
def _pack_sort_key(name: str) -> Tuple[int, str]:
|
|
match = re.match(r"^(\d+)", name)
|
|
if not match:
|
|
return (9999, name.casefold())
|
|
return (int(match.group(1)), name.casefold())
|
|
|
|
|
|
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), key=_pack_sort_key):
|
|
if name.lower().endswith(".json"):
|
|
out.append(os.path.join(STYLE_PACKS_DIR, name))
|
|
return out
|
|
|
|
|
|
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 style_sources_sig(legacy_path: str = LEGACY_STYLES_PATH) -> Tuple[Tuple[str, float, int], ...]:
|
|
pack_paths = iter_pack_paths()
|
|
sources = (pack_paths + [legacy_path]) if pack_paths else [legacy_path]
|
|
return tuple(_file_sig(path) for path in sources)
|
|
|
|
|
|
def _emit_problem(load_policy: str, message: str) -> None:
|
|
if load_policy == LOAD_POLICY_STRICT:
|
|
raise StyleLibraryError(message)
|
|
print(f"PromptStyler: WARN: {message}")
|
|
|
|
|
|
def _read_json(path: str, load_policy: str) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle) or {}
|
|
except Exception as exc:
|
|
_emit_problem(load_policy, f"unable to read style pack: {path} ({exc})")
|
|
return None
|
|
|
|
if not isinstance(data, dict):
|
|
_emit_problem(load_policy, f"style file must be a JSON object: {path}")
|
|
return None
|
|
return data
|
|
|
|
|
|
def _iter_raw_style_entries(path: str, load_policy: str) -> List[Dict[str, Any]]:
|
|
data = _read_json(path, load_policy)
|
|
if data is None:
|
|
return []
|
|
styles = data.get("styles", [])
|
|
if not isinstance(styles, list):
|
|
_emit_problem(load_policy, f"'styles' must be a list in: {path}")
|
|
return []
|
|
return [item for item in styles if isinstance(item, dict)]
|
|
|
|
|
|
def _normalize_variants(raw_models: Any, *, path: str, index: int, load_policy: str) -> Dict[str, Tuple[str, str]]:
|
|
if raw_models is None:
|
|
return {}
|
|
if not isinstance(raw_models, dict):
|
|
_emit_problem(load_policy, f"styles[{index}] models must be an object in: {path}")
|
|
return {}
|
|
|
|
out: Dict[str, Tuple[str, str]] = {}
|
|
for key, raw_value in raw_models.items():
|
|
if not isinstance(key, str) or not key.strip():
|
|
_emit_problem(load_policy, f"styles[{index}] has a blank model key in: {path}")
|
|
continue
|
|
if not isinstance(raw_value, dict):
|
|
_emit_problem(load_policy, f"styles[{index}] model '{key}' must be an object in: {path}")
|
|
continue
|
|
prefix = str(raw_value.get("prefix", "") or "")
|
|
suffix = str(raw_value.get("suffix", "") or "")
|
|
out[key] = (prefix, suffix)
|
|
return out
|
|
|
|
|
|
def _normalize_style(raw: Dict[str, Any], *, path: str, index: int, load_policy: str) -> Optional[StyleTemplate]:
|
|
sid = raw.get("id")
|
|
name = raw.get("name")
|
|
if sid is None or name is None:
|
|
_emit_problem(load_policy, f"styles[{index}] missing id or name in: {path}")
|
|
return None
|
|
|
|
default = raw.get("default", {})
|
|
if not isinstance(default, dict):
|
|
_emit_problem(load_policy, f"styles[{index}] default must be an object in: {path}")
|
|
return None
|
|
if "prefix" not in default or "suffix" not in default:
|
|
_emit_problem(load_policy, f"styles[{index}] default must include prefix and suffix in: {path}")
|
|
return None
|
|
|
|
tags_raw = raw.get("tags", []) or []
|
|
if not isinstance(tags_raw, list):
|
|
_emit_problem(load_policy, f"styles[{index}] tags must be a list in: {path}")
|
|
tags_raw = []
|
|
|
|
variants = _normalize_variants(raw.get("models", {}), path=path, index=index, load_policy=load_policy)
|
|
return StyleTemplate(
|
|
id=str(sid),
|
|
name=str(name),
|
|
category=str(raw.get("category", "Uncategorized")),
|
|
prefix=str(default.get("prefix", "") or ""),
|
|
suffix=str(default.get("suffix", "") or ""),
|
|
tags=tuple(str(tag).strip() for tag in tags_raw if str(tag).strip()),
|
|
variants=variants,
|
|
)
|
|
|
|
|
|
def _normalize_styles(raw_styles: Sequence[Dict[str, Any]], *, path: str, load_policy: str) -> List[StyleTemplate]:
|
|
out: List[StyleTemplate] = []
|
|
seen_ids = set()
|
|
seen_names = set()
|
|
for index, raw in enumerate(raw_styles):
|
|
style = _normalize_style(raw, path=path, index=index, load_policy=load_policy)
|
|
if style is None:
|
|
continue
|
|
if style.id in seen_ids:
|
|
_emit_problem(load_policy, f"duplicate id '{style.id}' in: {path}")
|
|
continue
|
|
if style.name in seen_names:
|
|
_emit_problem(load_policy, f"duplicate name '{style.name}' in: {path}")
|
|
continue
|
|
seen_ids.add(style.id)
|
|
seen_names.add(style.name)
|
|
out.append(style)
|
|
return out
|
|
|
|
|
|
def load_style_library(load_policy: str = LOAD_POLICY_LENIENT, legacy_path: str = LEGACY_STYLES_PATH) -> StyleLibrary:
|
|
if load_policy not in (LOAD_POLICY_LENIENT, LOAD_POLICY_STRICT):
|
|
raise ValueError(f"Unsupported load_policy: {load_policy}")
|
|
|
|
pack_paths = iter_pack_paths()
|
|
styles: List[StyleTemplate] = []
|
|
by_id: Dict[str, StyleTemplate] = {}
|
|
variants = {DEFAULT_VARIANT}
|
|
|
|
if not pack_paths:
|
|
raw_styles = _iter_raw_style_entries(legacy_path, load_policy)
|
|
styles = _normalize_styles(raw_styles, path=legacy_path, load_policy=load_policy)
|
|
else:
|
|
for path in pack_paths:
|
|
raw_styles = _iter_raw_style_entries(path, load_policy)
|
|
normalized = _normalize_styles(raw_styles, path=path, load_policy=load_policy)
|
|
for style in normalized:
|
|
if style.id in by_id:
|
|
_emit_problem(load_policy, f"duplicate id '{style.id}' across packs")
|
|
continue
|
|
styles.append(style)
|
|
by_id[style.id] = style
|
|
variants.update(style.variants.keys())
|
|
|
|
if not styles:
|
|
if load_policy == LOAD_POLICY_STRICT:
|
|
raise StyleLibraryError("no usable style packs found")
|
|
raw_styles = _iter_raw_style_entries(legacy_path, load_policy)
|
|
styles = _normalize_styles(raw_styles, path=legacy_path, load_policy=load_policy)
|
|
|
|
if not by_id:
|
|
for style in styles:
|
|
if style.id not in by_id:
|
|
by_id[style.id] = style
|
|
variants.update(style.variants.keys())
|
|
|
|
choices = choices_for_styles(styles) if styles else [NO_STYLES_CHOICE]
|
|
return StyleLibrary(
|
|
styles=tuple(styles),
|
|
by_id=by_id,
|
|
choices=choices,
|
|
variants=tuple([DEFAULT_VARIANT] + sorted(v for v in variants if v != DEFAULT_VARIANT)),
|
|
)
|
|
|
|
|
|
def get_cached_style_library(load_policy: str = LOAD_POLICY_LENIENT, legacy_path: str = LEGACY_STYLES_PATH) -> StyleLibrary:
|
|
sig = style_sources_sig(legacy_path)
|
|
if _CACHE_SIGS.get(load_policy) == sig:
|
|
return _CACHE_LIBRARIES[load_policy]
|
|
library = load_style_library(load_policy=load_policy, legacy_path=legacy_path)
|
|
_CACHE_SIGS[load_policy] = sig
|
|
_CACHE_LIBRARIES[load_policy] = library
|
|
return library
|
|
|
|
|
|
def choices_for_styles(styles: Sequence[StyleTemplate]) -> List[str]:
|
|
styles_sorted = sorted(styles, key=lambda style: (style.category.casefold(), style.name.casefold(), style.id))
|
|
return [f"{style.category} | {style.name} | {style.id}" for style in styles_sorted]
|
|
|
|
|
|
def style_by_choice(styles: Sequence[StyleTemplate], choice: str) -> Optional[StyleTemplate]:
|
|
if not choice:
|
|
return None
|
|
sid = choice.rsplit("|", 1)[-1].strip()
|
|
for style in styles:
|
|
if style.id == sid:
|
|
return style
|
|
return None
|
|
|
|
|
|
def resolve_style_legacy(
|
|
library: StyleLibrary,
|
|
*,
|
|
choice: str,
|
|
style_id_override: str,
|
|
) -> StyleTemplate:
|
|
style_id_override = (style_id_override or "").strip()
|
|
if style_id_override:
|
|
chosen = library.by_id.get(style_id_override)
|
|
if chosen is None:
|
|
raise StyleLibraryError(f"Unknown style_id_override: {style_id_override}")
|
|
return chosen
|
|
|
|
chosen = style_by_choice(library.styles, choice)
|
|
if chosen is None:
|
|
raise StyleLibraryError("No style selected.")
|
|
return chosen
|
|
|
|
|
|
def parse_tag_hint(value: str) -> Tuple[str, ...]:
|
|
if not value:
|
|
return ()
|
|
return tuple(part.strip().casefold() for part in value.split(",") if part.strip())
|
|
|
|
|
|
def _style_matches_filters(style: StyleTemplate, *, category_hint: str, tags: Sequence[str]) -> bool:
|
|
if category_hint and category_hint.casefold() not in style.category.casefold():
|
|
return False
|
|
if tags:
|
|
style_tags = {tag.casefold() for tag in style.tags}
|
|
if any(tag not in style_tags for tag in tags):
|
|
return False
|
|
return True
|
|
|
|
|
|
def filter_styles(
|
|
styles: Sequence[StyleTemplate],
|
|
*,
|
|
category_hint: str = "",
|
|
tag_hint: str = "",
|
|
) -> List[StyleTemplate]:
|
|
category_hint = category_hint.strip()
|
|
tags = parse_tag_hint(tag_hint)
|
|
return [style for style in styles if _style_matches_filters(style, category_hint=category_hint, tags=tags)]
|
|
|
|
|
|
def _search_tokens(query: str) -> List[str]:
|
|
return [token for token in re.split(r"[^a-z0-9_]+", query.casefold()) if token]
|
|
|
|
|
|
def _search_candidate(style: StyleTemplate, query: str, tokens: Sequence[str]) -> Optional[SearchCandidate]:
|
|
query_cf = query.casefold()
|
|
if query_cf == style.id.casefold():
|
|
return SearchCandidate(style=style, score=10000, matched_fields=("id_exact",))
|
|
if query_cf == style.name.casefold():
|
|
return SearchCandidate(style=style, score=9000, matched_fields=("name_exact",))
|
|
|
|
matched_fields: List[str] = []
|
|
score = 0
|
|
id_cf = style.id.casefold()
|
|
name_cf = style.name.casefold()
|
|
category_cf = style.category.casefold()
|
|
tags_cf = [tag.casefold() for tag in style.tags]
|
|
|
|
for token in tokens:
|
|
token_score = 0
|
|
if token in id_cf:
|
|
token_score += 60
|
|
matched_fields.append("id")
|
|
if token in name_cf:
|
|
token_score += 40
|
|
matched_fields.append("name")
|
|
if token in category_cf:
|
|
token_score += 25
|
|
matched_fields.append("category")
|
|
if any(token == tag for tag in tags_cf):
|
|
token_score += 35
|
|
matched_fields.append("tag_exact")
|
|
elif any(token in tag for tag in tags_cf):
|
|
token_score += 15
|
|
matched_fields.append("tag")
|
|
score += token_score
|
|
|
|
if score <= 0:
|
|
return None
|
|
|
|
score += min(len(tokens), 5)
|
|
return SearchCandidate(style=style, score=score, matched_fields=tuple(matched_fields))
|
|
|
|
|
|
def search_styles(
|
|
styles: Sequence[StyleTemplate],
|
|
query: str,
|
|
*,
|
|
category_hint: str = "",
|
|
tag_hint: str = "",
|
|
) -> List[SearchCandidate]:
|
|
query = query.strip()
|
|
if not query:
|
|
return []
|
|
|
|
filtered = filter_styles(styles, category_hint=category_hint, tag_hint=tag_hint)
|
|
tokens = _search_tokens(query)
|
|
if not tokens:
|
|
return []
|
|
|
|
candidates: List[SearchCandidate] = []
|
|
for style in filtered:
|
|
candidate = _search_candidate(style, query, tokens)
|
|
if candidate is not None:
|
|
candidates.append(candidate)
|
|
|
|
candidates.sort(
|
|
key=lambda item: (
|
|
-item.score,
|
|
item.style.category.casefold(),
|
|
item.style.name.casefold(),
|
|
item.style.id,
|
|
)
|
|
)
|
|
return candidates
|
|
|
|
|
|
def resolve_style(
|
|
library: StyleLibrary,
|
|
*,
|
|
selection_mode: str,
|
|
choice: str,
|
|
style_id_override: str,
|
|
style_search: str,
|
|
category_hint: str = "",
|
|
tag_hint: str = "",
|
|
) -> StyleTemplate:
|
|
style_id_override = (style_id_override or "").strip()
|
|
category_hint = (category_hint or "").strip()
|
|
tag_hint = (tag_hint or "").strip()
|
|
|
|
# Preserve old graphs: if a saved workflow already uses style_id_override and
|
|
# the new selection mode input is left at its default, still honor the id.
|
|
if selection_mode == "id" or (selection_mode == "dropdown" and style_id_override):
|
|
chosen = library.by_id.get(style_id_override)
|
|
if chosen is None:
|
|
raise StyleLibraryError(f"Unknown style_id_override: {style_id_override}")
|
|
return chosen
|
|
|
|
if selection_mode == "dropdown":
|
|
chosen = style_by_choice(library.styles, choice)
|
|
if chosen is None:
|
|
raise StyleLibraryError("No style selected.")
|
|
return chosen
|
|
|
|
if selection_mode != "search":
|
|
raise StyleLibraryError(f"Unsupported selection_mode: {selection_mode}")
|
|
|
|
matches = search_styles(
|
|
library.styles,
|
|
style_search,
|
|
category_hint=category_hint,
|
|
tag_hint=tag_hint,
|
|
)
|
|
if not matches:
|
|
raise StyleLibraryError(
|
|
f"No styles matched search query: {style_search.strip() or '(empty query)'}"
|
|
)
|
|
|
|
top = matches[0]
|
|
tied = [item for item in matches if item.score == top.score]
|
|
if len(tied) > 1:
|
|
preview = "; ".join(
|
|
f"{item.style.category} | {item.style.name} | {item.style.id}"
|
|
for item in matches[:5]
|
|
)
|
|
raise StyleLibraryError(f"Ambiguous style search '{style_search}': {preview}")
|
|
return top.style
|
|
|
|
|
|
def _style_limit(style_strength: str) -> Optional[int]:
|
|
return {
|
|
"subtle": 3,
|
|
"normal": 6,
|
|
"strong": None,
|
|
}.get(style_strength, None)
|
|
|
|
|
|
def _limit_phrases(phrases: Sequence[str], style_strength: str) -> List[str]:
|
|
limit = _style_limit(style_strength)
|
|
if limit is None:
|
|
return list(phrases)
|
|
return list(phrases[:limit])
|
|
|
|
|
|
def compose_default_prompt(
|
|
prompt: str,
|
|
style: StyleTemplate,
|
|
*,
|
|
style_strength: str = "strong",
|
|
dedupe_mode: str = "smart",
|
|
overlay_style: Optional[StyleTemplate] = None,
|
|
) -> Tuple[str, Dict[str, Any]]:
|
|
base_prefix = _limit_phrases(split_phrases(style.prefix), style_strength)
|
|
base_suffix = _limit_phrases(split_phrases(style.suffix), style_strength)
|
|
overlay_suffix = _limit_phrases(split_phrases(overlay_style.suffix), style_strength) if overlay_style else []
|
|
|
|
user_phrase_keys = {phrase.casefold() for phrase in split_phrases(prompt)}
|
|
style_phrase_keys = set()
|
|
|
|
def include_phrase(phrase: str) -> bool:
|
|
key = phrase.casefold()
|
|
if dedupe_mode == "off":
|
|
return True
|
|
if key in style_phrase_keys:
|
|
return False
|
|
if key in user_phrase_keys:
|
|
return False
|
|
style_phrase_keys.add(key)
|
|
return True
|
|
|
|
prefix_phrases = [phrase for phrase in base_prefix if include_phrase(phrase)]
|
|
suffix_phrases = [phrase for phrase in base_suffix if include_phrase(phrase)]
|
|
overlay_phrases = [phrase for phrase in overlay_suffix if include_phrase(phrase)]
|
|
|
|
parts = [
|
|
DEFAULT_PHRASE_SEPARATOR.join(prefix_phrases),
|
|
prompt,
|
|
DEFAULT_PHRASE_SEPARATOR.join(suffix_phrases + overlay_phrases),
|
|
]
|
|
styled_prompt = DEFAULT_PHRASE_SEPARATOR.join([part for part in parts if part])
|
|
return styled_prompt, {
|
|
"base_prefix_phrases": prefix_phrases,
|
|
"base_suffix_phrases": suffix_phrases,
|
|
"overlay_suffix_phrases": overlay_phrases,
|
|
}
|
|
|
|
|
|
def compose_prompt(
|
|
prompt: str,
|
|
style: StyleTemplate,
|
|
*,
|
|
template_variant: str,
|
|
style_strength: str = "strong",
|
|
dedupe_mode: str = "smart",
|
|
overlay_style: Optional[StyleTemplate] = None,
|
|
) -> Tuple[str, Dict[str, Any]]:
|
|
prompt = prompt or ""
|
|
applied_variant, prefix, suffix = style.variant_prompt(template_variant)
|
|
overlay_variant = DEFAULT_VARIANT
|
|
overlay_suffix = ""
|
|
|
|
if overlay_style is not None:
|
|
overlay_variant, _overlay_prefix, overlay_suffix = overlay_style.variant_prompt(template_variant)
|
|
|
|
if applied_variant == DEFAULT_VARIANT:
|
|
styled_prompt, details = compose_default_prompt(
|
|
prompt,
|
|
style,
|
|
style_strength=style_strength,
|
|
dedupe_mode=dedupe_mode,
|
|
overlay_style=overlay_style,
|
|
)
|
|
details["variant"] = applied_variant
|
|
details["overlay_variant"] = overlay_variant if overlay_style else ""
|
|
return styled_prompt, details
|
|
|
|
parts = [prompt, prefix, suffix, overlay_suffix]
|
|
styled_prompt = norm_space(" ".join(part for part in parts if part))
|
|
return styled_prompt, {
|
|
"variant": applied_variant,
|
|
"overlay_variant": overlay_variant if overlay_style else "",
|
|
"base_prefix_phrases": [],
|
|
"base_suffix_phrases": [],
|
|
"overlay_suffix_phrases": [],
|
|
}
|
|
|
|
|
|
def compose_prompt_legacy(
|
|
prompt: str,
|
|
style: StyleTemplate,
|
|
*,
|
|
template_variant: str,
|
|
) -> Tuple[str, Dict[str, Any]]:
|
|
prompt = prompt or ""
|
|
applied_variant, prefix, suffix = style.variant_prompt(template_variant)
|
|
|
|
if applied_variant != DEFAULT_VARIANT:
|
|
styled_prompt = norm_space(" ".join(part for part in (prompt, prefix, suffix) if part))
|
|
return styled_prompt, {
|
|
"variant": applied_variant,
|
|
"base_prefix_phrases": [],
|
|
"base_suffix_phrases": [],
|
|
}
|
|
|
|
prefix_phrases = split_phrases(style.prefix)
|
|
prompt_phrases = split_phrases(prompt)
|
|
suffix_phrases = split_phrases(style.suffix)
|
|
phrases = dedupe_phrases(prefix_phrases + prompt_phrases + suffix_phrases)
|
|
styled_prompt = DEFAULT_PHRASE_SEPARATOR.join(phrase for phrase in phrases if phrase)
|
|
return styled_prompt, {
|
|
"variant": DEFAULT_VARIANT,
|
|
"base_prefix_phrases": prefix_phrases,
|
|
"base_suffix_phrases": suffix_phrases,
|
|
}
|
|
|
|
|
|
def is_color_grade_style(style: StyleTemplate) -> bool:
|
|
return style.category.startswith("Color Grade")
|
|
|
|
|
|
def resolve_overlay_style(library: StyleLibrary, overlay_style_id: str) -> Optional[StyleTemplate]:
|
|
overlay_style_id = (overlay_style_id or "").strip()
|
|
if not overlay_style_id:
|
|
return None
|
|
chosen = library.by_id.get(overlay_style_id)
|
|
if chosen is None:
|
|
raise StyleLibraryError(f"Unknown overlay_style_id: {overlay_style_id}")
|
|
if not is_color_grade_style(chosen):
|
|
raise StyleLibraryError(
|
|
f"overlay_style_id must reference a Color Grade style: {overlay_style_id}"
|
|
)
|
|
return chosen
|
|
|
|
|
|
def make_style_meta(
|
|
*,
|
|
chosen_style: Optional[StyleTemplate],
|
|
overlay_style: Optional[StyleTemplate],
|
|
requested_variant: str,
|
|
applied_variant: str,
|
|
selection_mode: str,
|
|
style_search: str,
|
|
category_hint: str,
|
|
tag_hint: str,
|
|
style_strength: str,
|
|
dedupe_mode: str,
|
|
on_missing_style: str,
|
|
prompt_details: Mapping[str, Any],
|
|
error: str = "",
|
|
) -> str:
|
|
payload = {
|
|
"status": "error" if error else "ok",
|
|
"error": error,
|
|
"selection_mode": selection_mode,
|
|
"style_search": style_search,
|
|
"category_hint": category_hint,
|
|
"tag_hint": tag_hint,
|
|
"requested_variant": requested_variant,
|
|
"applied_variant": applied_variant,
|
|
"style_strength": style_strength,
|
|
"dedupe_mode": dedupe_mode,
|
|
"on_missing_style": on_missing_style,
|
|
"style": {
|
|
"id": chosen_style.id if chosen_style else "",
|
|
"name": chosen_style.name if chosen_style else "",
|
|
"category": chosen_style.category if chosen_style else "",
|
|
"tags": list(chosen_style.tags) if chosen_style else [],
|
|
},
|
|
"overlay": {
|
|
"id": overlay_style.id if overlay_style else "",
|
|
"name": overlay_style.name if overlay_style else "",
|
|
"category": overlay_style.category if overlay_style else "",
|
|
"tags": list(overlay_style.tags) if overlay_style else [],
|
|
},
|
|
"prompt_details": dict(prompt_details),
|
|
}
|
|
return json.dumps(payload, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def available_categories(styles: Sequence[StyleTemplate]) -> List[str]:
|
|
categories = {style.category for style in styles if style.category.strip()}
|
|
categories.update(CATEGORY_BASE_PREFIX.keys())
|
|
categories.update(CATEGORY_BASE_SUFFIX.keys())
|
|
return sorted(categories, key=lambda value: value.casefold())
|
|
|
|
|
|
def write_legacy_snapshot(path: str = LEGACY_STYLES_PATH, load_policy: str = LOAD_POLICY_STRICT) -> int:
|
|
library = load_style_library(load_policy=load_policy)
|
|
payload = {"version": 1, "styles": [style_to_dict(style) for style in library.styles]}
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, ensure_ascii=True)
|
|
handle.write("\n")
|
|
return len(library.styles)
|