@@ -0,0 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
### 1.0.1 — 2026-10-22 — Raise expansion limits, mirroring fixes
|
||||
**Why**: large wildcard lines (and wildcards nested inside them) can hold more than 64 `{…}` blocks; anything past the 64th was left in the prompt as raw braces.
|
||||
|
||||
**What changed**
|
||||
- Each brace pass now resolves up to **1024** `{…}` blocks (was 64).
|
||||
- The wildcard pass now expands up to **1024** `__tokens__` (was 128).
|
||||
- Fixed explicit `__name-mir__` in the Negative: it was expanded as a normal pick, landing on the **same** option as the Positive (so the Negative excluded exactly what the Positive asked for). It now becomes every option the Positive did not pick, or all options if the Positive never used that token.
|
||||
- The deep-mirror pass is capped at the same 1024 tokens.
|
||||
- Fixed single-line wildcard files such as `{a|b} dress, {c|d}` being split into broken options (`b} dress, {c`). Only a line that is one whole `{…}` block is split into choices; anything else is one option.
|
||||
- Fixed raw `{…}` text reaching the Negative from multi-line `*-mir` files: lines with braces are now flattened into plain options, like single-line `*-mir` files.
|
||||
- Fixed flattening dropping the space next to a brace (`{red|blue} hat` gave `redhat`).
|
||||
- Fixed leftover `, ,` when three or more commas ended up in a row. It had no cap, so a self-referencing wildcard could hang the node when `auto_neg_from_mir` was on.
|
||||
- Prompts that already fit under the old limit expand exactly as before (same seed + variety ⇒ same output).
|
||||
|
||||
### 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
|
||||
```
|
||||
@@ -16,16 +16,17 @@ A minimal **dynamic prompting + mirrored wildcards** node for ComfyUI.
|
||||
3. Restart ComfyUI.
|
||||
|
||||
## Node
|
||||
**DynPrompt Expand (mirrored wildcards)**
|
||||
- Inputs: `text`, `negative`, `seed`, `wildcard_dir`, `auto_neg_from_mir`
|
||||
**DynPrompt Expand (deep-mirrored)**
|
||||
- Inputs: `text`, `negative`, `seed`, `variety`, `wildcard_dir`, `auto_neg_from_mir`
|
||||
- Outputs: expanded `text`, expanded `negative`
|
||||
|
||||
## Node Inputs
|
||||
- positive: positive prompt
|
||||
- text: positive prompt
|
||||
- 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)
|
||||
- seed: this is used to determine how the node will select tags
|
||||
- variety: int between 0-100; creates extra randomness 'lanes' [Variety](#-variety-extra-randomness-lanes)
|
||||
- wildcard_dir: folder with your wildcard .txt files; leave blank to use the `wildcards/` folder next to the node
|
||||
- auto_neg_from_mir: automatically adds -mir tags to negative prompt without explicitly needing to add it to the negative prompt
|
||||
<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
|
||||
@@ -49,6 +50,7 @@ You can preview what the output is with the "preview any" node in utils
|
||||
|
||||
- Located in the `wildcards/` directory (configurable).
|
||||
- Each line is one possible expansion.
|
||||
- Blank lines and lines starting with `#` are ignored.
|
||||
- Lines may contain further braces `{}` and wildcard calls.
|
||||
|
||||
**Example: `wildcards/hats.txt`**
|
||||
@@ -100,7 +102,7 @@ Usage:
|
||||
- Positive: `portrait, __outfits__`
|
||||
- Negative: *(leave blank or put your usual negatives)*
|
||||
<br>
|
||||
- add node from: add node -> prompt -> DSynPrompt Expand (deep-mirrored)
|
||||
- add node from: add node -> prompt -> DynPrompt Expand (deep-mirrored)
|
||||
<br>
|
||||
<img src="https://raw.githubusercontent.com/RegulusAlpha/ComfyUI-DynPromptSimplified/refs/heads/main/images/2.png" alt="Preview 1" width="500"/>
|
||||
<br>
|
||||
@@ -155,7 +157,7 @@ Negative: lowres, bad anatomy, __hats-mir__
|
||||
- `variety` is an extra integer salt to create reproducible “lanes”.
|
||||
|
||||
**Expansion Order (Positive)**
|
||||
1) **Braces-first pass** (repeat until none left)
|
||||
1) **Braces-first pass** (repeat until none left, up to 1024 blocks)
|
||||
Collapse the left-most non-nested `{…}` using **choice#N**.
|
||||
2) **Wildcard pass** (left → right)
|
||||
Replace each `__token__` using **wild#M**:
|
||||
@@ -169,7 +171,9 @@ Negative: lowres, bad anatomy, __hats-mir__
|
||||
Normalize commas/spaces.
|
||||
|
||||
**Deep Mirroring (Negative complement)**
|
||||
- Your user Negative is expanded **normally** (same algorithm as Positive: braces → wildcards → braces).
|
||||
- Your user Negative is expanded **normally** (same algorithm as Positive: braces → wildcards → braces), except:
|
||||
- An explicit `__name-mir__` in the Negative becomes **every option the Positive did not pick** for that token (never the picked one).
|
||||
- If the Positive never used that token, it becomes **all** of its options.
|
||||
- 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):
|
||||
@@ -180,9 +184,10 @@ Negative: lowres, bad anatomy, __hats-mir__
|
||||
|
||||
**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.
|
||||
- Non-mir: if the whole line is one block like `{a|b|{c|d}|}`, split by **top-level** `|` (empties kept as `""`). A line like `{a|b} dress, {c|d}` is one option and its braces are picked normally.
|
||||
- `*-mir`: braces are **flattened** to leaf options (`{a|b|{c|d}}` → `["a","b","c","d"]`, `{red|blue} hat` → `["red hat","blue hat"]`) so “all-except-chosen” is well-defined.
|
||||
- **Multi-line files**: each non-empty, non-comment line is an option (lines may contain braces/wildcards).
|
||||
- `*-mir` files whose lines contain braces are flattened the same way, so the Negative gets plain options instead of raw `{…}`.
|
||||
- **Path safety**: allows subfolders (`a/b`), dotted names (`style.v1`); blocks traversal (`..`).
|
||||
|
||||
**Pseudocode (conceptual)**
|
||||
@@ -222,37 +227,10 @@ Negative: lowres, bad anatomy, __hats-mir__
|
||||
|
||||
## 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.
|
||||
- Expansion is capped to prevent runaway recursion: each brace pass resolves up to 1024 `{…}` blocks and the wildcard pass (including the mirror pass) up to 1024 `__tokens__`. Anything past the cap is left in the prompt as-is.
|
||||
- With `auto_neg_from_mir` ON, mirrored exclusions are appended to the negative whether or not you typed a negative.
|
||||
|
||||
## Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
### 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)
|
||||
|
||||
+28
-17
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
from typing import List
|
||||
from typing import Dict, List
|
||||
from .dynprompt.expander import (
|
||||
PromptExpander,
|
||||
WILDCARD_TOKEN_RE,
|
||||
@@ -27,13 +27,13 @@ def _merge_csv(*parts: str) -> str:
|
||||
out.append(chunk)
|
||||
return ", ".join(out)
|
||||
|
||||
def _collect_mirrors_deep(text: str, base_expander: PromptExpander) -> str:
|
||||
def _collect_mirrors_deep(text: str, base_expander: PromptExpander) -> Dict[str, List[str]]:
|
||||
"""
|
||||
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.
|
||||
- Expand wildcards left-to-right; for any token ending with '-mir', record
|
||||
"all except chosen" (ignore empties) under that token.
|
||||
- Replace tokens with their chosen values to permit nested discovery.
|
||||
"""
|
||||
mirror = PromptExpander(
|
||||
@@ -44,10 +44,11 @@ def _collect_mirrors_deep(text: str, base_expander: PromptExpander) -> str:
|
||||
|
||||
# choices first (consumes mirror's choice counter)
|
||||
s = mirror.collapse_choices(text or "")
|
||||
bag: List[str] = []
|
||||
bag: Dict[str, List[str]] = {}
|
||||
|
||||
# wildcard pass (consumes mirror's wild counter in the same sequence as Positive)
|
||||
while True:
|
||||
# same cap as the positive wildcard pass so self-referencing wildcards can't hang
|
||||
for _ in range(1024):
|
||||
m = WILDCARD_TOKEN_RE.search(s)
|
||||
if not m:
|
||||
break
|
||||
@@ -66,14 +67,12 @@ def _collect_mirrors_deep(text: str, base_expander: PromptExpander) -> str:
|
||||
|
||||
if token.endswith("-mir"):
|
||||
others = [o for i, o in enumerate(opts) if i != idx and o.strip()]
|
||||
if others:
|
||||
bag.extend(others)
|
||||
bag.setdefault(token, []).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)
|
||||
return bag
|
||||
|
||||
# -------------------- ComfyUI Node --------------------
|
||||
|
||||
@@ -81,8 +80,9 @@ class DynPromptExpand:
|
||||
"""
|
||||
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
|
||||
- Negative output: expands the user's Negative normally (pick-one), except
|
||||
that an explicit __*-mir__ becomes every option the Positive did NOT pick.
|
||||
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'.
|
||||
|
||||
@@ -129,13 +129,24 @@ class DynPromptExpand:
|
||||
# A) Positive prompt: full expansion (choices -> wildcards -> choices)
|
||||
pos_final = expander.expand_prompt(text or "", phase="pos")
|
||||
|
||||
# B) User Negative: expand normally (pick-one semantics; no mirroring here)
|
||||
neg_user_final = expander.expand_prompt(negative or "", phase="pos")
|
||||
# B) Deep mirror: collect exclusions for ANY __*-mir__ appearing during Positive expansion
|
||||
mirrored = _collect_mirrors_deep(text, expander) if (text or "").strip() else {}
|
||||
|
||||
# C) User Negative: an explicit __*-mir__ becomes the options the Positive did not pick
|
||||
# (all options if the Positive never used it); everything else expands normally (pick-one)
|
||||
def _mirror_token(m):
|
||||
token = m.group(1)
|
||||
if not token.endswith("-mir"):
|
||||
return m.group(0)
|
||||
if token in mirrored:
|
||||
return ", ".join(mirrored[token])
|
||||
return ", ".join(o for o in expander.read_options_for_token(token) if o.strip())
|
||||
|
||||
neg_user_final = expander.expand_prompt(WILDCARD_TOKEN_RE.sub(_mirror_token, negative or ""), phase="pos")
|
||||
|
||||
# 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)
|
||||
if auto_neg_from_mir:
|
||||
mirrored_additions = ", ".join(o for others in mirrored.values() for o in others)
|
||||
|
||||
# D) Merge user negative + mirrored exclusions (dedup; preserve order)
|
||||
neg_final = _merge_csv(neg_user_final, mirrored_additions)
|
||||
|
||||
+36
-26
@@ -49,31 +49,34 @@ def _split_top_level_alts(s: str) -> List[str]:
|
||||
return alts
|
||||
|
||||
|
||||
def _matching_brace(s: str, i: int) -> int:
|
||||
"""Index of the '}' that closes the '{' at s[i], or -1 if it never closes."""
|
||||
depth = 0
|
||||
for j in range(i, len(s)):
|
||||
if s[j] == '{':
|
||||
depth += 1
|
||||
elif s[j] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return j
|
||||
return -1
|
||||
|
||||
|
||||
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.
|
||||
Pieces are not stripped while joining ("{a|b} x" -> "a x"); callers strip the leaves.
|
||||
"""
|
||||
# Find first '{'
|
||||
i = expr.find('{')
|
||||
if i == -1:
|
||||
return [expr.strip()]
|
||||
return [expr]
|
||||
|
||||
# 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):
|
||||
j = _matching_brace(expr, i)
|
||||
if j == -1:
|
||||
# Malformed braces; return as-is
|
||||
return [expr.strip()]
|
||||
return [expr]
|
||||
|
||||
pre = expr[:i]
|
||||
inner = expr[i+1:j]
|
||||
@@ -83,7 +86,7 @@ def _flatten_brace_expr(expr: str) -> 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())
|
||||
results.append(pre + alt_exp + post_exp)
|
||||
return results
|
||||
|
||||
|
||||
@@ -176,12 +179,15 @@ class PromptExpander:
|
||||
|
||||
Behavior:
|
||||
* Multi-line file: each non-empty, non-comment line is one option.
|
||||
- For '-mir' tokens with braces: every line is FLATTENED into leaf
|
||||
options, so "all-except-chosen" never contains raw braces.
|
||||
* 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 '').
|
||||
- For non-mir tokens: if the whole line is one {…} block, split by
|
||||
TOP-LEVEL '|' into options (empty branches kept as ''); otherwise
|
||||
the line is one option and its braces collapse later.
|
||||
"""
|
||||
path = self._normalize_token_to_path(token)
|
||||
if not path:
|
||||
@@ -209,15 +215,19 @@ class PromptExpander:
|
||||
# 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)
|
||||
m = re.fullmatch(r"\{(.*)\}", line)
|
||||
if m:
|
||||
return _split_top_level_alts(m.group(1))
|
||||
# If unmatched, leave as single option
|
||||
# Non-mir: choose among top-level alts (empties allowed), but only when
|
||||
# the first '{' closes at the end; "{a|b} x, {c|d}" is a single option
|
||||
if line.startswith("{") and _matching_brace(line, 0) == len(line) - 1:
|
||||
return _split_top_level_alts(line[1:-1])
|
||||
return [line]
|
||||
# No braces => single option
|
||||
return [line]
|
||||
|
||||
# Multi-line -mir file with braces: flatten like the single-line case so the
|
||||
# mirrored Negative gets plain leaves instead of raw "{a|b}" text
|
||||
if token.endswith("-mir") and any("{" in t for t in raw):
|
||||
return _dedup_preserve([leaf for t in raw for leaf in _flatten_brace_expr(t)])
|
||||
|
||||
# Multi-line file: return lines as distinct options (braces inside a line
|
||||
# are collapsed later by the brace pass).
|
||||
return raw
|
||||
@@ -252,7 +262,7 @@ class PromptExpander:
|
||||
chosen = options[idx] # may be '' (empty branch)
|
||||
return s[:start] + chosen + s[end:]
|
||||
|
||||
def _expand_choices_recursively(self, s: str, limit: int = 64) -> str:
|
||||
def _expand_choices_recursively(self, s: str, limit: int = 1024) -> str:
|
||||
for _ in range(limit):
|
||||
if not INNER_BRACE_RE.search(s):
|
||||
break
|
||||
@@ -284,7 +294,7 @@ class PromptExpander:
|
||||
|
||||
return s[:start] + core + s[end:]
|
||||
|
||||
def _expand_wildcards_recursively(self, s: str, phase: str, limit: int = 128) -> str:
|
||||
def _expand_wildcards_recursively(self, s: str, phase: str, limit: int = 1024) -> str:
|
||||
for _ in range(limit):
|
||||
if not WILDCARD_TOKEN_RE.search(s):
|
||||
break
|
||||
@@ -306,6 +316,6 @@ class PromptExpander:
|
||||
# 3) Collapse any braces that came from wildcard lines
|
||||
out = self._expand_choices_recursively(out)
|
||||
# 4) Cleanup duplicated commas/spaces
|
||||
out = re.sub(r"\s*,\s*,\s*", ", ", out)
|
||||
out = re.sub(r"\s*,(\s*,)+\s*", ", ", out)
|
||||
out = re.sub(r"\s{2,}", " ", out).strip().strip(", ")
|
||||
return out
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "dynpromptsimplified"
|
||||
description = ""
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
license = {file = "LICENSE"}
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
|
||||
Reference in New Issue
Block a user