From 31dc17093ed176a2459cee82c3f3eb02ab42bc04 Mon Sep 17 00:00:00 2001 From: RegulusAlpha Date: Sat, 23 Aug 2025 16:13:28 -0400 Subject: [PATCH] fix wildcard nesting and negative prompt --- __pycache__/__init__.cpython-312.pyc | Bin 0 -> 304 bytes __pycache__/comfy_nodes.cpython-312.pyc | Bin 0 -> 6457 bytes comfy_nodes.py | 151 +++++++--- .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 245 bytes .../__pycache__/expander.cpython-312.pyc | Bin 0 -> 11911 bytes dynprompt/expander.py | 268 ++++++++++++++---- wildcards/hats.txt | 3 +- wildcards/test-mir.txt | 1 + 8 files changed, 332 insertions(+), 91 deletions(-) create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 __pycache__/comfy_nodes.cpython-312.pyc create mode 100644 dynprompt/__pycache__/__init__.cpython-312.pyc create mode 100644 dynprompt/__pycache__/expander.cpython-312.pyc create mode 100644 wildcards/test-mir.txt diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf2d6317ffe07a7e179612791aac7a168c4429b3 GIT binary patch literal 304 zcmX@j%ge<81ZM?SWfTGF#~=<2FhLogMSzUy3@HpLj5!Rsj8TlaOi@gX43$ip%r6;% zQkqP+g#G+oT;rX69D{@7eH{Y=JpJ5*Z%Kg#T|9#Wd>kX={TzK=;c|YO%(u9c^K;WG zowu5|C%nc+yFf%eTK4 D{O3^j literal 0 HcmV?d00001 diff --git a/__pycache__/comfy_nodes.cpython-312.pyc b/__pycache__/comfy_nodes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..abcbc2de0fbf4864bd249879cdbd57fa90407018 GIT binary patch literal 6457 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 literal 0 HcmV?d00001 diff --git a/comfy_nodes.py b/comfy_nodes.py index ceef251..93d356c 100644 --- a/comfy_nodes.py +++ b/comfy_nodes.py @@ -1,15 +1,94 @@ - -from typing import Dict, Any import os +import re +from typing import List +from .dynprompt.expander import ( + PromptExpander, + WILDCARD_TOKEN_RE, + _stable_pick_index, # reuse same salt/index math as the expander +) -from .dynprompt.expander import PromptExpander +# -------------------- 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, expander: PromptExpander) -> str: + """ + Deep mirroring pass: + - Simulate the expander's left-to-right wildcard pass on the POSITIVE prompt. + - For tokens ending with '-mir', compute the chosen index and collect + "all except chosen" into a bag. (STRICT lookup: '-mir.txt' only.) + - For non-mir tokens, just replace with the chosen option (no collection). + Notes: + * We collapse braces BEFORE scanning for wildcards to match the expander's order. + * Empty branches in -mir files are supported: + - If chosen == '' (empty), we add all NON-empty others to Negative. + - If chosen is non-empty, we add all other NON-empty options. + * Newly introduced nested wildcards (from chosen options) are handled + by continuing the while-loop until no __token__ remains. + """ + # Step 1: match expander's first phase (choices first) + s = expander.collapse_choices(text or "") + bag: List[str] = [] + + # Step 2: left-to-right wildcard pass (mirrors expander._expand_wildcards_recursively) + while True: + m = WILDCARD_TOKEN_RE.search(s) + if not m: + break + + token = m.group(1) + start, end = m.span() + + opts = expander.read_options_for_token(token) # STRICT; may be empty + if not opts: + # Drop unknown token to mirror main expander behavior + s = s[:start] + s[end:] + continue + + idx = _stable_pick_index(expander.seed, f"wild:{token}:{s}:{start}-{end}", len(opts)) + chosen = opts[idx] if 0 <= idx < len(opts) else "" + + # If this is a strict '-mir' token, accumulate "all except chosen" (ignore empties) + 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: """ - Expand dynamic prompts + mirrored wildcards for ComfyUI. - - Inputs: positive text, negative text, seed, wildcard_dir - - Outputs: expanded positive text, expanded negative text + 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 '-mir.txt' is consulted; no fallback to '.txt'. """ + @classmethod def INPUT_TYPES(cls): return { @@ -17,8 +96,14 @@ class DynPromptExpand: "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'."}), + "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)." + }), } } @@ -28,35 +113,31 @@ class DynPromptExpand: 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() + # 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: - # default to a 'wildcards' folder inside this custom node - wd = os.path.join(os.path.dirname(__file__), "wildcards") + wd = os.path.abspath(os.path.join(os.path.dirname(__file__), "wildcards")) - exp_pos = PromptExpander(seed=seed, wildcard_dir=wd).expand_prompt(text or "", phase="pos") + expander = PromptExpander(seed=seed, wildcard_dir=wd) - # 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") + # A) Positive prompt: full expansion (choices -> wildcards -> choices) + pos_final = expander.expand_prompt(text or "", phase="pos") - return (exp_pos, exp_neg) + # B) User Negative: expand normally (pick-one semantics; no mirroring here) + neg_user_final = expander.expand_prompt(negative or "", phase="pos") -NODE_CLASS_MAPPINGS = { - "DynPromptExpand": DynPromptExpand, -} -NODE_DISPLAY_NAME_MAPPINGS = { - "DynPromptExpand": "DynPrompt Expand (mirrored wildcards)", -} + # 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)"} diff --git a/dynprompt/__pycache__/__init__.cpython-312.pyc b/dynprompt/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44c1d280bd20c4a2e87661e0118412062e9f8f05 GIT binary patch literal 245 zcmX@j%ge<81ZM?SWrzdm#~=<2FhUuhK}x1Gq%fp2Mln<}YBJs8NUbPH%u7iv^3!Cz z#U3A@n3EG9zmnlINW-sk{oK@)%tZaf%-kG(b7RXi^RzT`-IOHbBwdqaOJiM&G{aTZlX-=wL5j)TwAlDc30ErLGjEsyoxJ5g3 K8rh3jfdT+c3`5!g literal 0 HcmV?d00001 diff --git a/dynprompt/__pycache__/expander.cpython-312.pyc b/dynprompt/__pycache__/expander.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c2e0cd3db36c87e944e74f1b92abee6be1b360d GIT binary patch literal 11911 zcmcIqYfv0lcJ7{@7xRW0352oLNXRgP0U;!zmn9=$wR#w@gdR34duo{OfkDH}sJjsY zH5+zURURa5G{kE&+DAOjk1UFu3Y+{0KULXE%BkHXj7MI^ZB<2;^^f>Rs!-Uf-Sv+o z=iKfd20W6Ts^m&@@9o?7)#sjbzH`pymrkdRg7nr4e=)rJB1QcMAM|9^Dy#nml{t!| zI66iRs3ko>lV`(#0ZK!R88r?VM@<7Jnley4J>t+OSt~i_KCM0tSf1L)SnOlvOne1r z=8b&C78n*Wa+Vv$0o#{at!a7rS70C%nA{aO z5WEy_M6wI-q`8CG^Nm@VitI5Oo)dyDv;StkTdXG9f53)lBto> zx1Uz6Cu3ARyggcd2mCdh}2Eo@TY zMP9fXjSsQ>Xi}QMf${J#FSe4IFAhf~UQEJVY)D9q#W{T>8x>heh>ni(TnjrmCTXKZ zXp0Yt(3xj1^GVp0$Tl{rb1Ae8lsAdESty`q($5G;JH?a?36iMLNfC(vU-ZRz8_XOH zwS}Uiv9`{R1Ch>1q%*(`b_@pggb#EC_D9;ofsS3B2X?pb+r{tZ_qTN?Mk5pFPqc-{ zL@6;Ej3+o=)Efgm6Y*~e3HZSN=qRioiSk?lnujjf8QI0K?2tL(~)vKLbZkF*4Ody%7f1Fv>>G zaJh-1gncr@(WKTc(`8wVDaS;zL1(-XB=&}+vzXq8#$6b0AAwbkWGyqSYMOMyPMEk2 zTFs=@L|Ut{6a#Q3Xx z3}vH*9Ztj($iZX~lq%<*0v-@G4iCj7UWkXJ=v5xre2u#WR5Su=TZK~S>z^Y@6ne_f zDCSTy$;UaxIFg9Q1=Q4jlgjo+m`qfd!Lf*9;y_x46+?Jf#6toRU|9i(PAQu-hJ>94 z$xeefN~nc*FThXyQ^*K2R&SZ^DQv2q?k+f-)4iX%+$lrB?o4}c9>{FU*8ixs;PBiz zHFGLsc+Yazl4XAESgzTbtJ#^aX?f@f6smkF80gbR6daZ5GczY=dJ9f>y5r{S+H-+CJB|$7e{VLIxi0aKX42vRg@z}DssI{>0EvpPD|J; zQ!<0Fq)C-oHgHD5>4vzs_|8Q5-A>*izk{mg7}7?^eINzuh-a-s-`@L_U0i})e2s6B zDb6g@wWtd~j$lRDv&_J=33sVJb7pupgX~%)q$^3XN&jBZCC6d!R;i-+&RX)W6W&#n zyfc@)YlL?;*)n3+N3NB!QLZ3+v0?tK6Vc}bUEnuTw-3PY1n2|{k=uJV1U5j79$8L8cRNk!>1}O2e!s;=C@ceiht8u^1cULlJEz4a$1g!5cv+ z4g3Hui6FD%!+e~ThWQCr;JGotJV45LLK-eA0-+MV^Yj0Pq>4L0Hzjx>>+_oxV+pO(vN9sFE*5_Bu0BF-7;uX-rX1^UyH{6Qe41f%$>6y-S_~wxkwNRO^ zpShS}Zoavu8r;4NeRHzl*#tAB&diuo^opCZJJfk@1y-skYsKyEwDH!7?|tX-x|JW> zTqz+fm(G<1NokAAjU-$e#bTyE(-?s@^%@ z@$2fg)bU4kZ41U<*X>PP3!dsZ*Q_hcWQX2&E?9Hy{^hMlb6bz*w|0H(IrfC1p5F&q z&8`)O^0cg2DTjBuPk0WGfD!pC>l73!4x*qwg325vp-v}BS2QYI6R189VggFwSwiqE z_(hnP>8q4L-+mFGvM9p(iXiYMfC`XX6y%Z~&s@Dk1TTW`Qt0k3dO|ir{ zFDOii<5U(k3C%FOVib8kt}ux)NujSP%w>LJBSyJ{90*J@s0);^7sd_1PkarsY3h;P zb!)GNPMIT%)#Ye6>X33}};M>G!s zx)hU4{TTcqnf@{LQ>NUht$9H@Rm+`NkxQ5o)e7ob_eo^KBp8680!@?0hzjiC!L}F$ zohM4sFz7Olzs4q`;mcsbi&AJX21a;T0JDY_#^Pcd7_VTUin^&zG?GFe{K7OQMFRUj z_rTY{@mhvM;&3cFs4%14Ud7DE!(bRI6~p{BE;9haGvrz?^e$~$ z>)Kd5wAwjGajX-b9o#0;;(~Q>Ml+dT%RN1-;!|HQCWx ze=oFpCJg=>#ijZty_y>`_71RQ2_}jxSIQUwZ{Vg)u*mlZZKNN|w)@%ZA$d@~9+tT& zd5UENfg>7jYMu<+RO;ZdFbhaK=hw#X_OrpDl(-B)6(nGSO4lT93JrtS6+7Afb7xOkl|dBl~LK z>DSq&c!G_<-UdVA%lh8d&8R)V#AAbzL=3z(k$sWn5`eKB8xM_wRW6PtlL-NzoA}nD z))qD#0}uyq5KL&X^=i95Z-<{f3xcGQ35@w6FgHNz*lMw4w`0$t@g2x)0 z@Ee40!X|~2kcs#Wx_07!dDv_$iD zOtPsgbQgW1XF|4FHjDJ_9kRKcp~V&bBQV!G!RWKA>zC$ou@Cq>;OkUSoCW{s;7^0+ zrhb)#ZaztJF}TWEiHA7?-W>^?PMJ8HYy$J}P0mir0WcEX`na`{bLdnPd=feizmD31eC$jcnFyYbED|F&eW^S!9b)DJ_}W6M zs5JSwM8E?>rKsao<*P=N)b9fj1Em||)!ERc1shv~ql2i2K{+SD>LZJN%~>vD~*yr@s`l{yIC+@lFqBm&Bq=Hlomi za0r-JF{@Yz(P=BOkKs_jQbrIJCk-~ILT~RAh^vosKw<#I>7O*>-Y2baMbL`Z1AdoM z194}B?&u`1SyNIXm<&N=&zOK9Alx31xVP$W*}$TPus5DN@wWsPHv?PX)HZaYh##*Q zATUr78H>e6LsEEHjVzdkARwRgTUCADheM5IB}NF1D|AFLBHJi5ctxWk#wGRBQo(Vv zz=yb?cDg|@8`S+y?%1I9%Xj-@m`}8Uf(EOfr98f6cT3LQGJihr?noIwaeE8iO{o*$ ztb>d0tV}m#z!_f{{@8i+LHnmQThhjYuV(Jl?5V6d?`vH41#-SX-q)6`jp z=iYPNb!5N2(2%d`On1RH9&O#TaOQ6>{`}(NnZFsxZ+&^G=H9bB5 z{`WI}#fUNpClj}vGySwkkK+0meko#JaeLtQ>phUZU$J5lDNDu|X)73Hz%CA5c5|OX zUsC9{Nkd!fq@lHS(unBcw+U#NV%#nngG)2T98={T{a3}15QR6eVnro^vMM0-3K$~+ z9V8;0fG1AuGgY1k!FLdluhu)`!g*+#Y}_EJPg6Y~;`n=zfsu=L8vBu(EqJRTPB-VA zb!ND1ciz*q>}kt+z{j=G$q?k8v8yLk8FdzbEBnr~RxyU@7kTX5x{Kbm*E3_%~a z=Z-uhXL=sG>v2Zk=DFju$LIQH`?Bp1eLGTDmaJ{_dmtm0Be^13Cqqa?)ovs*B*CMa z%1O+cv!U9Lct-?bv*KNVK9-{NFwG^?)e zsR%V`QN8_x!a-qm_gXIqI|Zc}GKv{>1G8j7WX6uxaz$h1m;lzcyn_nNs4Ts^CNdKsfoj4=&^o#ED-Ni{q5KT`i$@`YbE@2&9(%Vg)%D0* zzwB+wd7I`dA9|ZX(L8p0QdS7KS8iJN_;Vire0|>2GSjtUp{ia$B&|$YHL8;E$U3T` zL=md~14#W_HlQmssOc-@9;8f_O2CCOa=#|W5PWCK(RZC@Fo7&m<-EbQz&!d#qKn9> zsTP76nw*-~KhGiaJ!^ z*;Iv21?`5AmP@GMw`uk?1}r8`7)??YmJo*qHIU6fgzO6g#O^ekg`7BkZwLHVFF^JV zeF{8Bw58r)DT-)s8t;5(n9C5y2JhWb{x1{sQ-vPS%Z7L9IU4-WZANN>`b%b>zA4Hi@AgfIrBE-lll6wHz5{^vne~QfLu`)4;YOVD!gm6MJ@kncN zBzy!Vz6f*w{@8EEse)`WWe%fzSDleWm-P7H5wkLS*)l&A=(`dO@@@u5`k z@v%`}2uVELND|Y7Ch1;0zN~o|y6#_3 zfiHO&<-7d{OewBH1|)gpcz)T@kaIL-&*UAAB>>>5B!6^3zYPGe0Sz8kZCvV7PlB4;vB0}&By$hX-^^0AL9gCp{rp56Gp%0k{V@oZs z{7PKvANZx5cU=OYsNa?iKdj&XLHB~?{^^hF_ou5f-an~Z988%X+db)CxGME-ndyNW zRq%8U%pSnf63}6x-g7zq9;5P zWd2n=mvzumG)@6pK=p(9@_z&^vf%?-cR2uF+EjS)8~X0i0`3AVRn%|74a^9_yoIf% zjZB0vBV31U6Jtq9RWZ6&ZTig;JHw+a59q}aP0rs+De+&j3y)O0dlc!TX}=_8S6VFore zX}=`yY|(zgA7P96l6ZZ03;gjU3(SG$g@BP`VE{6?d>$LrRISQ4C-D_gKD(i&Yg_FO z(y7^rN%CiVlXV+7WZhdQas;kFO{wvSf~zXUJa$zryIOOu)_F1S>d>Bd-}SFl{fdj)v^9Nf?&R#rrL70@ zzJp6tCD_PSb?K_P+S%I7tNF^7Y1fLwOuv`~azQcgn-AaHvWnG}DlhH9gM$LNb^H6T zuVQtjWjpQ702!dja`PSUM^~}Ba+ List[str]: + """ + Split a non-nested brace's inner text by '|' into options, preserving empty branches. + Whitespace around options is trimmed, but empty strings are kept as ''. + """ + parts = s.split("|") + return [p.strip() for p in parts] # keep '' for empty branches + + +def _split_top_level_alts(s: str) -> List[str]: + """ + Split a possibly nested brace's inner text by top-level '|', honoring nesting. + Keeps empty alternatives as ''. + """ + alts: List[str] = [] + buf: List[str] = [] + depth = 0 + for ch in s: + if ch == '|' and depth == 0: + alts.append(''.join(buf).strip()) + buf = [] + continue + if ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + buf.append(ch) + alts.append(''.join(buf).strip()) + return alts + + +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. + """ + # Find first '{' + i = expr.find('{') + if i == -1: + return [expr.strip()] + + # 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): + # Malformed braces; return as-is + return [expr.strip()] + + pre = expr[:i] + inner = expr[i+1:j] + post = expr[j+1:] + + results: 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()) + return results + + +def _dedup_preserve(seq: List[str]) -> List[str]: + seen = set() + out: List[str] = [] + for x in seq: + key = x.strip().lower() + if key in seen: + continue + seen.add(key) + out.append(x.strip()) + return out -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: + """ + Deterministic index picker stable across runs/processes. + """ if n <= 0: return 0 h = hashlib.md5(f"{seed}:{salt}".encode("utf-8")).hexdigest() return int(h, 16) % n + +# --------------------------------------------------------------------------- +# Expander +# --------------------------------------------------------------------------- + class PromptExpander: + """ + Expands deterministically by seed: + + 1) {a|b|{c|d}|} --> brace choices (empty branch allowed) + 2) __token__ --> wildcards from /token.txt + * STRICT lookup: 'foo-mir' -> 'foo-mir.txt' ONLY (no fallback) + * Subfolders & dotted names supported (e.g., clothes/tops.v1) + 3) Runs braces again in case wildcard lines contain {…} + + Negative semantics are controlled by 'phase': + - phase="pos": pick-one for wildcards (normal) + - phase="neg": join all except chosen (used for mirroring) + (empty options are ignored when joining, so they don't add commas) + """ + 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 + # -------------------- Public helpers -------------------- - def _read_wildcard_lines(self, token: str) -> List[str]: - path = self._wildcard_path(token) + def collapse_choices(self, text: str) -> str: + """Deterministically collapse all {a|b|{c|d}|} blocks. No wildcard expansion.""" + return self._expand_choices_recursively(text or "") + + def read_options_for_token(self, token: str) -> List[str]: + """ + STRICT lookup: + - 'foo' -> 'foo.txt' + - 'foo-mir' -> 'foo-mir.txt' (NO fallback to 'foo.txt') + + Behavior: + * Multi-line file: each non-empty, non-comment line is one option. + * 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 ''). + """ + # Resolve and read file raw (without any brace splitting) + path = self._normalize_token_to_path(token) if not path: return [] - out: List[str] = [] + + raw: 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 + t = line.rstrip("\n\r").strip() + if not t: + # Ignore blank lines in multi-line files (empties should be explicit via braces) + continue + if t.startswith("#"): + continue + raw.append(t) + + if not raw: + return [] + + # Single-line file + if len(raw) == 1: + line = raw[0] + if "{" in line and "}" in line: + if token.endswith("-mir"): + # 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) + # e.g., "{red|blue| |green}" -> ["red","blue","","green"] + m = re.fullmatch(r"\{(.*)\}", line) + if m: + return _split_top_level_alts(m.group(1)) + # If unmatched, leave as single option + return [line] + # No braces => single option + return [line] + + # Multi-line file: return lines as distinct options (braces inside a line + # are collapsed later by the brace pass). + return raw + + # -------------------- IO & parsing ---------------------- + + def _normalize_token_to_path(self, token: str) -> Optional[str]: + if not self.wildcard_dir: + return None + token = token.replace("\\", "/").lstrip("/.") + # Block parent traversal + if ".." in token.split("/"): + return None + base = os.path.normpath(self.wildcard_dir) + p = os.path.normpath(os.path.join(base, f"{token}.txt")) + if not p.startswith(base): + return None + return p if os.path.isfile(p) else None + + # -------------------- Choice expansion ------------------ - # ---- 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) + options = _split_choices_keep_empty(m.group(1)) if not options: - return s # nothing to do + # Leave unchanged on malformed {} + return s idx = _stable_pick_index(self.seed, f"choice:{s}:{start}-{end}", len(options)) - chosen = options[idx] - return s[:start] + chosen + s[end+1:] + chosen = options[idx] # may be '' (empty branch) + return s[:start] + chosen + s[end:] 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 ------------------------- + # -------------------- Wildcard expansion ---------------- + 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 __ + token = m.group(1) 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) + options = self.read_options_for_token(token) if not options: - # Missing wildcard: drop it + # Missing file => drop token silently return s[:start] + "" + s[end:] idx = _stable_pick_index(self.seed, f"wild:{token}:{s}:{start}-{end}", len(options)) + chosen = options[idx] if 0 <= idx < len(options) else "" if phase == "pos": - core = options[idx] + core = chosen # may be '' => token disappears else: - # negative gets all except chosen - core = ", ".join([o for i, o in enumerate(options) if i != idx]) + # phase == "neg": join all except chosen; ignore empty strings + core = ", ".join(o for i, o in enumerate(options) if i != idx and o.strip()) return s[:start] + core + s[end:] @@ -99,26 +264,19 @@ class PromptExpander: s = self._expand_wildcards_once(s, phase=phase) return s - # ---- Public API -------------------------------------------------------- + # -------------------- 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 + # 1) Collapse braces first (enables optional segments via empty branches) out = self._expand_choices_recursively(out) - # 2) Expand wildcards fully for the phase + # 2) Expand wildcards (phase controls pos/neg semantics) out = self._expand_wildcards_recursively(out, phase=phase) - # 3) Expand braces again in case wildcard lines contained braces + # 3) Collapse any braces that came from wildcard lines 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(", ") + # 4) Cleanup duplicated commas/spaces + out = re.sub(r"\s*,\s*,\s*", ", ", out) + out = re.sub(r"\s{2,}", " ", out).strip().strip(", ") return out diff --git a/wildcards/hats.txt b/wildcards/hats.txt index 39b1da9..d04394d 100644 --- a/wildcards/hats.txt +++ b/wildcards/hats.txt @@ -1 +1,2 @@ -{red hat|blue hat|green hat} +{red hat|blue hat|green hat}, baseball hat +{red hat|blue hat|green hat}, beanie hat \ No newline at end of file diff --git a/wildcards/test-mir.txt b/wildcards/test-mir.txt new file mode 100644 index 0000000..618038f --- /dev/null +++ b/wildcards/test-mir.txt @@ -0,0 +1 @@ +{1|2|{3|4}} \ No newline at end of file