diff --git a/README.md b/README.md index d7365ad..3baf6db 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A minimal **dynamic prompting + mirrored wildcards** node for ComfyUI. -## Features +## Overview - `{a|b|{c|d}}` nested choice expansion (deterministic by seed). - `__name__` loads `wildcards/name.txt`. - `__name-mir__` strictly loads `wildcards/name-mir.txt`. @@ -25,6 +25,7 @@ A minimal **dynamic prompting + mirrored wildcards** node for ComfyUI. - negative: negative prompt - Seed: this is used to determine how the node will select tags - allow neg from mir: automatically adds -mir tags to negative prompt without explicitly needing to add it to the negative prompt +- variety: int between 0-10; creates extra randomness 'lanes' [Variety](#-variety-extra-randomness-lanes)
Wire the outputs into your usual `CLIP Text Encode` node
You can preview what the output is with the "preview any" node in utils @@ -109,6 +110,28 @@ Behavior: - With the checkbox enabled, the extension auto-injects `__hats-mir__` into the **negative** and expands it there as the comma‑separated “other” options. - If the seed picks **`red hat`** for positive, the negative gets **`blue hat, green hat`** automatically. +## 🎲 Variety (extra randomness lanes) + +**What it is:** An integer knob that creates alternate, reproducible randomness *lanes* without changing your seed or prompt. + +**Deterministic:** With the same `(seed, variety, prompt, files)`, you get the same positive & negative outputs every time. + +**Independence from fixed text:** Only decision points advance randomness—each `{…}` block and each `__wildcard__` / `__*-mir__`. Fixed words/commas/spaces do **not** affect picks. + +**How it works (under the hood):** +- Choices use `choice#0, choice#1, …` +- Wildcards use `wild#0, wild#1, …` (also salted with the token name) +- We hash `(seed, counter[, token], variety)` to pick an index. + +**When to use:** Keep `seed` fixed and sweep `variety = 0,1,2…` to explore different, reproducible variants from the same prompt. + +**Example** +```text +Prompt: __color__, {A|B|{C|D}}, __pose-mir__ +``` +(seed=123, variety=0) → picks one set of branches +(seed=123, variety=1) → different set of branches (still reproducible) + ## ⚙️ Usage - Write prompts as usual with `{}` and `__wildcards__`. @@ -121,10 +144,115 @@ Positive: portrait, {cinematic|studio|outdoor}, __hats__, soft lighting Negative: lowres, bad anatomy, __hats-mir__ ``` +## Logic + +**Determinism & Counters** +- Randomness depends **only** on decision points: + - Each non-nested `{…}` is the next **choice#N**. + - Each `__token__` (including `__*-mir__`) is the next **wild#M**. +- For each pick we hash `(seed, counter[, token], variety)` → index. +- Fixed text (words/commas/spaces) does **not** affect counters or picks. +- `variety` is an extra integer salt to create reproducible “lanes”. + +**Expansion Order (Positive)** +1) **Braces-first pass** (repeat until none left) + Collapse the left-most non-nested `{…}` using **choice#N**. +2) **Wildcard pass** (left → right) + Replace each `__token__` using **wild#M**: + - `__name__` → read `wildcards/name.txt` (supports subfolders like `a/b.txt`). + - `__name-mir__` → **strict** read `wildcards/name-mir.txt` only (no fallback). + - If a chosen line itself contains braces/wildcards, it’s injected and will be handled by subsequent passes. + - Missing file ⇒ token disappears (empty). +3) **Braces-second pass** + Collapse any `{…}` that arrived from wildcard lines. +4) **Cleanup** + Normalize commas/spaces. + +**Deep Mirroring (Negative complement)** +- Your user Negative is expanded **normally** (same algorithm as Positive: braces → wildcards → braces). +- If `auto_neg_from_mir` is **ON**, we run a **mirror pass** over the Positive prompt: + - Re-run the same brace + wildcard passes with the same `(seed, variety)` and counters. + - For every `__*-mir__` encountered (even if nested inside other wildcards): + - Compute the chosen option. + - Add **all non-empty alternatives except the chosen one** to a bag. + - Special case: if the chosen alternative is **empty** (from `{option|}`), add all **non-empty** options. +- Merge: `final_negative = dedup(user_negative_expanded + mirrored_bag)`. + +**File Semantics** +- **Single-line files**: + - Non-mir: if it’s `{a|b|{c|d}|}`, split by **top-level** `|` (empties kept as `""`). + - `*-mir`: if it’s `{a|b|{c|d}}`, **flatten** to leaf options `["a","b","c","d"]` so “all-except-chosen” is well-defined. +- **Multi-line files**: each non-empty, non-comment line is an option (lines may contain braces/wildcards). +- **Path safety**: allows subfolders (`a/b`), dotted names (`style.v1`); blocks traversal (`..`). + +**Pseudocode (conceptual)** +``` + counters = {choice: 0, wild: 0} + + def pick_choice(options): + i = hash(seed, f"choice#{counters.choice}", variety) % len(options) + counters.choice += 1 + return options[i] + + def pick_wild(token, options): + i = hash(seed, f"wild#{counters.wild}:{token}", variety) % len(options) + counters.wild += 1 + return options[i] + + # Positive: + text = collapse_braces(text, pick_choice) # pass 1 + text = expand_wildcards(text, pick_wild) # pass 2 + text = collapse_braces(text, pick_choice) # pass 3 + + # Negative: + neg = collapse_braces(neg, pick_choice) + neg = expand_wildcards(neg, pick_wild) + neg = collapse_braces(neg, pick_choice) + + # Deep mirror additions: + mirror_bag = run_positive_again_collecting_all_except_chosen_for('*-mir') + + final_negative = dedup_join(neg, mirror_bag) +``` +**Key Guarantees** +- Same `(seed, variety, prompt, files)` ⇒ same Positive & Negative. +- Adding/removing fixed text does **not** change picks. +- Changing decision points (adding/removing a `{…}` or `__wildcard__`) changes the sequence of draws (by design). + + ## Notes - Missing wildcard files resolve to empty strings. - Choice/wildcard expansion is capped to prevent runaway recursion. - If `negative` is blank and `auto_neg_from_mir` is ON, the node scans the positive for `__tokens__` and auto-builds a mirrored negative when possible. +## Changelog + +### 2025-08-24 — Counter-Based Randomness & “Variety” lanes +**Why**: fixed tokens could cause prompt to converge. + +**What changed** +- **Decision-only RNG**: Random choices now depend **only** on decision points (each `{…}` block and each `__wildcard__` / `__*-mir__` call), not on surrounding fixed text. + - Internally we hash `(seed, decision_counter[, token], variety)` where counters advance as we hit choices/wildcards left→right. + - Adding/removing fixed words, commas, or spacing no longer shifts picks. +- **Deterministic lanes via `variety`**: New integer input that adds an extra salt. + - Same `(seed, variety, prompt, files)` ⇒ same outputs. + - Sweep `variety = 0,1,2…` to get reproducible alternates without touching seed or prompt. +- **Deep mirroring (strict)**: `__foo-mir__` contributes mirrored exclusions to Negative even when **nested** inside other wildcards, and **only** reads `foo-mir.txt` (never falls back). +- **Empty-branch support in `*-mir.txt`**: `{option|}` is honored. + - If Positive picks `option`, Negative adds nothing for that token; if Positive picks the empty branch, Negative adds the non-empty options. +- **Single-line `*-mir.txt` with nested braces**: `{1|2|{3|4}}` is flattened to leaves so mirroring can compute “all-except-chosen” correctly. + +**Behavior notes** +- Fixed text no longer influences randomness. Only the **number/order** of decision points matters. +- Changing decision points (e.g., adding a `{…}` or another `__wildcard__`) will change the sequence of draws—as expected. +- Deep mirror uses the same counters as Positive, ensuring the Negative always gets the correct complement. + +**Example** +```text +Prompt: __color__, {A|B|{C|D}}, __pose-mir__ +Same seed, same variety → same picks (pos & neg) +Change only fixed words → picks stay identical +Bump variety (e.g., 0→1) → different, reproducible picks +``` License: GPLv3 (same as the original) diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index cf2d631..0000000 Binary files a/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/comfy_nodes.cpython-312.pyc b/__pycache__/comfy_nodes.cpython-312.pyc deleted file mode 100644 index abcbc2d..0000000 Binary files a/__pycache__/comfy_nodes.cpython-312.pyc and /dev/null differ diff --git a/comfy_nodes.py b/comfy_nodes.py index 93d356c..b938b86 100644 --- a/comfy_nodes.py +++ b/comfy_nodes.py @@ -4,7 +4,6 @@ from typing import List from .dynprompt.expander import ( PromptExpander, WILDCARD_TOKEN_RE, - _stable_pick_index, # reuse same salt/index math as the expander ) # -------------------- Small helpers -------------------- @@ -28,26 +27,26 @@ def _merge_csv(*parts: str) -> str: out.append(chunk) return ", ".join(out) -def _collect_mirrors_deep(text: str, expander: PromptExpander) -> str: +def _collect_mirrors_deep(text: str, base_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. + Deep mirroring pass with counter-based RNG: + - Use a fresh expander (same seed/wildcard_dir/variety) so counters start at 0. + - Collapse braces first (consumes choice counter). + - Expand wildcards left-to-right; for any token ending with '-mir', add + "all except chosen" (ignore empties) to the bag. + - Replace tokens with their chosen values to permit nested discovery. """ - # Step 1: match expander's first phase (choices first) - s = expander.collapse_choices(text or "") + mirror = PromptExpander( + seed=base_expander.seed, + wildcard_dir=base_expander.wildcard_dir, + variety=getattr(base_expander, "variety", 0), + ) + + # choices first (consumes mirror's choice counter) + s = mirror.collapse_choices(text or "") bag: List[str] = [] - # Step 2: left-to-right wildcard pass (mirrors expander._expand_wildcards_recursively) + # wildcard pass (consumes mirror's wild counter in the same sequence as Positive) while True: m = WILDCARD_TOKEN_RE.search(s) if not m: @@ -56,16 +55,15 @@ def _collect_mirrors_deep(text: str, expander: PromptExpander) -> str: token = m.group(1) start, end = m.span() - opts = expander.read_options_for_token(token) # STRICT; may be empty + opts = mirror.read_options_for_token(token) if not opts: - # Drop unknown token to mirror main expander behavior + # drop unknown 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)) + idx = mirror._pick_wild_index(token, 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: @@ -87,6 +85,10 @@ class DynPromptExpand: 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'. + + Counter-based RNG means fixed tokens don't influence randomness; only + decision points (braces/wildcards) advance the counters. Optional 'variety' + input gives multiple reproducible randomness lanes per seed. """ @classmethod @@ -96,6 +98,8 @@ class DynPromptExpand: "text": ("STRING", {"multiline": True, "default": ""}), "negative": ("STRING", {"multiline": True, "default": ""}), "seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1}), + "variety": ("INT", {"default": 0, "min": 0, "max": 100, + "tooltip": "Extra randomness lane. 0 = off; same seed+variety reproduce."}), "wildcard_dir": ("STRING", { "default": "", "tooltip": "Folder with wildcard .txt files (supports subfolders like clothes/tops.txt)" @@ -112,7 +116,7 @@ class DynPromptExpand: FUNCTION = "expand" CATEGORY = "prompt" - def expand(self, text: str, negative: str, seed: int, wildcard_dir: str, auto_neg_from_mir: bool): + def expand(self, text: str, negative: str, seed: int, variety: int, wildcard_dir: str, auto_neg_from_mir: bool): # Resolve wildcard dir (absolute, ~ supported). Fallback to ./wildcards next to this file. wd = (wildcard_dir or "").strip() if wd: @@ -120,7 +124,7 @@ class DynPromptExpand: if not wd: wd = os.path.abspath(os.path.join(os.path.dirname(__file__), "wildcards")) - expander = PromptExpander(seed=seed, wildcard_dir=wd) + expander = PromptExpander(seed=seed, wildcard_dir=wd, variety=variety) # A) Positive prompt: full expansion (choices -> wildcards -> choices) pos_final = expander.expand_prompt(text or "", phase="pos") diff --git a/dynprompt/__pycache__/__init__.cpython-312.pyc b/dynprompt/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 44c1d28..0000000 Binary files a/dynprompt/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/dynprompt/__pycache__/expander.cpython-312.pyc b/dynprompt/__pycache__/expander.cpython-312.pyc deleted file mode 100644 index 2c2e0cd..0000000 Binary files a/dynprompt/__pycache__/expander.cpython-312.pyc and /dev/null differ diff --git a/dynprompt/expander.py b/dynprompt/expander.py index 0998c08..63a111c 100644 --- a/dynprompt/expander.py +++ b/dynprompt/expander.py @@ -115,7 +115,7 @@ def _stable_pick_index(seed: int, salt: str, n: int) -> int: class PromptExpander: """ - Expands deterministically by seed: + Expands deterministically by seed and counter-based salts: 1) {a|b|{c|d}|} --> brace choices (empty branch allowed) 2) __token__ --> wildcards from /token.txt @@ -127,16 +127,45 @@ class PromptExpander: - 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) + + Counter-based randomness: + - Fixed text has no effect on picks. + - Choices use an incrementing choice counter (choice#0, choice#1, ...). + - Wildcards use an incrementing wild counter (wild#0, wild#1, ...), salted with token. + - Optional 'variety' integer allows multiple deterministic lanes per seed. """ - def __init__(self, seed: int, wildcard_dir: Optional[str] = None): + def __init__(self, seed: int, wildcard_dir: Optional[str] = None, variety: int = 0): self.seed = int(seed) if seed is not None else 0 self.wildcard_dir = wildcard_dir or "" + self.variety = int(variety) if variety else 0 + self._salt_extra = f":v{self.variety}" if self.variety else "" + # runtime counters (reset per expand) + self._choice_counter = 0 + self._wild_counter = 0 + + # -------------------- Counter RNG helpers -------------------- + + def _reset_counters(self): + self._choice_counter = 0 + self._wild_counter = 0 + + def _pick_choice_index(self, n: int) -> int: + idx = _stable_pick_index(self.seed, f"choice#{self._choice_counter}{self._salt_extra}", n) + self._choice_counter += 1 + return idx + + def _pick_wild_index(self, token: str, n: int) -> int: + idx = _stable_pick_index(self.seed, f"wild#{self._wild_counter}:{token}{self._salt_extra}", n) + self._wild_counter += 1 + return idx # -------------------- Public helpers -------------------- def collapse_choices(self, text: str) -> str: """Deterministically collapse all {a|b|{c|d}|} blocks. No wildcard expansion.""" + # Ensure counters match the main expand order if used standalone + self._reset_counters() return self._expand_choices_recursively(text or "") def read_options_for_token(self, token: str) -> List[str]: @@ -154,7 +183,6 @@ class PromptExpander: - 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 [] @@ -182,7 +210,6 @@ class PromptExpander: 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)) @@ -195,7 +222,7 @@ class PromptExpander: # are collapsed later by the brace pass). return raw - # -------------------- IO & parsing ---------------------- + # -------------------- IO & path safety ---------------------- def _normalize_token_to_path(self, token: str) -> Optional[str]: if not self.wildcard_dir: @@ -221,7 +248,7 @@ class PromptExpander: if not options: # Leave unchanged on malformed {} return s - idx = _stable_pick_index(self.seed, f"choice:{s}:{start}-{end}", len(options)) + idx = self._pick_choice_index(len(options)) chosen = options[idx] # may be '' (empty branch) return s[:start] + chosen + s[end:] @@ -246,7 +273,7 @@ class PromptExpander: # Missing file => drop token silently return s[:start] + "" + s[end:] - idx = _stable_pick_index(self.seed, f"wild:{token}:{s}:{start}-{end}", len(options)) + idx = self._pick_wild_index(token, len(options)) chosen = options[idx] if 0 <= idx < len(options) else "" if phase == "pos": @@ -269,6 +296,8 @@ class PromptExpander: def expand_prompt(self, text: str, phase: str = "pos") -> str: if not text: return "" + # Reset counters so fixed text doesn't affect picks; reproducible per call + self._reset_counters() out = text # 1) Collapse braces first (enables optional segments via empty branches) out = self._expand_choices_recursively(out)