add files via upload
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
# Loads node classes from comfy_nodes.py
|
||||
from .comfy_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
__all__ = ['expander']
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{red hat|blue hat|green hat}
|
||||
@@ -0,0 +1 @@
|
||||
{red hat|blue hat|green hat}
|
||||
Reference in New Issue
Block a user