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 image

image
-※ 設定後は折りたたんで使うのもおすすめです +※ 設定後は折りたたんで使うのもおすすめなのじゃ。 ## 入力例 @@ -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本体の掟に従っておるぞ!) PromptRandomChoice説明画像 +EX版 +PromptRandomChoiceEx説明画像 -## 付録 +## コピペ用おすすめ候補リスト -背景候補 +### 背景 ```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);