diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..75e73ee
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# --- Python関連 ---
+# これがJavaの.classに近いものじゃ。実行時に自動生成される。
+__pycache__/
+*.py[cod]
+*$py.class
+
+# 仮想環境(もしプロジェクト内に作っておるなら)
+venv/
+.venv/
+env/
+
+# --- エディタ・OS関連 ---
+# VS Codeの設定ファイル(個人設定が含まれることがあるので除外)
+.vscode/
+# macOSが勝手に作るゴミ
+.DS_Store
+# Windowsのサムネイルキャッシュ
+Thumbs.db
+
+# --- ComfyUI/カスタムノード特有 ---
+# もし一時ファイルやログを書き出す仕組みを作ったなら、それも追加じゃ
+*.log
diff --git a/README.md b/README.md
index 527f62c..81cea73 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,22 @@
フロー内に複数個置いても、それぞれ独立して動くぞ。
+## v0.4.0
+
+`Prompt Random Choice Ex` を追加したのじゃ。
+
+- `Prompt Random Choice`
+ - フラットな候補リストから1つ選ぶノード
+- `Prompt Random Choice Ex`
+ - フラット候補に加えて、`{}` による入れ子の候補展開に対応したノード
+ - 先にすべての葉候補へ展開し、最後に1回だけランダム選択します
+ - 展開済み候補リストは `options_text` が変わるまでキャッシュします
+
+どちらもノードとしての出力は同じじゃ。
+
+- `selected_text`
+- `selected_text_safe`
+
## 特徴
- **実行ごとにランダム選択**
@@ -38,6 +54,34 @@
- **明示的な空候補 `()` に対応**
- `()` が選ばれた場合、プロンプト向け出力は空文字に、ファイル名向け出力は `empty` になるのじゃ。
+## Prompt Random Choice Ex の追加要素
+
+- **フラット候補に加えて、`{}` による入れ子の候補展開に対応**
+ - しかも、ComfyUIの標準記法と異なり、`{}` 内を入れ子にできるのじゃ。
+ - `{}` 内の要素は内側から解釈され、選択項目がカンマで前後に接続される。
+ - `|` と改行の両方を区切りとして扱うので、混在していても動くぞ。
+ - 区切り文字が連続して出現した場合は、候補から取り除かれることに注意じゃ。
+ - 明示的に空白文字列を候補として返却させたい場合は、明示的に `()` と指定するのじゃ。
+
+ここは実例を見てもらった方が話が早そうじゃ。
+```text
+town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
+```
+
+これは、内部的には次の候補へ展開され、キャッシュされるのじゃ。
+
+```text
+town
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+
+この5候補から1つが選ばれるのじゃ。
+展開後の最終候補が、すべて等しい確率で選ばれるようにしてるのじゃな。
+
+
## 導入方法
ComfyUIの `custom_nodes` ディレクトリで、以下のコマンドを打ち込むのじゃ!
@@ -58,7 +102,7 @@ git clone https://github.com/ruminar/ComfyUI-PromptRandomChoice.git


-※ 設定後は折りたたんで使うのもおすすめです
+※ 設定後は折りたたんで使うのもおすすめなのじゃ。
## 入力例
@@ -119,6 +163,8 @@ day|day|day|sunset|night
昼を多めに出したい、たまに夕方や夜も混ぜたい、という時に便利じゃ。
+
+
## 仕様
- `|` または実際の改行で分割
@@ -130,6 +176,83 @@ day|day|day|sunset|night
- `change_every` が 2 以上なら、その回数ぶん同じ候補を維持する
- 実行時にタイトルへ `Choice: lake` や `Choice: (empty) (2/3)` のように表示する
+## Prompt Random Choice
+
+候補リストから1つ選びます。
+
+```text
+town|park|lake|coffee shop
+```
+
+`()` は明示的な空候補です。
+
+```text
+()|(full body:0.9)
+```
+
+## Prompt Random Choice Ex
+
+`Prompt Random Choice` と同じフラットな候補リストをそのまま使えます。
+
+```text
+town|park|lake|coffee shop
+```
+
+さらに、候補の中に `{}` を書くことで、選ばれた候補にだけ追加候補をぶら下げられます。
+
+```text
+town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
+```
+
+この入力は、内部的には次の候補へ展開されます。
+
+```text
+town
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+
+この5候補から1つが選ばれます。
+
+### 複数行の Ex 例
+
+```text
+zoo{
+ animals{
+ birds
+ penguins
+ }
+ aquarium{
+ fish
+ jellyfish
+ }
+}
+```
+
+出力例:
+
+```text
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+
+### Ex のルール
+
+- 選択候補の区切り文字は、実際の改行 または `|`
+- 空候補は無視
+- `()` は明示的な空候補
+- `{}` の内部も、実際の改行 または `|` で候補分割
+- `{}` は最内側からすべての葉候補へ展開
+- 展開結果は親要素へ `, ` で接続
+- 最終候補リストから最後に1回だけランダム選択
+- 展開済み候補リストは `options_text` が変わるまでキャッシュ
+- 展開候補数と展開回数には安全上限があります
+- リテラルの `{` / `}` をプロンプト文字として使う用途は非対応
+
## 出力
- `selected_text`
@@ -140,6 +263,14 @@ day|day|day|sunset|night
ファイル名向けに安全化した出力です。
`selected_text` が空なら `empty` を返します。
+## 推奨構成
+
+`selected_text` を kjnodes の `Join String Multi` などへ接続し、区切り文字は結合ノード側で管理するのがおすすめじゃ。
+
+複数の `Prompt Random Choice` / `Prompt Random Choice Ex` を並べることで、背景・時間帯・天気・構図などを別々にランダム化するのじゃ。
+
+ただし、Ex はすべての要素を1つにまとめるためのノードではなく、`zoo` の時だけ動物候補を追加するような、親子関係のある候補を扱うためのノードとして使うのがおすすめじゃ。
+
## 標準選択記法 `{day|night|morning}` との違いについて
@@ -176,92 +307,634 @@ GPL-3.0(ComfyUI本体の掟に従っておるぞ!)
+EX版
+
-## 付録
+## コピペ用おすすめ候補リスト
-背景候補
+### 背景
```text
Indoor,
girl's room,
+bedroom,
+living room,
+kitchen,
+dining room,
+bathroom,
+attic,
+basement,
coffee shop,
+cafe terrace,
library,
+private library,
classroom,
+music room,
+science room,
+computer room,
+school infirmary,
+gymnasium,
office,
+meeting room,
+conference room,
laboratory,
+medical room,
+art studio,
art gallery,
museum,
+aquarium,
+planetarium,
+observatory interior,
bookstore,
bakery,
+flower shop,
+convenience store,
+supermarket,
restaurant,
+bar,
+diner,
+karaoke room,
+arcade,
+game center,
+cinema,
concert hall,
theater,
+dance studio,
school hallway,
+locker room,
+stairwell,
+elevator hall,
greenhouse,
-observatory,
+train interior,
+subway interior,
+airport terminal,
+shopping mall,
+hotel room,
+lobby,
+chapel interior,
+
Outdoor,
-City,
+city,
town,
+downtown,
+residential area,
park,
+playground,
+plaza,
rooftop,
+balcony,
+terrace,
train station,
+bus stop,
+airport runway,
shopping street,
courtyard,
bridge,
+crosswalk,
+intersection,
riverside,
+canal,
harbor,
+port,
+boardwalk,
marketplace,
+festival street,
+food stall area,
alley,
+back alley,
village,
+suburban street,
+schoolyard,
+campus,
+parking lot,
+construction site,
+amusement park,
+theme park,
+zoo,
+stadium exterior,
+cemetery,
+clock tower,
+lighthouse,
+windmill,
+waterfront,
+
Nature,
lake,
+pond,
+waterfall,
+river,
+stream,
flower garden,
+rose garden,
forest,
+bamboo forest,
+pine forest,
+rainforest,
grasslands,
+meadow,
+savanna,
sea,
+ocean,
+coral reef,
mountain,
+mountain path,
+hilltop,
+valley,
+cliff,
flower field,
+sunflower field,
+lavender field,
beach,
+shore,
island,
+tropical island,
cave,
+crystal cave,
botanical garden,
+jungle,
+swamp,
+marsh,
+desert,
+oasis,
+snowfield,
+glacier,
+ice cave,
+volcanic area,
+hot spring,
+starry sky,
+night sky,
+aurora,
+
Traditional,
-Fantasy-ish,
Japanese garden,
shrine,
temple,
+tea house,
+tatami room,
+engawa,
+onsen,
+ryokan,
+festival grounds,
+torii gate,
+bamboo grove path,
castle,
fortress,
palace,
ruins,
+old town,
+stone pavement,
+pagoda,
+dojo,
+samurai residence,
+courtyard garden,
```
-時刻
+### 時刻
```text
-day|day|day|morning|sunset|night
+()|day|day|day|morning|sunset|night
```
-天候
+### 天候
```text
-()|(clear sky:0.9)|(clear sky:0.9)|(clear sky:0.9)|(cloudy sky:0.9)|rain|snow
+()|Strong sunshine|(clear sky:0.9)|(clear sky:0.9)|(clear sky:0.9)|(cloudy sky:0.9)|rain|snow|Rainbow after Rain|storm, thunder
```
-光
+### 光
```text
()|soft lighting|warm lighting|natural lighting|(backlighting:0.8)|(dramatic lighting:0.8)|(cinematic lighting:0.8)
```
-姿勢、視線、動作
+### 姿勢、視線、動作
```text
-()|standing|sitting|walking|looking at viewer|waving|hands on hips|jumping|running|skipping|looking up
+()|standing|sitting|walking|looking at viewer|waving|hands on hips|jumping high|running|skipping|looking up
```
-表情
+### 表情
```text
()|smiling|gentle smile|serious expression|surprised expression|slightly surprised|shy expression|happy expression|smiling, open mouth|slightly open mouth|closed-mouth smile
```
-構図
+### 構図
```text
()|(face close-up:0.9)|upper body|upper body|full body|full body|full body|full body|full body|wide shot|(from side:0.8)|(from above:0.8)|(low angle:0.8)|(from behind, looking back:0.8)
```
+
+### 背景 Ex 版
+
+背景は、項目ごとにまとめてあるから、全部くっつけて1つの背景ノードにするのも、
+それぞれ別ノードにして組み合わせるのも、おぬしの好きな方を選べるようにしたぞ!
+同じ項目を繰り返したり、不要な項目を削除したりして、おぬし好みのプロンプトに育てておくれなのじゃ。
+
+#### Ex 標準背景
+```text
+indoor{
+ girl's room,
+ bedroom,
+ living room,
+ kitchen,
+ dining room,
+ bathroom,
+ attic,
+ basement,
+ coffee shop,
+ cafe terrace,
+ library,
+ private library,
+ classroom,
+ music room,
+ science room,
+ computer room,
+ school infirmary,
+ gymnasium,
+ office,
+ meeting room,
+ conference room,
+ laboratory,
+ medical room,
+ art studio,
+ art gallery,
+ museum,
+ aquarium,
+ planetarium,
+ observatory interior,
+ bookstore,
+ bakery,
+ flower shop,
+ convenience store,
+ supermarket,
+ restaurant,
+ bar,
+ diner,
+ karaoke room,
+ arcade,
+ game center,
+ cinema,
+ concert hall,
+ theater,
+ dance studio,
+ school hallway,
+ locker room,
+ stairwell,
+ elevator hall,
+ greenhouse,
+ train interior,
+ subway interior,
+ airport terminal,
+ shopping mall,
+ hotel room,
+ lobby,
+ chapel interior,
+}
+Outdoor{
+ city,
+ town,
+ downtown,
+ residential area,
+ park,
+ playground,
+ plaza,
+ rooftop,
+ balcony,
+ terrace,
+ train station,
+ bus stop,
+ airport runway,
+ shopping street,
+ courtyard,
+ bridge,
+ crosswalk,
+ intersection,
+ riverside,
+ canal,
+ harbor,
+ port,
+ boardwalk,
+ marketplace,
+ festival street,
+ food stall area,
+ alley,
+ back alley,
+ village,
+ suburban street,
+ schoolyard,
+ campus,
+ parking lot,
+ construction site,
+ amusement park,
+ theme park,
+ zoo,
+ stadium exterior,
+ cemetery,
+ clock tower,
+ lighthouse,
+ windmill,
+ waterfront,
+}
+Nature{
+ lake,
+ pond,
+ waterfall,
+ river,
+ stream,
+ flower garden,
+ rose garden,
+ forest,
+ bamboo forest,
+ pine forest,
+ rainforest,
+ grasslands,
+ meadow,
+ savanna,
+ sea,
+ ocean,
+ coral reef,
+ mountain,
+ mountain path,
+ hilltop,
+ valley,
+ cliff,
+ flower field,
+ sunflower field,
+ lavender field,
+ beach,
+ shore,
+ island,
+ tropical island,
+ cave,
+ crystal cave,
+ botanical garden,
+ jungle,
+ swamp,
+ marsh,
+ desert,
+ oasis,
+ snowfield,
+ glacier,
+ ice cave,
+ volcanic area,
+ hot spring,
+ starry sky,
+ night sky,
+ aurora,
+}
+traditional{
+ Japanese garden,
+ shrine,
+ temple,
+ tea house,
+ tatami room,
+ engawa,
+ onsen,
+ ryokan,
+ festival grounds,
+ torii gate,
+ bamboo grove path,
+ castle,
+ fortress,
+ palace,
+ ruins,
+ old town,
+ stone pavement,
+ pagoda,
+ dojo,
+ samurai residence,
+ courtyard garden,
+}
+```
+
+#### Ex ファンタジー特盛背景
+```text
+Fantasy{
+ (),
+ magic library,
+ alchemy workshop,
+ wizard tower,
+ enchanted forest,
+ fairy garden,
+ floating island,
+ sky castle,
+ crystal palace,
+ ancient ruins,
+ mystic cave,
+ dragon's lair,
+ underground city,
+ sacred spring,
+ giant tree,
+ mirror lake,
+ celestial garden,
+ forgotten temple,
+ phantom town,
+ clockwork city,
+ throne room,
+ dungeon,
+ cathedral,
+ portal site,
+ magic academy,
+ sorcerer's tower,
+ witch's cottage,
+ fairy village,
+ elven forest,
+ dwarf mine,
+ crystal cave,
+ ancient altar,
+ holy sanctuary,
+ forbidden library,
+ sky temple,
+ floating garden,
+ moonlit lake,
+ starlight forest,
+ enchanted castle,
+ royal palace,
+ hidden village,
+ ancient labyrinth,
+ monster arena,
+ summoning chamber,
+}
+Japanese-style Fantasy{
+ (),
+ moonlit shrine,
+ mystic shrine,
+ ancient shrine,
+ forgotten shrine,
+ mountain shrine,
+ forest shrine,
+ torii gate,
+ spirit forest,
+ youkai village,
+ oni castle,
+ kitsune shrine,
+ tanuki forest,
+ sacred mountain,
+ hidden onsen,
+ samurai castle,
+ ninja village,
+ abandoned temple,
+ bamboo spirit path,
+ misty bamboo forest,
+ sakura spirit realm,
+ red torii path,
+ shrine festival night,
+ haunted Japanese mansion,
+ old samurai residence,
+ floating lantern river,
+ dragon god shrine,
+ celestial fox shrine,
+ underworld gate,
+}
+Chinese-style Fantasy{
+ (),
+ ancient Chinese palace,
+ imperial palace,
+ jade palace,
+ celestial palace,
+ xianxia sect,
+ martial arts sect,
+ mountain cultivation temple,
+ immortal mountain,
+ cloud sea,
+ bamboo mountain path,
+ lotus pond,
+ moon gate garden,
+ Chinese courtyard,
+ ancient Chinese city,
+ lantern street,
+ water town,
+ stone bridge town,
+ dragon palace,
+ phoenix palace,
+ taoist temple,
+ misty peak,
+ sword cultivation arena,
+ heavenly staircase,
+ jade pavilion,
+ floating pagoda,
+ immortal cave,
+ spirit spring,
+ celestial river,
+}
+Fantasy-ish{
+ (),
+ gothic castle,
+ vampire mansion,
+ haunted mansion,
+ dark cathedral,
+ graveyard,
+ crypt,
+ necromancer's lair,
+ witch market,
+ night carnival,
+ dream world,
+ mirror world,
+ toy kingdom,
+ candy kingdom,
+ steampunk city,
+ airship dock,
+ mechanical tower,
+ clock tower interior,
+ abandoned laboratory,
+ magical observatory,
+ starship temple,
+ ancient machine room,
+ lost civilization,
+ desert ruins,
+ sunken city,
+ underwater palace,
+ ice palace,
+ volcanic fortress,
+ shadow realm,
+ celestial battlefield,
+}
+```
+
+#### Ex 季節/イベント背景
+```text
+Spring{
+ (),
+ cherry blossoms,
+ sakura avenue,
+ hanami,
+ spring festival,
+ graduation ceremony,
+ entrance ceremony,
+ easter,
+ flower viewing picnic,
+ rainy season,
+ hydrangea garden,
+ children's day,
+ doll festival,
+ easter egg hunt,
+}
+Summer{
+ (),
+ summer festival,
+ festival night,
+ food stalls,
+ lantern festival,
+ bon festival,
+ fireworks,
+ fireworks festival,
+ poolside,
+ water park,
+ beach party,
+ tropical vacation,
+ campground,
+ tanabata,
+ tanabata festival,
+ star festival,
+}
+Autumn{
+ (),
+ autumn leaves,
+ maple forest,
+ harvest festival,
+ moon viewing,
+ halloween,
+ halloween party,
+ halloween street,
+ pumpkin patch,
+ haunted house,
+ masquerade party,
+ autumn festival,
+ thanksgiving,
+}
+Winter{
+ (),
+ snowy town,
+ snow festival,
+ ice skating rink,
+ christmas,
+ christmas market,
+ christmas tree,
+ christmas party,
+ illuminations,
+ winter holiday,
+ new year,
+ new year's shrine visit,
+ first sunrise,
+ snowy shrine,
+ winter illuminations,
+ new year's festival,
+ new year's eve party,
+ winter market,
+ christmas dinner,
+ holiday shopping street,
+ valentine's day,
+}
+(){
+ white day,
+ wedding ceremony,
+ birthday party,
+ anniversary,
+ school festival,
+ cultural festival,
+ sports festival,
+ idol concert,
+ live event,
+ tea party,
+ garden party,
+ picnic,
+ parade,
+ carnival,
+ temple fair,
+}
+```
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9537bed..b01f79a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,42 +1,56 @@
-## v0.3.0
+## v0.4.0
-- 出力を整理
- - `selected_text` : プロンプト向け出力
- - `selected_text_safe` : ファイル名向け出力
-- 明示的な空候補 `()` に対応
- - `()` が選ばれた場合、プロンプト向け出力は空文字になり、ファイル名向け出力は `empty` になります
+`Prompt Random Choice Ex` を追加しました。
-`ComfyUI-PromptRandomChoice` は、プロンプト候補リストから実行のたびにランダムで1項目を選び、ポジティブプロンプトなどへ追加しやすい形で出力する ComfyUI カスタムノードです。
+## 追加
-## 主な機能
+- `Prompt Random Choice Ex`
+ - `Prompt Random Choice` と同じフラット候補リストに対応
+ - `{}` による入れ子の候補展開に対応
+ - すべての葉候補へ展開してから、最後に1回だけランダム選択
+ - 展開結果を親要素へ `, ` で接続
+ - 展開済み候補リストを `options_text` 単位でキャッシュ
+ - `selected_text` / `selected_text_safe` を出力
+ - `(){...}` のような空親グループに対応
+ - 親要素 `()` は出力されません
+ - 子要素だけが候補になります
-- プロンプト候補から実行ごとにランダムで1項目を選択
-- 候補は `|` または実際の改行で区切り
-- 候補の前後にある空白や `,` を自動で trim
-- 選ばれた候補を `selected_text` として通常の文字列で出力
-- ファイル名向けに安全化した `selected_text_safe` を出力
-- `change_every` に対応
- - `1` なら毎回選び直し
- - `3` なら同じ候補を3回返してから次を選択
-- 実行中の選択結果をノードタイトルに表示
- - 例: `Choice: coffee shop`
- - 例: `Choice: lake (2/3)`
-- 複数ノード設置に対応
- - 背景用、時間帯用、天気用などを別々にランダム化可能
-- 同じ候補を複数回書くことで、選ばれやすさを調整可能
- - 例: `day|day|day|sunset|night`
-- 明示的な空候補 `()` に対応
- - `()` が選ばれた場合、プロンプト向け出力は空文字になり、ファイル名向け出力は `empty` になります
+## 例
-## 注意
+```text
+town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
+```
+これは、内部的には次の候補へ展開されます。
-区切り文字は **実際の改行** または **`|`** です。
+```text
+town
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+この5候補から1つが選ばれます。
+
-`,` は区切り文字ではありません。
-ただし、各候補の前後に付いている `,` は自動で取り除きます。
-詳しい使い方は README.md を参照してください。
+親要素 `()` の例
+```text
+(){
+ white day
+ wedding ceremony
+ birthday party
+}
+```
-## ライセンス
+内部的には次の候補へ展開されます。
-GPL-3.0
\ No newline at end of file
+```text
+white day
+wedding ceremony
+birthday party
+
+## 備考
+
+- `Prompt Random Choice Ex` は、すべての葉候補へ展開してから最後に1回だけランダム選択します
+- 展開済み候補リストは `options_text` 単位でキャッシュします
+- 通常の `Prompt Random Choice` は、フラットな候補選択用としてそのまま残しています
diff --git a/nodes.py b/nodes.py
index 1e8df1f..12d3375 100644
--- a/nodes.py
+++ b/nodes.py
@@ -18,10 +18,56 @@ flower field,
starry sky,
coffee shop,"""
+DEFAULT_OPTIONS_TEXT_EX = """town
+zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
+coffee shop{cake|coffee cup}
+amusement park{ferris wheel|carousel|balloons}
+(){
+ white day
+ wedding ceremony
+ birthday party
+}"""
+
+MAX_EXPAND_STEPS = 64
+MAX_EXPANDED_OPTIONS = 4096
+
def _split_options(options_text: str):
- text = str(options_text or "")
- parts = re.split(r"\||\r?\n", text)
+ """
+ Split candidates by "|" or actual newline only at brace depth 0.
+
+ Empty candidates are ignored.
+ "()" is treated as an explicit empty candidate and becomes "".
+
+ This function is shared by PromptRandomChoice and PromptRandomChoiceEx.
+ For Ex, nested blocks such as zoo{animals|birds} stay together when
+ splitting the top-level candidate list.
+ """
+ text = str(options_text or "").replace("\r\n", "\n").replace("\r", "\n")
+
+ parts = []
+ current = []
+ depth = 0
+
+ for char in text:
+ if char == "{":
+ depth += 1
+ current.append(char)
+ continue
+
+ if char == "}":
+ depth = max(0, depth - 1)
+ current.append(char)
+ continue
+
+ if depth == 0 and char in ("|", "\n"):
+ parts.append("".join(current))
+ current = []
+ continue
+
+ current.append(char)
+
+ parts.append("".join(current))
options = []
for part in parts:
@@ -42,6 +88,43 @@ def _split_options(options_text: str):
return options
+def _normalize_prompt_fragment(text: str):
+ """
+ Normalize comma-connected prompt fragments after nested expansion.
+
+ Standalone "()" fragments are removed here, which enables:
+ (){white day|wedding ceremony}
+ to become:
+ white day
+ wedding ceremony
+
+ This also keeps the syntax forgiving:
+ zoo{aquarium,{fish|jellyfish}}
+ becomes:
+ zoo, aquarium, fish
+ """
+ value = str(text or "")
+
+ # Normalize line breaks/tabs to spaces.
+ value = re.sub(r"[\t\r\n]+", " ", value)
+
+ # Normalize spaces around commas.
+ value = re.sub(r"\s*,\s*", ",", value)
+
+ # Split comma-connected fragments, remove empty fragments and explicit
+ # empty marker fragments, then join them in a prompt-friendly form.
+ parts = []
+ for part in value.split(","):
+ item = part.strip()
+ if not item:
+ continue
+ if item == "()":
+ continue
+ parts.append(item)
+
+ return ", ".join(parts)
+
+
def _safe_text(value: str):
text = str(value or "")
@@ -52,19 +135,184 @@ def _safe_text(value: str):
text = re.sub(r'[<>:"|?*\x00-\x1f]', "_", text)
text = re.sub(r"[\s,]+", "_", text)
text = text.replace("(", "_").replace(")", "_")
+ text = text.replace("{", "_").replace("}", "_")
text = re.sub(r"_+", "_", text)
text = text.strip().strip("._-")
return text or "empty"
-class PromptRandomChoice:
+def _find_first_block(text: str):
+ """
+ Return (start, end) for the first balanced {...} block.
+
+ If braces are unmatched, return None and leave the text as literal.
+ """
+ value = str(text or "")
+ start = value.find("{")
+ if start < 0:
+ return None
+
+ depth = 0
+ for index in range(start, len(value)):
+ char = value[index]
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return start, index
+
+ return None
+
+
+def _expand_all_nested_choices(option_text: str):
+ """
+ Expand one top-level candidate into all final leaf candidates.
+
+ Ex uses leaf-uniform probability:
+ 1. expand all nested {...} branches first
+ 2. choose one item from the expanded leaf list
+
+ Example:
+ town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
+
+ becomes:
+ town
+ zoo, animals, birds
+ zoo, animals, penguins
+ zoo, aquarium, fish
+ zoo, aquarium, jellyfish
+
+ Empty parent groups are supported:
+ (){white day|wedding ceremony}
+
+ becomes:
+ white day
+ wedding ceremony
+ """
+
+ def expand_recursive(value: str, depth: int):
+ if depth > MAX_EXPAND_STEPS:
+ raise ValueError(
+ "PromptRandomChoiceEx expansion exceeded the maximum expansion steps. "
+ "Please check nested braces."
+ )
+
+ block = _find_first_block(value)
+ if block is None:
+ return [_normalize_prompt_fragment(value)]
+
+ start, end = block
+ prefix = value[:start]
+ body = value[start + 1:end]
+ suffix = value[end + 1:]
+
+ inner_options = _split_options(body)
+ if not inner_options:
+ inner_options = [""]
+
+ results = []
+ for inner in inner_options:
+ inner_expanded_options = expand_recursive(inner, depth + 1)
+
+ for inner_expanded in inner_expanded_options:
+ replacement = f", {inner_expanded}" if inner_expanded else ""
+ candidate = prefix + replacement + suffix
+ expanded_candidates = expand_recursive(candidate, depth + 1)
+
+ for expanded in expanded_candidates:
+ results.append(expanded)
+ if len(results) > MAX_EXPANDED_OPTIONS:
+ raise ValueError(
+ "PromptRandomChoiceEx expanded too many options "
+ f"({len(results)} > {MAX_EXPANDED_OPTIONS})."
+ )
+
+ return results
+
+ initial = str(option_text or "").strip(" \t\r\n,")
+ return expand_recursive(initial, 0)
+
+
+def _build_expanded_options(options_text: str):
+ top_options = _split_options(options_text)
+ expanded_options = []
+
+ for option in top_options:
+ for expanded in _expand_all_nested_choices(option):
+ # Keep empty results because they can come from explicit ().
+ expanded_options.append(expanded)
+
+ if len(expanded_options) > MAX_EXPANDED_OPTIONS:
+ raise ValueError(
+ "PromptRandomChoiceEx expanded too many options "
+ f"({len(expanded_options)} > {MAX_EXPANDED_OPTIONS})."
+ )
+
+ return tuple(expanded_options)
+
+
+class _RandomChoiceStateMixin:
def __init__(self):
self._current_choice = None
self._repeat_index = 0
self._last_options_key = None
self._last_change_every = None
+ @classmethod
+ def IS_CHANGED(cls, **kwargs):
+ return time.time_ns()
+
+ def _reset_state(self):
+ self._current_choice = None
+ self._repeat_index = 0
+ self._last_options_key = None
+ self._last_change_every = None
+
+ def _select_new_choice(self, options):
+ self._current_choice = secrets.choice(options)
+ self._repeat_index = 1
+
+ def _run_choice(self, options, change_every):
+ change_every = max(1, int(change_every))
+ options = tuple(options)
+ options_key = options
+
+ if not options:
+ self._reset_state()
+ return "", "empty", "(empty)", 0, change_every
+
+ state_changed = (
+ self._last_options_key != options_key
+ or self._last_change_every != change_every
+ or self._current_choice not in options
+ )
+
+ if state_changed or self._current_choice is None:
+ self._select_new_choice(options)
+ elif self._repeat_index < change_every:
+ self._repeat_index += 1
+ else:
+ self._select_new_choice(options)
+
+ self._last_options_key = options_key
+ self._last_change_every = change_every
+
+ selected_text = str(self._current_choice or "")
+ selected_text_safe = _safe_text(selected_text)
+ selected_text_title = selected_text if selected_text else "(empty)"
+
+ return (
+ selected_text,
+ selected_text_safe,
+ selected_text_title,
+ self._repeat_index,
+ change_every,
+ )
+
+
+class PromptRandomChoice(_RandomChoiceStateMixin):
@classmethod
def INPUT_TYPES(cls):
return {
@@ -93,60 +341,86 @@ class PromptRandomChoice:
FUNCTION = "pick"
CATEGORY = "utils/prompt"
- @classmethod
- def IS_CHANGED(cls, **kwargs):
- return time.time_ns()
-
- def _reset_state(self):
- self._current_choice = None
- self._repeat_index = 0
- self._last_options_key = None
- self._last_change_every = None
-
- def _select_new_choice(self, options):
- self._current_choice = secrets.choice(options)
- self._repeat_index = 1
-
def pick(self, options_text, change_every):
options = _split_options(options_text)
- change_every = max(1, int(change_every))
- options_key = tuple(options)
-
- if not options:
- self._reset_state()
- return {
- "ui": {
- "selected_text_title": ["(empty)"],
- "repeat_index": [0],
- "change_every": [change_every],
- },
- "result": ("", "empty"),
- }
-
- state_changed = (
- self._last_options_key != options_key
- or self._last_change_every != change_every
- or self._current_choice not in options
+ selected_text, selected_text_safe, selected_text_title, repeat_index, change_every = self._run_choice(
+ options,
+ change_every,
)
- if state_changed or self._current_choice is None:
- self._select_new_choice(options)
- elif self._repeat_index < change_every:
- self._repeat_index += 1
- else:
- self._select_new_choice(options)
-
- self._last_options_key = options_key
- self._last_change_every = change_every
-
- selected_text = str(self._current_choice or "")
- selected_text_safe = _safe_text(selected_text)
- selected_text_title = selected_text if selected_text else "(empty)"
-
return {
"ui": {
"selected_text_title": [selected_text_title],
- "repeat_index": [self._repeat_index],
+ "repeat_index": [repeat_index],
+ "change_every": [change_every],
+ },
+ "result": (selected_text, selected_text_safe),
+ }
+
+
+class PromptRandomChoiceEx(_RandomChoiceStateMixin):
+ def __init__(self):
+ super().__init__()
+ self._expanded_options_cache_key = None
+ self._expanded_options_cache = tuple()
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "options_text": (
+ "STRING",
+ {
+ "multiline": True,
+ "default": DEFAULT_OPTIONS_TEXT_EX,
+ },
+ ),
+ "change_every": (
+ "INT",
+ {
+ "default": 1,
+ "min": 1,
+ "max": 999999,
+ "step": 1,
+ },
+ ),
+ },
+ }
+
+ RETURN_TYPES = ("STRING", "STRING")
+ RETURN_NAMES = ("selected_text", "selected_text_safe")
+ FUNCTION = "pick"
+ CATEGORY = "utils/prompt"
+
+ def _reset_state(self):
+ super()._reset_state()
+ # Keep the expansion cache; it depends only on options_text.
+ # Selection state and expansion cache have different lifecycles.
+
+ def _get_expanded_options_cached(self, options_text):
+ cache_key = str(options_text or "")
+
+ if self._expanded_options_cache_key == cache_key:
+ return self._expanded_options_cache
+
+ expanded_options = _build_expanded_options(cache_key)
+
+ self._expanded_options_cache_key = cache_key
+ self._expanded_options_cache = expanded_options
+
+ return expanded_options
+
+ def pick(self, options_text, change_every):
+ options = self._get_expanded_options_cached(options_text)
+ selected_text, selected_text_safe, selected_text_title, repeat_index, change_every = self._run_choice(
+ options,
+ change_every,
+ )
+
+ return {
+ "ui": {
+ "selected_text_title": [selected_text_title],
+ "repeat_index": [repeat_index],
"change_every": [change_every],
},
"result": (selected_text, selected_text_safe),
@@ -155,8 +429,10 @@ class PromptRandomChoice:
NODE_CLASS_MAPPINGS = {
"PromptRandomChoice": PromptRandomChoice,
+ "PromptRandomChoiceEx": PromptRandomChoiceEx,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptRandomChoice": "Prompt Random Choice",
+ "PromptRandomChoiceEx": "Prompt Random Choice Ex",
}
diff --git a/web/prompt_random_choice.js b/web/prompt_random_choice.js
index e44f2ec..c3ebca2 100644
--- a/web/prompt_random_choice.js
+++ b/web/prompt_random_choice.js
@@ -1,7 +1,10 @@
import { app } from "../../scripts/app.js";
const EXTENSION_NAME = "ruminar.PromptRandomChoice";
-const NODE_NAME = "PromptRandomChoice";
+const NODE_NAMES = new Set([
+ "PromptRandomChoice",
+ "PromptRandomChoiceEx",
+]);
function shorten(text, maxLength = 40) {
const value = String(text ?? "");
@@ -15,10 +18,13 @@ app.registerExtension({
name: EXTENSION_NAME,
beforeRegisterNodeDef(nodeType, nodeData) {
- if (String(nodeData?.name ?? "") !== NODE_NAME) {
+ if (!NODE_NAMES.has(String(nodeData?.name ?? ""))) {
return;
}
+ const nodeName = String(nodeData?.name ?? "");
+ const prefix = nodeName === "PromptRandomChoiceEx" ? "Ex" : "Ch";
+ const maxTitleLength = nodeName === "PromptRandomChoiceEx" ? 36 : 40;
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
@@ -29,9 +35,9 @@ app.registerExtension({
const changeEvery = Number(message?.change_every?.[0] ?? 1);
if (changeEvery <= 1) {
- this.title = `Choice: ${shorten(selectedTitle)}`;
+ this.title = `${prefix}: ${shorten(selectedTitle)}`;
} else {
- this.title = `Choice: ${shorten(selectedTitle)} (${repeatIndex}/${changeEvery})`;
+ this.title = `${prefix}: ${shorten(selectedTitle)} (${repeatIndex}/${changeEvery})`;
}
this.setDirtyCanvas?.(true, true);