From f600a01ac716b74c27e742b38dab3a5cc555dd9e Mon Sep 17 00:00:00 2001 From: RegulusAlpha Date: Sat, 23 Aug 2025 14:40:22 -0400 Subject: [PATCH] add files via upload --- README.md | 49 ++++++++++++ __init__.py | 2 + comfy_nodes.py | 62 +++++++++++++++ dynprompt/__init__.py | 1 + dynprompt/expander.py | 124 ++++++++++++++++++++++++++++++ example_workflow.json | 170 +++++++++++++++++++++++++++++++++++++++++ wildcards/hats-mir.txt | 1 + wildcards/hats.txt | 1 + 8 files changed, 410 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 comfy_nodes.py create mode 100644 dynprompt/__init__.py create mode 100644 dynprompt/expander.py create mode 100644 example_workflow.json create mode 100644 wildcards/hats-mir.txt create mode 100644 wildcards/hats.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..44315ce --- /dev/null +++ b/README.md @@ -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: `/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) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..83746a8 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# Loads node classes from comfy_nodes.py +from .comfy_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS diff --git a/comfy_nodes.py b/comfy_nodes.py new file mode 100644 index 0000000..ceef251 --- /dev/null +++ b/comfy_nodes.py @@ -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)", +} diff --git a/dynprompt/__init__.py b/dynprompt/__init__.py new file mode 100644 index 0000000..ef1ab15 --- /dev/null +++ b/dynprompt/__init__.py @@ -0,0 +1 @@ +__all__ = ['expander'] diff --git a/dynprompt/expander.py b/dynprompt/expander.py new file mode 100644 index 0000000..74c780f --- /dev/null +++ b/dynprompt/expander.py @@ -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 diff --git a/example_workflow.json b/example_workflow.json new file mode 100644 index 0000000..07b344a --- /dev/null +++ b/example_workflow.json @@ -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 + ] + } + } + ] +} \ No newline at end of file diff --git a/wildcards/hats-mir.txt b/wildcards/hats-mir.txt new file mode 100644 index 0000000..39b1da9 --- /dev/null +++ b/wildcards/hats-mir.txt @@ -0,0 +1 @@ +{red hat|blue hat|green hat} diff --git a/wildcards/hats.txt b/wildcards/hats.txt new file mode 100644 index 0000000..39b1da9 --- /dev/null +++ b/wildcards/hats.txt @@ -0,0 +1 @@ +{red hat|blue hat|green hat}