From 8b3ac7c3ed3adaea3be8c2c9fffd970a58002b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Sun, 17 May 2026 20:06:44 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Prompt=20Random=20Choice=20Ex=E3=82=92?= =?UTF-8?q?=E6=96=B0=E8=A6=8F=E4=BD=9C=E6=88=90=E3=80=82=E9=81=B8=E6=8A=9E?= =?UTF-8?q?=E8=82=A2=E3=81=AE=E5=85=A5=E3=82=8C=E5=AD=90=E5=B1=95=E9=96=8B?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 22 +++ README.md | 124 +++++++++++++++-- RELEASE_NOTES.md | 57 ++++---- nodes.py | 271 +++++++++++++++++++++++++++++------- web/prompt_random_choice.js | 7 +- 5 files changed, 388 insertions(+), 93 deletions(-) create mode 100644 .gitignore 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..39908ee 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,20 @@ フロー内に複数個置いても、それぞれ独立して動くぞ。 +## v0.4.0 + +`Prompt Random Choice Ex` を追加したのじゃ。 + +- `Prompt Random Choice` + - フラットな候補リストから1つ選ぶノード +- `Prompt Random Choice Ex` + - フラット候補に加えて、`{}` による入れ子の候補展開に対応したノード + +どちらもノードしての出力は同じです。 + +- `selected_text` +- `selected_text_safe` + ## 特徴 - **実行ごとにランダム選択** @@ -38,6 +52,25 @@ - **明示的な空候補 `()` に対応** - `()` が選ばれた場合、プロンプト向け出力は空文字に、ファイル名向け出力は `empty` になるのじゃ。 +## Prompt Random Choice Ex の追加要素 + +- **フラット候補に加えて、`{}` による入れ子の候補展開に対応** + - しかも、ComfyUIの標準記法と異なり、`{}` 内を入れ子にできるのじゃ。 + - `{}` 内の要素は内側から解釈され、選択項目がカンマで前後に接続される。 + - `|` と改行の両方を区切りとして扱うので、混在していても動くぞ。 + - 区切り文字が連続して出現した場合は、候補から取り除かれることに注意じゃ。 + - 明示的に空白文字列を候補として返却させたい場合は、明示的に `()` と指定するのじゃ。 + +ここは実例を見てもらった方が話が早そうじゃ。 +```text +town|zoo{animals{birds|penguins}|aquarium,{()|fish|jellyfish}} +town|zoo{animals,birds|aquarium,jellyfish} +town|zoo,animals,birds +town +``` +こんな感じで動くぞ。 +
+ ## 導入方法 ComfyUIの `custom_nodes` ディレクトリで、以下のコマンドを打ち込むのじゃ! @@ -119,6 +152,8 @@ day|day|day|sunset|night 昼を多めに出したい、たまに夕方や夜も混ぜたい、という時に便利じゃ。 +
+ ## 仕様 - `|` または実際の改行で分割 @@ -130,6 +165,18 @@ day|day|day|sunset|night - `change_every` が 2 以上なら、その回数ぶん同じ候補を維持する - 実行時にタイトルへ `Choice: lake` や `Choice: (empty) (2/3)` のように表示する +### Ex のルール + +- 選択候補の区切り文字は、実際の改行 または `|` +- 空候補は無視 +- `()` は明示的な空候補 +- `{}` の内部も、実際の改行 または `|` で候補分割 +- `{}` は最内側からランダムに展開 +- 展開結果は親要素へ `, ` で接続 +- `{}` がなくなるまで繰り返し展開 +- 展開回数には安全上限があります +- リテラルの `{` / `}` をプロンプト文字として使う用途は非対応 + ## 出力 - `selected_text` @@ -177,9 +224,9 @@ GPL-3.0(ComfyUI本体の掟に従っておるぞ!) PromptRandomChoice説明画像 -## 付録 +## コピペ用おすすめ候補リスト -背景候補 +### 背景 ```text Indoor, @@ -236,32 +283,93 @@ palace, ruins, ``` -時刻 +### 背景 Ex 版 + +```text +indoor{ + girl's room + coffee shop + library + classroom + office + laboratory + art gallery + museum + bookstore + bakery + restaurant + concert hall + theater + school hallway + greenhouse + observatory +} +city{ + town + park + rooftop + train station + shopping street + courtyard + bridge + riverside + harbor + marketplace + alley + village +} +nature{ + lake + flower garden + forest + grasslands + sea + mountain + flower field + beach + island + cave + botanical garden +} +traditional{ + japanese garden + shrine + temple +} +fantasy-ish{ + castle + fortress + palace + ruins +} +``` + +### 時刻 ```text 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 ``` -光 +### 光 ```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 ``` -表情 +### 表情 ```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) ``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9537bed..f46c847 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,42 +1,33 @@ -## 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` と同じフラット候補リストに対応 + - `{}` による入れ子の候補展開に対応 + - 最内側の `{}` からランダムに展開 + - 展開結果を親要素へ `, ` で接続 + - `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 +``` -詳しい使い方は README.md を参照してください。 +## 備考 -## ライセンス - -GPL-3.0 \ No newline at end of file +通常の `Prompt Random Choice` は、フラットな候補選択用としてそのまま残しています。 +階層候補が必要な場合だけ `Prompt Random Choice Ex` を使ってください。 diff --git a/nodes.py b/nodes.py index 1e8df1f..541e18c 100644 --- a/nodes.py +++ b/nodes.py @@ -18,10 +18,51 @@ 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}""" + +INNER_BLOCK_PATTERN = re.compile(r"\{([^{}]*)\}", re.DOTALL) +MAX_EXPAND_STEPS = 64 + 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 +83,72 @@ def _split_options(options_text: str): return options +def _choose_from_options_text(options_text: str): + options = _split_options(options_text) + if not options: + return "" + return secrets.choice(options) + + +def _normalize_prompt_fragment(text: str): + """ + Normalize comma-connected prompt fragments after nested expansion. + + This keeps the syntax forgiving: + zoo{aquarium,{fish|jellyfish}} + can safely become: + zoo, aquarium, fish + even if intermediate expansion produced duplicate commas. + """ + 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) + + # Collapse duplicate commas. + value = re.sub(r"(,\s*){2,}", ", ", value) + + # Collapse spaces. + value = re.sub(r"\s+", " ", value) + + # Trim awkward edges. + value = value.strip(" \t\r\n,") + return value + + +def _expand_nested_choice(text: str): + """ + Expand nested {...} blocks from the innermost blocks outward. + + Rule: + - {...} chooses one candidate from inside. + - Inside candidates are split by top-level "|" or actual newline. + - The selected nested result is comma-connected to the parent text. + - "()" is an explicit empty candidate. + - Unmatched braces are left as-is after the expansion limit is reached. + + Example: + zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}} + -> zoo, animals, birds + -> zoo, aquarium, jellyfish + """ + value = str(text or "").strip(" \t\r\n,") + + for _ in range(MAX_EXPAND_STEPS): + match = INNER_BLOCK_PATTERN.search(value) + if not match: + break + + chosen = _choose_from_options_text(match.group(1)) + replacement = f", {chosen}" if chosen else "" + value = value[:match.start()] + replacement + value[match.end():] + + return _normalize_prompt_fragment(value) + + def _safe_text(value: str): text = str(value or "") @@ -52,19 +159,74 @@ 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: +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 _pick_candidate(self, options): + return secrets.choice(options) + + def _select_new_choice(self, options): + self._current_choice = self._pick_candidate(options) + self._repeat_index = 1 + + def _run_choice(self, options, change_every): + change_every = max(1, int(change_every)) + options_key = tuple(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 + ) + + 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 +255,67 @@ 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): + @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 _pick_candidate(self, options): + raw_choice = secrets.choice(options) + return _expand_nested_choice(raw_choice) + + def pick(self, options_text, change_every): + options = _split_options(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 +324,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..70dc2e4 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,7 +18,7 @@ app.registerExtension({ name: EXTENSION_NAME, beforeRegisterNodeDef(nodeType, nodeData) { - if (String(nodeData?.name ?? "") !== NODE_NAME) { + if (!NODE_NAMES.has(String(nodeData?.name ?? ""))) { return; } From d4a9ad4fdd3df7f049c03b65f1b570341c1bc2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Mon, 18 May 2026 03:11:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Prompt=20Random=20Choice=20Ex=E3=81=AE?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=82=92=E5=BC=B7=E5=8C=96=E3=81=97=E3=80=81?= =?UTF-8?q?=E3=81=99=E3=81=B9=E3=81=A6=E3=81=AE=E8=91=89=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E3=82=92=E5=B1=95=E9=96=8B=E3=81=97=E3=81=A6=E3=81=8B=E3=82=89?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=83=80=E3=83=A0=E9=81=B8=E6=8A=9E=E3=82=92?= =?UTF-8?q?=E8=A1=8C=E3=81=86=E3=82=88=E3=81=86=E3=81=AB=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=80=82=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81=E5=B1=95?= =?UTF-8?q?=E9=96=8B=E6=B8=88=E3=81=BF=E5=80=99=E8=A3=9C=E3=83=AA=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E7=AE=A1=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 100 +++++++++++++++++++++++--- RELEASE_NOTES.md | 7 +- nodes.py | 183 ++++++++++++++++++++++++++++------------------- 3 files changed, 207 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 39908ee..e0eadcf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ - フラットな候補リストから1つ選ぶノード - `Prompt Random Choice Ex` - フラット候補に加えて、`{}` による入れ子の候補展開に対応したノード + - 先にすべての葉候補へ展開し、最後に1回だけランダム選択します + - 展開済み候補リストは `options_text` が変わるまでキャッシュします どちらもノードしての出力は同じです。 @@ -63,12 +65,21 @@ ここは実例を見てもらった方が話が早そうじゃ。 ```text -town|zoo{animals{birds|penguins}|aquarium,{()|fish|jellyfish}} -town|zoo{animals,birds|aquarium,jellyfish} -town|zoo,animals,birds -town +town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}} ``` -こんな感じで動くぞ。 + +これは、内部的には次の候補へ展開され、キャッシュされるのじゃ。 + +```text +town +zoo, animals, birds +zoo, animals, penguins +zoo, aquarium, fish +zoo, aquarium, jellyfish +``` + +この5候補から1つが選ばれるのじゃ。
+入れ子になった各要素が、すべて等しい確率で出現するように工夫しておるのじゃ。
## 導入方法 @@ -165,16 +176,81 @@ 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` が変わるまでキャッシュ +- 展開候補数と展開回数には安全上限があります - リテラルの `{` / `}` をプロンプト文字として使う用途は非対応 ## 出力 @@ -187,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}` との違いについて diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f46c847..2d532b7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -7,8 +7,9 @@ - `Prompt Random Choice Ex` - `Prompt Random Choice` と同じフラット候補リストに対応 - `{}` による入れ子の候補展開に対応 - - 最内側の `{}` からランダムに展開 + - すべての葉候補へ展開してから、最後に1回だけランダム選択 - 展開結果を親要素へ `, ` で接続 + - 展開済み候補リストを `options_text` 単位でキャッシュ - `selected_text` / `selected_text_safe` を出力 ## 例 @@ -16,8 +17,7 @@ ```text town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}} ``` - -出力例: +これは、内部的には次の候補へ展開されます。 ```text town @@ -26,6 +26,7 @@ zoo, animals, penguins zoo, aquarium, fish zoo, aquarium, jellyfish ``` +この5候補から1つが選ばれます。 ## 備考 diff --git a/nodes.py b/nodes.py index 541e18c..a866f93 100644 --- a/nodes.py +++ b/nodes.py @@ -25,6 +25,7 @@ amusement park{ferris wheel|carousel|balloons}""" INNER_BLOCK_PATTERN = re.compile(r"\{([^{}]*)\}", re.DOTALL) MAX_EXPAND_STEPS = 64 +MAX_EXPANDED_OPTIONS = 4096 def _split_options(options_text: str): @@ -33,10 +34,6 @@ def _split_options(options_text: str): 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") @@ -68,12 +65,9 @@ def _split_options(options_text: str): for part in parts: item = str(part).strip(" \t\r\n,") - # Empty parts caused by repeated delimiters are ignored. if not item: continue - # Explicit empty choice. - # Example: ()|(full body:0.9) if item == "()": options.append("") continue @@ -83,72 +77,16 @@ def _split_options(options_text: str): return options -def _choose_from_options_text(options_text: str): - options = _split_options(options_text) - if not options: - return "" - return secrets.choice(options) - - def _normalize_prompt_fragment(text: str): - """ - Normalize comma-connected prompt fragments after nested expansion. - - This keeps the syntax forgiving: - zoo{aquarium,{fish|jellyfish}} - can safely become: - zoo, aquarium, fish - even if intermediate expansion produced duplicate commas. - """ 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) - - # Collapse duplicate commas. value = re.sub(r"(,\s*){2,}", ", ", value) - - # Collapse spaces. value = re.sub(r"\s+", " ", value) - - # Trim awkward edges. value = value.strip(" \t\r\n,") return value -def _expand_nested_choice(text: str): - """ - Expand nested {...} blocks from the innermost blocks outward. - - Rule: - - {...} chooses one candidate from inside. - - Inside candidates are split by top-level "|" or actual newline. - - The selected nested result is comma-connected to the parent text. - - "()" is an explicit empty candidate. - - Unmatched braces are left as-is after the expansion limit is reached. - - Example: - zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}} - -> zoo, animals, birds - -> zoo, aquarium, jellyfish - """ - value = str(text or "").strip(" \t\r\n,") - - for _ in range(MAX_EXPAND_STEPS): - match = INNER_BLOCK_PATTERN.search(value) - if not match: - break - - chosen = _choose_from_options_text(match.group(1)) - replacement = f", {chosen}" if chosen else "" - value = value[:match.start()] + replacement + value[match.end():] - - return _normalize_prompt_fragment(value) - - def _safe_text(value: str): text = str(value or "") @@ -166,6 +104,95 @@ def _safe_text(value: str): return text or "empty" +def _find_first_brace_block(text: str): + """Return (open_index, close_index) for the first balanced {...} block.""" + open_index = text.find("{") + if open_index < 0: + return None + + depth = 0 + for index in range(open_index, len(text)): + char = text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return open_index, index + + # Unmatched "{". Treat it as normal text rather than looping forever. + return None + + +def _expand_one_expression(text: str, depth: int = 0): + """ + Expand one candidate into all final leaf candidates. + + This expands the first outermost {...} block, chooses every top-level + branch in that block, and then recursively expands only the selected + branch. This avoids duplicating unrelated branches. + """ + if depth > MAX_EXPAND_STEPS: + raise ValueError( + "PromptRandomChoiceEx expansion exceeded the maximum expansion steps. " + "Please check nested braces." + ) + + value = str(text or "").strip(" \t\r\n,") + block = _find_first_brace_block(value) + + if block is None: + return [_normalize_prompt_fragment(value)] + + open_index, close_index = block + prefix = value[:open_index] + body = value[open_index + 1:close_index] + suffix = value[close_index + 1:] + + branch_options = _split_options(body) + if not branch_options: + branch_options = [""] + + expanded = [] + for branch in branch_options: + if branch: + candidate = f"{prefix}, {branch}{suffix}" + else: + candidate = f"{prefix}{suffix}" + + for item in _expand_one_expression(candidate, depth + 1): + normalized = _normalize_prompt_fragment(item) + expanded.append(normalized) + + if len(expanded) > MAX_EXPANDED_OPTIONS: + raise ValueError( + "PromptRandomChoiceEx expanded too many options " + f"({len(expanded)} > {MAX_EXPANDED_OPTIONS})." + ) + + return expanded + + +def _build_expanded_options(options_text: str): + top_options = _split_options(options_text) + expanded_options = [] + + for option in top_options: + for expanded in _expand_one_expression(option): + # Keep "" when it came from explicit (). It allows Ex to return an + # intentional empty selection just like PromptRandomChoice. + 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 @@ -183,16 +210,14 @@ class _RandomChoiceStateMixin: self._last_options_key = None self._last_change_every = None - def _pick_candidate(self, options): - return secrets.choice(options) - def _select_new_choice(self, options): - self._current_choice = self._pick_candidate(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_key = tuple(options) + options = tuple(options) + options_key = options if not options: self._reset_state() @@ -201,6 +226,7 @@ class _RandomChoiceStateMixin: 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: @@ -273,6 +299,11 @@ class PromptRandomChoice(_RandomChoiceStateMixin): 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 { @@ -301,12 +332,20 @@ class PromptRandomChoiceEx(_RandomChoiceStateMixin): FUNCTION = "pick" CATEGORY = "utils/prompt" - def _pick_candidate(self, options): - raw_choice = secrets.choice(options) - return _expand_nested_choice(raw_choice) + 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 = _split_options(options_text) + 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, From 9db7e6779686fcf2a2db2ef095eb5401ba1f4f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Mon, 18 May 2026 03:20:19 +0900 Subject: [PATCH 3/6] Update README.md with minor text corrections --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e0eadcf..8b9f3cb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 先にすべての葉候補へ展開し、最後に1回だけランダム選択します - 展開済み候補リストは `options_text` が変わるまでキャッシュします -どちらもノードしての出力は同じです。 +どちらもノードしての出力は同じじゃ。 - `selected_text` - `selected_text_safe` @@ -102,7 +102,7 @@ git clone https://github.com/ruminar/ComfyUI-PromptRandomChoice.git image

image
-※ 設定後は折りたたんで使うのもおすすめです +※ 設定後は折りたたんで使うのもおすすめなのじゃ。 ## 入力例 @@ -265,11 +265,11 @@ zoo, aquarium, jellyfish ## 推奨構成 -`selected_text` を kjnodes の `Join String Multi` などへ接続し、区切り文字は結合ノード側で管理するのがおすすめです。 +`selected_text` を kjnodes の `Join String Multi` などへ接続し、区切り文字は結合ノード側で管理するのがおすすめじゃ。 -複数の `Prompt Random Choice` / `Prompt Random Choice Ex` を並べることで、背景・時間帯・天気・構図などを別々にランダム化できます。 +複数の `Prompt Random Choice` / `Prompt Random Choice Ex` を並べることで、背景・時間帯・天気・構図などを別々にランダム化するのじゃ。 -ただし、Ex はすべての要素を1つにまとめるためのノードではなく、`zoo` の時だけ動物候補を追加するような、親子関係のある候補を扱うためのノードとして使うのがおすすめです。 +ただし、Ex はすべての要素を1つにまとめるためのノードではなく、`zoo` の時だけ動物候補を追加するような、親子関係のある候補を扱うためのノードとして使うのがおすすめじゃ。
@@ -307,6 +307,8 @@ GPL-3.0(ComfyUI本体の掟に従っておるぞ!) PromptRandomChoice説明画像 +EX版 +PromptRandomChoiceEx説明画像 ## コピペ用おすすめ候補リスト From 29c67ae63571ba754630d660d006e128d1c74a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Wed, 20 May 2026 00:42:03 +0900 Subject: [PATCH 4/6] =?UTF-8?q?README.md=E3=81=AE=E6=96=87=E8=A8=80?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=E3=81=97=E3=80=81=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E5=80=99=E8=A3=9C=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AB=E6=96=B0?= =?UTF-8?q?=E3=81=97=E3=81=84=E9=81=B8=E6=8A=9E=E8=82=A2=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 615 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 547 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 8b9f3cb..c4794a4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 先にすべての葉候補へ展開し、最後に1回だけランダム選択します - 展開済み候補リストは `options_text` が変わるまでキャッシュします -どちらもノードしての出力は同じじゃ。 +どちらもノードとしての出力は同じじゃ。 - `selected_text` - `selected_text_safe` @@ -79,7 +79,7 @@ zoo, aquarium, jellyfish ``` この5候補から1つが選ばれるのじゃ。
-入れ子になった各要素が、すべて等しい確率で出現するように工夫しておるのじゃ。 +展開後の最終候補が、すべて等しい確率で選ばれるようにしてるのじゃな。
## 導入方法 @@ -317,127 +317,186 @@ 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, -``` - -### 背景 Ex 版 - -```text -indoor{ - girl's room - coffee shop - library - classroom - office - laboratory - art gallery - museum - bookstore - bakery - restaurant - concert hall - theater - school hallway - greenhouse - observatory -} -city{ - town - park - rooftop - train station - shopping street - courtyard - bridge - riverside - harbor - marketplace - alley - village -} -nature{ - lake - flower garden - forest - grasslands - sea - mountain - flower field - beach - island - cave - botanical garden -} -traditional{ - japanese garden - shrine - temple -} -fantasy-ish{ - 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 ``` ### 光 @@ -459,3 +518,423 @@ day|day|day|morning|sunset|night ```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, +} +``` From 2cb7db177e2f05b423a1d28a110e27876512cace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Wed, 20 May 2026 00:51:27 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Prompt=20Random=20Choice=20Ex=E3=81=AE?= =?UTF-8?q?=E7=A9=BA=E8=A6=AA=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E5=AF=BE=E5=BF=9C=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=82?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=E8=82=A2=E3=81=AE=E6=AD=A3=E8=A6=8F=E5=8C=96?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=82=92=E6=94=B9=E5=96=84=E3=81=97=E3=80=81?= =?UTF-8?q?=E9=9A=8E=E5=B1=A4=E5=80=99=E8=A3=9C=E3=81=AE=E5=B1=95=E9=96=8B?= =?UTF-8?q?=E3=82=92=E5=BC=B7=E5=8C=96=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RELEASE_NOTES.md | 26 ++++++- nodes.py | 176 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 145 insertions(+), 57 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2d532b7..b01f79a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -11,6 +11,9 @@ - 展開結果を親要素へ `, ` で接続 - 展開済み候補リストを `options_text` 単位でキャッシュ - `selected_text` / `selected_text_safe` を出力 + - `(){...}` のような空親グループに対応 + - 親要素 `()` は出力されません + - 子要素だけが候補になります ## 例 @@ -27,8 +30,27 @@ zoo, aquarium, fish zoo, aquarium, jellyfish ``` この5候補から1つが選ばれます。 +
+ + +親要素 `()` の例 +```text +(){ + white day + wedding ceremony + birthday party +} +``` + +内部的には次の候補へ展開されます。 + +```text +white day +wedding ceremony +birthday party ## 備考 -通常の `Prompt Random Choice` は、フラットな候補選択用としてそのまま残しています。 -階層候補が必要な場合だけ `Prompt Random Choice Ex` を使ってください。 +- `Prompt Random Choice Ex` は、すべての葉候補へ展開してから最後に1回だけランダム選択します +- 展開済み候補リストは `options_text` 単位でキャッシュします +- 通常の `Prompt Random Choice` は、フラットな候補選択用としてそのまま残しています diff --git a/nodes.py b/nodes.py index a866f93..12d3375 100644 --- a/nodes.py +++ b/nodes.py @@ -21,9 +21,13 @@ 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}""" +amusement park{ferris wheel|carousel|balloons} +(){ + white day + wedding ceremony + birthday party +}""" -INNER_BLOCK_PATTERN = re.compile(r"\{([^{}]*)\}", re.DOTALL) MAX_EXPAND_STEPS = 64 MAX_EXPANDED_OPTIONS = 4096 @@ -34,6 +38,10 @@ def _split_options(options_text: str): 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") @@ -65,9 +73,12 @@ def _split_options(options_text: str): for part in parts: item = str(part).strip(" \t\r\n,") + # Empty parts caused by repeated delimiters are ignored. if not item: continue + # Explicit empty choice. + # Example: ()|(full body:0.9) if item == "()": options.append("") continue @@ -78,13 +89,40 @@ def _split_options(options_text: str): 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) - value = re.sub(r"\s*,\s*", ", ", value) - value = re.sub(r"(,\s*){2,}", ", ", value) - value = re.sub(r"\s+", " ", value) - value = value.strip(" \t\r\n,") - return 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): @@ -104,73 +142,97 @@ def _safe_text(value: str): return text or "empty" -def _find_first_brace_block(text: str): - """Return (open_index, close_index) for the first balanced {...} block.""" - open_index = text.find("{") - if open_index < 0: +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(open_index, len(text)): - char = text[index] + for index in range(start, len(value)): + char = value[index] if char == "{": depth += 1 elif char == "}": depth -= 1 if depth == 0: - return open_index, index + return start, index - # Unmatched "{". Treat it as normal text rather than looping forever. return None -def _expand_one_expression(text: str, depth: int = 0): +def _expand_all_nested_choices(option_text: str): """ - Expand one candidate into all final leaf candidates. + Expand one top-level candidate into all final leaf candidates. - This expands the first outermost {...} block, chooses every top-level - branch in that block, and then recursively expands only the selected - branch. This avoids duplicating unrelated branches. + 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 """ - if depth > MAX_EXPAND_STEPS: - raise ValueError( - "PromptRandomChoiceEx expansion exceeded the maximum expansion steps. " - "Please check nested braces." - ) - value = str(text or "").strip(" \t\r\n,") - block = _find_first_brace_block(value) + 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." + ) - if block is None: - return [_normalize_prompt_fragment(value)] + block = _find_first_block(value) + if block is None: + return [_normalize_prompt_fragment(value)] - open_index, close_index = block - prefix = value[:open_index] - body = value[open_index + 1:close_index] - suffix = value[close_index + 1:] + start, end = block + prefix = value[:start] + body = value[start + 1:end] + suffix = value[end + 1:] - branch_options = _split_options(body) - if not branch_options: - branch_options = [""] + inner_options = _split_options(body) + if not inner_options: + inner_options = [""] - expanded = [] - for branch in branch_options: - if branch: - candidate = f"{prefix}, {branch}{suffix}" - else: - candidate = f"{prefix}{suffix}" + results = [] + for inner in inner_options: + inner_expanded_options = expand_recursive(inner, depth + 1) - for item in _expand_one_expression(candidate, depth + 1): - normalized = _normalize_prompt_fragment(item) - expanded.append(normalized) + 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) - if len(expanded) > MAX_EXPANDED_OPTIONS: - raise ValueError( - "PromptRandomChoiceEx expanded too many options " - f"({len(expanded)} > {MAX_EXPANDED_OPTIONS})." - ) + 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 expanded + return results + + initial = str(option_text or "").strip(" \t\r\n,") + return expand_recursive(initial, 0) def _build_expanded_options(options_text: str): @@ -178,9 +240,8 @@ def _build_expanded_options(options_text: str): expanded_options = [] for option in top_options: - for expanded in _expand_one_expression(option): - # Keep "" when it came from explicit (). It allows Ex to return an - # intentional empty selection just like PromptRandomChoice. + 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: @@ -192,7 +253,6 @@ def _build_expanded_options(options_text: str): return tuple(expanded_options) - class _RandomChoiceStateMixin: def __init__(self): self._current_choice = None @@ -332,6 +392,11 @@ class PromptRandomChoiceEx(_RandomChoiceStateMixin): 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 "") @@ -339,6 +404,7 @@ class PromptRandomChoiceEx(_RandomChoiceStateMixin): 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 From 2f4daaef3a33d6e63602bf11802d470cae3c4d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Wed, 20 May 2026 01:46:22 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=E3=83=8E=E3=83=BC=E3=83=89=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=88=E3=83=AB=20=E3=83=97=E3=83=AC=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=83=83=E3=82=AF=E3=82=B9=E3=82=92=E7=9F=AD=E7=B8=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 2 +- web/prompt_random_choice.js | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c4794a4..81cea73 100644 --- a/README.md +++ b/README.md @@ -506,7 +506,7 @@ courtyard garden, ### 姿勢、視線、動作 ```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 ``` ### 表情 diff --git a/web/prompt_random_choice.js b/web/prompt_random_choice.js index 70dc2e4..c3ebca2 100644 --- a/web/prompt_random_choice.js +++ b/web/prompt_random_choice.js @@ -22,6 +22,9 @@ app.registerExtension({ 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) { @@ -32,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);