Files
EricRollei-Local_LLM_Prompt…/core/builder/compose.py
T
Eric HissandCopilot 373ea8bda3 v2.2.0: Prompt Builder nodes, wildcard review tool, docs refresh
- Add Wildcard Prompt Composer and Wildcard Character Composer builder nodes (core/builder, web/prompt_builder.js, builders/, vp wildcard lists)

- Add browser-based wildcard review tool (tools/wildcard_review) with manifest-based undo

- Add wildcard catalog/curation, surreal compiler/lint/scene, profile token budget

- README: document all 18 nodes, bump to v2.2.0, fix stale node/platform lists; CHANGELOG 2.2.0

- Rewrite NODE_COMPARISON for 18 nodes; fix QUICKSTART (dead test_setup.py ref, menu path) and QUICK_REFERENCE node list

- gitignore review manifests and CLAUDE.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-28 20:36:44 -07:00

273 lines
12 KiB
Python

"""
Composer state -> wildcard template text, and prompt sections in platform order.
The builder widget stores its state as JSON in the node's hidden `composer` widget:
{
"v": 1,
"parts": {
"subject": [ {"t": "text", "v": "a"}, {"t": "tok", "id": 1}, {"t": "text", "v": "in typical uniform"} ]
},
"tokens": {
"1": {"slot": "age", "key": "cyberrealistic_v10/cyberdelia/miscellaneous/cyber_age",
"mode": "subset", "picks": ["young adult", "elderly"], "count": 1, "keep": "exact"}
}
}
mode random any entry of the list __key__
fixed always picks[0] (may be your own text) {1$$value}
subset random among picks {a|b|c}
exclude random, never one of picks __key!{a|b}__
count how many distinct picks, joined with " and " {2$$ and $$...}
keep how the LLM rewriter treats the pick (binding markers of the wildcard engine):
meaning (default, wording may change), exact (=, kept word for word), loose (?, may be dropped)
Text between pills is used as typed, and may itself hold wildcard syntax ({red|blue}, __name__). Pieces are joined with single spaces and a space before
, . ; : ! ? is removed, so "a", pill, ", with" reads "a young adult, with".
(c) Eric Hiss. Dual license, see LICENSE.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
MODES = ("random", "fixed", "subset", "exclude")
KEEP_MARKERS = {"meaning": "", "concept": "", "exact": "=", "verbatim": "=", "loose": "?"}
MAX_COUNT = 8
JOINER = " and "
# Canonical prompt sections. This tuple is the SEED order (section_seed_offset): new sections are
# only ever appended, so saved workflows keep re-rolling the same picks. See dev/PROMPT_TAXONOMY_SPEC.md.
SECTIONS = ("subject", "action", "setting", "lighting", "style", "camera", "format", "composition", "text", "prompt")
# The order sections are built and joined in when neither the node nor the platform says otherwise.
DEFAULT_ORDER = ("format", "prompt", "subject", "action", "setting", "lighting", "composition", "camera", "style", "text")
# Sections a node shows only when added ("+ Section").
OPTIONAL_SECTIONS = ("text", "prompt")
SECTION_LABELS = {
"format": "Format", "subject": "Subject", "action": "Action", "setting": "Setting",
"lighting": "Light & atmosphere", "composition": "Composition", "camera": "Camera",
"style": "Style & finish", "text": "Text", "prompt": "Prompt",
}
# Words in a profile's `structure` line that name each section.
_SECTION_WORDS = {
"prompt": r"full prompt|base prompt|prompt file",
"format": r"format|image type|type of image|medium|intended use|use first|photograph of|poster|product shot",
"subject": r"subjects?|who|character|entity",
"action": r"actions?|pose|poses|motion|doing",
"setting": r"setting|background|context|environment|location|scene|place",
"lighting": r"light|lighting|atmosphere",
"composition": r"composition|compositional|framing|layout|placement|viewpoint|shot size",
"camera": r"camera|technical|lens|focal length|aperture|shot",
"style": r"style|aesthetic|art ?style|finish|stylisation|stylization",
"text": r"text|lettering|typography|wording",
}
_ESCAPE = re.compile(r"([{}|$\[\]#\\])")
_SPACE_BEFORE_PUNCT = re.compile(r"\s+([,.;:!?])")
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
def escape_literal(text: str) -> str:
"""Make typed text or a picked entry safe inside wildcard syntax."""
escaped = _ESCAPE.sub(r"\\\1", text or "")
return escaped.replace("__", "\\_\\_")
def join_pieces(pieces: Sequence[str]) -> str:
joined = " ".join(piece.strip() for piece in pieces if piece and piece.strip())
joined = _SPACE_BEFORE_PUNCT.sub(r"\1", joined)
return _MULTI_SPACE.sub(" ", joined).strip()
@dataclass
class Token:
id: str
slot: str = ""
key: str = ""
mode: str = "random"
picks: List[str] = field(default_factory=list)
count: int = 1
keep: str = "meaning"
@classmethod
def from_dict(cls, token_id: str, data: Dict[str, Any]) -> "Token":
mode = str(data.get("mode", "random"))
try:
count = int(data.get("count", 1))
except (TypeError, ValueError):
count = 1
return cls(
id=str(token_id),
slot=str(data.get("slot", "") or ""),
key=str(data.get("key", "") or "").strip().strip("_").replace("\\", "/"),
mode=mode if mode in MODES else "random",
picks=[str(pick) for pick in (data.get("picks") or []) if str(pick).strip()],
count=max(1, min(MAX_COUNT, count)),
keep=str(data.get("keep", "meaning") or "meaning"),
)
@property
def marker(self) -> str:
return KEEP_MARKERS.get(self.keep, "")
def syntax(self) -> str:
"""Wildcard-engine text for this pill. '' when it cannot produce anything."""
marker = self.marker
many = self.count > 1
count_prefix = f"{self.count}$${JOINER}$$" if many else ""
if self.mode == "fixed" and self.picks:
return "{" + marker + "1$$" + escape_literal(self.picks[0]) + "}"
if self.mode == "subset" and self.picks:
options = "|".join(escape_literal(pick) for pick in self.picks)
if not many and len(self.picks) == 1:
return "{" + marker + "1$$" + options + "}"
return "{" + marker + count_prefix + options + "}"
if not self.key:
return ""
if self.mode == "exclude" and self.picks:
# Exclusions cannot contain | { } or a newline (grammar limit); those picks are skipped.
usable = [pick for pick in self.picks if not re.search(r"[|{}\n]", pick)]
body = "__" + self.key + ("!{" + "|".join(usable) + "}" if usable else "") + "__"
else:
body = "__" + self.key + "__"
if many:
return "{" + marker + count_prefix + body + "}"
return "__" + marker + body[2:] if marker else body
@dataclass
class ComposerState:
parts: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
tokens: Dict[str, Token] = field(default_factory=dict)
locks: Dict[str, int] = field(default_factory=dict) # section -> the seed it is held at
fill: Dict[str, bool] = field(default_factory=dict) # section -> an empty section is written by the LLM
order: List[str] = field(default_factory=list) # the node's own section order ([] = the platform's)
kinds: Dict[str, str] = field(default_factory=dict) # subject section -> person | animal | ... | scene
errors: List[str] = field(default_factory=list)
@classmethod
def from_json(cls, raw: str) -> "ComposerState":
state = cls()
if not (raw or "").strip():
return state
try:
data = json.loads(raw)
except ValueError as error:
# Not JSON: treat the whole thing as a plain subject template (hand-written or pasted).
state.parts["subject"] = [{"t": "raw", "v": raw}]
state.errors.append(f"composer is not JSON ({error}); used as plain text")
return state
if not isinstance(data, dict):
state.errors.append("composer JSON is not an object; ignored")
return state
for token_id, token in (data.get("tokens") or {}).items():
if isinstance(token, dict):
state.tokens[str(token_id)] = Token.from_dict(str(token_id), token)
for section, segments in (data.get("parts") or {}).items():
if isinstance(segments, list):
state.parts[str(section)] = [segment for segment in segments if isinstance(segment, dict)]
for section, seed in (data.get("locks") or {}).items():
try:
state.locks[str(section)] = int(seed)
except (TypeError, ValueError):
state.errors.append(f"{section}: lock seed {seed!r} is not a number; ignored")
state.fill = {str(section): True for section, on in (data.get("fill") or {}).items() if on}
order = data.get("order") or []
if isinstance(order, list):
state.order = [str(section) for section in order if str(section) in SECTIONS]
kinds = data.get("kinds") or {}
if isinstance(kinds, dict):
state.kinds = {str(section): str(kind) for section, kind in kinds.items() if kind}
return state
def scene_only(self, section: str) -> bool:
"""A subject section set to 'Scene only': deliberately no subject."""
return base_section(section) == "subject" and self.kinds.get(section) == "scene"
def templates(self) -> Dict[str, str]:
"""section -> wildcard template text."""
out: Dict[str, str] = {}
for section, segments in self.parts.items():
pieces: List[str] = []
for segment in segments:
kind = segment.get("t")
if kind in ("text", "raw"):
# Typed text keeps wildcard syntax working: {red|blue} or __name__ typed by hand still resolve.
pieces.append(str(segment.get("v", "")))
elif kind == "tok":
token = self.tokens.get(str(segment.get("id")))
if token is None:
self.errors.append(f"{section}: pill {segment.get('id')} has no settings; skipped")
continue
syntax = token.syntax()
if not syntax:
self.errors.append(f"{section}: {token.slot or 'pill'} has no list chosen; skipped")
continue
pieces.append(syntax)
text = join_pieces(pieces)
if text:
out[section] = text
return out
# ----------------------------------------------------------------------------- platform order
def base_section(section: str) -> str:
"""'subject_2' -> 'subject'."""
return re.sub(r"_\d+$", "", section or "")
def section_label(section: str) -> str:
match = re.match(r"(.+)_(\d+)$", section or "")
if match:
return f"{SECTION_LABELS.get(match.group(1), match.group(1).title())} {match.group(2)}"
return SECTION_LABELS.get(section, (section or "").title())
def section_seed_offset(section: str) -> int:
"""Each section rolls from seed + its offset, so re-rolling or locking one leaves the others alone."""
base = base_section(section)
index = SECTIONS.index(base) if base in SECTIONS else len(SECTIONS) + (sum(map(ord, base)) % 97)
extra = re.search(r"_(\d+)$", section or "")
return index + (100 * (int(extra.group(1)) - 1) if extra else 0)
def section_order(structure: str) -> List[str]:
"""
Canonical sections in the order a profile's `structure` line mentions them. Sections the
line does not mention follow in DEFAULT_ORDER. "" -> DEFAULT_ORDER.
"""
lowered = (structure or "").lower()
positions: List[Tuple[int, int, str]] = []
for default_index, section in enumerate(DEFAULT_ORDER):
match = re.search(r"\b(?:" + _SECTION_WORDS[section] + r")\b", lowered)
position = match.start() if match else 10_000 + default_index
positions.append((position, default_index, section))
return [section for _pos, _index, section in sorted(positions)]
def order_sections(sections, order: Sequence[str]) -> List[str]:
"""
The given section names in platform order. Subject 2, 3 ... follow their base section
(Krea 2 keeps each subject with its own attributes); unknown sections go last.
"""
names = list(sections)
def key(name: str):
base = base_section(name)
position = list(order).index(base) if base in order else len(order)
number = int(name.rsplit("_", 1)[1]) if name != base else 1
return (position, number, name)
return sorted(names, key=key)
def join_sections(sections: Dict[str, str], order: Sequence[str]) -> str:
"""Section texts in platform order (unknown sections last), comma-joined."""
ordered = [section for section in order_sections(sections, order) if sections[section].strip()]
return join_pieces([sections[section].strip().rstrip(",") + "," for section in ordered]).rstrip(",").strip()