Prompt Random Choice Exを新規作成。選択肢の入れ子展開に対応。

This commit is contained in:
るみなー先生
2026-05-17 20:06:44 +09:00
parent a7c7bf5c5c
commit 8b3ac7c3ed
5 changed files with 388 additions and 93 deletions
+22
View File
@@ -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
+116 -8
View File
@@ -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
```
こんな感じで動くぞ。
<br/>
## 導入方法
ComfyUIの `custom_nodes` ディレクトリで、以下のコマンドを打ち込むのじゃ!
@@ -119,6 +152,8 @@ day|day|day|sunset|night
昼を多めに出したい、たまに夕方や夜も混ぜたい、という時に便利じゃ。
<br/>
## 仕様
- `|` または実際の改行で分割
@@ -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本体の掟に従っておるぞ!)
<img width="1055" height="1491" alt="PromptRandomChoice説明画像" src="https://github.com/user-attachments/assets/7a4f1b5f-c77b-4e47-90af-cbd0330c85fe" />
## 付録
## コピペ用おすすめ候補リスト
背景候補
### 背景
```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)
```
+24 -33
View File
@@ -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
通常の `Prompt Random Choice` は、フラットな候補選択用としてそのまま残しています。
階層候補が必要な場合だけ `Prompt Random Choice Ex` を使ってください。
+221 -50
View File
@@ -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",
}
+5 -2
View File
@@ -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;
}