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] =?UTF-8?q?Prompt=20Random=20Choice=20Ex=E3=81=AE=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=82=92=E5=BC=B7=E5=8C=96=E3=81=97=E3=80=81=E3=81=99?= =?UTF-8?q?=E3=81=B9=E3=81=A6=E3=81=AE=E8=91=89=E5=80=99=E8=A3=9C=E3=82=92?= =?UTF-8?q?=E5=B1=95=E9=96=8B=E3=81=97=E3=81=A6=E3=81=8B=E3=82=89=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=83=80=E3=83=A0=E9=81=B8=E6=8A=9E=E3=82=92=E8=A1=8C?= =?UTF-8?q?=E3=81=86=E3=82=88=E3=81=86=E3=81=AB=E5=A4=89=E6=9B=B4=E3=80=82?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81=E5=B1=95=E9=96=8B?= =?UTF-8?q?=E6=B8=88=E3=81=BF=E5=80=99=E8=A3=9C=E3=83=AA=E3=82=B9=E3=83=88?= =?UTF-8?q?=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,