add files via upload

This commit is contained in:
RegulusAlpha
2025-08-23 14:40:22 -04:00
committed by GitHub
commit f600a01ac7
8 changed files with 410 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# ComfyUI-DynPromptSimplified
A minimal **dynamic prompting + mirrored wildcards** node for ComfyUI.
## Features
- `{a|b|{c|d}}` nested choice expansion (deterministic by seed).
- `__name__` loads `wildcards/name.txt`.
- `__name-mir__` strictly loads `wildcards/name-mir.txt`.
- In **negative** phase, if `__name__` is used and `name-mir.txt` exists, it auto-mirrors to **all options except the chosen one**.
- Deterministic selection per seed.
## Install
1. Copy this folder to: `<ComfyUI>/custom_nodes/ComfyUI-DynPromptSimplified`
2. (Optional) Put your wildcard files in `ComfyUI-DynPromptSimplified/wildcards/` or set `wildcard_dir` to any folder.
3. Restart ComfyUI.
## Node
**DynPrompt Expand (mirrored wildcards)**
- Inputs: `text`, `negative`, `seed`, `wildcard_dir`, `auto_neg_from_mir`
- Outputs: expanded `text`, expanded `negative`
Wire the outputs into your usual `CLIP Text Encode` → `KSampler` chain.
## Examples
Wildcards:
```
wildcards/hats.txt
{red hat|blue hat|green hat}
wildcards/hats-mir.txt
{red hat|blue hat|green hat}
```
Prompt:
```
a portrait wearing __hats-mir__, background {studio|outdoor|cyberpunk}
```
- Positive becomes a single chosen hat (by seed).
- Negative becomes the **other** hats, comma-separated.
## 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.
License: GPLv3 (same as the original)
+2
View File
@@ -0,0 +1,2 @@
# Loads node classes from comfy_nodes.py
from .comfy_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+62
View File
@@ -0,0 +1,62 @@
from typing import Dict, Any
import os
from .dynprompt.expander import PromptExpander
class DynPromptExpand:
"""
Expand dynamic prompts + mirrored wildcards for ComfyUI.
- Inputs: positive text, negative text, seed, wildcard_dir
- Outputs: expanded positive text, expanded negative text
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"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'."}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "negative")
FUNCTION = "expand"
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()
if not wd:
# default to a 'wildcards' folder inside this custom node
wd = os.path.join(os.path.dirname(__file__), "wildcards")
exp_pos = PromptExpander(seed=seed, wildcard_dir=wd).expand_prompt(text or "", phase="pos")
# 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")
return (exp_pos, exp_neg)
NODE_CLASS_MAPPINGS = {
"DynPromptExpand": DynPromptExpand,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DynPromptExpand": "DynPrompt Expand (mirrored wildcards)",
}
+1
View File
@@ -0,0 +1 @@
__all__ = ['expander']
+124
View File
@@ -0,0 +1,124 @@
import os
import re
import hashlib
from typing import List, Optional, Tuple
WILDCARD_TOKEN_RE = re.compile(r"__([A-Za-z0-9_\-]+)__")
INNER_BRACE_RE = re.compile(r"\{([^{}]+)\}")
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:
if n <= 0:
return 0
h = hashlib.md5(f"{seed}:{salt}".encode("utf-8")).hexdigest()
return int(h, 16) % n
class PromptExpander:
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
def _read_wildcard_lines(self, token: str) -> List[str]:
path = self._wildcard_path(token)
if not path:
return []
out: 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
# ---- 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)
if not options:
return s # nothing to do
idx = _stable_pick_index(self.seed, f"choice:{s}:{start}-{end}", len(options))
chosen = options[idx]
return s[:start] + chosen + s[end+1:]
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 -------------------------
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 __
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)
if not options:
# Missing wildcard: drop it
return s[:start] + "" + s[end:]
idx = _stable_pick_index(self.seed, f"wild:{token}:{s}:{start}-{end}", len(options))
if phase == "pos":
core = options[idx]
else:
# negative gets all except chosen
core = ", ".join([o for i, o in enumerate(options) if i != idx])
return s[:start] + core + s[end:]
def _expand_wildcards_recursively(self, s: str, phase: str, limit: int = 128) -> str:
for _ in range(limit):
if not WILDCARD_TOKEN_RE.search(s):
break
s = self._expand_wildcards_once(s, phase=phase)
return s
# ---- 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
out = self._expand_choices_recursively(out)
# 2) Expand wildcards fully for the phase
out = self._expand_wildcards_recursively(out, phase=phase)
# 3) Expand braces again in case wildcard lines contained braces
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(", ")
return out
+170
View File
@@ -0,0 +1,170 @@
{
"last_node_id": 6,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Load Checkpoint",
"pos": [
80,
120
],
"inputs": {
"ckpt_name": [
"STRING",
"model.safetensors"
]
}
},
{
"id": 2,
"type": "DynPrompt Expand (mirrored wildcards)",
"pos": [
80,
240
],
"inputs": {
"text": [
"STRING",
"portrait wearing __hats-mir__"
],
"negative": [
"STRING",
""
],
"seed": [
"INT",
12345
],
"wildcard_dir": [
"STRING",
""
],
"auto_neg_from_mir": [
"BOOLEAN",
true
]
}
},
{
"id": 3,
"type": "CLIP Text Encode",
"pos": [
420,
200
],
"inputs": {
"clip": [
"CLIP",
1,
1
],
"text": [
"STRING",
2,
0
]
}
},
{
"id": 4,
"type": "CLIP Text Encode",
"pos": [
420,
300
],
"inputs": {
"clip": [
"CLIP",
1,
1
],
"text": [
"STRING",
2,
1
]
}
},
{
"id": 5,
"type": "KSampler",
"pos": [
720,
220
],
"inputs": {
"model": [
"MODEL",
1,
0
],
"seed": [
"INT",
12345
],
"steps": [
"INT",
20
],
"cfg": [
"FLOAT",
4.5
],
"sampler_name": [
"STRING",
"euler"
],
"scheduler": [
"STRING",
"normal"
],
"denoise": [
"FLOAT",
1.0
],
"positive": [
"CONDITIONING",
3,
0
],
"negative": [
"CONDITIONING",
4,
0
],
"latent_image": [
"LATENT",
[
"EmptyLatentImage",
{
"width": 768,
"height": 1024,
"batch_size": 1
}
]
]
}
},
{
"id": 6,
"type": "VAE Decode",
"pos": [
980,
220
],
"inputs": {
"samples": [
"LATENT",
5,
0
],
"vae": [
"VAE",
1,
2
]
}
}
]
}
+1
View File
@@ -0,0 +1 @@
{red hat|blue hat|green hat}
+1
View File
@@ -0,0 +1 @@
{red hat|blue hat|green hat}