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)