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; }