feat: update project version and description; add runtime state management and server routes

- Bump version to 0.6.0 and enhance project description in pyproject.toml.
- Introduce runtime state management with RuntimeCandidateState and RuntimeCandidateStateStore classes to handle candidate updates and state retrieval.
- Implement server routes for updating and reading runtime candidate states, handling various request scenarios and errors.
- Add tests for runtime layout and prompt random choice functionality, ensuring robust validation and state management.
- Enhance frontend with runtime candidate editor, including UI updates and synchronization with server state.
This commit is contained in:
るみなー先生
2026-08-19 20:39:39 +09:00
parent 751145e4df
commit 08b86f3683
17 changed files with 1961 additions and 336 deletions
+25
View File
@@ -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
+18
View File
@@ -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`。
+85
View File
@@ -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予約名、過大な長さを回避する。
+51
View File
@@ -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回維持される。
+101
View File
@@ -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は可変領域、同期状態フッターは固定領域として配置する。
+111 -7
View File
@@ -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つが選ばれるのじゃ。<br/>
展開後の最終候補が、すべて等しい確率で選ばれるようにしてるのじゃな。
最終葉候補がすべて等しい確率になるよう、各枝の葉数を使って1経路だけを選ぶため、完成文字列を事前に全列挙はしません。
<br/>
## 導入方法
@@ -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
<br/>
## 開発仕様と回帰テスト
実装時に守る仕様は [`.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本体の掟に従っておるぞ!)
+33
View File
@@ -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
```
## 備考
+5
View File
@@ -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__ = [
+296
View File
@@ -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", ""
+211 -325
View File
@@ -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",
}
+2 -2
View File
@@ -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"
+192
View File
@@ -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()
+70
View File
@@ -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()})
+16
View File
@@ -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"/);
});
+294
View File
@@ -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()
+10 -2
View File
@@ -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) {
+441
View File
@@ -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;
};
},
});