@@ -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)
|
||||
<br>
|
||||
Wire the outputs into your usual `CLIP Text Encode` node <br>
|
||||
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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+27
-23
@@ -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: '<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.
|
||||
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 '<name>-mir.txt' is consulted; no fallback to '<name>.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")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+36
-7
@@ -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 <wildcard_dir>/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)
|
||||
|
||||
Reference in New Issue
Block a user