diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..cf2d631 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/comfy_nodes.cpython-312.pyc b/__pycache__/comfy_nodes.cpython-312.pyc new file mode 100644 index 0000000..abcbc2d Binary files /dev/null and b/__pycache__/comfy_nodes.cpython-312.pyc differ diff --git a/comfy_nodes.py b/comfy_nodes.py index ceef251..93d356c 100644 --- a/comfy_nodes.py +++ b/comfy_nodes.py @@ -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: '-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 '-mir.txt' is consulted; no fallback to '.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)"} diff --git a/dynprompt/__pycache__/__init__.cpython-312.pyc b/dynprompt/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..44c1d28 Binary files /dev/null and b/dynprompt/__pycache__/__init__.cpython-312.pyc differ diff --git a/dynprompt/__pycache__/expander.cpython-312.pyc b/dynprompt/__pycache__/expander.cpython-312.pyc new file mode 100644 index 0000000..2c2e0cd Binary files /dev/null and b/dynprompt/__pycache__/expander.cpython-312.pyc differ diff --git a/dynprompt/expander.py b/dynprompt/expander.py index 74c780f..0998c08 100644 --- a/dynprompt/expander.py +++ b/dynprompt/expander.py @@ -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 /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 diff --git a/wildcards/hats.txt b/wildcards/hats.txt index 39b1da9..d04394d 100644 --- a/wildcards/hats.txt +++ b/wildcards/hats.txt @@ -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 \ No newline at end of file diff --git a/wildcards/test-mir.txt b/wildcards/test-mir.txt new file mode 100644 index 0000000..618038f --- /dev/null +++ b/wildcards/test-mir.txt @@ -0,0 +1 @@ +{1|2|{3|4}} \ No newline at end of file