fix wildcard nesting and negative prompt
This commit is contained in:
Binary file not shown.
Binary file not shown.
+116
-35
@@ -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
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
{1|2|{3|4}}
|
||||
Reference in New Issue
Block a user