Prompt Random Choice Exを新規作成。選択肢の入れ子展開に対応。
This commit is contained in:
+22
@@ -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
|
||||
@@ -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
@@ -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` を使ってください。
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user