fix wildcard nesting and negative prompt

This commit is contained in:
RegulusAlpha
2025-08-23 16:13:28 -04:00
parent 8572b4c6e7
commit 31dc17093e
8 changed files with 332 additions and 91 deletions
Binary file not shown.
Binary file not shown.
+116 -35
View File
@@ -1,15 +1,94 @@
from typing import Dict, Any
import os
import re
from typing import List
from .dynprompt.expander import (
PromptExpander,
WILDCARD_TOKEN_RE,
_stable_pick_index, # reuse same salt/index math as the expander
)
from .dynprompt.expander import PromptExpander
# -------------------- Small helpers --------------------
def _csv_chunks(s: str) -> List[str]:
"""Split a comma list into items, trimming whitespace and skipping empties."""
return [p.strip() for p in (s or "").split(",") if p.strip()]
def _merge_csv(*parts: str) -> str:
"""
Merge multiple comma lists, preserving order of first appearance
and de-duplicating case-insensitively.
"""
seen = set()
out = []
for part in parts:
for chunk in _csv_chunks(part):
key = chunk.lower()
if key not in seen:
seen.add(key)
out.append(chunk)
return ", ".join(out)
def _collect_mirrors_deep(text: str, expander: PromptExpander) -> str:
"""
Deep mirroring pass:
- Simulate the expander's left-to-right wildcard pass on the POSITIVE prompt.
- For tokens ending with '-mir', compute the chosen index and collect
"all except chosen" into a bag. (STRICT lookup: '<name>-mir.txt' only.)
- For non-mir tokens, just replace with the chosen option (no collection).
Notes:
* We collapse braces BEFORE scanning for wildcards to match the expander's order.
* Empty branches in -mir files are supported:
- If chosen == '' (empty), we add all NON-empty others to Negative.
- If chosen is non-empty, we add all other NON-empty options.
* Newly introduced nested wildcards (from chosen options) are handled
by continuing the while-loop until no __token__ remains.
"""
# Step 1: match expander's first phase (choices first)
s = expander.collapse_choices(text or "")
bag: List[str] = []
# Step 2: left-to-right wildcard pass (mirrors expander._expand_wildcards_recursively)
while True:
m = WILDCARD_TOKEN_RE.search(s)
if not m:
break
token = m.group(1)
start, end = m.span()
opts = expander.read_options_for_token(token) # STRICT; may be empty
if not opts:
# Drop unknown token to mirror main expander behavior
s = s[:start] + s[end:]
continue
idx = _stable_pick_index(expander.seed, f"wild:{token}:{s}:{start}-{end}", len(opts))
chosen = opts[idx] if 0 <= idx < len(opts) else ""
# If this is a strict '-mir' token, accumulate "all except chosen" (ignore empties)
if token.endswith("-mir"):
others = [o for i, o in enumerate(opts) if i != idx and o.strip()]
if others:
bag.extend(others)
# Replace the token with its chosen text and continue scanning (to handle nesting)
s = s[:start] + chosen + s[end:]
# Return as CSV; dedup/merge handled by _merge_csv
return ", ".join(bag)
# -------------------- ComfyUI Node --------------------
class DynPromptExpand:
"""
Expand dynamic prompts + mirrored wildcards for ComfyUI.
- Inputs: positive text, negative text, seed, wildcard_dir
- Outputs: expanded positive text, expanded negative text
ComfyUI node:
- Positive output: expands braces & wildcards normally (pick-one).
- Negative output: expands the user's Negative normally (pick-one), then
appends deep-mirrored exclusions from ANY __*-mir__ encountered in the
Positive (including those nested inside wildcard files). Strict '-mir':
only '<name>-mir.txt' is consulted; no fallback to '<name>.txt'.
"""
@classmethod
def INPUT_TYPES(cls):
return {
@@ -17,8 +96,14 @@ class DynPromptExpand:
"text": ("STRING", {"multiline": True, "default": ""}),
"negative": ("STRING", {"multiline": True, "default": ""}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1}),
"wildcard_dir": ("STRING", {"default": "", "tooltip": "Folder with wildcard .txt files"}),
"auto_neg_from_mir": ("BOOLEAN", {"default": True, "tooltip": "If negative contains __name__ and name-mir.txt exists, auto-mirror to 'all except chosen'."}),
"wildcard_dir": ("STRING", {
"default": "",
"tooltip": "Folder with wildcard .txt files (supports subfolders like clothes/tops.txt)"
}),
"auto_neg_from_mir": ("BOOLEAN", {
"default": True,
"tooltip": "Append deep mirrored exclusions for any __*-mir__ found in Positive (strict lookup)."
}),
}
}
@@ -28,35 +113,31 @@ class DynPromptExpand:
CATEGORY = "prompt"
def expand(self, text: str, negative: str, seed: int, wildcard_dir: str, auto_neg_from_mir: bool):
# Normalize wildcard dir (supports empty -> local /wildcards as fallback)
wd = wildcard_dir.strip()
# Resolve wildcard dir (absolute, ~ supported). Fallback to ./wildcards next to this file.
wd = (wildcard_dir or "").strip()
if wd:
wd = os.path.abspath(os.path.expanduser(wd))
if not wd:
# default to a 'wildcards' folder inside this custom node
wd = os.path.join(os.path.dirname(__file__), "wildcards")
wd = os.path.abspath(os.path.join(os.path.dirname(__file__), "wildcards"))
exp_pos = PromptExpander(seed=seed, wildcard_dir=wd).expand_prompt(text or "", phase="pos")
expander = PromptExpander(seed=seed, wildcard_dir=wd)
# If auto_neg is enabled and user left negative empty, derive mirrored negatives
if auto_neg_from_mir and (not (negative or "").strip()):
# Build synthetic negative by scanning positive for __tokens__
# We let the expander handle auto-mirroring in neg-phase.
import re
tokens = sorted(set(re.findall(r"__([A-Za-z0-9_\-]+)__", text or "")))
# Limit to tokens that have a corresponding -mir file or normal file
cand = []
for t in tokens:
if os.path.isfile(os.path.join(wd, f"{t}-mir.txt")) or os.path.isfile(os.path.join(wd, f"{t}.txt")):
cand.append(f"__{t}__")
neg_src = ", ".join(cand)
exp_neg = PromptExpander(seed=seed, wildcard_dir=wd).expand_prompt(neg_src, phase="neg")
else:
exp_neg = PromptExpander(seed=seed, wildcard_dir=wd).expand_prompt(negative or "", phase="neg")
# A) Positive prompt: full expansion (choices -> wildcards -> choices)
pos_final = expander.expand_prompt(text or "", phase="pos")
return (exp_pos, exp_neg)
# B) User Negative: expand normally (pick-one semantics; no mirroring here)
neg_user_final = expander.expand_prompt(negative or "", phase="pos")
NODE_CLASS_MAPPINGS = {
"DynPromptExpand": DynPromptExpand,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DynPromptExpand": "DynPrompt Expand (mirrored wildcards)",
}
# C) Deep mirror: collect exclusions for ANY __*-mir__ appearing during Positive expansion
mirrored_additions = ""
if auto_neg_from_mir and (text or "").strip():
mirrored_additions = _collect_mirrors_deep(text, expander)
# D) Merge user negative + mirrored exclusions (dedup; preserve order)
neg_final = _merge_csv(neg_user_final, mirrored_additions)
return (pos_final, neg_final)
# ComfyUI registration
NODE_CLASS_MAPPINGS = {"DynPromptExpand": DynPromptExpand}
NODE_DISPLAY_NAME_MAPPINGS = {"DynPromptExpand": "DynPrompt Expand (deep-mirrored)"}
Binary file not shown.
Binary file not shown.
+213 -55
View File
@@ -1,94 +1,259 @@
import os
import re
import hashlib
from typing import List, Optional, Tuple
from typing import List, Optional
WILDCARD_TOKEN_RE = re.compile(r"__([A-Za-z0-9_\-]+)__")
INNER_BRACE_RE = re.compile(r"\{([^{}]+)\}")
# ---------------------------------------------------------------------------
# Regexes
# ---------------------------------------------------------------------------
# Allow subfolders (/) and dotted names (.) plus hyphen/underscore
WILDCARD_TOKEN_RE = re.compile(r"__([A-Za-z0-9_\-/.]+)__")
# Matches a single brace block that does NOT contain nested braces.
# We allow empty branches (e.g., {a| |b|}) via [^{}]*.
INNER_BRACE_RE = re.compile(r"\{([^{}]*)\}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _split_choices_keep_empty(s: str) -> List[str]:
"""
Split a non-nested brace's inner text by '|' into options, preserving empty branches.
Whitespace around options is trimmed, but empty strings are kept as ''.
"""
parts = s.split("|")
return [p.strip() for p in parts] # keep '' for empty branches
def _split_top_level_alts(s: str) -> List[str]:
"""
Split a possibly nested brace's inner text by top-level '|', honoring nesting.
Keeps empty alternatives as ''.
"""
alts: List[str] = []
buf: List[str] = []
depth = 0
for ch in s:
if ch == '|' and depth == 0:
alts.append(''.join(buf).strip())
buf = []
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
buf.append(ch)
alts.append(''.join(buf).strip())
return alts
def _flatten_brace_expr(expr: str) -> List[str]:
"""
Expand a string containing brace expressions (with possible nesting) into all leaf strings.
Empty branches are preserved as '' when they reduce to nothing.
"""
# Find first '{'
i = expr.find('{')
if i == -1:
return [expr.strip()]
# Find its matching '}'
depth = 0
j = i
while j < len(expr):
if expr[j] == '{':
depth += 1
elif expr[j] == '}':
depth -= 1
if depth == 0:
break
j += 1
if j >= len(expr):
# Malformed braces; return as-is
return [expr.strip()]
pre = expr[:i]
inner = expr[i+1:j]
post = expr[j+1:]
results: List[str] = []
for alt in _split_top_level_alts(inner):
for alt_exp in _flatten_brace_expr(alt):
for post_exp in _flatten_brace_expr(post):
results.append((pre + alt_exp + post_exp).strip())
return results
def _dedup_preserve(seq: List[str]) -> List[str]:
seen = set()
out: List[str] = []
for x in seq:
key = x.strip().lower()
if key in seen:
continue
seen.add(key)
out.append(x.strip())
return out
def _split_choices(s: str) -> List[str]:
return [p.strip() for p in s.split("|")]
def _stable_pick_index(seed: int, salt: str, n: int) -> int:
"""
Deterministic index picker stable across runs/processes.
"""
if n <= 0:
return 0
h = hashlib.md5(f"{seed}:{salt}".encode("utf-8")).hexdigest()
return int(h, 16) % n
# ---------------------------------------------------------------------------
# Expander
# ---------------------------------------------------------------------------
class PromptExpander:
"""
Expands deterministically by seed:
1) {a|b|{c|d}|} --> brace choices (empty branch allowed)
2) __token__ --> wildcards from <wildcard_dir>/token.txt
* STRICT lookup: 'foo-mir' -> 'foo-mir.txt' ONLY (no fallback)
* Subfolders & dotted names supported (e.g., clothes/tops.v1)
3) Runs braces again in case wildcard lines contain {…}
Negative semantics are controlled by 'phase':
- phase="pos": pick-one for wildcards (normal)
- phase="neg": join all except chosen (used for mirroring)
(empty options are ignored when joining, so they don't add commas)
"""
def __init__(self, seed: int, wildcard_dir: Optional[str] = None):
self.seed = int(seed) if seed is not None else 0
self.wildcard_dir = wildcard_dir or ""
# ---- Wildcards --------------------------------------------------------
def _wildcard_path(self, token: str) -> Optional[str]:
if not self.wildcard_dir:
return None
p = os.path.join(self.wildcard_dir, f"{token}.txt")
return p if os.path.isfile(p) else None
# -------------------- Public helpers --------------------
def _read_wildcard_lines(self, token: str) -> List[str]:
path = self._wildcard_path(token)
def collapse_choices(self, text: str) -> str:
"""Deterministically collapse all {a|b|{c|d}|} blocks. No wildcard expansion."""
return self._expand_choices_recursively(text or "")
def read_options_for_token(self, token: str) -> List[str]:
"""
STRICT lookup:
- 'foo' -> 'foo.txt'
- 'foo-mir' -> 'foo-mir.txt' (NO fallback to 'foo.txt')
Behavior:
* Multi-line file: each non-empty, non-comment line is one option.
* Single-line without braces: the whole line is one option.
* Single-line WITH braces:
- For '-mir' tokens: FLATTEN nested braces into leaf options
(preserve empty leaves as '').
- For non-mir tokens: split by TOP-LEVEL '|' into options
(empty branches kept as '').
"""
# Resolve and read file raw (without any brace splitting)
path = self._normalize_token_to_path(token)
if not path:
return []
out: List[str] = []
raw: List[str] = []
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
t = line.strip()
if t:
out.append(t)
return out
t = line.rstrip("\n\r").strip()
if not t:
# Ignore blank lines in multi-line files (empties should be explicit via braces)
continue
if t.startswith("#"):
continue
raw.append(t)
if not raw:
return []
# Single-line file
if len(raw) == 1:
line = raw[0]
if "{" in line and "}" in line:
if token.endswith("-mir"):
# Deep flatten for -mir so "all-except-chosen" is well-defined over leaves
return _dedup_preserve(_flatten_brace_expr(line))
else:
# Non-mir: choose among top-level alts (empties allowed)
# e.g., "{red|blue| |green}" -> ["red","blue","","green"]
m = re.fullmatch(r"\{(.*)\}", line)
if m:
return _split_top_level_alts(m.group(1))
# If unmatched, leave as single option
return [line]
# No braces => single option
return [line]
# Multi-line file: return lines as distinct options (braces inside a line
# are collapsed later by the brace pass).
return raw
# -------------------- IO & parsing ----------------------
def _normalize_token_to_path(self, token: str) -> Optional[str]:
if not self.wildcard_dir:
return None
token = token.replace("\\", "/").lstrip("/.")
# Block parent traversal
if ".." in token.split("/"):
return None
base = os.path.normpath(self.wildcard_dir)
p = os.path.normpath(os.path.join(base, f"{token}.txt"))
if not p.startswith(base):
return None
return p if os.path.isfile(p) else None
# -------------------- Choice expansion ------------------
# ---- Choice expansion --------------------------------------------------
def _expand_choices_once(self, s: str) -> str:
# Expand the first innermost {...} only once
m = INNER_BRACE_RE.search(s)
if not m:
return s
start, end = m.span()
body = m.group(1)
options = _split_choices(body)
options = _split_choices_keep_empty(m.group(1))
if not options:
return s # nothing to do
# Leave unchanged on malformed {}
return s
idx = _stable_pick_index(self.seed, f"choice:{s}:{start}-{end}", len(options))
chosen = options[idx]
return s[:start] + chosen + s[end+1:]
chosen = options[idx] # may be '' (empty branch)
return s[:start] + chosen + s[end:]
def _expand_choices_recursively(self, s: str, limit: int = 64) -> str:
# Safeguard against runaway nesting
for _ in range(limit):
if not INNER_BRACE_RE.search(s):
break
s = self._expand_choices_once(s)
return s
# ---- Wildcard expansion w/ mirrored semantics -------------------------
# -------------------- Wildcard expansion ----------------
def _expand_wildcards_once(self, s: str, phase: str) -> str:
m = WILDCARD_TOKEN_RE.search(s)
if not m:
return s
token = m.group(1) # raw token without __
token = m.group(1)
start, end = m.span()
# For negative phase: prefer "-mir" file if it exists for auto-mirroring
token_for_options = token
if phase == "neg" and not token.endswith("-mir"):
if self._wildcard_path(token + "-mir"):
token_for_options = token + "-mir"
options = self._read_wildcard_lines(token_for_options)
options = self.read_options_for_token(token)
if not options:
# Missing wildcard: drop it
# Missing file => drop token silently
return s[:start] + "" + s[end:]
idx = _stable_pick_index(self.seed, f"wild:{token}:{s}:{start}-{end}", len(options))
chosen = options[idx] if 0 <= idx < len(options) else ""
if phase == "pos":
core = options[idx]
core = chosen # may be '' => token disappears
else:
# negative gets all except chosen
core = ", ".join([o for i, o in enumerate(options) if i != idx])
# phase == "neg": join all except chosen; ignore empty strings
core = ", ".join(o for i, o in enumerate(options) if i != idx and o.strip())
return s[:start] + core + s[end:]
@@ -99,26 +264,19 @@ class PromptExpander:
s = self._expand_wildcards_once(s, phase=phase)
return s
# ---- Public API --------------------------------------------------------
# -------------------- Public API -----------------------
def expand_prompt(self, text: str, phase: str = "pos") -> str:
"""
phase: "pos" for positive prompt, "neg" for negative prompt.
- {a|b|{c|d}} expands deterministically by seed.
- __name__ loads wildcards/name.txt
- __name-mir__ loads wildcards/name-mir.txt (strict)
- In neg-phase, if __name__ is used and name-mir.txt exists, the
node auto-mirrors by expanding to all options except the chosen one.
"""
if not text:
return ""
out = text
# 1) Expand braces fully
# 1) Collapse braces first (enables optional segments via empty branches)
out = self._expand_choices_recursively(out)
# 2) Expand wildcards fully for the phase
# 2) Expand wildcards (phase controls pos/neg semantics)
out = self._expand_wildcards_recursively(out, phase=phase)
# 3) Expand braces again in case wildcard lines contained braces
# 3) Collapse any braces that came from wildcard lines
out = self._expand_choices_recursively(out)
# Remove accidental double commas/spaces
import re as _re
out = _re.sub(r"\s*,\s*,\s*", ", ", out).strip().strip(", ")
# 4) Cleanup duplicated commas/spaces
out = re.sub(r"\s*,\s*,\s*", ", ", out)
out = re.sub(r"\s{2,}", " ", out).strip().strip(", ")
return out
+2 -1
View File
@@ -1 +1,2 @@
{red hat|blue hat|green hat}
{red hat|blue hat|green hat}, baseball hat
{red hat|blue hat|green hat}, beanie hat
+1
View File
@@ -0,0 +1 @@
{1|2|{3|4}}