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本体の掟に従っておるぞ!)
-## 付録
+## コピペ用おすすめ候補リスト
-背景候補
+### 背景
```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;
}