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:
@@ -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
|
||||
@@ -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`。
|
||||
@@ -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予約名、過大な長さを回避する。
|
||||
@@ -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回維持される。
|
||||
@@ -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は可変領域、同期状態フッターは固定領域として配置する。
|
||||
@@ -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本体の掟に従っておるぞ!)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 備考
|
||||
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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", ""
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()})
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user