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; + }; + }, +});