Files
2025-08-24 15:16:45 -04:00

148 lines
5.6 KiB
Python

import os
import re
from typing import List
from .dynprompt.expander import (
PromptExpander,
WILDCARD_TOKEN_RE,
)
# -------------------- Small helpers --------------------
def _csv_chunks(s: str) -> List[str]:
"""Split a comma list into items, trimming whitespace and skipping empties."""
return [p.strip() for p in (s or "").split(",") if p.strip()]
def _merge_csv(*parts: str) -> str:
"""
Merge multiple comma lists, preserving order of first appearance
and de-duplicating case-insensitively.
"""
seen = set()
out = []
for part in parts:
for chunk in _csv_chunks(part):
key = chunk.lower()
if key not in seen:
seen.add(key)
out.append(chunk)
return ", ".join(out)
def _collect_mirrors_deep(text: str, base_expander: PromptExpander) -> 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.
- Replace tokens with their chosen values to permit nested discovery.
"""
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] = []
# wildcard pass (consumes mirror's wild counter in the same sequence as Positive)
while True:
m = WILDCARD_TOKEN_RE.search(s)
if not m:
break
token = m.group(1)
start, end = m.span()
opts = mirror.read_options_for_token(token)
if not opts:
# drop unknown to mirror main expander behavior
s = s[:start] + s[end:]
continue
idx = mirror._pick_wild_index(token, len(opts))
chosen = opts[idx] if 0 <= idx < len(opts) else ""
if token.endswith("-mir"):
others = [o for i, o in enumerate(opts) if i != idx and o.strip()]
if others:
bag.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)
# -------------------- ComfyUI Node --------------------
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
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
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}),
"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)"
}),
"auto_neg_from_mir": ("BOOLEAN", {
"default": True,
"tooltip": "Append deep mirrored exclusions for any __*-mir__ found in Positive (strict lookup)."
}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "negative")
FUNCTION = "expand"
CATEGORY = "prompt"
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:
wd = os.path.abspath(os.path.expanduser(wd))
if not wd:
wd = os.path.abspath(os.path.join(os.path.dirname(__file__), "wildcards"))
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")
# B) User Negative: expand normally (pick-one semantics; no mirroring here)
neg_user_final = expander.expand_prompt(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)
# D) Merge user negative + mirrored exclusions (dedup; preserve order)
neg_final = _merge_csv(neg_user_final, mirrored_additions)
return (pos_final, neg_final)
# ComfyUI registration
NODE_CLASS_MAPPINGS = {"DynPromptExpand": DynPromptExpand}
NODE_DISPLAY_NAME_MAPPINGS = {"DynPromptExpand": "DynPrompt Expand (deep-mirrored)"}