diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml
new file mode 100644
index 0000000..9c82eba
--- /dev/null
+++ b/.github/workflows/regression-tests.yml
@@ -0,0 +1,25 @@
+name: Regression tests
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - name: Run Python regression tests
+ run: python -B -m unittest discover -s tests -v
+ - name: Check frontend JavaScript syntax
+ run: |
+ node --check web/prompt_random_choice.js
+ node --check web/runtime_prompt_random_choice.js
+ - name: Run frontend regression tests
+ run: node --test tests/runtime_layout.test.mjs
diff --git a/.spec/README.md b/.spec/README.md
new file mode 100644
index 0000000..51ae496
--- /dev/null
+++ b/.spec/README.md
@@ -0,0 +1,18 @@
+# PromptRandomChoice specification index
+
+このディレクトリは、実装より先に守るべき仕様と退行条件を記録する。
+
+- [candidate-language.md](candidate-language.md): 共通候補記法、`#`、`!`、Ex文法
+- [runtime-behavior.md](runtime-behavior.md): Runtime同期、revision、`change_every`、エラー分類
+- [regression-matrix.md](regression-matrix.md): 仕様と自動テストの対応表
+
+## 変更時の原則
+
+1. 挙動を変更する前に該当仕様を更新する。
+2. 仕様変更には対応する回帰テストを追加する。
+3. `python -m unittest discover -s tests` を成功させる。
+4. JavaScript変更時は `node --check web/prompt_random_choice.js` と
+ `node --check web/runtime_prompt_random_choice.js` を成功させる。
+5. 互換性に影響する変更はREADMEとRELEASE_NOTESにも記載する。
+
+現在の対象バージョンは `0.6.0`。
diff --git a/.spec/candidate-language.md b/.spec/candidate-language.md
new file mode 100644
index 0000000..93c0b1c
--- /dev/null
+++ b/.spec/candidate-language.md
@@ -0,0 +1,85 @@
+# Candidate language specification
+
+## 対象ノード
+
+| ノード | `#` | `!` | 入れ子 |
+|---|---|---|---|
+| Prompt Random Choice | コメント | 通常文字 | なし |
+| Prompt Random Choice Ex | 各階層でコメント | 通常文字 | あり |
+| Runtime Prompt Random Choice | コメント | 強制選択 | なし |
+| Runtime Prompt Random Choice Ex | 各階層でコメント | 各階層で強制選択 | あり |
+
+## 共通候補分割
+
+- 候補区切りは実改行または `|`。
+- CRLFとCRはLFへ正規化する。
+- 候補前後の空白、タブ、改行、カンマを除去する。
+- 空候補は無視する。
+- `()` は明示的な空文字候補。
+- 制御記号はtrim後の先頭1文字だけを判定する。
+
+## `#` コメント
+
+- 先頭が `#` の候補全体を除外する。
+- `#` 自体を除去して通常候補へ戻す処理ではない。
+- `#!candidate` はコメント。
+- Exでは各階層に同じ規則を再帰適用する。
+
+## Runtime限定の `!` 強制選択
+
+- 同じ階層に `!` 候補があれば、上から最初の1件だけを残す。
+- 通常候補と2件目以降の `!` 候補は、その階層では選択しない。
+- 出力から制御用の先頭 `!` を1文字だけ除去する。
+- `!()` は空文字を強制選択する。
+- 単独の `!` はHard Error。
+- `!!candidate` は `!candidate` を強制出力する。
+- 従来版2ノードでは `!` を通常文字として出力する。
+
+`!` は現在の`change_every`保持期間へ割り込まず、次回再選択時に評価する。
+
+## Exの階層と確率
+
+- 1候補は直接の子グループを最大1個持てる。
+- 子候補はさらに子グループを持てるため、縦方向の入れ子は許可する。
+- 子を持つ親候補を同じ階層へ複数並べることも許可する。
+- 各階層で `#` と、Runtimeの場合は最初の `!` を適用する。
+- 絞り込み後の最終葉候補は、すべて等しい確率で選択する。
+- 実装は各枝の葉数を重みとして1経路だけを選び、完成文字列を全列挙しない。
+- 同じ文字列へ至る経路が複数あれば、それぞれ独立した葉として重みになる。
+
+例:
+
+```text
+A{x|y|z}
+B{!p|q}
+```
+
+Runtime Exでの最終葉は `A, x`、`A, y`、`A, z`、`B, p` の4件で、
+各確率は `1/4`。
+
+## 禁止する直積構文
+
+同じ候補へ直接子グループを複数並べる構文は禁止する。
+
+```text
+A{B|C}{D|E}
+```
+
+- 2個目の直接子グループを構文解析中に検出する。
+- 候補展開や葉選択を開始する前にHard Errorとする。
+- 自動的な `|` 補完や式の書き換えはしない。
+- エラーには対象候補、位置、理由、String Joinを使う代替方法を含める。
+
+許可例:
+
+```text
+black hair{straight long hair|short cut}
+blonde hair{long hair{side braid|black ribbon}|wavy long hair}
+```
+
+## 出力
+
+- 親子の非空文字列を `, ` で結合する。
+- 独立したランダム軸は別ノードへ分け、String Joinで結合する。
+- 空の`selected_text`に対する`selected_text_safe`は `empty`。
+- `selected_text_safe`はファイル名禁止記号、Windows予約名、過大な長さを回避する。
diff --git a/.spec/regression-matrix.md b/.spec/regression-matrix.md
new file mode 100644
index 0000000..f730468
--- /dev/null
+++ b/.spec/regression-matrix.md
@@ -0,0 +1,51 @@
+# Regression matrix
+
+## 自動実行
+
+```powershell
+$py = "C:\path\to\python.exe"
+& $py -B -m unittest discover -s tests
+node --check web/prompt_random_choice.js
+node --check web/runtime_prompt_random_choice.js
+node --test tests/runtime_layout.test.mjs
+```
+
+GitHub Actionsでは `.github/workflows/regression-tests.yml` が同じ検証を行う。
+
+## 仕様とテストの対応
+
+| 保護対象 | 主なテスト |
+|---|---|
+| 従来版の `!` は通常文字 | `test_flat_comments_and_non_runtime_bang_are_literal`, `test_non_runtime_ex_keeps_bang_as_text` |
+| Runtimeの最初の `!` | `test_runtime_flat_uses_first_forced_candidate`, `test_ex_comments_and_first_force_apply_recursively` |
+| `!()` / 単独`!` / `!!` | `test_runtime_forced_empty_and_bare_bang`, `test_double_bang_removes_only_control_marker`, `test_runtime_ex_forced_empty_is_a_leaf` |
+| `#`コメントと全件コメント | `test_all_commented_candidates_produce_empty_pool`, `test_ex_comments_and_first_force_apply_recursively` |
+| Exの葉均等候補集合 | `test_ex_filters_each_level_and_keeps_leaf_uniform_paths` |
+| 複数親と縦入れ子 | `test_ex_multiple_parents_and_vertical_nesting` |
+| 直積の展開前拒否 | `test_adjacent_direct_groups_are_rejected_before_selection` |
+| 強制で隠れた枝もHard Error検査 | `test_hard_error_is_detected_even_in_branch_overridden_by_force` |
+| Soft/Hard分類 | `test_incomplete_braces_are_distinguished`, `test_adjacent_groups_classify_as_hard_error` |
+| Runtime編集が保持周期へ割り込まない | `test_edit_does_not_interrupt_change_every` |
+| Soft incompleteは最後の正常revision | `test_incomplete_uses_last_valid_revision` |
+| Hard Errorは黙ってフォールバックしない | `test_hard_error_never_falls_back` |
+| Hard Error修正後の復帰 | `test_hard_error_clears_after_valid_text_is_accepted` |
+| revisionは正常変更だけで進む | `test_revisions_advance_only_for_valid_text` |
+| 受理済み空文字と状態なしの区別 | `test_accepted_empty_text_overrides_queued_fallback` |
+| 古いクライアント更新の拒否 | `test_stale_runtime_update_is_rejected` |
+| ノード登録名とRuntime入力 | `test_node_mappings_and_runtime_inputs_are_stable` |
+| 全ノードが単一トップ階層へ表示 | `test_all_nodes_share_one_top_level_menu_category` |
+| 通常版は候補変更で再選択 | `test_regular_node_resets_when_candidates_change` |
+| ファイル名安全化 | `test_safe_text_handles_device_names_and_length` |
+| Runtime textareaがノード高へ追従 | `runtime_layout.test.mjs` |
+
+## 手動確認項目
+
+- ComfyUI再起動後にRuntimeノード2種がメニューへ表示される。
+- textareaで日本語IME変換中に同期されない。
+- `EDITING → SYNCING → LIVE`が視認できる。
+- 括弧不一致で`SYNTAX INCOMPLETE`となり生成を継続する。
+- 直積入力で`HARD ERROR`となり、次回実行が停止する。
+- Hard Error修正後に`LIVE`へ復帰する。
+- Workflow保存・再読込で候補テキストとstate keyが維持される。
+- ノード複製時にstate keyが重複しない。
+- `change_every=3`の途中編集でも現在の完成文字列が3回維持される。
diff --git a/.spec/runtime-behavior.md b/.spec/runtime-behavior.md
new file mode 100644
index 0000000..286b709
--- /dev/null
+++ b/.spec/runtime-behavior.md
@@ -0,0 +1,101 @@
+# Runtime behavior specification
+
+## 目的
+
+Queue投入済みで未実行のジョブが、ノード実行時点でサーバーに受理済みの
+最新候補集合を参照できるようにする。実行開始済みのジョブは変更しない。
+
+## Queue時とRuntime時の分離
+
+Queue時に確定するもの:
+
+- グラフと接続
+- `change_every`
+- サーバー状態がない場合のフォールバック候補テキスト
+- 永続化された`state_key`
+
+Runtime同期するもの:
+
+- 候補入力テキスト
+- 入力状態
+- 受理済み候補revision
+
+`change_every`はRuntime同期しない。
+
+## 選択寿命
+
+Runtimeで更新されるのは候補集合であり、現在選択中の完成文字列ではない。
+
+```text
+change_every = 3
+実行1: revision 1からAを選択
+編集: revision 2を受理
+実行2: A
+実行3: A
+実行4: revision 2から再選択
+```
+
+- 候補編集でrepeat countをリセットしない。
+- 選択中候補の削除、`#`化、内容変更でも保持期間中は完成文字列を維持する。
+- `!`の追加・削除も次回再選択時に反映する。
+- `change_every`入力値自体がQueue間で変わった場合は、新周期として選択し直す。
+- `selected_revision`は現在の完成文字列を決めたrevisionを表す。
+
+## サーバー状態
+
+状態は少なくとも次を保持する。
+
+```text
+state_key
+node_kind
+input_text
+input_status
+error
+accepted_text
+revision
+client_id
+client_sequence
+```
+
+- `accepted_text=None` と受理済み空文字 `accepted_text=""` を区別する。
+- revisionは正常な候補テキストが変わった場合だけ増加する。
+- incomplete/hard errorだけではrevisionを増加させない。
+- クライアントごとの連番で古い更新と連番再利用を拒否する。
+- 状態はメモリ内にあり、ComfyUI再起動で消える。
+- 状態がなければQueueへ保存された候補テキストを使用する。
+
+## エラー分類
+
+### Soft incomplete
+
+例: 波括弧の閉じ忘れ、余分な閉じ括弧。
+
+- 新しい正常revisionとして受理しない。
+- 直前の`accepted_text`とrevisionを維持する。
+- Hard Error状態は解除する。
+- 実行は最後の正常候補集合で継続する。
+- 正常候補が一度もなく、Queueフォールバックも不正なら実行エラー。
+- UIは `EDITING / SYNTAX INCOMPLETE` と使用中revisionを表示する。
+
+### Hard error
+
+例: 隣接する複数の直接子グループ、単独の `!`、上限超過。
+
+- サーバーはHard Error状態を保存する。
+- 最後の正常候補を黙って使用しない。
+- 現在の`change_every`保持期間中でも、次回実行を`ValueError`で停止する。
+- 正常な入力が受理されたらHard Errorを解除する。
+- UIは `HARD ERROR` と理由を表示する。
+
+## フロントエンド同期
+
+- 120ms debounce。
+- 日本語IME変換中は送信しない。
+- 1ノード内の送信は順番に処理する。
+- UTF-8で512KiBを上限とする。
+- Workflow複製で重複した`state_key`は再発行する。
+- 入力内容は通常どおりWorkflowへ保存する。
+- `EDITING / SYNCING / LIVE / SYNC ERROR`を表示する。
+- サーバーに状態があれば、その`input_text`を初期表示へ復元する。
+- Runtime textareaはDOMウィジェットの可変領域を使用し、ノードの高さ変更へ追従する。
+- textareaは可変領域、同期状態フッターは固定領域として配置する。
diff --git a/README.md b/README.md
index 452469c..936846e 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,24 @@
フロー内に複数個置いても、それぞれ独立して動くぞ。
+## v0.6.0
+
+候補を実行中に編集できるRuntime版と、候補リストを操作しやすくする制御記号を追加したのじゃ。
+
+- `Runtime Prompt Random Choice`
+- `Runtime Prompt Random Choice Ex`
+- `#` で始まる候補を一時的にコメントアウト
+- Runtime版では `!` で始まる候補を次回再選択時に強制選択
+- Ex版の直積構文を展開前に検出して停止
+- Runtime編集中の波括弧不一致は、最後に受理した正常な候補を使って待機
+
+互換性に関する注意:
+
+- 従来版でも先頭 `#` はコメントとして扱われます
+- `scene{day|night}{clear|rain}` のような隣接子グループは使用できなくなりました
+ - 今回のバージョンより、Ex版での直積を禁止しています
+ - 直積を実現したい場合は、それぞれ別のExノードへ分け、String Joinで結合してください
+
## v0.5.0
`Safe Random Seed` を追加したのじゃ。
@@ -67,6 +85,14 @@
- **明示的な空候補 `()` に対応**
- `()` が選ばれた場合、プロンプト向け出力は空文字に、ファイル名向け出力は `empty` になるのじゃ。
+- **候補のコメントアウト**
+ - trim後の先頭が `#` の候補は選択対象から除外されます。
+ - Ex版では各階層で利用できます。
+
+- **Runtime編集**
+ - Runtime版では、Queue投入後でもサーバーが受理した最新候補集合を参照できます。
+ - `!` はRuntime版だけで強制選択記号として働き、従来版では通常文字です。
+
## Prompt Random Choice Ex の追加要素
- **フラット候補に加えて、`{}` による入れ子の候補展開に対応**
@@ -81,7 +107,7 @@
town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
-これは、内部的には次の候補へ展開され、キャッシュされるのじゃ。
+これは、概念上は次の葉候補を持つのじゃ。
```text
town
@@ -92,7 +118,7 @@ zoo, aquarium, jellyfish
```
この5候補から1つが選ばれるのじゃ。
-展開後の最終候補が、すべて等しい確率で選ばれるようにしてるのじゃな。
+最終葉候補がすべて等しい確率になるよう、各枝の葉数を使って1経路だけを選ぶため、完成文字列を事前に全列挙はしません。
## 導入方法
@@ -105,6 +131,8 @@ git clone https://github.com/ruminar/ComfyUI-PromptRandomChoice.git
## 使い方
+ノード追加メニューの `Prompt Random Choice` カテゴリに、通常版・Runtime版・Safe Random Seedがまとまっています。
+
1. `Prompt Random Choice` ノードを置く
2. `options_text` に候補を入れる (入力例を参照)
3. 必要に応じて `change_every` を選ぶ
@@ -185,6 +213,8 @@ day|day|day|sunset|night
- 各候補の前後の空白と `,` を trim
- 空候補は無視
- `()` は明示的な空候補として扱う
+- trim後の先頭が `#` の候補はコメントとして除外
+- `!` は従来版では通常文字として扱う
- `change_every` が 1 なら毎回選び直す
- `change_every` が 2 以上なら、その回数ぶん同じ候補を維持する
- 実行時にタイトルへ `Ch: lake` や `Ch: (empty) (2/3)` のように表示する
@@ -234,7 +264,7 @@ town|park|lake|coffee shop
town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
-この入力は、内部的には次の候補へ展開されます。
+この入力は、概念上は次の最終葉候補を持ちます。
```text
town
@@ -276,13 +306,75 @@ zoo, aquarium, jellyfish
- 空候補は無視
- `()` は明示的な空候補
- `{}` の内部も、実際の改行 または `|` で候補分割
-- `{}` は最内側からすべての葉候補へ展開
+- 各階層で `#` コメント候補を除外
- 展開結果は親要素へ `, ` で接続
-- 最終候補リストから最後に1回だけランダム選択
-- 展開済み候補リストは `options_text` が変わるまでキャッシュ
-- 展開候補数と展開回数には安全上限があります
+- 最終葉候補が均等確率になるよう、葉数を重みとして1経路だけ選択
+- 完成した全候補文字列は事前に列挙しない
+- 入れ子の深さと葉候補数には安全上限があります
+- 1候補が持てる直接の子グループは1個まで
+- `A{B|C}{D|E}` のような直積構文は展開前にエラー
- リテラルの `{` / `}` をプロンプト文字として使う用途は非対応
+### 直積構文について
+
+縦方向の入れ子と、子を持つ親候補を同じ階層へ複数並べることはできます。
+
+```text
+black hair{straight long hair|short cut}
+blonde hair{long hair|wavy long hair}
+```
+
+一方、同じ候補へ子グループを横に複数並べる構文は使用できません。
+
+```text
+A{B|C}{D|E}
+```
+
+この構文は直積を作るため、候補展開前にエラーになります。独立した軸は別の `Prompt Random Choice Ex` ノードへ分け、String Joinで結合してください。
+
+## Runtime Prompt Random Choice
+
+`Runtime Prompt Random Choice` と `Runtime Prompt Random Choice Ex` は、実行時点でComfyUIサーバーが受理済みの最新候補テキストを参照します。候補入力欄には同期状態が表示されます。
+
+- `EDITING`: 編集中または送信待ち
+- `SYNCING`: サーバーへ送信中
+- `LIVE`: 最新入力をサーバーが受理済み
+- `EDITING / SYNTAX INCOMPLETE`: Exの波括弧が編集中で不一致。最後の正常revisionを使用
+- `HARD ERROR`: サポートしない完成済み構文。修正されるまで次回実行を停止
+- `SYNC ERROR`: 通信または同期エラー
+
+### `!` 強制選択
+
+`!` はRuntime版だけで有効です。同じ階層に複数ある場合は、上から最初の候補を使用します。先頭の `!` は出力されません。
+
+```text
+black hair
+!blonde hair
+!silver hair
+```
+
+この場合は `blonde hair` を使用します。`!()` は空文字を強制選択します。従来版の `Prompt Random Choice` / `Prompt Random Choice Ex` では `!` は通常文字です。
+
+Runtime Exでは各階層で最初の `!` を適用します。`!` はその階層の枝だけを絞り込み、選択された枝の子階層は引き続き評価されます。
+
+### Runtimeと`change_every`
+
+Runtimeでリアルタイム更新されるのは候補集合であり、現在選択中の完成文字列ではありません。`change_every = 3` なら、途中で候補を編集・削除・コメントアウトしたり `!` を追加しても、現在の結果を3回出力するまで維持します。
+
+```text
+実行1: Aを選択
+実行2: A
+実行3: A
+実行4: 最新revisionの候補集合から再選択
+```
+
+`!` は次回の再選択時に通常のランダム選択を上書きし、保持期間へ割り込みません。候補編集を次のジョブへ反映したい場合は `change_every = 1` を使用してください。`change_every` 自体はRuntime同期されず、Queue投入時の値を使います。
+
+### Runtime Exの構文エラー
+
+- 波括弧不一致は編集中の一時状態として扱い、新しいrevisionにはしません。最後に受理した正常な候補集合で生成を継続します。
+- 隣接子グループによる直積はHard Errorです。古い候補へ黙ってフォールバックせず、次回実行でエラーにします。
+
## 出力
- `selected_text`
@@ -329,6 +421,18 @@ zoo, aquarium, jellyfish
+## 開発仕様と回帰テスト
+
+実装時に守る仕様は [`.spec/README.md`](.spec/README.md) を起点にまとめています。
+
+標準ライブラリだけで回帰テストを実行できます。
+
+```bash
+python -B -m unittest discover -s tests -v
+```
+
+pushとPull RequestではGitHub ActionsがPython回帰テストとJavaScript構文検査を自動実行します。
+
## ライセンス
GPL-3.0(ComfyUI本体の掟に従っておるぞ!)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index aa1ce77..c9ee856 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,35 @@
+## v0.6.0
+
+候補をQueue投入後も編集できるRuntime版と、候補制御記号を追加しました。
+
+## 追加
+
+- `Runtime Prompt Random Choice`
+- `Runtime Prompt Random Choice Ex`
+- 全選択ノードで、先頭 `#` の候補をコメントとして除外
+- Runtime版限定で、先頭 `!` の候補を次回再選択時に強制選択
+ - 同じ階層に複数ある場合は最初の1件を使用
+ - `!()` は空文字を強制選択
+- Runtime入力欄の `EDITING / SYNCING / LIVE / SYNC ERROR` 表示
+- Runtime ExのSoft Incomplete / Hard Error表示
+
+## 変更
+
+- Runtimeの候補編集は現在の`change_every`保持期間へ割り込まず、次回再選択時に反映
+- Runtimeのtextareaがノードの高さ変更へ追従するように修正
+- 全ノードの追加メニューをトップ階層の `Prompt Random Choice` カテゴリへ統一
+- Exは最終葉候補の均等確率を維持しながら、葉数を使って1経路だけを選択
+- `selected_text_safe`でWindows予約名を回避し、長さを制限
+
+## 互換性に関する注意
+
+- 従来版でも先頭 `#` は通常候補ではなくコメントになります
+- Exの `A{B|C}{D|E}` のような隣接子グループは、直積を防ぐため展開前にエラーになります
+- Exの波括弧不一致は明示的な構文エラーになります
+- 独立したランダム軸は別のExノードへ分け、String Joinで結合してください
+
+---
+
## v0.5.0
`Safe Random Seed` を追加しました。
@@ -66,6 +98,7 @@ zoo, aquarium, jellyfish
white day
wedding ceremony
birthday party
+```
## 備考
diff --git a/__init__.py b/__init__.py
index fc7085e..f238921 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,5 +1,10 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+try:
+ from . import server_routes as _server_routes # noqa: F401
+except Exception as exc:
+ print(f"[PromptRandomChoice] Runtime route registration warning: {exc}")
+
WEB_DIRECTORY = "./web"
__all__ = [
diff --git a/candidate_parser.py b/candidate_parser.py
new file mode 100644
index 0000000..fb34998
--- /dev/null
+++ b/candidate_parser.py
@@ -0,0 +1,296 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import re
+import secrets
+
+
+MAX_NESTING_DEPTH = 64
+MAX_LEAF_OPTIONS = 4096
+
+
+class CandidateSyntaxError(ValueError):
+ """Base class for candidate-language errors."""
+
+
+class IncompleteCandidateSyntaxError(CandidateSyntaxError):
+ """The text may become valid when the user finishes editing it."""
+
+
+class UnsupportedCandidateSyntaxError(CandidateSyntaxError):
+ """The text is structurally complete but uses unsupported syntax."""
+
+
+def normalize_prompt_fragment(text: str) -> str:
+ value = str(text or "")
+ value = re.sub(r"[\t\r\n]+", " ", value)
+ value = re.sub(r"\s*,\s*", ",", value)
+
+ parts = []
+ for part in value.split(","):
+ item = part.strip()
+ if item and item != "()":
+ parts.append(item)
+ return ", ".join(parts)
+
+
+def split_candidate_parts(
+ options_text: str,
+ *,
+ validate_braces: bool = True,
+) -> list[str]:
+ """Split top-level candidates while preserving nested child groups."""
+ text = str(options_text or "").replace("\r\n", "\n").replace("\r", "\n")
+ parts: list[str] = []
+ current: list[str] = []
+ depth = 0
+
+ for char in text:
+ if char == "{":
+ depth += 1
+ current.append(char)
+ continue
+ if char == "}":
+ if depth == 0 and not validate_braces:
+ current.append(char)
+ continue
+ depth -= 1
+ if depth < 0:
+ raise IncompleteCandidateSyntaxError(
+ "Unmatched closing brace '}' in candidate text."
+ )
+ current.append(char)
+ continue
+ if depth == 0 and char in ("|", "\n"):
+ parts.append("".join(current))
+ current = []
+ continue
+ current.append(char)
+
+ if depth and validate_braces:
+ raise IncompleteCandidateSyntaxError(
+ "Unmatched opening brace '{' in candidate text."
+ )
+ parts.append("".join(current))
+ return [part.strip(" \t\r\n,") for part in parts if part.strip(" \t\r\n,")]
+
+
+def _decode_leaf(value: str, *, forced: bool) -> str:
+ item = value[1:].strip() if forced else value
+ if forced and not item:
+ raise UnsupportedCandidateSyntaxError(
+ "A forced candidate must not be empty. Use '!()' to force an empty string."
+ )
+ if item == "()":
+ return ""
+ return item
+
+
+@dataclass(frozen=True)
+class FlatCandidatePool:
+ options: tuple[str, ...]
+ forced: bool = False
+
+ @property
+ def leaf_count(self) -> int:
+ return len(self.options)
+
+ def choose(self) -> str:
+ if not self.options:
+ return ""
+ return secrets.choice(self.options)
+
+
+def build_flat_pool(options_text: str, *, allow_force: bool) -> FlatCandidatePool:
+ parts = split_candidate_parts(options_text, validate_braces=False)
+ active: list[str] = []
+ first_forced: str | None = None
+
+ for part in parts:
+ if part.startswith("#"):
+ continue
+ is_forced = allow_force and part.startswith("!")
+ value = _decode_leaf(part, forced=is_forced)
+ if is_forced:
+ if first_forced is None:
+ first_forced = value
+ continue
+ active.append(value)
+
+ if first_forced is not None:
+ return FlatCandidatePool((first_forced,), forced=True)
+ return FlatCandidatePool(tuple(active))
+
+
+def _format_multiple_group_error(candidate: str, position: int) -> str:
+ pointer = " " * position + "^"
+ return (
+ "Unsupported syntax: A candidate may contain only one direct child group.\n\n"
+ f"{candidate}\n{pointer}\n\n"
+ "Multiple direct child groups are not supported because they create "
+ "a Cartesian product during candidate expansion. Use separate Prompt "
+ "Random Choice Ex nodes and combine their outputs with String Join."
+ )
+
+
+def _find_direct_group(candidate: str) -> tuple[int, int] | None:
+ depth = 0
+ start = -1
+ first_group: tuple[int, int] | None = None
+
+ for index, char in enumerate(candidate):
+ if char == "{":
+ if depth == 0:
+ if first_group is not None:
+ raise UnsupportedCandidateSyntaxError(
+ _format_multiple_group_error(candidate, index)
+ )
+ start = index
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth < 0:
+ raise IncompleteCandidateSyntaxError(
+ "Unmatched closing brace '}' in candidate text."
+ )
+ if depth == 0:
+ first_group = (start, index)
+
+ if depth:
+ raise IncompleteCandidateSyntaxError(
+ "Unmatched opening brace '{' in candidate text."
+ )
+ return first_group
+
+
+@dataclass(frozen=True)
+class ExCandidate:
+ prefix: str
+ suffix: str
+ children: "ExCandidatePool | None" = None
+
+ @property
+ def leaf_count(self) -> int:
+ return self.children.leaf_count if self.children is not None else 1
+
+ def select_by_index(self, index: int) -> str:
+ if self.children is None:
+ return normalize_prompt_fragment(self.prefix + self.suffix)
+ child = self.children.select_by_index(index)
+ replacement = f", {child}" if child else ""
+ return normalize_prompt_fragment(self.prefix + replacement + self.suffix)
+
+
+@dataclass(frozen=True)
+class ExCandidatePool:
+ candidates: tuple[ExCandidate, ...]
+ forced: bool = False
+
+ @property
+ def leaf_count(self) -> int:
+ return sum(candidate.leaf_count for candidate in self.candidates)
+
+ def select_by_index(self, index: int) -> str:
+ if index < 0 or index >= self.leaf_count:
+ raise IndexError("leaf candidate index is out of range")
+ remaining = index
+ for candidate in self.candidates:
+ if remaining < candidate.leaf_count:
+ return candidate.select_by_index(remaining)
+ remaining -= candidate.leaf_count
+ raise IndexError("leaf candidate index is out of range")
+
+ def choose(self) -> str:
+ count = self.leaf_count
+ if count == 0:
+ return ""
+ return self.select_by_index(secrets.randbelow(count))
+
+
+def _parse_ex_candidate(
+ raw_candidate: str,
+ *,
+ allow_force: bool,
+ depth: int,
+) -> ExCandidate:
+ if depth > MAX_NESTING_DEPTH:
+ raise UnsupportedCandidateSyntaxError(
+ f"PromptRandomChoiceEx nesting exceeds {MAX_NESTING_DEPTH} levels."
+ )
+
+ forced = allow_force and raw_candidate.startswith("!")
+ candidate = raw_candidate[1:].strip() if forced else raw_candidate
+ if forced and not candidate:
+ raise UnsupportedCandidateSyntaxError(
+ "A forced candidate must not be empty. Use '!()' to force an empty string."
+ )
+
+ group = _find_direct_group(candidate)
+ if group is None:
+ leaf = _decode_leaf(candidate, forced=False)
+ return ExCandidate(prefix=leaf, suffix="")
+
+ start, end = group
+ prefix = candidate[:start]
+ suffix = candidate[end + 1 :]
+ child_text = candidate[start + 1 : end]
+ children = _parse_ex_pool(child_text, allow_force=allow_force, depth=depth + 1)
+ if not children.candidates:
+ children = ExCandidatePool((ExCandidate(prefix="", suffix=""),))
+ return ExCandidate(prefix=prefix, suffix=suffix, children=children)
+
+
+def _parse_ex_pool(
+ options_text: str,
+ *,
+ allow_force: bool,
+ depth: int,
+) -> ExCandidatePool:
+ parts = split_candidate_parts(options_text)
+ active_candidates: list[ExCandidate] = []
+ first_forced_candidate: ExCandidate | None = None
+
+ for part in parts:
+ if part.startswith("#"):
+ continue
+ is_forced = allow_force and part.startswith("!")
+ candidate = _parse_ex_candidate(
+ part,
+ allow_force=allow_force,
+ depth=depth,
+ )
+ if is_forced:
+ if first_forced_candidate is None:
+ first_forced_candidate = candidate
+ else:
+ active_candidates.append(candidate)
+
+ candidates = (
+ (first_forced_candidate,)
+ if first_forced_candidate is not None
+ else tuple(active_candidates)
+ )
+ pool = ExCandidatePool(candidates, forced=first_forced_candidate is not None)
+ if pool.leaf_count > MAX_LEAF_OPTIONS:
+ raise UnsupportedCandidateSyntaxError(
+ "PromptRandomChoiceEx contains too many leaf candidates "
+ f"({pool.leaf_count} > {MAX_LEAF_OPTIONS})."
+ )
+ return pool
+
+
+def build_ex_pool(options_text: str, *, allow_force: bool) -> ExCandidatePool:
+ return _parse_ex_pool(options_text, allow_force=allow_force, depth=0)
+
+
+def classify_runtime_text(options_text: str, *, is_ex: bool) -> tuple[str, str]:
+ try:
+ if is_ex:
+ build_ex_pool(options_text, allow_force=True)
+ else:
+ build_flat_pool(options_text, allow_force=True)
+ except IncompleteCandidateSyntaxError as exc:
+ return "incomplete", str(exc)
+ except UnsupportedCandidateSyntaxError as exc:
+ return "hard_error", str(exc)
+ return "valid", ""
diff --git a/nodes.py b/nodes.py
index 7101474..b9a431d 100644
--- a/nodes.py
+++ b/nodes.py
@@ -1,7 +1,24 @@
+from __future__ import annotations
+
import re
import secrets
import time
+try:
+ from .candidate_parser import (
+ UnsupportedCandidateSyntaxError,
+ build_ex_pool,
+ build_flat_pool,
+ )
+ from .runtime_state import RUNTIME_CANDIDATE_STATE_STORE
+except ImportError:
+ from candidate_parser import (
+ UnsupportedCandidateSyntaxError,
+ build_ex_pool,
+ build_flat_pool,
+ )
+ from runtime_state import RUNTIME_CANDIDATE_STATE_STORE
+
DEFAULT_OPTIONS_TEXT = """town,
girl's room,
@@ -28,110 +45,22 @@ amusement park{ferris wheel|carousel|balloons}
birthday party
}"""
-MAX_EXPAND_STEPS = 64
-MAX_EXPANDED_OPTIONS = 4096
MAX_SAFE_SEED = 2**53 - 1
+CATEGORY = "Prompt Random Choice"
+WINDOWS_RESERVED_NAMES = {
+ "CON",
+ "PRN",
+ "AUX",
+ "NUL",
+ *(f"COM{index}" for index in range(1, 10)),
+ *(f"LPT{index}" for index in range(1, 10)),
+}
-def _split_options(options_text: str):
- """
- 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:
- 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
-
- options.append(item)
-
- return options
-
-
-def _normalize_prompt_fragment(text: str):
- """
- Normalize comma-connected prompt fragments after nested expansion.
-
- Standalone "()" fragments are removed here, which enables:
- (){white day|wedding ceremony}
- to become:
- white day
- wedding ceremony
-
- This also keeps the syntax forgiving:
- zoo{aquarium,{fish|jellyfish}}
- becomes:
- zoo, aquarium, fish
- """
- 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)
-
- # Split comma-connected fragments, remove empty fragments and explicit
- # empty marker fragments, then join them in a prompt-friendly form.
- parts = []
- for part in value.split(","):
- item = part.strip()
- if not item:
- continue
- if item == "()":
- continue
- parts.append(item)
-
- return ", ".join(parts)
-
-
-def _safe_text(value: str):
+def _safe_text(value: str) -> str:
text = str(value or "")
-
if not text:
return "empty"
-
text = text.replace("\\", "/").replace("/", "_")
text = re.sub(r'[<>:"|?*\x00-\x1f]', "_", text)
text = re.sub(r"[\s,]+", "_", text)
@@ -139,178 +68,120 @@ def _safe_text(value: str):
text = text.replace("{", "_").replace("}", "_")
text = re.sub(r"_+", "_", text)
text = text.strip().strip("._-")
-
- return text or "empty"
+ if not text:
+ return "empty"
+ if text.upper() in WINDOWS_RESERVED_NAMES:
+ text = f"_{text}"
+ return text[:200].rstrip(" .") or "empty"
-def _find_first_block(text: str):
- """
- Return (start, end) for the first balanced {...} block.
-
- If braces are unmatched, return None and leave the text as literal.
- """
- value = str(text or "")
- start = value.find("{")
- if start < 0:
- return None
-
- depth = 0
- for index in range(start, len(value)):
- char = value[index]
- if char == "{":
- depth += 1
- elif char == "}":
- depth -= 1
- if depth == 0:
- return start, index
-
- return None
+def _options_input(default: str) -> tuple[str, dict]:
+ return (
+ "STRING",
+ {"multiline": True, "default": default, "dynamicPrompts": False},
+ )
-def _expand_all_nested_choices(option_text: str):
- """
- Expand one top-level candidate into all final leaf candidates.
-
- Ex uses leaf-uniform probability:
- 1. expand all nested {...} branches first
- 2. choose one item from the expanded leaf list
-
- Example:
- town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
-
- becomes:
- town
- zoo, animals, birds
- zoo, animals, penguins
- zoo, aquarium, fish
- zoo, aquarium, jellyfish
-
- Empty parent groups are supported:
- (){white day|wedding ceremony}
-
- becomes:
- white day
- wedding ceremony
- """
-
- def expand_recursive(value: str, depth: int):
- if depth > MAX_EXPAND_STEPS:
- raise ValueError(
- "PromptRandomChoiceEx expansion exceeded the maximum expansion steps. "
- "Please check nested braces."
- )
-
- block = _find_first_block(value)
- if block is None:
- return [_normalize_prompt_fragment(value)]
-
- start, end = block
- prefix = value[:start]
- body = value[start + 1:end]
- suffix = value[end + 1:]
-
- inner_options = _split_options(body)
- if not inner_options:
- inner_options = [""]
-
- results = []
- for inner in inner_options:
- inner_expanded_options = expand_recursive(inner, depth + 1)
-
- for inner_expanded in inner_expanded_options:
- replacement = f", {inner_expanded}" if inner_expanded else ""
- candidate = prefix + replacement + suffix
- expanded_candidates = expand_recursive(candidate, depth + 1)
-
- for expanded in expanded_candidates:
- results.append(expanded)
- if len(results) > MAX_EXPANDED_OPTIONS:
- raise ValueError(
- "PromptRandomChoiceEx expanded too many options "
- f"({len(results)} > {MAX_EXPANDED_OPTIONS})."
- )
-
- return results
-
- initial = str(option_text or "").strip(" \t\r\n,")
- return expand_recursive(initial, 0)
+def _change_every_input() -> tuple[str, dict]:
+ return (
+ "INT",
+ {"default": 1, "min": 1, "max": 999999, "step": 1},
+ )
-def _build_expanded_options(options_text: str):
- top_options = _split_options(options_text)
- expanded_options = []
-
- for option in top_options:
- for expanded in _expand_all_nested_choices(option):
- # Keep empty results because they can come from explicit ().
- 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)
+def _state_key_input() -> tuple[str, dict]:
+ return (
+ "STRING",
+ {"default": "", "multiline": False, "dynamicPrompts": False},
+ )
class _RandomChoiceStateMixin:
def __init__(self):
- self._current_choice = None
+ self._current_choice: str | None = None
self._repeat_index = 0
- self._last_options_key = None
- self._last_change_every = None
+ self._last_source_key: str | None = None
+ self._last_change_every: int | None = None
+ self._selected_revision = 0
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time_ns()
- def _reset_state(self):
+ def _reset_selection(self):
self._current_choice = None
self._repeat_index = 0
- self._last_options_key = None
- self._last_change_every = None
+ self._selected_revision = 0
- def _select_new_choice(self, options):
- self._current_choice = secrets.choice(options)
+ def _select_new_choice(self, pool, selected_revision: int):
+ self._current_choice = pool.choose()
self._repeat_index = 1
+ self._selected_revision = selected_revision
- def _run_choice(self, options, change_every):
+ def _run_choice(
+ self,
+ pool,
+ change_every,
+ *,
+ source_key: str,
+ reset_on_source_change: bool,
+ latest_revision: int = 0,
+ ):
change_every = max(1, int(change_every))
- options = tuple(options)
- options_key = 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
- or self._current_choice not in options
+ source_changed = self._last_source_key != source_key
+ interval_changed = (
+ self._last_change_every is not None
+ and self._last_change_every != change_every
)
+ needs_selection = self._current_choice is None or interval_changed
+ if reset_on_source_change and source_changed:
+ needs_selection = True
- 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)
+ if not needs_selection:
+ if self._repeat_index < change_every:
+ self._repeat_index += 1
+ else:
+ needs_selection = True
- self._last_options_key = options_key
+ self._last_source_key = source_key
self._last_change_every = change_every
+ if needs_selection:
+ if pool.leaf_count == 0:
+ self._reset_selection()
+ return "", "empty", "(empty)", 0, change_every, 0
+ self._select_new_choice(pool, latest_revision)
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,
+ _safe_text(selected_text),
+ selected_text if selected_text else "(empty)",
+ self._repeat_index,
+ change_every,
+ self._selected_revision,
+ )
+
+ @staticmethod
+ def _format_result(choice_result, *, latest_revision=0, runtime_status=""):
+ (
selected_text,
selected_text_safe,
selected_text_title,
- self._repeat_index,
+ repeat_index,
change_every,
- )
+ selected_revision,
+ ) = choice_result
+ return {
+ "ui": {
+ "selected_text_title": [selected_text_title],
+ "repeat_index": [repeat_index],
+ "change_every": [change_every],
+ "latest_revision": [latest_revision],
+ "selected_revision": [selected_revision],
+ "runtime_status": [runtime_status],
+ },
+ "result": (selected_text, selected_text_safe),
+ }
class PromptRandomChoice(_RandomChoiceStateMixin):
@@ -318,136 +189,147 @@ class PromptRandomChoice(_RandomChoiceStateMixin):
def INPUT_TYPES(cls):
return {
"required": {
- "options_text": (
- "STRING",
- {
- "multiline": True,
- "default": DEFAULT_OPTIONS_TEXT,
- },
- ),
- "change_every": (
- "INT",
- {
- "default": 1,
- "min": 1,
- "max": 999999,
- "step": 1,
- },
- ),
- },
+ "options_text": _options_input(DEFAULT_OPTIONS_TEXT),
+ "change_every": _change_every_input(),
+ }
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("selected_text", "selected_text_safe")
FUNCTION = "pick"
- CATEGORY = "utils/prompt"
+ CATEGORY = CATEGORY
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,
+ source = str(options_text or "")
+ result = self._run_choice(
+ build_flat_pool(source, allow_force=False),
change_every,
+ source_key=source,
+ reset_on_source_change=True,
)
-
- return {
- "ui": {
- "selected_text_title": [selected_text_title],
- "repeat_index": [repeat_index],
- "change_every": [change_every],
- },
- "result": (selected_text, selected_text_safe),
- }
+ return self._format_result(result)
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 {
"required": {
- "options_text": (
- "STRING",
- {
- "multiline": True,
- "default": DEFAULT_OPTIONS_TEXT_EX,
- },
- ),
- "change_every": (
- "INT",
- {
- "default": 1,
- "min": 1,
- "max": 999999,
- "step": 1,
- },
- ),
- },
+ "options_text": _options_input(DEFAULT_OPTIONS_TEXT_EX),
+ "change_every": _change_every_input(),
+ }
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("selected_text", "selected_text_safe")
FUNCTION = "pick"
- CATEGORY = "utils/prompt"
-
- def _reset_state(self):
- super()._reset_state()
- # Keep the expansion cache; it depends only on options_text.
- # Selection state and expansion cache have different lifecycles.
-
- 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
+ CATEGORY = CATEGORY
def pick(self, options_text, change_every):
- options = self._get_expanded_options_cached(options_text)
- selected_text, selected_text_safe, selected_text_title, repeat_index, change_every = self._run_choice(
- options,
+ source = str(options_text or "")
+ result = self._run_choice(
+ build_ex_pool(source, allow_force=False),
change_every,
+ source_key=source,
+ reset_on_source_change=True,
+ )
+ return self._format_result(result)
+
+
+class _RuntimeRandomChoiceMixin(_RandomChoiceStateMixin):
+ IS_EX = False
+ STATE_KEY_PREFIX = "prc-runtime"
+
+ @classmethod
+ def IS_CHANGED(cls, **kwargs):
+ return float("NaN")
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ default = DEFAULT_OPTIONS_TEXT_EX if cls.IS_EX else DEFAULT_OPTIONS_TEXT
+ return {
+ "required": {
+ "options_text": _options_input(default),
+ "change_every": _change_every_input(),
+ "state_key": _state_key_input(),
+ },
+ "hidden": {"unique_id": "UNIQUE_ID"},
+ }
+
+ def _resolve_state_key(self, state_key, unique_id) -> str:
+ key = state_key.strip() if isinstance(state_key, str) else ""
+ kind = "ex" if self.IS_EX else "flat"
+ return key or f"{self.STATE_KEY_PREFIX}-{kind}-{unique_id}"
+
+ def _runtime_source(self, options_text, state_key, unique_id):
+ key = self._resolve_state_key(state_key, unique_id)
+ state = RUNTIME_CANDIDATE_STATE_STORE.get(key)
+ if state is None:
+ return str(options_text or ""), 0, "fallback"
+ expected_kind = "ex" if self.IS_EX else "flat"
+ if state.node_kind != expected_kind:
+ raise ValueError("Runtime state belongs to another node kind.")
+ if state.input_status == "hard_error":
+ raise UnsupportedCandidateSyntaxError(state.error)
+ if state.accepted_text is not None:
+ return state.accepted_text, state.revision, state.input_status
+ return str(options_text or ""), 0, state.input_status
+
+ def pick(self, options_text, change_every, state_key="", unique_id=None):
+ source, latest_revision, runtime_status = self._runtime_source(
+ options_text, state_key, unique_id
+ )
+ pool = (
+ build_ex_pool(source, allow_force=True)
+ if self.IS_EX
+ else build_flat_pool(source, allow_force=True)
+ )
+ result = self._run_choice(
+ pool,
+ change_every,
+ source_key=source,
+ reset_on_source_change=False,
+ latest_revision=latest_revision,
+ )
+ return self._format_result(
+ result,
+ latest_revision=latest_revision,
+ runtime_status=runtime_status,
)
- return {
- "ui": {
- "selected_text_title": [selected_text_title],
- "repeat_index": [repeat_index],
- "change_every": [change_every],
- },
- "result": (selected_text, selected_text_safe),
- }
+
+class RuntimePromptRandomChoice(_RuntimeRandomChoiceMixin):
+ IS_EX = False
+ RETURN_TYPES = ("STRING", "STRING")
+ RETURN_NAMES = ("selected_text", "selected_text_safe")
+ FUNCTION = "pick"
+ CATEGORY = CATEGORY
+
+
+class RuntimePromptRandomChoiceEx(_RuntimeRandomChoiceMixin):
+ IS_EX = True
+ RETURN_TYPES = ("STRING", "STRING")
+ RETURN_NAMES = ("selected_text", "selected_text_safe")
+ FUNCTION = "pick"
+ CATEGORY = CATEGORY
class SafeRandomSeed:
@classmethod
def INPUT_TYPES(cls):
- return {
- "required": {},
- }
+ return {"required": {}}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "generate"
- CATEGORY = "utils/random"
+ CATEGORY = CATEGORY
@classmethod
def IS_CHANGED(cls, **kwargs):
- # Force re-execution for each queued run.
return secrets.token_hex(16)
def generate(self):
seed = secrets.randbelow(MAX_SAFE_SEED + 1)
-
return {
"ui": {
"seed_title": [f"Seed: {seed}"],
@@ -460,11 +342,15 @@ class SafeRandomSeed:
NODE_CLASS_MAPPINGS = {
"PromptRandomChoice": PromptRandomChoice,
"PromptRandomChoiceEx": PromptRandomChoiceEx,
+ "RuntimePromptRandomChoice": RuntimePromptRandomChoice,
+ "RuntimePromptRandomChoiceEx": RuntimePromptRandomChoiceEx,
"SafeRandomSeed": SafeRandomSeed,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptRandomChoice": "Prompt Random Choice",
"PromptRandomChoiceEx": "Prompt Random Choice Ex",
+ "RuntimePromptRandomChoice": "Runtime Prompt Random Choice",
+ "RuntimePromptRandomChoiceEx": "Runtime Prompt Random Choice Ex",
"SafeRandomSeed": "Safe Random Seed",
}
diff --git a/pyproject.toml b/pyproject.toml
index 28b3fc6..bb8d03f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "prompt-random-choice"
-version = "0.5.0"
-description = "A ComfyUI custom node that randomly selects one item from a list of prompt suggestions each time it is executed."
+version = "0.6.0"
+description = "A ComfyUI custom node for random and runtime selection from prompt candidate lists, designed to pair with ComfyUI-StringJoinTools for live prompt composition during queued generation."
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
diff --git a/runtime_state.py b/runtime_state.py
new file mode 100644
index 0000000..03c17ef
--- /dev/null
+++ b/runtime_state.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+from threading import RLock
+from time import time
+from typing import Optional
+
+
+MAX_RUNTIME_TEXT_BYTES = 512 * 1024
+VALID_STATUSES = frozenset({"valid", "incomplete", "hard_error"})
+
+
+class StaleRuntimeCandidateUpdate(ValueError):
+ """Raised when one browser session sends an obsolete update."""
+
+
+@dataclass(frozen=True)
+class RuntimeCandidateState:
+ state_key: str
+ node_kind: str
+ input_text: str
+ input_status: str
+ error: str
+ accepted_text: Optional[str]
+ revision: int
+ client_id: str
+ client_sequence: int
+ updated_at: float
+
+ def to_dict(self) -> dict:
+ data = asdict(self)
+ data.pop("client_id", None)
+ data.pop("client_sequence", None)
+ return data
+
+
+class RuntimeCandidateStateStore:
+ def __init__(self) -> None:
+ self._lock = RLock()
+ self._states: dict[str, RuntimeCandidateState] = {}
+ self._client_updates: dict[tuple[str, str], tuple[int, str, str]] = {}
+
+ @staticmethod
+ def _validate(
+ *,
+ state_key: str,
+ node_kind: str,
+ text: str,
+ input_status: str,
+ error: str,
+ client_id: str,
+ client_sequence: int,
+ ) -> tuple[str, str, str, str, str, str, int]:
+ if not isinstance(state_key, str):
+ raise TypeError("state_key must be a string")
+ key = state_key.strip()
+ if not key:
+ raise ValueError("state_key must not be empty")
+ if len(key) > 256:
+ raise ValueError("state_key must be at most 256 characters")
+
+ if node_kind not in {"flat", "ex"}:
+ raise ValueError("node_kind must be 'flat' or 'ex'")
+ if not isinstance(text, str):
+ raise TypeError("text must be a string")
+ if len(text.encode("utf-8")) > MAX_RUNTIME_TEXT_BYTES:
+ raise ValueError(
+ f"text must be at most {MAX_RUNTIME_TEXT_BYTES} UTF-8 bytes"
+ )
+ if input_status not in VALID_STATUSES:
+ raise ValueError("input_status is invalid")
+ if not isinstance(error, str):
+ raise TypeError("error must be a string")
+ message = error.strip()
+ if input_status == "valid" and message:
+ raise ValueError("valid input must not include an error")
+ if input_status != "valid" and not message:
+ raise ValueError("invalid input must include an error")
+
+ if not isinstance(client_id, str):
+ raise TypeError("client_id must be a string")
+ session = client_id.strip()
+ if not session:
+ raise ValueError("client_id must not be empty")
+ if len(session) > 128:
+ raise ValueError("client_id must be at most 128 characters")
+ if isinstance(client_sequence, bool) or not isinstance(client_sequence, int):
+ raise TypeError("client_sequence must be an integer")
+ if client_sequence < 0:
+ raise ValueError("client_sequence must not be negative")
+
+ return key, node_kind, text, input_status, message, session, client_sequence
+
+ def update(
+ self,
+ *,
+ state_key: str,
+ node_kind: str,
+ text: str,
+ input_status: str,
+ error: str,
+ client_id: str,
+ client_sequence: int,
+ ) -> RuntimeCandidateState:
+ key, kind, value, status, message, session, sequence = self._validate(
+ state_key=state_key,
+ node_kind=node_kind,
+ text=text,
+ input_status=input_status,
+ error=error,
+ client_id=client_id,
+ client_sequence=client_sequence,
+ )
+
+ signature = (value, kind)
+ with self._lock:
+ previous = self._states.get(key)
+ if previous is not None and previous.node_kind != kind:
+ raise ValueError("state_key is already used by another Runtime node kind")
+
+ previous_client_update = self._client_updates.get((key, session))
+ if previous_client_update is not None:
+ previous_sequence, previous_text, previous_kind = previous_client_update
+ if sequence < previous_sequence:
+ raise StaleRuntimeCandidateUpdate(
+ "client_sequence is older than the accepted update"
+ )
+ if sequence == previous_sequence:
+ if signature != (previous_text, previous_kind):
+ raise StaleRuntimeCandidateUpdate(
+ "client_sequence was already used for different text"
+ )
+ if (
+ previous is not None
+ and previous.client_id == session
+ and previous.input_text == value
+ ):
+ return previous
+ raise StaleRuntimeCandidateUpdate(
+ "update was already superseded by another client"
+ )
+
+ accepted_text = previous.accepted_text if previous is not None else None
+ revision = previous.revision if previous is not None else 0
+ if status == "valid":
+ if accepted_text != value:
+ accepted_text = value
+ revision += 1
+
+ state = RuntimeCandidateState(
+ state_key=key,
+ node_kind=kind,
+ input_text=value,
+ input_status=status,
+ error=message,
+ accepted_text=accepted_text,
+ revision=revision,
+ client_id=session,
+ client_sequence=sequence,
+ updated_at=time(),
+ )
+ self._states[key] = state
+ self._client_updates[(key, session)] = (sequence, value, kind)
+ return state
+
+ def get(self, state_key: str) -> Optional[RuntimeCandidateState]:
+ if not isinstance(state_key, str):
+ return None
+ key = state_key.strip()
+ if not key:
+ return None
+ with self._lock:
+ return self._states.get(key)
+
+ def clear(self, state_key: str) -> bool:
+ if not isinstance(state_key, str):
+ return False
+ key = state_key.strip()
+ if not key:
+ return False
+ with self._lock:
+ removed = self._states.pop(key, None) is not None
+ for client_key in [
+ client_key
+ for client_key in self._client_updates
+ if client_key[0] == key
+ ]:
+ self._client_updates.pop(client_key, None)
+ return removed
+
+
+RUNTIME_CANDIDATE_STATE_STORE = RuntimeCandidateStateStore()
diff --git a/server_routes.py b/server_routes.py
new file mode 100644
index 0000000..23d2dd4
--- /dev/null
+++ b/server_routes.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+try:
+ from aiohttp import web
+ from server import PromptServer
+except ImportError:
+ web = None
+ PromptServer = None
+
+try:
+ from .candidate_parser import classify_runtime_text
+ from .runtime_state import (
+ RUNTIME_CANDIDATE_STATE_STORE,
+ StaleRuntimeCandidateUpdate,
+ )
+except ImportError:
+ from candidate_parser import classify_runtime_text
+ from runtime_state import (
+ RUNTIME_CANDIDATE_STATE_STORE,
+ StaleRuntimeCandidateUpdate,
+ )
+
+
+if PromptServer is not None and web is not None:
+
+ @PromptServer.instance.routes.post(
+ "/prompt_random_choice/runtime_candidate_state"
+ )
+ async def update_runtime_candidate_state(request):
+ try:
+ data = await request.json()
+ if not isinstance(data, dict):
+ raise TypeError("request body must be a JSON object")
+ node_kind = data.get("node_kind", "")
+ text = data.get("text")
+ if not isinstance(text, str):
+ raise TypeError("text must be a string")
+ status, error = classify_runtime_text(text, is_ex=node_kind == "ex")
+ state = RUNTIME_CANDIDATE_STATE_STORE.update(
+ state_key=data.get("state_key", ""),
+ node_kind=node_kind,
+ text=text,
+ input_status=status,
+ error=error,
+ client_id=data.get("client_id", ""),
+ client_sequence=data.get("client_sequence"),
+ )
+ return web.json_response({"ok": True, "state": state.to_dict()})
+ except StaleRuntimeCandidateUpdate as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=409)
+ except (TypeError, ValueError) as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=400)
+ except Exception as exc:
+ return web.json_response(
+ {"ok": False, "error": f"Unexpected Runtime state error: {exc}"},
+ status=500,
+ )
+
+ @PromptServer.instance.routes.get(
+ "/prompt_random_choice/runtime_candidate_state/{state_key}"
+ )
+ async def read_runtime_candidate_state(request):
+ state_key = request.match_info.get("state_key", "")
+ state = RUNTIME_CANDIDATE_STATE_STORE.get(state_key)
+ if state is None:
+ return web.json_response(
+ {"ok": False, "error": "Runtime candidate state not found"},
+ status=404,
+ )
+ return web.json_response({"ok": True, "state": state.to_dict()})
diff --git a/tests/runtime_layout.test.mjs b/tests/runtime_layout.test.mjs
new file mode 100644
index 0000000..21cef7e
--- /dev/null
+++ b/tests/runtime_layout.test.mjs
@@ -0,0 +1,16 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const source = readFileSync(
+ new URL("../web/runtime_prompt_random_choice.js", import.meta.url),
+ "utf8",
+);
+
+test("Runtime editor uses resizable DOM widget layout options", () => {
+ assert.doesNotMatch(source, /domWidget\.computeSize\s*=/);
+ assert.match(source, /getMinHeight:\s*\(\)\s*=>\s*96/);
+ assert.match(source, /getHeight:\s*\(\)\s*=>\s*"100%"/);
+ assert.match(source, /afterResize:/);
+ assert.match(source, /flex:\s*"1 1 auto"/);
+});
diff --git a/tests/test_prompt_random_choice.py b/tests/test_prompt_random_choice.py
new file mode 100644
index 0000000..d9f8141
--- /dev/null
+++ b/tests/test_prompt_random_choice.py
@@ -0,0 +1,294 @@
+import math
+import unittest
+from unittest.mock import patch
+
+from candidate_parser import (
+ IncompleteCandidateSyntaxError,
+ UnsupportedCandidateSyntaxError,
+ build_ex_pool,
+ build_flat_pool,
+ classify_runtime_text,
+)
+from nodes import (
+ NODE_CLASS_MAPPINGS,
+ PromptRandomChoice,
+ PromptRandomChoiceEx,
+ RuntimePromptRandomChoice,
+ RuntimePromptRandomChoiceEx,
+ _safe_text,
+)
+from runtime_state import (
+ RUNTIME_CANDIDATE_STATE_STORE,
+ RuntimeCandidateStateStore,
+ StaleRuntimeCandidateUpdate,
+)
+
+
+def update_runtime_state(
+ *, key, text, kind="flat", sequence=1, client="test-client"
+):
+ status, error = classify_runtime_text(text, is_ex=kind == "ex")
+ return RUNTIME_CANDIDATE_STATE_STORE.update(
+ state_key=key,
+ node_kind=kind,
+ text=text,
+ input_status=status,
+ error=error,
+ client_id=client,
+ client_sequence=sequence,
+ )
+
+
+class CandidateParserTests(unittest.TestCase):
+ def test_flat_comments_and_non_runtime_bang_are_literal(self):
+ pool = build_flat_pool("# disabled\n!literal\nnormal", allow_force=False)
+ self.assertEqual(pool.options, ("!literal", "normal"))
+
+ def test_runtime_flat_uses_first_forced_candidate(self):
+ pool = build_flat_pool("!first\nnormal\n!second", allow_force=True)
+ self.assertEqual(pool.options, ("first",))
+ self.assertTrue(pool.forced)
+
+ def test_runtime_forced_empty_and_bare_bang(self):
+ self.assertEqual(build_flat_pool("normal\n!()", allow_force=True).options, ("",))
+ with self.assertRaisesRegex(UnsupportedCandidateSyntaxError, "forced candidate"):
+ build_flat_pool("normal\n!", allow_force=True)
+
+ def test_double_bang_removes_only_control_marker(self):
+ self.assertEqual(
+ build_flat_pool("!!candidate", allow_force=True).options,
+ ("!candidate",),
+ )
+
+ def test_all_commented_candidates_produce_empty_pool(self):
+ self.assertEqual(build_flat_pool("# first\n# second", allow_force=False).leaf_count, 0)
+ self.assertEqual(build_ex_pool("# first\n# second", allow_force=False).leaf_count, 0)
+
+ def test_ex_filters_each_level_and_keeps_leaf_uniform_paths(self):
+ pool = build_ex_pool("A{x|y|z}\nB{!p|q}", allow_force=True)
+ self.assertEqual(pool.leaf_count, 4)
+ self.assertEqual(
+ [pool.select_by_index(index) for index in range(4)],
+ ["A, x", "A, y", "A, z", "B, p"],
+ )
+
+ def test_ex_comments_and_first_force_apply_recursively(self):
+ pool = build_ex_pool(
+ "!A{#x|!y|!z}\n!B{p}",
+ allow_force=True,
+ )
+ self.assertEqual(pool.leaf_count, 1)
+ self.assertEqual(pool.select_by_index(0), "A, y")
+
+ def test_runtime_ex_forced_empty_is_a_leaf(self):
+ pool = build_ex_pool("A{x|!()|y}", allow_force=True)
+ self.assertEqual(pool.leaf_count, 1)
+ self.assertEqual(pool.select_by_index(0), "A")
+
+ def test_ex_multiple_parents_and_vertical_nesting(self):
+ pool = build_ex_pool(
+ "black hair{straight long hair|short cut}\n"
+ "blonde hair{long hair{side braid|black ribbon}|wavy long hair}",
+ allow_force=False,
+ )
+ self.assertEqual(
+ [pool.select_by_index(index) for index in range(pool.leaf_count)],
+ [
+ "black hair, straight long hair",
+ "black hair, short cut",
+ "blonde hair, long hair, side braid",
+ "blonde hair, long hair, black ribbon",
+ "blonde hair, wavy long hair",
+ ],
+ )
+
+ def test_non_runtime_ex_keeps_bang_as_text(self):
+ pool = build_ex_pool("!A{x|y}", allow_force=False)
+ self.assertEqual(
+ [pool.select_by_index(index) for index in range(2)],
+ ["!A, x", "!A, y"],
+ )
+
+ def test_adjacent_direct_groups_are_rejected_before_selection(self):
+ with self.assertRaises(UnsupportedCandidateSyntaxError) as context:
+ build_ex_pool("scene{day|night}{clear|rain}", allow_force=False)
+ message = str(context.exception)
+ self.assertIn("only one direct child group", message)
+ self.assertIn("Cartesian product", message)
+ self.assertIn("^", message)
+ self.assertIn("String Join", message)
+
+ def test_incomplete_braces_are_distinguished(self):
+ for text in ("A{x|y", "A{x|y}}"):
+ with self.subTest(text=text):
+ with self.assertRaises(IncompleteCandidateSyntaxError):
+ build_ex_pool(text, allow_force=False)
+ self.assertEqual(classify_runtime_text(text, is_ex=True)[0], "incomplete")
+
+ def test_adjacent_groups_classify_as_hard_error(self):
+ status, error = classify_runtime_text("scene{day|night}{clear|rain}", is_ex=True)
+ self.assertEqual(status, "hard_error")
+ self.assertIn("Cartesian product", error)
+
+ def test_hard_error_is_detected_even_in_branch_overridden_by_force(self):
+ status, error = classify_runtime_text(
+ "!A{x}\nB{day|night}{clear|rain}",
+ is_ex=True,
+ )
+ self.assertEqual(status, "hard_error")
+ self.assertIn("Cartesian product", error)
+
+
+class RuntimeNodeTests(unittest.TestCase):
+ def tearDown(self):
+ for key in (
+ "runtime-hold",
+ "runtime-incomplete",
+ "runtime-hard-error",
+ "runtime-hard-recovery",
+ "runtime-empty",
+ ):
+ RUNTIME_CANDIDATE_STATE_STORE.clear(key)
+
+ def test_edit_does_not_interrupt_change_every(self):
+ key = "runtime-hold"
+ update_runtime_state(key=key, text="black hair\nblonde hair", sequence=1)
+ node = RuntimePromptRandomChoice()
+ with patch("candidate_parser.secrets.choice", side_effect=lambda values: values[0]):
+ first = node.pick("queued", 3, key, "1")
+ update_runtime_state(key=key, text="!blonde hair", sequence=2)
+ second = node.pick("queued", 3, key, "1")
+ third = node.pick("queued", 3, key, "1")
+ fourth = node.pick("queued", 3, key, "1")
+
+ self.assertEqual(first["result"][0], "black hair")
+ self.assertEqual(
+ [result["result"][0] for result in (second, third, fourth)],
+ ["black hair", "black hair", "blonde hair"],
+ )
+ self.assertEqual(second["ui"]["latest_revision"], [2])
+ self.assertEqual(second["ui"]["selected_revision"], [1])
+ self.assertEqual(fourth["ui"]["selected_revision"], [2])
+
+ def test_incomplete_uses_last_valid_revision(self):
+ key = "runtime-incomplete"
+ update_runtime_state(key=key, text="A{x|y}", kind="ex", sequence=1)
+ state = update_runtime_state(key=key, text="A{x|y", kind="ex", sequence=2)
+ self.assertEqual(state.input_status, "incomplete")
+ self.assertEqual(state.accepted_text, "A{x|y}")
+ self.assertEqual(state.revision, 1)
+ result = RuntimePromptRandomChoiceEx().pick("fallback", 1, key, "1")
+ self.assertIn(result["result"][0], {"A, x", "A, y"})
+ self.assertEqual(result["ui"]["runtime_status"], ["incomplete"])
+
+ def test_hard_error_never_falls_back(self):
+ key = "runtime-hard-error"
+ update_runtime_state(key=key, text="A{x|y}", kind="ex", sequence=1)
+ state = update_runtime_state(key=key, text="A{x|y}{p|q}", kind="ex", sequence=2)
+ self.assertEqual(state.input_status, "hard_error")
+ self.assertEqual(state.accepted_text, "A{x|y}")
+ with self.assertRaisesRegex(UnsupportedCandidateSyntaxError, "Cartesian product"):
+ RuntimePromptRandomChoiceEx().pick("fallback", 3, key, "1")
+
+ def test_runtime_nodes_always_invalidate_cache(self):
+ self.assertTrue(math.isnan(RuntimePromptRandomChoice.IS_CHANGED()))
+ self.assertTrue(math.isnan(RuntimePromptRandomChoiceEx.IS_CHANGED()))
+
+ def test_hard_error_clears_after_valid_text_is_accepted(self):
+ key = "runtime-hard-recovery"
+ update_runtime_state(key=key, text="A{x}", kind="ex", sequence=1)
+ update_runtime_state(key=key, text="A{x}{y}", kind="ex", sequence=2)
+ recovered = update_runtime_state(key=key, text="B{z}", kind="ex", sequence=3)
+ self.assertEqual(recovered.input_status, "valid")
+ self.assertEqual(recovered.revision, 2)
+ self.assertEqual(
+ RuntimePromptRandomChoiceEx().pick("fallback", 1, key, "1")["result"][0],
+ "B, z",
+ )
+
+ def test_accepted_empty_text_overrides_queued_fallback(self):
+ key = "runtime-empty"
+ state = update_runtime_state(key=key, text="", sequence=1)
+ self.assertEqual(state.accepted_text, "")
+ self.assertEqual(
+ RuntimePromptRandomChoice().pick("queued candidate", 1, key, "1")["result"],
+ ("", "empty"),
+ )
+
+
+class StateAndCompatibilityTests(unittest.TestCase):
+ def test_revisions_advance_only_for_valid_text(self):
+ store = RuntimeCandidateStateStore()
+ valid = store.update(
+ state_key="revision", node_kind="ex", text="A{x|y}",
+ input_status="valid", error="", client_id="browser", client_sequence=1,
+ )
+ incomplete = store.update(
+ state_key="revision", node_kind="ex", text="A{x|y",
+ input_status="incomplete", error="Unmatched opening brace",
+ client_id="browser", client_sequence=2,
+ )
+ hard = store.update(
+ state_key="revision", node_kind="ex", text="A{x}{y}",
+ input_status="hard_error", error="Cartesian product",
+ client_id="browser", client_sequence=3,
+ )
+ self.assertEqual((valid.revision, incomplete.revision, hard.revision), (1, 1, 1))
+ self.assertEqual(incomplete.accepted_text, "A{x|y}")
+ self.assertEqual(hard.accepted_text, "A{x|y}")
+
+ def test_regular_node_resets_when_candidates_change(self):
+ node = PromptRandomChoice()
+ with patch("candidate_parser.secrets.choice", side_effect=lambda values: values[0]):
+ self.assertEqual(node.pick("A\nB", 3)["result"][0], "A")
+ self.assertEqual(node.pick("C\nD", 3)["result"][0], "C")
+
+ def test_ex_node_result_shape(self):
+ self.assertEqual(
+ PromptRandomChoiceEx().pick("A{x}", 1)["result"],
+ ("A, x", "A_x"),
+ )
+
+ def test_safe_text_handles_device_names_and_length(self):
+ self.assertEqual(_safe_text("CON"), "_CON")
+ self.assertEqual(len(_safe_text("x" * 300)), 200)
+
+ def test_stale_runtime_update_is_rejected(self):
+ store = RuntimeCandidateStateStore()
+ common = {
+ "state_key": "ordered",
+ "node_kind": "flat",
+ "input_status": "valid",
+ "error": "",
+ "client_id": "browser",
+ }
+ store.update(text="A", client_sequence=2, **common)
+ with self.assertRaises(StaleRuntimeCandidateUpdate):
+ store.update(text="B", client_sequence=1, **common)
+
+ def test_node_mappings_and_runtime_inputs_are_stable(self):
+ self.assertEqual(
+ set(NODE_CLASS_MAPPINGS),
+ {
+ "PromptRandomChoice",
+ "PromptRandomChoiceEx",
+ "RuntimePromptRandomChoice",
+ "RuntimePromptRandomChoiceEx",
+ "SafeRandomSeed",
+ },
+ )
+ for node_class in (RuntimePromptRandomChoice, RuntimePromptRandomChoiceEx):
+ inputs = node_class.INPUT_TYPES()
+ self.assertIn("state_key", inputs["required"])
+ self.assertEqual(inputs["hidden"]["unique_id"], "UNIQUE_ID")
+ self.assertFalse(inputs["required"]["options_text"][1]["dynamicPrompts"])
+
+ def test_all_nodes_share_one_top_level_menu_category(self):
+ self.assertEqual(
+ {node_class.CATEGORY for node_class in NODE_CLASS_MAPPINGS.values()},
+ {"Prompt Random Choice"},
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/prompt_random_choice.js b/web/prompt_random_choice.js
index 2b29a5e..e5a15b0 100644
--- a/web/prompt_random_choice.js
+++ b/web/prompt_random_choice.js
@@ -4,6 +4,8 @@ const EXTENSION_NAME = "ruminar.PromptRandomChoice";
const NODE_NAMES = new Set([
"PromptRandomChoice",
"PromptRandomChoiceEx",
+ "RuntimePromptRandomChoice",
+ "RuntimePromptRandomChoiceEx",
"SafeRandomSeed",
]);
@@ -24,8 +26,14 @@ app.registerExtension({
}
const nodeName = String(nodeData?.name ?? "");
- const prefix = nodeName === "PromptRandomChoiceEx" ? "Ex" : "Ch";
- const maxTitleLength = nodeName === "PromptRandomChoiceEx" ? 36 : 40;
+ const prefixes = {
+ PromptRandomChoice: "Ch",
+ PromptRandomChoiceEx: "Ex",
+ RuntimePromptRandomChoice: "Rt",
+ RuntimePromptRandomChoiceEx: "RtEx",
+ };
+ const prefix = prefixes[nodeName] ?? "Ch";
+ const maxTitleLength = nodeName.includes("Ex") ? 36 : 40;
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
diff --git a/web/runtime_prompt_random_choice.js b/web/runtime_prompt_random_choice.js
new file mode 100644
index 0000000..7aaec36
--- /dev/null
+++ b/web/runtime_prompt_random_choice.js
@@ -0,0 +1,441 @@
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+
+const TARGETS = new Map([
+ ["RuntimePromptRandomChoice", "flat"],
+ ["RuntimePromptRandomChoiceEx", "ex"],
+]);
+const LIVE_ROUTE = "/prompt_random_choice/runtime_candidate_state";
+const DEBOUNCE_MS = 120;
+const MAX_TEXT_BYTES = 512 * 1024;
+const LIVE_BACKGROUND_TINT = "rgba(255, 210, 70, 0.1)";
+
+function widgetByName(node, name) {
+ return node.widgets?.find((widget) => widget.name === name);
+}
+
+function hideSavedWidget(widget) {
+ if (!widget) return;
+ if (!widget.__promptRandomChoiceHidden) {
+ widget.__promptRandomChoiceHidden = true;
+ widget.type = "hidden";
+ widget.hidden = true;
+ widget.disabled = true;
+ widget.serialize = true;
+ widget.options = { ...(widget.options ?? {}), hidden: true };
+ widget.computeSize = () => [0, -4];
+ widget.draw = () => {};
+ }
+
+ const elements = new Set([
+ widget.element,
+ widget.inputEl,
+ widget.domWidget?.element,
+ widget.options?.element,
+ ]);
+ for (const element of elements) {
+ if (!(element instanceof HTMLElement)) continue;
+ element.hidden = true;
+ element.setAttribute("aria-hidden", "true");
+ element.style.setProperty("display", "none", "important");
+ element.style.setProperty("visibility", "hidden", "important");
+ element.style.setProperty("pointer-events", "none", "important");
+ }
+}
+
+function randomId(prefix) {
+ if (globalThis.crypto?.randomUUID) {
+ return `${prefix}-${globalThis.crypto.randomUUID()}`;
+ }
+ return `${prefix}-${Date.now().toString(36)}-${Math.random()
+ .toString(36)
+ .slice(2)}`;
+}
+
+function nodeClass(node) {
+ return node.comfyClass ?? node.type;
+}
+
+function nodeKind(node) {
+ return TARGETS.get(nodeClass(node));
+}
+
+function ensureUniqueStateKey(node) {
+ const keyWidget = widgetByName(node, "state_key");
+ if (!keyWidget) return "";
+ let key = typeof keyWidget.value === "string" ? keyWidget.value.trim() : "";
+ const duplicate = (app.graph?._nodes ?? []).some((other) => {
+ const otherKey = widgetByName(other, "state_key")?.value;
+ return (
+ other !== node &&
+ TARGETS.has(nodeClass(other)) &&
+ typeof otherKey === "string" &&
+ otherKey.trim() === key
+ );
+ });
+ if (!key || duplicate) {
+ key = randomId(`prc-${nodeKind(node) ?? "runtime"}`);
+ keyWidget.value = key;
+ node.graph?.change?.();
+ }
+ return key;
+}
+
+function characterCount(value) {
+ return Array.from(value).length;
+}
+
+function utf8ByteCount(value) {
+ return new TextEncoder().encode(value).length;
+}
+
+function markWorkflowChanged(node) {
+ node.graph?.change?.();
+ node.setDirtyCanvas?.(true, false);
+}
+
+function setStatus(node, status, detail = "") {
+ const ui = node.__promptRandomChoiceRuntimeUI;
+ if (!ui) return;
+ ui.status.textContent = detail ? `${status} · ${detail}` : status;
+ ui.status.dataset.state = status;
+ ui.status.style.color =
+ status === "HARD ERROR" || status === "SYNC ERROR"
+ ? "rgba(255,155,135,0.98)"
+ : status === "LIVE"
+ ? "rgba(165,255,195,0.95)"
+ : status === "SYNCING"
+ ? "rgba(255,220,135,0.95)"
+ : "rgba(215,215,225,0.9)";
+}
+
+function setStateStatus(node, state) {
+ const status = state?.input_status;
+ if (status === "hard_error") {
+ setStatus(node, "HARD ERROR", state?.error ?? "unsupported syntax");
+ } else if (status === "incomplete") {
+ const revision = Number(state?.revision ?? 0);
+ setStatus(
+ node,
+ "EDITING / SYNTAX INCOMPLETE",
+ revision > 0
+ ? `using revision ${revision}`
+ : "no accepted revision yet",
+ );
+ } else {
+ const revision = Number(state?.revision ?? 0);
+ setStatus(node, "LIVE", `revision ${revision}`);
+ }
+}
+
+function renderCount(node, value) {
+ const ui = node.__promptRandomChoiceRuntimeUI;
+ if (!ui) return;
+ const bytes = utf8ByteCount(value);
+ ui.count.textContent =
+ bytes > MAX_TEXT_BYTES
+ ? `${characterCount(value)} characters · ${bytes} / ${MAX_TEXT_BYTES} bytes`
+ : `${characterCount(value)} characters`;
+ ui.count.style.color =
+ bytes > MAX_TEXT_BYTES
+ ? "rgba(255,155,135,0.98)"
+ : "rgba(210,210,220,0.72)";
+}
+
+function setLocalText(node, value, { sync = false, dirty = false } = {}) {
+ const text = typeof value === "string" ? value : String(value ?? "");
+ const optionsWidget = widgetByName(node, "options_text");
+ const ui = node.__promptRandomChoiceRuntimeUI;
+ const changed = Boolean(optionsWidget && optionsWidget.value !== text);
+ if (changed) optionsWidget.value = text;
+ if (ui && ui.textarea.value !== text) ui.textarea.value = text;
+ renderCount(node, text);
+ if (dirty && changed) markWorkflowChanged(node);
+ if (sync) scheduleSync(node, text);
+ return text;
+}
+
+function scheduleSync(node, value, immediate = false) {
+ const sync = node.__promptRandomChoiceRuntimeSync;
+ if (!sync) return;
+ sync.desiredText = value;
+ clearTimeout(sync.timer);
+ if (!sync.inFlight && sync.acknowledgedText === value) return;
+ setStatus(node, "EDITING");
+ sync.timer = setTimeout(
+ () => void flushSync(node),
+ immediate ? 0 : DEBOUNCE_MS,
+ );
+}
+
+async function responseError(response) {
+ try {
+ const payload = await response.json();
+ return payload?.error ?? `${response.status} ${response.statusText}`;
+ } catch {
+ return `${response.status} ${response.statusText}`;
+ }
+}
+
+async function flushSync(node) {
+ const sync = node.__promptRandomChoiceRuntimeSync;
+ if (!sync || sync.composing || sync.inFlight) return;
+ clearTimeout(sync.timer);
+ sync.timer = null;
+ sync.inFlight = true;
+ try {
+ while (!sync.composing && sync.acknowledgedText !== sync.desiredText) {
+ const sentText = sync.desiredText;
+ if (utf8ByteCount(sentText) > MAX_TEXT_BYTES) {
+ throw new Error(`Text exceeds the ${MAX_TEXT_BYTES}-byte server limit`);
+ }
+ const sequence = ++sync.nextSequence;
+ setStatus(node, "SYNCING");
+ const response = await api.fetchApi(LIVE_ROUTE, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ state_key: ensureUniqueStateKey(node),
+ node_kind: nodeKind(node),
+ text: sentText,
+ client_id: sync.clientId,
+ client_sequence: sequence,
+ }),
+ });
+ if (!response.ok) throw new Error(await responseError(response));
+ const payload = await response.json();
+ sync.acknowledgedText = sentText;
+ sync.revision = payload?.state?.revision ?? sync.revision;
+ if (sync.desiredText === sentText) setStateStatus(node, payload?.state);
+ }
+ } catch (error) {
+ console.error("[PromptRandomChoice] Runtime sync failed", error);
+ setStatus(node, "SYNC ERROR", String(error?.message ?? error));
+ } finally {
+ sync.inFlight = false;
+ }
+}
+
+function buildUI(node) {
+ const optionsWidget = widgetByName(node, "options_text");
+ const keyWidget = widgetByName(node, "state_key");
+ if (!optionsWidget || !keyWidget) return;
+ hideSavedWidget(optionsWidget);
+ hideSavedWidget(keyWidget);
+ if (node.__promptRandomChoiceRuntimeUI) return;
+
+ const root = document.createElement("div");
+ Object.assign(root.style, {
+ display: "flex",
+ flexDirection: "column",
+ gap: "5px",
+ width: "100%",
+ height: "100%",
+ boxSizing: "border-box",
+ padding: "2px 0",
+ });
+ const textarea = document.createElement("textarea");
+ textarea.spellcheck = false;
+ textarea.placeholder = "Candidates used when this Runtime node executes";
+ Object.assign(textarea.style, {
+ width: "100%",
+ height: "100%",
+ minHeight: "64px",
+ flex: "1 1 auto",
+ resize: "none",
+ boxSizing: "border-box",
+ padding: "8px",
+ border: "1px solid rgba(255,210,70,0.35)",
+ borderRadius: "4px",
+ background: "rgba(60,45,0,0.18)",
+ color: "inherit",
+ fontFamily: "monospace",
+ fontSize: "12px",
+ lineHeight: "1.35",
+ });
+ const footer = document.createElement("div");
+ Object.assign(footer.style, {
+ display: "flex",
+ flex: "0 0 auto",
+ justifyContent: "space-between",
+ gap: "8px",
+ padding: "0 2px",
+ fontSize: "10px",
+ });
+ const status = document.createElement("span");
+ const count = document.createElement("span");
+ count.style.marginLeft = "auto";
+ footer.append(status, count);
+ root.append(textarea, footer);
+
+ const domWidget = node.addDOMWidget(
+ "runtime_candidate_editor",
+ "prompt-random-choice-runtime-editor",
+ root,
+ {
+ serialize: false,
+ hideOnZoom: false,
+ getMinHeight: () => 96,
+ getHeight: () => "100%",
+ afterResize: () => {
+ root.style.height = "100%";
+ textarea.style.height = "100%";
+ },
+ getValue: () => textarea.value,
+ setValue: (value) =>
+ setLocalText(node, value, { sync: true, dirty: true }),
+ },
+ );
+ node.__promptRandomChoiceRuntimeUI = { root, textarea, status, count, domWidget };
+ node.__promptRandomChoiceRuntimeSync = {
+ clientId: randomId("client"),
+ nextSequence: 0,
+ desiredText:
+ typeof optionsWidget.value === "string" ? optionsWidget.value : "",
+ acknowledgedText: null,
+ revision: null,
+ composing: false,
+ inFlight: false,
+ timer: null,
+ editVersion: 0,
+ };
+
+ const absorb = (event) => event.stopPropagation();
+ for (const eventName of [
+ "pointerdown",
+ "mousedown",
+ "mouseup",
+ "click",
+ "dblclick",
+ "keyup",
+ ]) {
+ textarea.addEventListener(eventName, absorb);
+ }
+ textarea.addEventListener("keydown", (event) => {
+ const save =
+ (event.ctrlKey || event.metaKey) &&
+ !event.altKey &&
+ event.key.toLowerCase() === "s";
+ if (!save) {
+ event.stopPropagation();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ scheduleSync(node, textarea.value, true);
+ const commandId = event.shiftKey
+ ? "Comfy.SaveWorkflowAs"
+ : "Comfy.SaveWorkflow";
+ app.extensionManager?.command?.execute?.(commandId, {
+ errorHandler: (error) =>
+ console.error(`[PromptRandomChoice] ${commandId} failed`, error),
+ });
+ });
+ textarea.addEventListener("compositionstart", () => {
+ node.__promptRandomChoiceRuntimeSync.composing = true;
+ setStatus(node, "EDITING");
+ });
+ textarea.addEventListener("compositionend", () => {
+ const sync = node.__promptRandomChoiceRuntimeSync;
+ sync.composing = false;
+ sync.editVersion += 1;
+ const value = setLocalText(node, textarea.value, { dirty: true });
+ scheduleSync(node, value, true);
+ });
+ textarea.addEventListener("input", () => {
+ const sync = node.__promptRandomChoiceRuntimeSync;
+ sync.editVersion += 1;
+ const value = setLocalText(node, textarea.value, { dirty: true });
+ if (sync.composing) sync.desiredText = value;
+ else scheduleSync(node, value);
+ });
+ textarea.addEventListener("blur", () => {
+ if (!node.__promptRandomChoiceRuntimeSync.composing) {
+ scheduleSync(node, textarea.value, true);
+ }
+ });
+ setLocalText(node, optionsWidget.value);
+ setStatus(node, "SYNCING", "checking server state");
+}
+
+async function initialiseFromServer(node) {
+ buildUI(node);
+ if (!node.__promptRandomChoiceRuntimeUI) return;
+ if (node.__promptRandomChoiceRuntimeInitialising) {
+ return node.__promptRandomChoiceRuntimeInitialising;
+ }
+ const operation = (async () => {
+ const key = ensureUniqueStateKey(node);
+ const sync = node.__promptRandomChoiceRuntimeSync;
+ const editVersion = sync.editVersion;
+ const response = await api.fetchApi(`${LIVE_ROUTE}/${encodeURIComponent(key)}`);
+ if (response.status === 404) {
+ sync.desiredText = setLocalText(
+ node,
+ widgetByName(node, "options_text")?.value ?? "",
+ );
+ sync.acknowledgedText = null;
+ await flushSync(node);
+ return;
+ }
+ if (!response.ok) throw new Error(await responseError(response));
+ const payload = await response.json();
+ const state = payload?.state;
+ if (typeof state?.input_text !== "string") {
+ throw new Error("Server returned an invalid Runtime candidate state");
+ }
+ sync.revision = state.revision ?? null;
+ sync.acknowledgedText = state.input_text;
+ if (sync.editVersion !== editVersion) {
+ await flushSync(node);
+ return;
+ }
+ sync.desiredText = state.input_text;
+ setLocalText(node, state.input_text, { dirty: true });
+ setStateStatus(node, state);
+ })()
+ .catch((error) => {
+ console.error("[PromptRandomChoice] Runtime initialisation failed", error);
+ setStatus(node, "SYNC ERROR", String(error?.message ?? error));
+ })
+ .finally(() => {
+ node.__promptRandomChoiceRuntimeInitialising = null;
+ });
+ node.__promptRandomChoiceRuntimeInitialising = operation;
+ return operation;
+}
+
+app.registerExtension({
+ name: "ruminar.PromptRandomChoice.Runtime",
+ async beforeRegisterNodeDef(nodeType, nodeData) {
+ if (!TARGETS.has(nodeData.name)) return;
+ const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ const result = originalOnNodeCreated?.apply(this, arguments);
+ setTimeout(() => void initialiseFromServer(this), 0);
+ return result;
+ };
+ const originalOnConfigure = nodeType.prototype.onConfigure;
+ nodeType.prototype.onConfigure = function () {
+ const result = originalOnConfigure?.apply(this, arguments);
+ setTimeout(() => void initialiseFromServer(this), 0);
+ return result;
+ };
+ const originalOnDrawBackground = nodeType.prototype.onDrawBackground;
+ nodeType.prototype.onDrawBackground = function (ctx) {
+ const result = originalOnDrawBackground?.apply(this, arguments);
+ if (!this.flags?.collapsed) {
+ ctx.save();
+ ctx.fillStyle = LIVE_BACKGROUND_TINT;
+ ctx.fillRect(
+ 1,
+ 0,
+ Math.max(0, Number(this.size?.[0] ?? 0) - 2),
+ Math.max(0, Number(this.size?.[1] ?? 0)),
+ );
+ ctx.restore();
+ }
+ return result;
+ };
+ },
+});