Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f356826e9f | ||
|
|
003f3408fc | ||
|
|
54198f196a | ||
|
|
3ff76aacc6 | ||
|
|
ec8be7ee6f | ||
|
|
117fcad22a | ||
|
|
feed327c23 | ||
|
|
82ddbb340c | ||
|
|
31f02962f5 | ||
|
|
413f0d5068 | ||
|
|
bec84ac30b | ||
|
|
61e16ddce0 | ||
|
|
174a67a569 | ||
|
|
d903f33a31 |
@@ -2,6 +2,190 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.3.89
|
||||
|
||||
### バグ修正: v0.3.88で追加したLab機能への実動作確認フィードバック3件を反映
|
||||
|
||||
v0.3.88で追加したLabタブのPromptセル拡張機能について、実際に触ったユーザーから3件の修正依頼を受けた。
|
||||
|
||||
**1. Get from Imageが任意のファイルを選ばせる仕様だった**: 当初はファイル選択ダイアログで別途画像を選ぶ実装だったが、「Labタブで現在読み込んでいる画像から取得したい」という指摘を受け、`_lab.sourceImageFilename`(IMAGE欄に既にドロップ・アップロード済みの画像)を`/view?filename=...&type=input`経由で取得し`File`オブジェクトへ変換した上で`extractAllMetadata()`にかける方式に変更。ファイル選択ダイアログ(隠し`<input type="file">`)は不要になったため削除。画像未読込の場合は既存の`labNoSourceImage`トーストで案内する。
|
||||
|
||||
**2. スタイル適用が`{prompt}`プレースホルダを置換していた**: `generate-tab.js`の`_applyNamedStyle()`と同じ「スタイルテンプレートの`{prompt}`を既存テキストで置換する」ロジックを流用していたが、「既存のプロンプトはクリアしないで追加したい。既存があればカンマ区切り、なければそのまま」という明確な要望を受け、`_applyStyleToText()`をプレースホルダ置換なしの単純追記(`既存 + ", " + style.prompt`、既存が空ならstyle.promptをそのまま)に書き換えた。GenerateUI本来のStyle機能(`_applyNamedStyle`)とは意図的に異なる仕様になったため、コード上にその旨のコメントを残した。
|
||||
|
||||
**3. GenerateUIタブ Modelサブタブの各フィルターにクリアボタンがなかった**: Checkpoint/VAE/Diffusion Model/ControlNet/Hypernetwork(`comfyui-editor.js`の`renderModelTab`、ループでフィルター入力を動的生成)とLora Single(`renderLoraPane`)のフィルター入力に、Models/Nodes/Workflow/Galleryタブの検索ボックスやLabタブのCheckpoint/VAEフィルターと同じ`setupSearchClearBtn()`(`util.js`)+`.wfm-search-wrap`/`.wfm-search-clear-btn`パターンでオーバーレイ✕クリアボタンを追加。`renderModelTab`は複数フィルターをループ生成するため、各入力に`id="wfm-model-${s.key}-filter"`/クリアボタンに`id="wfm-model-${s.key}-filter-clear"`を採番し、フィルター再構築処理を`input`イベントと`setupSearchClearBtn`のコールバックで共有する形にした。
|
||||
|
||||
**あわせてヘルプを更新**: Labサブタブのヘルプ(`helpLab8`、index.html+i18n.js日英中)を新しい挙動(Get from Imageの取得元、スタイルの追記方式)に合わせて修正。
|
||||
|
||||
**検証**: Kaptureで実ブラウザ確認 — GenerateUI Modelタブの各フィルター(Checkpoint/Lora)で入力→✕クリア→リスト復元、Labタブで画像未読込時のGet from Imageのエラートースト、既存Positiveテキストへのスタイル追記結果(`{prompt}`が置換されずそのまま末尾に追加され、既存テキストが先頭に保持されることを確認)。いずれもコンソールエラーなし(既存の無関係なOllama接続エラーを除く)。
|
||||
|
||||
**How to apply**: 新機能を実装した直後の「実際に触ってみた」フィードバックは、当初の設計判断(本実装ではEagle本来のUI流用や`{prompt}`置換の踏襲)を覆す具体的な仕様変更を伴うことが多い。既存の類似機能(ここでは`generate-tab.js`の`_applyNamedStyle`)のロジックをそのまま流用するのではなく、要望に応じて意図的に別実装へ分岐させてよい場面があることを踏まえ、分岐させた箇所には理由をコメントで残すこと。フィルター入力を動的ループで生成するコード(`renderModelTab`等)にクリアボタンを追加する際は、ループ内で一意なIDを採番し`setupSearchClearBtn`をループ内で呼び出す。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.88
|
||||
|
||||
### 機能拡張: Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
|
||||
|
||||
v0.3.87で追加したLabタブ(GenerateUI)に対するユーザーからの一連の改善要望をまとめて対応。
|
||||
|
||||
**Promptセル編集モーダルの拡張**:
|
||||
- **Get from GenerateUI**: 現在GenerateUIに読み込まれているワークフローの`comfyUI.currentAnalysis.prompt_nodes`からPositive/Negativeを直接取得
|
||||
- **Get from Image**: 画像/ワークフローJSONをドロップ/選択すると、Metadataタブの抽出ロジック(`metadata-tab.js`の`extractAllMetadata()`。新たに`export`して再利用)でプロンプトを抽出
|
||||
- **Style適用**: 当初はモーダル内に独自のスタイルドロップダウンを設けたが、ユーザーから「GenerateUIツールバー上部の`#wfm-style-enabled`チェックボックス/`#wfm-style-select`ドロップダウンをそのまま使えないか」と指摘を受け設計変更。モーダル内の選択UIを廃し、Applyボタンが常にツールバー側の現在値を読んで適用する方式にした(チェックボックスOFFまたは未選択ならエラートースト)。ボタンラベルにも選択中のスタイル名を動的表示(例:「Apply: cinematic-diva」)。プロンプトへのマージ処理自体は`generate-tab.js`の`_applyNamedStyle()`と同じ置換ルール(`{prompt}`プレースホルダ置換/ネガティブ追記)を、ワークフローではなく文字列に対して行う`_applyStyleToText()`として実装
|
||||
|
||||
**Checkpoint/VAEセルにフィルター機能追加**:
|
||||
- GenerateUIのModelサブタブ(`comfyui-editor.js`の`renderModelTab`)と同じ「Filter...」テキスト入力で`<select>`の候補を絞り込み
|
||||
- 続けて「オーバーレイ✕でクリア可能にしたい、Modelタブの各フィルタリング機能・Raw JSON検索と同様に」という要望を受け、Models/Nodes/Workflow/Gallery各タブの検索ボックスやRaw JSON検索と同じ`setupSearchClearBtn()`(`util.js`)+`.wfm-search-wrap`/`.wfm-search-clear-btn`(`main.css`)パターンを流用し、入力欄内に✕クリアボタンをオーバーレイ表示するよう変更
|
||||
|
||||
**Plan JSONサブタブ追加**:
|
||||
- Setting/Resultsに続く3つ目のサブタブとして新設。現在のLab状態(列のキーフレーム・Note・Batch数・画像連鎖設定・元画像・結果一覧)を、プランファイル保存時と同じ構造のJSONとして`json-highlight.js`の`syncJsonHighlight()`で構文ハイライト表示する
|
||||
- **Refresh**: Settingタブの現在値で再同期(未適用の編集は破棄)
|
||||
- **Apply to Setting**: テキストエリアの内容を`JSON.parse`し、Plan Load(`_loadPlanFromFile`/`_loadPlanFromServer`)と共通の`_applyPlanData(filename, data)`を呼んでSettingタブへ反映。不正なJSONの場合は`JSON.parse`の例外メッセージをそのままエラートーストに出し、状態は変更しない
|
||||
- 保存用のプランオブジェクト構築ロジックが`_savePlan()`内にインライン実装されていたのを`_buildPlanData(name)`として切り出し、Save・Plan JSON表示の双方から共用するようリファクタリング
|
||||
|
||||
**あわせてヘルプを更新**: Labサブタブのヘルプページ(`index.html`+`i18n.js`日英中+`app.js`の`helpIdMap`)に、Promptモーダルの追加ボタン群とPlan JSONサブタブの説明を追記。
|
||||
|
||||
**検証**: 各機能をKaptureで実ブラウザ確認 — Get from GenerateUI/Get from Imageでの実際のプロンプト取得、Style Apply(トップバーOFF時のエラートースト・ON時の`{prompt}`置換とネガティブ追記)、Checkpoint/VAEフィルターの絞り込みと✕クリア、Plan JSONの表示→編集→Apply→Setting反映、および不正JSON入力時のエラーハンドリング。いずれもコンソールエラーなし(既存の無関係なOllama接続エラーを除く)。
|
||||
|
||||
**How to apply**: 複数モジュールで使う汎用UI部品(`setupSearchClearBtn`、`extractAllMetadata`等)は積極的にexportして使い回すこと。「モーダル内に専用UIを新設する」より「既存のツールバー等の状態をそのまま参照する」方がユーザーにとって一貫性があり実装も軽い場合があるため、要望の意図が既存UIの流用にあるかどうかを都度確認する。プラン保存構造とJSONビュー表示は同じビルダー関数(`_buildPlanData`)を共有させ、二重管理を避ける。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.87
|
||||
|
||||
### 追加機能: GenerateUIタブに実験的な「Lab」サブタブを追加(I2Iバッチ生成・キーフレーム方式)
|
||||
|
||||
「I2Iのバッチ生成を、回数ごとにCheckpoint/VAE/Prompt/KSamplerを個別に変更しながら実行したい」という依頼を受け、GenerateUIの新しいサブタブとして実装(ブランチ`feature/lab-tab-i2i-batch`)。既存のBatchサブタブが「1軸を単純に流すバッチ」なのに対し、Labは「列(Checkpoint/VAE/Prompt/KSampler)ごとに独立したキーフレームで、何回目からどう変わるか」を指定できる点が異なる。
|
||||
|
||||
**データモデルと解決ロジック**: 各列は行グリッドを共有せず、`{atIteration, value, revertToBase}`のキーフレーム配列を個別に持つ。反復回数Nでの実効値は「N以下で最大のatIterationのキーフレーム」を採用し、次のキーフレームまで引き継ぐ。`revertToBase`は1回目の値(空なら読み込み中ワークフローの既定値)を再利用する。1回目のキーフレームは削除不可で、空欄なら常にワークフロー本来の設定を使う。**Labはどの列も`comfyUI.currentWorkflow`を直接書き換えず、実行時にディープクローンした上でのみ値を適用する**ため、Input/Model/Settings/Batchサブタブの編集状態と干渉しない。
|
||||
|
||||
**バックエンド**: `py/services/lab_plan_service.py`(`wildcard_service.py`のディレクトリCRUDパターンを踏襲)+`py/routes/lab_routes.py`を新設し、`GET/POST /api/wfm/lab/plans*`を`py/wfm.py`に登録。プランは`user/default/Workflow-Studio/lab_plan/<name>.json`として保存し、結果画像がある場合は同stemの`<name>.png`(最大9枚・3列のコンタクトシート、Canvasで生成しbase64で送信)を`workflow_service.py`の`change_thumbnail`と同様のサムネイル対のパターンで自動保存する。
|
||||
|
||||
**フロントエンド**: `static/js/lab-tab.js`(新規、`feeder-tab.js`と同じ「GenerateUIサブタブモジュール」パターン)を`generate-tab.js`から`initLabTab()`として呼び出し、サブタブ切替時に共通右ペイン(Generate/Seed/Batch状況パネル)を`display:none`にする分岐を追加。Setting/Resultsの2画面構成で、Setting画面は「画像ドロップ+プラン読込ドロップゾーン(左)/4列キーフレーム(中央)/Run・Batch数・Note・Plan Save(右)」、Results画面は「元画像(左)/生成結果グリッド最大9枚(中央)」。
|
||||
|
||||
**実装後にユーザーからの追加要望で反映した変更点**:
|
||||
|
||||
- キーフレームセルの表示を、2回目以降は「値/`Applied: N`」の2行表示に変更(1回目のみ従来の1行表示のまま)
|
||||
- 生成した各画像をEagle自動保存に対応(設定タブの`eagleAutoSave`を尊重)。`getEagleSettings`/`saveToEagle`は元々`generate-tab.js`にのみ定義されていたが、`lab-tab.js`からの循環import(`generate-tab.js`→`lab-tab.js`→`generate-tab.js`)を避けるため`static/js/util.js`へ移設し、両ファイルからimportする形に整理
|
||||
- Plan Saveの下に「Save As」ボタンを追加。Plan Saveは読込済みプランがあれば無言で上書き、Save Asはプラン読込中でも常に新しい名前をプロンプトで確認する
|
||||
- 「Auto」チェックボックス(実行完了時の自動保存)を削除 — インデックス画像の自動生成があるため不要と判断
|
||||
- 「Use generated image for next」チェックボックスを追加。ONの場合、2回目以降は直前の反復で生成された1枚目の画像をI2I入力として使う連鎖生成に対応。再アップロードは行わず、ComfyUIのLoadImageが解釈できる`"name [type]"`(`folder_paths.get_annotated_filepath`が対応する記法。サブフォルダがあれば`"subfolder/name [type]"`)形式の参照をそのまま`image`ウィジェットに設定する方式を採用
|
||||
- インデックス画像の最大枚数を10→9、グリッド列数を5→3(3×3相当)に変更
|
||||
- Plan Loadを、独自のサムネイル一覧モーダルから、IMAGEドロップゾーンと同じ見た目のドラッグ&ドロップ+クリック選択に変更。`.json`をドロップした場合はブラウザ内で直接読み込み(サーバー不要)、`.png`(インデックス画像)をドロップした場合は同じstemの`.json`をサーバーから取得する。ブラウザの仕様上Web page側からファイル選択ダイアログの初期フォルダを指定することはできないため、初回のみユーザーが`lab_plan`フォルダまで手動で移動する必要がある旨をヘルプに明記
|
||||
|
||||
**ヘルプ更新**: 既存の3点セット規約(`index.html`のヘルプページ+`i18n.js`の日英中3言語+`app.js`の`applyI18nToHtml`内`helpIdMap`)に沿って、サイドバーに「Lab subtab」ページを新設(Feederページと同じ構成: 概要+カード3枚 - 画像と列/実行とプランファイル/Results)。
|
||||
|
||||
**検証**: 各段階でKaptureによる実ブラウザE2E確認を実施 — サブタブ切替時の右ペイン非表示化、4列の+/-とセル編集モーダル(1回目/2回目以降の項目差)、Plan Save(無言上書き)とSave As(常にプロンプト表示)の挙動差、Plan Loadドロップゾーンのクリック/ドラッグ配線、ヘルプページの表示とスクロール、各段階でコンソールエラーが出ていないことを確認。実際のRun実行によるバッチ生成そのものはユーザー自身の環境で確認済み。
|
||||
|
||||
**How to apply**: GenerateUIサブタブを新設する際は`feeder-tab.js`のモジュールパターン(`export function initXxxTab()`を`generate-tab.js`から呼び出し、`moveRawJsonToTab()`の特殊タブ判定に追加)を踏襲する。サブタブがGenerateUI共通の右ペインを使わない場合は、`generate-tab.js`のサブタブ切替ハンドラで`.wfm-gen-right`の`display`を明示的に切り替えること(`.wfm-gen-subtab-content.active`は`display:flex; flex-direction:row`がデフォルトなので、縦積みレイアウトが必要なサブタブは`#wfm-gen-subtab-<name>.active { flex-direction: column; }`のようなCSS上書きが別途必要になる点に注意)。複数モジュールから使う汎用関数(Eagle保存など)は、モジュール間の循環importを避けるため`util.js`のような共有モジュールに置く。ComfyUIの生成済み画像を再度I2I入力として使いたい場合は、再アップロードせずとも`"filename [type]"`のannotated-filepath記法で直接参照できる。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.86
|
||||
|
||||
### バグ修正: 画像を一括生成(I2I)等の連続実行で "GalleryMetadataStore: save error: dictionary changed size during iteration" が発生
|
||||
|
||||
comfyui-comic-creator側で「画像を一括生成(I2I)」を実行すると、ログに`[ERROR] GalleryMetadataStore: save error: dictionary changed size during iteration`が出る不具合をユーザー報告により調査。
|
||||
|
||||
**原因**: ギャラリー機能のメタデータ永続化クラス`GalleryMetadataStore`(`py/services/gallery_metadata.py`)は、`self._data["images"]`という単一の辞書を、フォルダを定期的に再スキャンするバックグラウンドスレッド(`gallery_service.py`の`_bg_index_folder`)と、リクエストを処理するメインスレッドの双方から、ロックなしで並行に読み書きしていた。`_save_to_disk()`の`json.dump(self._data, ...)`は内部で`self._data["images"]`を反復してシリアライズするが、その最中に別スレッドがキーを追加/削除すると、Pythonの`RuntimeError: dictionary changed size during iteration`が発生する。画像を一括生成(I2I)はコマごとに生成結果のメタデータ保存リクエストを短時間に連発するため、バックグラウンドインデックススレッドとの競合が起きやすいタイミングだった。同様の反復(`.items()`/`.values()`/`for key in ...`)は`rename_group`/`delete_group`/`list_images_in_group`/`cleanup_stale_images`等、他の複数メソッドにも存在し、いずれも無防備だった。
|
||||
|
||||
**修正**: `GalleryMetadataStore`に`threading.RLock`を追加し、`_data`を読み書きする全メソッド(`save`/`get`/`delete`/`rename_path`/`cleanup_stale_images`/グループ管理系すべて)を`with self._lock:`で保護した。`ensure_group()`が内部で`create_group()`を呼ぶ再入呼び出しがあるため、通常の`Lock`ではなく`RLock`を採用。
|
||||
|
||||
**How to apply**: バックグラウンドスレッド(`_bg_index_folder`等)とリクエストハンドラの双方から触られる共有可変状態(辞書・リスト)を持つクラスを新設・変更する際は、読み取り専用に見えるメソッドも含めて必ずロックで保護すること。「dictionary changed size during iteration」はこの種の無防備な共有辞書の典型的な症状であり、例外を握りつぶすログ出力(`except Exception as e: logger.error(...)`)の裏に隠れて原因特定が遅れやすい。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.85
|
||||
|
||||
### 機能拡張: Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negativeプロンプト対応を追加
|
||||
|
||||
Comic Creator側のスクリプトタブ「画像を一括生成」(T2I)が、既存のI2I/Inpaint連携(`_wfmReceiveI2IRunRequest`/`_wfmReceiveInpaintRequest`)と同じ「デフォルトワークフローを実行前に自動読み込み」機能を持てるよう、`_wfmReceiveGenerateRequest`(gallery-tab.js)を拡張した。
|
||||
|
||||
- 引数に`negative`・`workflowData`・`workflowFilename`を追加。`workflowData`が渡された場合は既存2関数と同じパターン(`comfyUI.connected`確認→未接続なら`checkConnection()`→モデルリスト未取得なら`loadModelLists()`→`loadWorkflowIntoEditor()`)で実行前にワークフローを読み込む。渡されない場合は従来通りGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
- `negative`が渡された場合は`comfyEditor.setPromptText("negative", negative, { workflow, analysis })`で反映(`ai-tab.js`のInpaint連携と同じ呼び出しパターン)。
|
||||
|
||||
**経緯**: ユーザーから「Workflow Studioの生成UIタブに`ernie_t2i.json`を読み込んで実行したが、別のワークフローが実行されたようだ」との報告を受け調査。原因はコードのバグではなく、ユーザーが手動でワークフローをロードしたのがComic Creator画面内に埋め込まれたWorkflow Studio(`wfmgallery-iframe`)ではなく、ブラウザの別タブとして独立に開いた`/wfm`だったこと(同じURLでも別ウィンドウ=別JS実行コンテキストのため`comfyUI.currentWorkflow`等の状態が共有されない)と判明。この調査を踏まえ、T2I版にもI2I版同様のデフォルトワークフロー指定機能を持たせることで、Comic Creator画面から明示的にワークフローを固定できるようにした(対応する連携コードはComic Creator側`14-integrations.js`/`26-auto-comic-bridge.js`、詳細はcomfyui-comic-creator側のDEVLOG参照)。
|
||||
|
||||
Kaptureで実機E2E確認済み: Comic Creator画面内のWorkflow Studioタブ経由でT2I設定に`ernie_t2i.json`を指定し、スクリプトタブ「画像を一括生成」からRunを実行。ComfyUIサーバーの`/queue`監視・コンソールログで、`ernie_t2i.json`のモデル(`Ernie-Image-Turbo`)による生成が実際に行われ画像がEagleへ保存されたことを確認(2コマとも成功、レイアウトタブへ自動挿入)。新規コード起因のコンソールエラーなし。
|
||||
|
||||
## v0.3.84
|
||||
|
||||
### バグ修正: ernie_t2i.jsonをGenerateUIタブで実行すると "Prompt outputs failed validation" エラー
|
||||
|
||||
v0.3.83でプロンプト表示は修正できたが、ユーザーが実際にGenerateを実行すると`Required input is missing: source`で失敗する不具合が新たに見つかった。ComfyUIサーバーへ直接`/prompt`をPOSTして検証エラーの詳細を取得したところ、原因は`PreviewAny`("Preview as Text"、テキストデバッグ表示用)ノードの必須入力`source`(型`*`のワイルドカード、ウィジェット表現を持たない)が空になっていたこと。
|
||||
|
||||
Ernie Imageのサブグラフでは、外側の折りたたみノードが持つ`width`/`height`ウィジェット値が、内部で**2方向に分岐**していた: 一方は`EmptyFlux2LatentImage.width`(ウィジェット値として注入・解決済み)、もう一方は`PreviewAny.source`(幅の値をプロンプトテンプレート文字列 `{width}` に埋め込むために使われる中継ノード)。既存の`_flattenSubgraphs`の境界値注入ロジックは「1つの境界ポート→1つの内部ターゲット」を前提にしており、`inputPortTargets`が複数ターゲットのうち最後の1つで上書きされていたため、fan-out先の一方(多くの場合ウィジェットではない方)が完全に未接続のまま残っていた。
|
||||
|
||||
- `inputPortTargets`をポートごとに配列で保持するよう変更し、全fan-outターゲットを追跡できるようにした
|
||||
- ウィジェット注入ループをポート単位で回すよう再構成: ウィジェット型ターゲットには従来通り`widgets_values`へ注入しつつ、非ウィジェット型(`PreviewAny.source`のようなワイルドカード必須入力)には`_wfmLiteralInputs`という新しい経路でリテラル値をそのまま`inputs`に注入するようにした(`convertUiToApi`側で`_wfmLiteralInputs`をマージ)
|
||||
- 外側ノードの`widgets_values`が空(=ユーザーが未設定のテンプレートそのまま)の場合でも、fan-out先の別ターゲット(例: `EmptyFlux2LatentImage`)が保持する内部デフォルト値を読み取ってフォールバックとして使うようにし、`image_ernie_image.json`(未設定テンプレート)でも同様に動作するようにした
|
||||
|
||||
**検証**: `/object_info`を提供する実際のComfyUIサーバーに対しNode.jsから直接`/prompt`をPOSTして修正前後の検証結果を比較(修正前: `node_errors`に`Required input is missing: source`、修正後: `node_errors: {}`)。さらに実ブラウザのGenerateUIタブから`ernie_t2i.json`を読み込みGenerateボタンを実行し、実際に画像が生成され「Done (1 image)」になることを確認。
|
||||
|
||||
**How to apply**: サブグラフの境界ウィジェット値は1つのポートから複数の内部ノードへ分岐しうる(今回のような「実際に使う値」と「表示・テンプレート埋め込み用の値」の二重利用パターン)。`_flattenSubgraphs`の境界値解決ロジックを触る際は、常に「1ポート=1ターゲット」ではなく「1ポート=Nターゲット」であることを前提にすること。またウィジェット表現を持たない必須入力(型`*`など)はwidgets_valuesに書けないため、`_wfmLiteralInputs`のような別経路でリテラル値を注入する。
|
||||
|
||||
## v0.3.83
|
||||
|
||||
### 追加機能: Ernie Image(ernie-image / ernie-image-turbo)ワークフロー対応 + プロンプトがリンク経由のノードから供給されるパターンの修正
|
||||
|
||||
Flux2 Klein(`Flux2-Klein_00025.json`)とErnie Image系2ワークフロー(`image_ernie_image.json` / `ernie_t2i.json`)で、GenerateUIタブ・Metadataタブ・サイドパネルIタブのいずれもプロンプトが表示されない不具合を修正した。
|
||||
|
||||
いずれも「プロンプトの実体が`CLIPTextEncode`自身のウィジェット値ではなく、リンク経由で外部の別ノードから供給される」という、v0.3.81/v0.3.82で対応した構成とは異なる新しいパターンが原因だった。
|
||||
|
||||
- **Flux2 Klein**: `CLIPTextEncode.text`が`PrimitiveStringMultiline`ノードへのリンク参照になっている。GenerateUIタブ側(`comfyui-workflow.js`)は既存のrole伝播ロジックで対応済みだったが、Metadataタブ側(`metadata-tab.js`の`resolveLinkedText()`、API形式のリンク解決用)が`text`/`text_g`/`prompt`等のキーしか見ておらず、`PrimitiveStringMultiline`が使う`value`キーを見ていなかったため未検出だった。
|
||||
- **Ernie Image**: プロンプト強化トグル(`prompt_enhancement`)用に`ComfySwitchNode`(if/else switch)を使い、生プロンプトを保持する`PrimitiveStringMultiline`と、LLMで書き換える`TextGenerate`ノードのどちらかへ実行時に切り替える構成。`TextGenerate`の出力は実行時まで内容が決まらず静的解析では取得不能なため、3タブとも`ComfySwitchNode`の両分岐(`on_false`/`on_true`)を辿った上で、リテラルな文字列へ解決できる方(=ユーザーが入力した元のプロンプト)を採用するようにした。
|
||||
|
||||
`comfyui-workflow.js`(GenerateUIタブの`analyzeWorkflow()`のPass 1bロール伝播に`ComfySwitchNode`対応を追加)、`metadata-tab.js`(`resolveLinkedText()`と`extractPromptsFromNodeSet()`を再帰的な解決ロジックに書き直し、`ComfySwitchNode`と`value`キーに対応)、`node_sets_menu.js`(サイドパネルIタブのローカル複製、同様の修正)の3ファイルを修正。
|
||||
|
||||
**検証**: 実際に稼働中のComfyUIサーバー(`/object_info`)を使い、`convertUiToApi()`によるサブグラフ展開を経た上でGenerateUIタブ・Metadataタブ・サイドパネルIタブの3箇所すべてで実ブラウザ上からプロンプトが正しく表示されることを確認。
|
||||
|
||||
**あわせてヘルプを更新**: GenerateUIタブ(`helpGen2`)・Metadataタブ(`helpMetadata5`)のヘルプ本文、およびMetadataタブ内の対応フォーマット補足(`metaFormatTodo`)に記載している対応サブグラフワークフローの例にErnie Imageを追加(EN/JA/ZH)。
|
||||
|
||||
**How to apply**: [[v0.3.82]]で整理した「GenerateUIタブとMetadataタブ(およびIタブの複製)は別実装で自動同期しない」という注意点に加え、プロンプトが`CLIPTextEncode`自身の値ではなくリンク経由で別ノードから来るパターン(`PrimitiveStringMultiline`直結、`ComfySwitchNode`等の中継ノード経由)は今後も現れうる。中継ノードがLLM呼び出しなど実行時にしか値が決まらないノードへ分岐している場合は、無理に実際の出力値を再現しようとせず、静的に解決できる(=ユーザーが直接入力した)分岐を優先して表示する方針でよい。
|
||||
|
||||
## v0.3.82
|
||||
|
||||
### バグ修正: MetadataタブとサイドパネルIタブでLongCat/Boogu/HiDream E1/FireRed等のプロンプトが表示されない不具合
|
||||
|
||||
v0.3.81でFlux.2 Klein/LongCat/Boogu/HiDream E1/FireRedをGenerateUIタブに対応させた後、ユーザーがAI TOOL Chatで実際に5系統すべてを生成しMetadataタブで確認したところ、`qwen_image_edit2509`/`2511`・LongCat・Boogu・FireRedの画像でプロンプトが一切表示されない不具合が見つかった。同じ画像をGenerateUIタブへ読み込むと正しく表示されたことから、GenerateUIタブの解析(`comfyui-workflow.js`の`analyzeWorkflow()`)とは別に実装されているMetadataタブ独自のプロンプト抽出ロジック(`extractPromptsAPI()`/`extractPromptsFromNodeSet()`)が、v0.3.81で対応した新しいノードパターンに追従していなかったことが原因と判明した。
|
||||
|
||||
- **`prompt`キーの欠落**: PNG画像は`workflow`チャンク(UI形式)より`prompt`チャンク(ComfyUIが実行時に生成した、既にAPI形式のペイロードそのもの)が優先的に読まれるため、常に`extractPromptsAPI()`のtextMap構築ロジックが使われる。ここが`text`/`text_g`キーのみを見ており、`TextEncodeQwenImageEdit`(Plus含む)や`TextEncodeBooguEdit`が使う`prompt`キーを見ていなかった(LongCat/Boogu/FireRed、および同種のQwen-Image-Edit系公式テンプレートすべてに影響)。
|
||||
- **Guider経由のrole判定漏れ**: `SamplerCustomAdvanced`はpositive/negativeを直接持たず、`guider`入力の先にある`CFGGuider`/`DualCFGGuider`/`BasicGuider`ノードが持つため、サンプラーノードのinputsを直接見るだけの既存ロジックでは見つけられなかった(Flux.2 Klein/HiDream E1)。GenerateUIタブ側で先に実装した「guiderノードを辿る」対応と同じロジックをMetadataタブ側にも追加。
|
||||
- **`TextEncodeBooguEdit`の1ノード2ロール対応**: `prompt`/`negative_prompt`を1ノードに両方持つ構造は`TextEncodeMageFlowEdit`と同じパターンのため、既存の専用分岐にBooguも合流させた。
|
||||
- **`InstructPixToPixConditioning`の追加ホップ解決**: HiDream E1のこのノードはpositive/negativeを中継するだけで自身はテキストを持たないため、guiderの`cond1`/`negative`から一段先まで辿るようにした。
|
||||
|
||||
`extractPromptsAPI()`(API形式JSON・PNGの`prompt`チャンク用)と`extractPromptsFromNodeSet()`(UI形式・サブグラフ内共用)の両方を修正。さらに`node_sets_menu.js`(ComfyUIサイドパネルのIタブ、`web/comfyui/`はESモジュールをimportできない制約から`comfyui-workflow.js`/`metadata-tab.js`相当のロジックを`_wfm`接頭辞でローカル複製している)にも同じ修正を適用した。
|
||||
|
||||
**あわせてヘルプを更新**: GenerateUIタブ(`helpGen2`)・Metadataタブ(`helpMetadata5`)のヘルプ本文、およびMetadataタブ内の対応フォーマット補足(`metaFormatTodo`)に記載している対応サブグラフワークフローの例に、LongCat/Boogu/HiDream E1/FireRedを追加(EN/JA/ZH)。あわせて`metaFormatTodo`にMage-Flowが記載漏れだった既存の抜けも修正した。
|
||||
|
||||
**検証**: ユーザーがAI TOOL Chatで実際に5系統のワークフローを生成し、Metadataタブでプロンプトが正しく表示されることを確認。サイドパネルIタブでも同様に確認。
|
||||
|
||||
**How to apply**: GenerateUIタブとMetadataタブ(および複製先のIタブ)は、ワークフロー解析ロジックを別々に実装しており自動的には同期しない。新しいノードパターン(プロンプトの格納キー名、guider経由のconditioning構造など)へ対応する際は、`comfyui-workflow.js`の`analyzeWorkflow()`だけでなく`metadata-tab.js`の`extractPromptsAPI()`/`extractPromptsFromNodeSet()`、および`node_sets_menu.js`のローカル複製版も合わせて確認・修正すること。特にPNG画像のメタデータは`prompt`(API形式)チャンクが`workflow`(UI形式)チャンクより優先されるため、実機検証はワークフローJSONの直接ドロップだけでなく、実際に生成したPNG画像でも行うこと(今回はまさにこの違いで問題が後から発覚した)。
|
||||
|
||||
## v0.3.81
|
||||
|
||||
### 追加機能: GenerateUIタブでFlux.2 Klein / LongCat / Boogu / HiDream E1 / FireRedのImage Editワークフローに対応
|
||||
|
||||
「Flux2.Klein4b_comic.json / Flux2.Klein9b_comic.jsonをGenerateUIタブで動作させたい」という依頼を皮切りに、続けて`longcat_comic` / `boogu_image_comic` / `hidream_comic` / `Firered_comic`の4ワークフローにも対応してほしいという依頼が続いた。いずれも公式・サードパーティのImage Edit系ワークフローで、`KSampler`を使わない「Advanced Sampling」構成(`RandomNoise`+何らかのGuiderノード+`KSamplerSelect`+`*Scheduler`+`SamplerCustom`系ノードに分散した seed/steps/cfg/sampler/scheduler)や、独自のプロンプト/サンプラーノードを使っており、既存の`analyzeWorkflow()`(`comfyui-workflow.js`)が前提とするKSampler中心の設計では検出できず、GenerateUIタブのSettings/Latentパネルが「ノードが見つかりません」表示のまま操作不能だった。
|
||||
|
||||
- **Flux.2 Klein(4b/9b)**: `CFGGuider`+`RandomNoise`+`SamplerCustomAdvanced`+`KSamplerSelect`+`Flux2Scheduler`という構成。`SamplerCustomAdvanced`を起点にguider/noise/sampler/schedulerの4ノードをたどり、Settingsパネルに複合サンプラーとして集約表示・編集できるようにした。`EmptyFlux2LatentImage`もLatentパネルに対応させ、`GetImageSize`へリンクされたwidth/heightは編集不可の代わりに空欄+説明表示にして誤上書きを防止。`CFGGuider`/`BasicGuider`をpositive/negative判定の起点にも追加。
|
||||
- **LongCat**: 標準`KSampler`だが独自プロンプトノード`TextEncodeQwenImageEdit`(Plus無し版)を使用。`FluxGuidance`/`FluxKontextMultiReferenceLatentMethod`をrole伝播のパススルーノード集合(`COND_PASSTHROUGH`)に追加。
|
||||
- **Boogu**: `SamplerCustom`(`SamplerCustomAdvanced`とは入力構成が別物で、`CFGGuider`/`RandomNoise`を介さず自身が`positive`/`negative`/`cfg`/`noise_seed`を直接持つ)を誤って同一視していた既存実装を分離し、それぞれ専用ロジックで検出。独自プロンプトノード`TextEncodeBooguEdit`(1ノードに`prompt`/`negative_prompt`を両方保持)にも対応。
|
||||
- **HiDream E1**: `cond1`/`cond2`/`negative`という3つのconditioning入力を持つ特殊ガイダンスノード`DualCFGGuider`(`cfg`の代わりに`cfg_conds`/`cfg_cond2_negative`を持つ)と、画像編集用の中継ノード`InstructPixToPixConditioning`(positive/negative別々の入力・出力を持つため、通常の`COND_PASSTHROUGH`一括処理では役割を取り違える)のrole伝播に個別対応。
|
||||
- **FireRed**: `KSampler`のseed/steps/cfgがPrimitiveノード経由でリンクされる構成(値が数値ではなくリンク参照配列になる)で、既存の直接値前提のコードがリンク配列をそのままSettingsパネルに表示しようとしていた不具合を修正(数値でない場合は`undefined`扱いにし、リンク接続時は空欄+「linked」表示、Apply時は未入力なら書き込みをスキップ)。
|
||||
|
||||
**実機検証で発見・修正した既存バグ(今回の対応範囲拡大で初めて表面化)**
|
||||
|
||||
- サブグラフ内部で完結するリンク(`GetImageSize`→`EmptyFlux2LatentImage`など)の扱いが誤っており、`widgets_values`変換で`batch_size`が`1024`になったり`CLIPTextEncode`の`text`が消えたりする不具合。
|
||||
- 同じロール(positive/negative)のプロンプトノードが複数存在する場合、ドロップダウンの選択とテキストエリアの表示内容が食い違う不具合(全`<option>`に無条件で`selected`を付けていたため)。
|
||||
- ミュート/バイパスしたサブグラフの内部ノードにmode情報が伝播せず、無効なパイプラインのノードがModelタブ等のデフォルト表示に紛れ込む不具合。
|
||||
- サブグラフ展開処理(`_flattenSubgraphs`)の`widgets_values`ノーマライズが「配列長とwidget数が完全一致する場合のみ」動作する設計だったため、seed直後に`control_after_generate`拡張値(`'fixed'`等)が挟まるノード(`KSampler`等)では条件が不成立となり、リンクされたwidget(steps/cfg等)のエントリが除去されないまま後続処理に渡り、cfg/denoiseなど後続フィールドの値が1つずつズレて上書きされる不具合(Firered_comicの実機検証で発覚)。`_simulateWidgetValues`/`_stripLegacyLinkedWidgetValues`/`_findInjectedWidgetIndex`という共通ロジックに全面書き直し、legacy-full/modern形式の自動判定と`control_after_generate`込みの正確な位置計算・注入に統一した。
|
||||
- `Reroute`ノード(配線整理用のUI専用パススルーノードで、ComfyUIバックエンドにノードクラスが存在しない)がそのままAPIペイロードに含まれ、`missing_node_type`の400エラーで生成が失敗する不具合(hidream_comicを実際にGenerateして初めて発覚)。既存のBypass(mode:4)ノード解決ロジック(`_resolveBypassSource`)をReroute対応に拡張し、常に配線を透過解決するようにした。Rerouteの入力型は`"*"`(汎用型)のまま保存されることが多く、Bypass用の型一致検索では見つからないため、Reroute(常に1入力1出力)は型チェックを省略する専用分岐を追加。
|
||||
|
||||
**検証**: 実際にComfyUI上で6ワークフロー(Flux2.Klein4b_comic/Flux2.Klein9b_comic/longcat_comic/boogu_image_comic/hidream_comic/Firered_comic)をGenerateUIタブへ読み込み、Prompt/Settings/Model/Imageタブの表示とRaw JSON変換結果を1つずつ突き合わせて確認。hidream_comicは実際にGenerateを実行し、画像生成成功(Eagle保存ログ)まで確認。
|
||||
|
||||
**How to apply**: KSamplerを使わない「Advanced Sampling」構成(`CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `SamplerCustom`(`Advanced`) + `KSamplerSelect` + `*Scheduler`)に新しく遭遇したら、`SamplerCustomAdvanced`/`SamplerCustom`自体を起点にguider/scheduler/samplerノードをたどるのが基本形だが、guiderノードのconditioning入力名(`positive`/`negative` vs `cond1`/`cond2`/`negative`等)やSamplerCustom系のwidget有無はノードごとに異なるため、実際のノード定義を確認してから個別対応を追加すること。サブグラフの`widgets_values`関連の不具合は、`_simulateWidgetValues`系の共通ロジックが「legacy-full/modern形式の自動判定」「`control_after_generate`込みの正確な位置計算」まで一括して担うようになったため、今後同種の問題が出たら個別対応ではなくこの共通関数を確認・拡張するのが筋。`Reroute`のようなComfyコア標準の「UI専用でバックエンドに実体がないノード」は他にも存在しうるため、同様のエラー(`missing_node_type`)に遭遇したら`_resolveBypassSource`の仕組みを拡張する方針で対応する。
|
||||
|
||||
## v0.3.80
|
||||
|
||||
### 追加機能: Comic Creator「半自動マンガ作成」向けブリッジ(LLMプロンプト下書き・バッチ画像生成)
|
||||
|
||||
@@ -12,6 +12,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
|
||||
- Batch generation across models, samplers, prompts, and workflows
|
||||
- Image Feeder for continuous folder-based generation
|
||||
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Checkpoint/VAE/Prompt/KSampler
|
||||
|
||||
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
|
||||
|
||||
@@ -21,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -80,7 +81,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
|
||||
### GenerateUI Tab (v0.3.5)
|
||||
|
||||
- **5-tab layout** — Input / Model / Settings / Feeder / Batch tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
|
||||
- **6-tab layout** — Input / Model / Settings / Feeder / Batch / **Lab** tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API
|
||||
- **Input tab** — Prompt and Image inner tabs (drag-and-drop upload); Prompt tab shows Positive Prompt and Negative Prompt textareas, plus an **Embeddings selector** at the bottom (Filter + Select + Weight input + Paste button); Paste inserts `(embedding:Name:weight)` at the cursor position of the last focused textarea (defaults to Positive when neither is focused); Raw JSON (540px) in the right column
|
||||
- **Revert button** (v0.3.70) — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value (the reverse of Apply)
|
||||
@@ -99,7 +100,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Center pane** — group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint/Lora groups come from the Models tab, Prompt groups from the Prompt tab, Workflow groups from the Workflow tab — check a group to add all its members, expand ▶ to select individually
|
||||
- **Right pane (Batch Queue)** — 7 columns: Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler / **Style**; each column header has an enable checkbox (radio behavior: only one at a time); count shown per column; Style batch applies each selected style sequentially to a workflow copy
|
||||
- **Lora batch prompt sync** (v0.3.69) — for each selected LoRA, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt (rebuilt from the original prompt each time, not accumulated), then restored to the pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast suggests a Refresh or restart
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and more
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and Guider-based "Advanced Sampling" workflows (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler` — Flux.2 Klein, LongCat, Boogu, HiDream E1, etc.)
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
|
||||
### Feeder subtab (v0.3.5 / v0.3.42)
|
||||
@@ -127,6 +128,20 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **After Gen modes** — Loop (wrap at end and continue indefinitely), Increment (stop automatically when last image is reached), Fixed (always use the same index)
|
||||
- **Run / Stop controls** — left pane Run / Stop buttons manage the generation loop; After Gen combo (loop / increment / fixed), ▶ Run, and ■ Stop widgets are also available directly on the WFS_GalleryFeeder node in the ComfyUI canvas (`gallery_feeder_extension.js`)
|
||||
|
||||
### Lab subtab (v0.3.87 – v0.3.89)
|
||||
|
||||
An experimental I2I batch generator: runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone.
|
||||
|
||||
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 4 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
|
||||
- **Per-column keyframes** — Checkpoint, VAE, Prompt, KSampler each hold an independent list of `{iteration, value}` keyframes; +/− per column adds/removes a keyframe (iteration #1 is fixed and can't be removed); click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it
|
||||
- **Checkpoint / VAE cell filtering** (v0.3.88) — a Filter... box above the value dropdown narrows the list live, same as GenerateUI's Model tab; overlay ✕ clear button matches the Models/Nodes/Workflow/Gallery tab search boxes
|
||||
- **Prompt cell extras** (v0.3.88, refined in v0.3.89) — **Get from GenerateUI** fills Positive/Negative from the workflow currently loaded in GenerateUI; **Get from Image** extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); **Style** appends whichever style is currently selected in GenerateUI's own top-bar Style checkbox/dropdown to the Positive/Negative text below, comma-separated after any existing text — it never clears what's already there (Lab has no separate style picker of its own)
|
||||
- **Use generated image for next** — optional checkbox that chains iterations: from iteration 2 onward the previous iteration's first output image is fed back in as the I2I source instead of the original dropped image, using ComfyUI's `"name [type]"` annotated-filename reference (no re-upload needed)
|
||||
- **Plan files** — Plan Save (silently overwrites the loaded plan) / Save As (always asks for a new name) / Plan Clear; plans are stored as `<name>.json` in `user/default/Workflow-Studio/lab_plan/`, alongside an auto-generated `<name>.png` index image (a 3-per-row contact sheet of up to 9 result thumbnails)
|
||||
- **Plan Load** — same drag-and-drop / click-to-browse drop zone as the Image slot; drop a plan's `.json` (read directly in the browser) or its `.png` index-image thumbnail (fetches the matching `.json` by filename)
|
||||
- **Plan JSON tab** (v0.3.88) — view/edit the current plan as raw JSON (same shape saved to disk); Refresh re-reads it from the Setting tab, Apply to Setting parses your edits and writes them back into the Setting tab's columns/note/batch/chain-image/source-image/results — the same effect as loading that JSON as a plan file, without saving it first
|
||||
- **Eagle integration** — every image Lab generates is auto-saved to Eagle the same way as GenerateUI's own Generate button, if enabled in the Settings tab
|
||||
|
||||
### Prompt Tab
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
|
||||
@@ -145,11 +160,11 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
|
||||
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
|
||||
- **File drop** — drop a ComfyUI-generated PNG / WebP or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
|
||||
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
|
||||
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2, Mage-Flow, LongCat, Boogu, HiDream E1, FireRed); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
|
||||
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
|
||||
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
|
||||
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
|
||||
- **Format support** — ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 subgraph workflows), SD WebUI, SD Forge, Fooocus
|
||||
- **Format support** — ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed subgraph workflows), SD WebUI, SD Forge, Fooocus
|
||||
- **Format note** — supported formats and covered model types are always shown in the left column
|
||||
|
||||
### Settings Tab
|
||||
@@ -410,6 +425,40 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.89
|
||||
|
||||
- **Lab tab — Get from Image now reads Lab's own loaded image** — instead of opening a separate file picker, it fetches the image already loaded in Lab's Image drop zone (`/view` on the uploaded input file) and extracts its embedded prompt metadata
|
||||
- **Lab tab — Style now always appends, never clears** — instead of substituting a `{prompt}` placeholder into the style template (as GenerateUI's own Style feature does), Lab's Style button appends the style's text after any existing Positive/Negative content with a comma, or uses it as-is if the field was empty
|
||||
- **GenerateUI Model tab — overlay ✕ clear buttons** — Checkpoint, VAE, Diffusion Model, ControlNet, Hypernetwork, and LoRA (Single) filter boxes now have the same overlay ✕ clear button as the Models/Nodes/Workflow/Gallery tab search boxes
|
||||
|
||||
### v0.3.88
|
||||
|
||||
- **Lab tab — Prompt cell extras** — Get from GenerateUI (fills Positive/Negative from the currently loaded workflow), Get from Image (extracts a prompt from a dropped/selected image or workflow JSON, reusing the Metadata tab's `extractAllMetadata()`), and Style (applies whichever style is currently selected in GenerateUI's own top-bar Style checkbox/dropdown — no separate style picker inside Lab)
|
||||
- **Lab tab — Checkpoint/VAE cell filtering** — a Filter... box narrows the dropdown live, same as GenerateUI's Model tab, with the same overlay ✕ clear button used by the Models/Nodes/Workflow/Gallery search boxes (`setupSearchClearBtn()`)
|
||||
- **Lab tab — new Plan JSON subtab** — view/edit the current plan as raw JSON (syntax highlighted, same shape as the saved file); Refresh re-syncs it from the Setting tab, Apply to Setting parses your edits and writes them back into the Setting tab's columns/note/batch/chain-image/source-image/results, the same as loading that JSON as a plan file without saving it first
|
||||
|
||||
### v0.3.87
|
||||
|
||||
- **New: GenerateUI — Lab subtab** — experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, with Checkpoint/VAE/Prompt/KSampler each independently overridable via a per-column list of keyframes (`{iteration, value}`) instead of the Batch tab's single shared axis; the effective value at iteration N carries forward from the latest keyframe ≤ N, and a "revert to #1" option reuses the baseline value. Every iteration runs on a cloned workflow, so the loaded workflow (and the Input/Model/Settings/Batch tabs) is never modified. Includes an optional "use generated image for next" chain mode (iteration 2+ feeds the previous output back in as the I2I source via ComfyUI's `"name [type]"` reference, no re-upload needed), Eagle auto-save integration, and Plan Save/Save As/Load (drag-and-drop `.json` or its `.png` index-image thumbnail) with plans stored under `user/default/Workflow-Studio/lab_plan/` alongside an auto-generated contact-sheet thumbnail (up to 9 result images, 3 per row).
|
||||
|
||||
### v0.3.84
|
||||
|
||||
- **Bug fix: `ernie_t2i.json` failed to Generate with "Prompt outputs failed validation"** — the actual failure was `Required input is missing: source` on a `PreviewAny` ("Preview as Text") node. Ernie Image's subgraph fans a single boundary widget value (e.g. `width`) out to *two* internal targets — `EmptyFlux2LatentImage.width` (a real widget) and a `PreviewAny.source` (a required wildcard `*` input with no widget representation, used to render the value into a prompt-template string). The subgraph-flattening logic only ever tracked the last of several fan-out targets per boundary port, so one of the two was always left completely unconnected. Now every fan-out target is tracked and resolved: widget-type targets get the value written into `widgets_values` as before, and non-widget targets get it injected as a plain literal input value. Also handles unfilled templates (outer `widgets_values` empty) by falling back to the sibling widget target's own template-authored default.
|
||||
- Verified against a live ComfyUI server: `/prompt` validation passes with `node_errors: {}`, and an actual Generate run from the GenerateUI tab completes successfully.
|
||||
|
||||
### v0.3.83
|
||||
|
||||
- **Bug fix: prompts not shown for Flux.2 Klein / Ernie Image workflows** — fixed in the GenerateUI tab, Metadata tab, and sidebar Info tab. Two related patterns weren't detected: (1) `CLIPTextEncode.text` linked to a `PrimitiveStringMultiline` node instead of holding the text directly — the Metadata/Info tab's link resolver only recognized `text`/`text_g`/`prompt` input keys, not `PrimitiveStringMultiline`'s `value` key (GenerateUI's own analysis already handled this). (2) Ernie Image's prompt-enhancement toggle routes the prompt through a `ComfySwitchNode` ("If/Else Switch") that picks between the raw `PrimitiveStringMultiline` text and an LLM-rewritten version from a `TextGenerate` node — since the LLM output can't be known statically, all three tabs now follow both switch branches and use whichever resolves to a literal string (i.e. the user's original prompt).
|
||||
- **Help & format notes updated** — GenerateUI/Metadata tab help text and the Metadata tab's "supported formats" note now include Ernie Image alongside the existing subgraph-workflow entries (EN/JA/ZH).
|
||||
|
||||
### v0.3.82
|
||||
|
||||
- **GenerateUI tab — Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Image Edit workflow support** — these use ComfyUI's Guider-based "Advanced Sampling" pattern (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler`, spreading seed/steps/cfg/sampler/scheduler across several nodes instead of one `KSampler`) plus model-specific text-encoder nodes (`TextEncodeQwenImageEdit`, `TextEncodeBooguEdit`), none of which the previous KSampler-centric analysis could detect — the Settings/Latent panels showed "no node found" and were unusable. `SamplerCustomAdvanced`/`SamplerCustom` are now resolved back to their driving Guider/Scheduler/KSamplerSelect nodes for editing; `EmptyFlux2LatentImage` is supported in the Latent panel (linked width/height shown blank instead of a stale default).
|
||||
- **Bug fix: subgraph `widgets_values` normalization** — rewrote the subgraph-flattening logic that strips linked-widget entries from a template node's `widgets_values`; the previous length-based detection didn't account for the `control_after_generate` extra value that follows `seed`, so on nodes where a linked widget (e.g. `steps`) sits right after `seed` (e.g. `KSampler`), every subsequent widget value ended up shifted by one slot — `cfg`/`denoise` silently got the wrong value. Also fixed a related bug where `EmptyFlux2LatentImage.batch_size` came out as `1024` instead of `1` on Flux.2 Klein workflows.
|
||||
- **Bug fix: `Reroute` node caused a 400 error** — `Reroute` is a UI-only passthrough node with no backend node class; including it in the API payload (as happened with HiDream E1's UNETLoader→BasicScheduler/DualCFGGuider fan-out) made ComfyUI reject the prompt with `missing_node_type`. Now resolved through transparently, the same way Bypass-mode nodes already were.
|
||||
- **Bug fix: Metadata tab / sidebar Info tab prompt extraction** — these have their own prompt-extraction logic separate from GenerateUI's analysis, and it hadn't kept up: PNG metadata is read from the `prompt` chunk (already API-format) before `workflow`, so the text-encoder scan needed to recognize the `prompt` input key (used by `TextEncodeQwenImageEdit`/`TextEncodeBooguEdit`, not just `text`/`text_g`), resolve `SamplerCustomAdvanced` through its Guider node to find positive/negative, handle `TextEncodeBooguEdit`'s single-node prompt+negative_prompt layout, and follow one extra hop through `InstructPixToPixConditioning`. Without these, prompts from LongCat/Boogu/FireRed/Qwen-Image-Edit-2509/2511 images showed up empty even though the same image loaded correctly into GenerateUI.
|
||||
- **Help & format notes updated** — GenerateUI/Metadata tab help text and the Metadata tab's "supported formats" note now list LongCat/Boogu/HiDream E1/FireRed alongside the existing Flux.2/Qwen-Image/Z-Image/Mage-Flow entries (EN/JA/ZH).
|
||||
|
||||
### v0.3.80
|
||||
|
||||
- **Comic Creator — Semi-Automatic Manga bridges** — added `window._wfmReceiveLLMPromptRequest(context)` and `window._wfmReceiveGenerateRequest(prompt, width, height)` (`gallery-tab.js`), following the same "assign to `window`, called across the iframe boundary" pattern as the existing I2I/Inpaint bridges. The LLM bridge drafts an image-generation prompt from scene/character/dialogue context using the AI tab's existing Ollama/LM Studio connection logic (`callLLM`/`loadAiSettings`/`isValidBackendUrl`, now exported from `ai-tab.js`); the generate bridge runs a txt2img generation with the workflow currently loaded in the GenerateUI tab, writing the requested width/height into the detected `EmptyLatentImage`/`EmptySD3LatentImage` node the same way the Settings tab's Latent Image panel does, and returns the result image URL.
|
||||
|
||||
@@ -70,6 +70,7 @@ PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Lab tab plan file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import LAB_PLAN_DIR
|
||||
from ..services.lab_plan_service import LabPlanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = LabPlanService(LAB_PLAN_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/lab/plans", handle_list)
|
||||
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
|
||||
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing lab plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting lab plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
+107
-81
@@ -3,6 +3,7 @@ Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,14 +25,24 @@ class GalleryMetadataStore:
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
|
||||
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
|
||||
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
|
||||
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
|
||||
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
|
||||
"dictionary changed size during iteration" が発生するため、_data へアクセスする
|
||||
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
|
||||
内部呼び出しするため、通常のLockではなくRLockを使う)。
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
# コンストラクタからのみ呼ばれ、この時点ではまだ他スレッドと共有されていないためロック不要
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
@@ -44,6 +55,7 @@ class GalleryMetadataStore:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
|
||||
def _save_to_disk(self):
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
@@ -58,113 +70,126 @@ class GalleryMetadataStore:
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
with self._lock:
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
with self._lock:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
with self._lock:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
|
||||
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
with self._lock:
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
with self._lock:
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def rename_path(self, old_path: str, new_path: str) -> bool:
|
||||
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
|
||||
old_key = self._normalize_path(old_path)
|
||||
new_key = self._normalize_path(new_path)
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
|
||||
"""folder_path 配下の存在しないファイルのメタデータを削除する。
|
||||
@@ -173,14 +198,15 @@ class GalleryMetadataStore:
|
||||
folder_key = self._normalize_path(folder_path)
|
||||
if not folder_key.endswith("/"):
|
||||
folder_key += "/"
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
with self._lock:
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"batch_count": data.get("batch_count", 1),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +41,22 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Lab plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = LAB_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -57,11 +73,14 @@ class WorkflowStudio:
|
||||
# Dynamic workflow file serving (thumbnails etc.)
|
||||
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
|
||||
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -75,5 +94,6 @@ class WorkflowStudio:
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
skill_routes.setup_routes(app)
|
||||
lab_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.3.80"
|
||||
version = "0.3.89"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/* Lab tab — experimental I2I batch generation with per-column keyframes */
|
||||
|
||||
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
|
||||
Lab needs its Setting/Results nav stacked above the panel body. */
|
||||
#wfm-gen-subtab-lab.active {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100% - 33px);
|
||||
}
|
||||
|
||||
.wfm-lab-panel.active { display: flex; }
|
||||
|
||||
.wfm-lab-planjson-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
|
||||
|
||||
.wfm-lab-setting-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-pane:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-left { width: 220px; flex-shrink: 0; }
|
||||
.wfm-lab-center { flex: 1; padding: 0; }
|
||||
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
|
||||
|
||||
.wfm-lab-side-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------- Keyframe columns ---------------- */
|
||||
|
||||
.wfm-lab-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-col-btns { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-col-cells {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-cell-index {
|
||||
font-weight: 600;
|
||||
color: var(--wfm-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-value {
|
||||
color: var(--wfm-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-applied {
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-cell-prompt-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
|
||||
|
||||
.wfm-lab-modal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-btnrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------------- Results layout ---------------- */
|
||||
|
||||
.wfm-lab-results-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-results-left {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-results-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-lab-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
border: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-result-item { position: relative; }
|
||||
|
||||
.wfm-lab-result-index {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
}
|
||||
@@ -394,6 +394,21 @@ function applyI18nToHtml() {
|
||||
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
|
||||
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
|
||||
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
|
||||
"wfm-help-lab-title": "helpLabTitle",
|
||||
"wfm-help-lab-desc": "helpLabDesc",
|
||||
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
|
||||
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
|
||||
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
|
||||
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
|
||||
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
|
||||
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
|
||||
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
|
||||
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
|
||||
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
|
||||
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
|
||||
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
|
||||
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
|
||||
"wfm-help-lab-results-3": "helpLabResults3",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
@@ -517,6 +532,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-trouble-9": "helpTrouble9",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
|
||||
+71
-34
@@ -5,7 +5,7 @@
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── Latent Image preset state ─────────────────────────────
|
||||
const _LATENT_PRESET_KEY = "wfm_latent_presets";
|
||||
@@ -367,7 +367,7 @@ export const comfyEditor = {
|
||||
<label>Positive Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${positiveNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
@@ -379,7 +379,7 @@ export const comfyEditor = {
|
||||
<label>Negative Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${negativeNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
@@ -541,7 +541,10 @@ export const comfyEditor = {
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label>${s.label}</label>
|
||||
<input type="text" class="wfm-input wfm-model-filter" placeholder="Filter..." data-target="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
@@ -559,8 +562,8 @@ export const comfyEditor = {
|
||||
|
||||
// Filter inputs
|
||||
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const targetId = input.dataset.target;
|
||||
const targetId = input.dataset.target;
|
||||
const rebuild = () => {
|
||||
const select = document.getElementById(targetId);
|
||||
if (!select) return;
|
||||
const filter = input.value.toLowerCase();
|
||||
@@ -570,7 +573,9 @@ export const comfyEditor = {
|
||||
.filter((m) => m.toLowerCase().includes(filter))
|
||||
.map((m) => `<option value="${m}">${m}</option>`)
|
||||
.join("");
|
||||
});
|
||||
};
|
||||
input.addEventListener("input", rebuild);
|
||||
setupSearchClearBtn(input.id, `${input.id}-clear`, rebuild);
|
||||
});
|
||||
|
||||
// Apply buttons
|
||||
@@ -704,7 +709,10 @@ export const comfyEditor = {
|
||||
|
||||
<!-- Single tab -->
|
||||
<div class="wfm-lora-tab-content" id="wfm-lora-panel-single">
|
||||
<input type="text" class="wfm-input" id="wfm-lora-filter" placeholder="Filter...">
|
||||
<div class="wfm-search-wrap" style="width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input" id="wfm-lora-filter" placeholder="Filter...">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-lora-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select" id="wfm-lora-select">
|
||||
${loras.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
@@ -804,8 +812,9 @@ export const comfyEditor = {
|
||||
}
|
||||
|
||||
// ── Single: filter ───────────────────────────────────
|
||||
document.getElementById("wfm-lora-filter")?.addEventListener("input", (e) => {
|
||||
const filter = e.target.value.toLowerCase();
|
||||
const loraFilterInput = document.getElementById("wfm-lora-filter");
|
||||
const rebuildLoraSingleList = () => {
|
||||
const filter = loraFilterInput?.value.toLowerCase() || "";
|
||||
const select = document.getElementById("wfm-lora-select");
|
||||
if (!select) return;
|
||||
select.innerHTML = loras
|
||||
@@ -813,7 +822,9 @@ export const comfyEditor = {
|
||||
.map((m) => `<option value="${m}">${m}</option>`)
|
||||
.join("");
|
||||
_refreshLoraSingleDynamic(metadata, civitaiCache);
|
||||
});
|
||||
};
|
||||
loraFilterInput?.addEventListener("input", rebuildLoraSingleList);
|
||||
setupSearchClearBtn("wfm-lora-filter", "wfm-lora-filter-clear", rebuildLoraSingleList);
|
||||
|
||||
// ── Single: update SYNTAX/TRIGGERS on model or strength change ──
|
||||
document.getElementById("wfm-lora-select")?.addEventListener("change", () => _refreshLoraSingleDynamic(metadata, civitaiCache));
|
||||
@@ -1030,23 +1041,39 @@ export const comfyEditor = {
|
||||
const sampler = analysis.sampler_nodes?.[0];
|
||||
const latent = analysis.latent_nodes?.[0];
|
||||
|
||||
// "Advanced Sampling" workflows (Flux.1/Flux.2, SD3.5, Chroma...) spread seed/steps/cfg/
|
||||
// sampler/scheduler/denoise across RandomNoise + CFGGuider + KSamplerSelect + a *Scheduler
|
||||
// node instead of one KSampler. sampler.*NodeId (set in analyzeWorkflow) says which real
|
||||
// node each field writes back to; null/"" means that workflow has no such control at all
|
||||
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
|
||||
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
|
||||
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;padding-right:14px;border-right:1px solid var(--wfm-border);">
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">${sampler?.advanced ? "Sampler (Advanced)" : "KSampler"}</h3>
|
||||
${sampler ? `
|
||||
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
|
||||
<input type="hidden" id="wfm-settings-sampler-id"
|
||||
data-seed-key="${sampler.seedKey || "seed"}"
|
||||
data-seed-node-id="${sampler.seedNodeId ?? sampler.id}"
|
||||
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
|
||||
data-cfg-key="${sampler.cfgKey || "cfg"}"
|
||||
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
|
||||
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
|
||||
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
|
||||
<div class="wfm-form-group">
|
||||
<label>Seed</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? ""}" placeholder="${sampler.seed === undefined ? "linked" : ""}">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Steps</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? 20}" min="1" max="200">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? ""}" placeholder="${sampler.steps === undefined ? "linked" : ""}" min="1" max="200">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>CFG</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? 7}" step="0.5" min="0">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? ""}" placeholder="${sampler.cfg === undefined ? "linked" : ""}" step="0.5" min="0">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Sampler</label>
|
||||
@@ -1056,13 +1083,15 @@ export const comfyEditor = {
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Scheduler</label>
|
||||
<select class="wfm-select" id="wfm-settings-scheduler">
|
||||
${this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")}
|
||||
<select class="wfm-select" id="wfm-settings-scheduler" ${schedulerAvailable ? "" : "disabled"}>
|
||||
${schedulerAvailable
|
||||
? this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")
|
||||
: `<option value="">N/A</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Denoise</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? 1.0}" step="0.05" min="0" max="1">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? ""}" placeholder="${denoiseAvailable && sampler.denoise === undefined ? "linked" : ""}" step="0.05" min="0" max="1" ${denoiseAvailable ? "" : "disabled"}>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
|
||||
@@ -1073,17 +1102,18 @@ export const comfyEditor = {
|
||||
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
|
||||
<div class="wfm-form-group">
|
||||
<label>Width</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? 512}" step="8" min="64">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? ""}" placeholder="${latent.width === undefined ? "linked" : ""}" step="8" min="64">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Height</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? 512}" step="8" min="64">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? ""}" placeholder="${latent.height === undefined ? "linked" : ""}" step="8" min="64">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Batch Size</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
|
||||
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
|
||||
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
|
||||
${[512, 768, 1024, 2048].map((s) => `<button class="wfm-btn wfm-btn-sm wfm-latent-sq-btn" data-size="${s}">${s}</button>`).join("")}
|
||||
@@ -1109,17 +1139,21 @@ export const comfyEditor = {
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
|
||||
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
// KSamplerAdvanced uses noise_seed; fall back to seed
|
||||
const seedKey = document.getElementById("wfm-settings-sampler-id")?.dataset?.seedKey || "seed";
|
||||
inputs[seedKey] = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
|
||||
inputs.steps = parseInt(document.getElementById("wfm-settings-steps")?.value) || 20;
|
||||
inputs.cfg = parseFloat(document.getElementById("wfm-settings-cfg")?.value) || 7;
|
||||
inputs.sampler_name = document.getElementById("wfm-settings-sampler-name")?.value;
|
||||
inputs.scheduler = document.getElementById("wfm-settings-scheduler")?.value;
|
||||
inputs.denoise = parseFloat(document.getElementById("wfm-settings-denoise")?.value) || 1.0;
|
||||
const ds = document.getElementById("wfm-settings-sampler-id")?.dataset || {};
|
||||
const write = (nodeId, key, value) => {
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
comfyUI.currentWorkflow[nodeId].inputs[key] = value;
|
||||
};
|
||||
const seedEl = document.getElementById("wfm-settings-seed");
|
||||
if (seedEl?.value !== "") write(ds.seedNodeId, ds.seedKey || "seed", parseInt(seedEl.value) || -1);
|
||||
const stepsEl = document.getElementById("wfm-settings-steps");
|
||||
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
|
||||
const cfgEl = document.getElementById("wfm-settings-cfg");
|
||||
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
|
||||
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
|
||||
const denoiseEl = document.getElementById("wfm-settings-denoise");
|
||||
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
|
||||
_syncRawJson();
|
||||
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
|
||||
});
|
||||
@@ -1128,8 +1162,11 @@ export const comfyEditor = {
|
||||
const nodeId = document.getElementById("wfm-settings-latent-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
inputs.width = parseInt(document.getElementById("wfm-settings-width")?.value) || 512;
|
||||
inputs.height = parseInt(document.getElementById("wfm-settings-height")?.value) || 512;
|
||||
const wEl = document.getElementById("wfm-settings-width");
|
||||
const hEl = document.getElementById("wfm-settings-height");
|
||||
// Blank = leave the (linked) width/height alone instead of clobbering it with a default
|
||||
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
|
||||
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
|
||||
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
|
||||
_syncRawJson();
|
||||
});
|
||||
|
||||
+397
-80
@@ -18,31 +18,42 @@ let _lastBypassedNodes = [];
|
||||
let _lastMutedNodes = [];
|
||||
|
||||
/**
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
|
||||
* node's output slot to an input slot of the same type and following the connection
|
||||
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
|
||||
* where a bypassed node is removed but its wires are reconnected end-to-end.
|
||||
* Trace a link's source through any Bypass-mode (mode 4) nodes AND Reroute nodes, matching each
|
||||
* one's output slot to an input slot of the same type and following the connection upstream
|
||||
* (recursively, for chains of either/both). Mirrors ComfyUI's own Bypass behavior, where a
|
||||
* bypassed node is removed but its wires are reconnected end-to-end — Reroute needs the identical
|
||||
* treatment because it's a UI-only passthrough node with no backend node class at all: leaving it
|
||||
* in the API payload makes ComfyUI reject the whole prompt with "missing_node_type" (Reroute
|
||||
* always has to be resolved through, regardless of its mode).
|
||||
* Returns null when no pass-through source exists (the matching input is unconnected),
|
||||
* in which case the caller should treat the input as unlinked.
|
||||
*/
|
||||
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
|
||||
const idStr = String(nodeId);
|
||||
const node = nodeById[idStr];
|
||||
if (!node || node.mode !== 4) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass chain guard
|
||||
if (!node || (node.mode !== 4 && node.type !== "Reroute")) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass/reroute chain guard
|
||||
|
||||
visited.add(idStr);
|
||||
const outputs = node.outputs || [];
|
||||
const inputs = node.inputs || [];
|
||||
const outType = outputs[slot]?.type;
|
||||
|
||||
let inIdx = -1;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
let inIdx;
|
||||
if (node.type === "Reroute") {
|
||||
// Reroute is always exactly one input -> one output. Its stored input type is commonly
|
||||
// the generic "*" even when the output shows the propagated concrete type (e.g. MODEL),
|
||||
// so the type-match search used for Bypass below would fail to find it — just take the
|
||||
// sole input.
|
||||
inIdx = 0;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
const outType = outputs[slot]?.type;
|
||||
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
|
||||
inIdx = slot;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
}
|
||||
}
|
||||
if (inIdx === -1) return null;
|
||||
if (inIdx === -1 || inIdx === undefined) return null;
|
||||
|
||||
const upstream = linkMap[idStr][inIdx];
|
||||
if (!upstream) return null;
|
||||
@@ -170,6 +181,124 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a subgraph-template node's widget input (by name) is actually wired end-to-end —
|
||||
* either redirected from an outer parent-graph link, or wired to another internal node within the
|
||||
* same subgraph template (boundary links from the subgraph's own input ports, origin_id === -10,
|
||||
* don't count: those resolve to a plain injected widget value instead, handled separately).
|
||||
*/
|
||||
function _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name) {
|
||||
const inputDef = (remappedNode.inputs || []).find((i) => i.widget && i.name === name);
|
||||
if (!inputDef) return false;
|
||||
const slotIdx = remappedNode.inputs.indexOf(inputDef);
|
||||
if (redirectedTargets.has(`${remappedNode.id}:${slotIdx}`)) return true;
|
||||
return inputDef.link != null && (sgDef.links || []).some(
|
||||
(l) => l.id === inputDef.link && String(l.target_id) === origInternalId && l.target_slot === slotIdx && l.origin_id !== -10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate consuming widgets_values against object_info's widget name/type order, accounting for
|
||||
* frontend-only "control_after_generate" extras (e.g. "fixed" after a seed) that occupy one extra
|
||||
* slot and shift every later index by one. When skipLinked is true, widgets the caller's
|
||||
* isLinkedName() flags as linked are treated as consuming zero widgets_values entries (the
|
||||
* "modern" ComfyUI format); with it false, every widget is assumed present (the "legacy full"
|
||||
* format some subgraph templates store). Returns each widget's actual widgets_values index
|
||||
* (or -1 if the array ran out), plus consumedIndices — every index a widget's own turn advanced
|
||||
* past (normally just its value, but two when a control_after_generate extra was skipped) — and
|
||||
* how many entries were consumed in total (the latter is how callers detect which of the two
|
||||
* formats a given widgets_values array actually follows).
|
||||
*
|
||||
* consumedIndices matters because convertUiToApi's own widget-mapping loop only skips a
|
||||
* control_after_generate extra while advancing past the *next* widget's turn (see its
|
||||
* `!linkedSlotNames.has(name)` branch) — if that next widget turns out to be linked, the loop
|
||||
* `continue`s before ever looking at the extra, leaving it unconsumed and silently misread as the
|
||||
* value of whatever non-linked widget comes after. Stripping a linked widget's position alone
|
||||
* (without its swallowed extra) reproduces exactly that bug one level up, in the injection step.
|
||||
*/
|
||||
function _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, skipLinked) {
|
||||
let idx = 0;
|
||||
const positions = {};
|
||||
const consumedIndices = {};
|
||||
for (let nIdx = 0; nIdx < widgetNames.length; nIdx++) {
|
||||
const name = widgetNames[nIdx];
|
||||
if (skipLinked && isLinkedName(name)) {
|
||||
positions[name] = -1;
|
||||
consumedIndices[name] = [];
|
||||
continue;
|
||||
}
|
||||
if (idx >= widgetsValues.length) {
|
||||
positions[name] = -1;
|
||||
consumedIndices[name] = [];
|
||||
continue;
|
||||
}
|
||||
const consumed = [idx];
|
||||
let p = idx;
|
||||
idx++;
|
||||
if (_isExtraWidgetValue(widgetsValues[p], widgetTypes[nIdx]) && idx < widgetsValues.length) {
|
||||
consumed.push(idx);
|
||||
p = idx;
|
||||
idx++;
|
||||
}
|
||||
positions[name] = p;
|
||||
consumedIndices[name] = consumed;
|
||||
}
|
||||
return { positions, consumedIndices, consumed: idx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a subgraph-template node's widgets_values entries belonging to widget slots that are
|
||||
* actually linked (see _isLinkedWidgetName), so convertUiToApi's own widget-mapping loop — which
|
||||
* assumes the "modern" format where linked widgets contribute zero widgets_values entries — maps
|
||||
* every remaining widget to the right value instead of drifting by however many linked entries
|
||||
* were left in place. No-op when widgets_values already follows the modern format (nothing to
|
||||
* strip) or the node has no widget inputs at all.
|
||||
*/
|
||||
function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets) {
|
||||
const widgetsValues = remappedNode.widgets_values;
|
||||
const allWidgetInputs = (remappedNode.inputs || []).filter((i) => i.widget);
|
||||
if (!widgetsValues || allWidgetInputs.length === 0) return;
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
|
||||
// If skipping linked widgets already accounts for every entry, widgets_values is already in
|
||||
// the modern (no dangling linked entries) format — nothing to strip.
|
||||
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true);
|
||||
if (skipSim.consumed === widgetsValues.length) return;
|
||||
|
||||
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
|
||||
// indices the linked ones occupy — including any control_after_generate extra a linked
|
||||
// widget's own turn would have consumed, which must be dropped too (see _simulateWidgetValues).
|
||||
const fullSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, false);
|
||||
const linkedIdx = new Set();
|
||||
for (const name of widgetNames) {
|
||||
if (isLinkedName(name)) {
|
||||
for (const i of fullSim.consumedIndices[name] || []) linkedIdx.add(i);
|
||||
}
|
||||
}
|
||||
if (linkedIdx.size > 0) {
|
||||
remappedNode.widgets_values = widgetsValues.filter((_, i) => !linkedIdx.has(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the widgets_values array index that corresponds to a given widget input name on a
|
||||
* subgraph-template node, for injecting an outer subgraph-node's un-linked widget value into it.
|
||||
* Assumes _stripLegacyLinkedWidgetValues has already normalized widgets_values to the modern
|
||||
* format (linked widgets contribute zero entries), matching what convertUiToApi's own
|
||||
* widget-mapping loop expects.
|
||||
*/
|
||||
function _findInjectedWidgetIndex(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetName) {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const widgetsValues = remappedNode.widgets_values || [];
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const sim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true);
|
||||
return sim.positions[targetName] ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a subgraph-containing UI workflow into a plain UI workflow.
|
||||
*
|
||||
@@ -226,16 +355,20 @@ function _flattenSubgraphs(workflow, objectInfo) {
|
||||
}
|
||||
|
||||
// === Map subgraph input ports → internal target {nodeId, slot} ===
|
||||
// A single boundary port can fan out to MULTIPLE internal targets (e.g. a "width" pin
|
||||
// feeding both EmptyFlux2LatentImage.width as a widget AND a PreviewAny.source wildcard
|
||||
// link purely for canvas display) — collect all of them, not just the last one seen.
|
||||
const inputPortTargets = {};
|
||||
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
|
||||
const port = sgDef.inputs[portIdx];
|
||||
for (const linkId of (port.linkIds || [])) {
|
||||
const iLink = (sgDef.links || []).find(l => l.id === linkId);
|
||||
if (iLink && iLink.origin_id === -10) {
|
||||
inputPortTargets[portIdx] = {
|
||||
if (!inputPortTargets[portIdx]) inputPortTargets[portIdx] = [];
|
||||
inputPortTargets[portIdx].push({
|
||||
targetNodeId: nodeIdRemap[iLink.target_id],
|
||||
targetSlot: iLink.target_slot,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +403,11 @@ function _flattenSubgraphs(workflow, objectInfo) {
|
||||
? (sgDef.inputs || []).findIndex(p => p.name === inputName)
|
||||
: -1;
|
||||
if (portIdx === -1) portIdx = dstSlot;
|
||||
const target = inputPortTargets[portIdx];
|
||||
// Only the port's first fanned-out target is redirected here (a real outer link
|
||||
// feeding a fanned-out port is rare — not seen in any known template so far).
|
||||
// The far more common case (an outer *widget value*, no real link, fanning out to
|
||||
// multiple internal targets) is handled fully below, for every target.
|
||||
const target = inputPortTargets[portIdx]?.[0];
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
@@ -289,80 +426,102 @@ function _flattenSubgraphs(workflow, objectInfo) {
|
||||
|
||||
// === Add internal nodes (remapped IDs) ===
|
||||
const remappedByOrigId = {};
|
||||
const normalizedNodeIds = new Set();
|
||||
for (const iNode of sgDef.nodes) {
|
||||
const remapped = JSON.parse(JSON.stringify(iNode));
|
||||
remapped.id = nodeIdRemap[iNode.id];
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format
|
||||
// (one entry per widget input, including linked ones), while convertUiToApi's
|
||||
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
|
||||
// entry at all). Detect the full format by comparing lengths and strip the
|
||||
// entries belonging to inputs that are *actually* linked end-to-end (per
|
||||
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
|
||||
// links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// stripped — their value lives in the outer node's widgets_values instead and
|
||||
// is injected below.
|
||||
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
|
||||
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
|
||||
const linkedIdx = new Set();
|
||||
allWidgetInputs.forEach((inp, i) => {
|
||||
const slotIdx = remapped.inputs.indexOf(inp);
|
||||
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
|
||||
});
|
||||
if (linkedIdx.size > 0) {
|
||||
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
|
||||
normalizedNodeIds.add(remapped.id);
|
||||
}
|
||||
// Propagate the outer subgraph-node's Mute/Bypass mode onto every internal node.
|
||||
// Without this, muting or bypassing a whole subgraph instance (e.g. disabling one of
|
||||
// several parallel Flux.2 sampling pipelines via Ctrl-B) leaves its internal nodes at
|
||||
// their template-authored mode (normally 0/enabled), so convertUiToApi's mode check
|
||||
// below never excludes them — they end up in the API payload and can be picked up by
|
||||
// node-existence-based UI logic (e.g. "first matching node found") ahead of the nodes
|
||||
// actually driving the enabled pipeline.
|
||||
if (node.mode === 2 || node.mode === 4) {
|
||||
remapped.mode = node.mode;
|
||||
}
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format (one
|
||||
// entry per widget input, including ones that are actually linked — either redirected
|
||||
// from an outer parent-graph link, or wired to another internal node within the same
|
||||
// subgraph template, e.g. EmptyFlux2LatentImage.width <- GetImageSize). Strip those
|
||||
// entries so convertUiToApi's widget-mapping loop (which assumes the "modern" format:
|
||||
// linked widgets contribute zero entries) doesn't drift every subsequent widget by
|
||||
// however many linked entries were left in place — see _stripLegacyLinkedWidgetValues.
|
||||
// Boundary links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// treated as linked here — those resolve via the outer-value injection step below.
|
||||
_stripLegacyLinkedWidgetValues(objectInfo, remapped, sgDef, String(iNode.id), redirectedTargets);
|
||||
|
||||
addedNodes.push(remapped);
|
||||
remappedByOrigId[iNode.id] = remapped;
|
||||
}
|
||||
|
||||
// === Inject outer subgraph-node widget values into internal nodes ===
|
||||
// Only un-linked widget inputs — linked ones are already redirected above.
|
||||
// Only un-linked widget inputs — linked ones are already redirected above. A port can fan
|
||||
// out to several internal targets (see inputPortTargets collection above) — apply the same
|
||||
// value to every one of them, not just the first. Iterating per PORT (rather than per
|
||||
// outer widget) matters for unfilled templates whose outer widgets_values is empty ([]) —
|
||||
// there is then no outer override to apply, but the fan-out's widget-type target (e.g.
|
||||
// EmptyFlux2LatentImage.width) still holds its own template-authored default, and that
|
||||
// same value is what a sibling non-widget target (e.g. a PreviewAny used to render the
|
||||
// value into a text template) needs too.
|
||||
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
|
||||
outerWidgetInputs.forEach((inp, widgetIdx) => {
|
||||
if (inp.link != null) return;
|
||||
if (widgetIdx >= (node.widgets_values || []).length) return;
|
||||
const outerValue = node.widgets_values[widgetIdx];
|
||||
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
|
||||
const targets = inputPortTargets[portIdx];
|
||||
if (!targets || targets.length === 0) continue;
|
||||
|
||||
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
|
||||
if (portIdx === -1) return;
|
||||
const target = inputPortTargets[portIdx];
|
||||
if (!target) return;
|
||||
const port = sgDef.inputs[portIdx];
|
||||
const outerInp = outerWidgetInputs.find((inp) => inp.name === port.name);
|
||||
const outerWidgetIdx = outerInp ? outerWidgetInputs.indexOf(outerInp) : -1;
|
||||
const hasOuterOverride = !!outerInp && outerInp.link == null && outerWidgetIdx !== -1
|
||||
&& outerWidgetIdx < (node.widgets_values || []).length;
|
||||
const outerValue = hasOuterOverride ? node.widgets_values[outerWidgetIdx] : undefined;
|
||||
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) return;
|
||||
// Value to apply to non-widget (wildcard) fan-out targets: the outer override if
|
||||
// present, otherwise whatever a sibling widget-type target ends up holding (its own
|
||||
// unmodified template default, when there is no outer override).
|
||||
let fallbackLiteral = outerValue;
|
||||
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) return;
|
||||
for (const target of targets) {
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) continue;
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) continue;
|
||||
|
||||
// When the internal node's widgets_values was normalized above (stripped of
|
||||
// linked-slot entries), the index must be computed against that same reduced
|
||||
// set of widget names — object_info's full name order (which still includes
|
||||
// e.g. width/height) would be off by however many entries were stripped.
|
||||
let iWidgetIdx;
|
||||
if (normalizedNodeIds.has(remappedNode.id)) {
|
||||
const remainingNames = (remappedNode.inputs || [])
|
||||
.filter((i) => i.widget)
|
||||
.filter((i) => {
|
||||
const slotIdx = remappedNode.inputs.indexOf(i);
|
||||
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
|
||||
})
|
||||
.map((i) => i.name);
|
||||
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
|
||||
} else {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
|
||||
// Compute where in widgets_values this widget's value actually lives — accounting
|
||||
// for linked slots (which consume no entry) and control_after_generate-style
|
||||
// extras (which shift later indices by one). See _findInjectedWidgetIndex for why
|
||||
// a plain name-index lookup silently corrupts sibling widgets here.
|
||||
const iWidgetIdx = _findInjectedWidgetIndex(
|
||||
objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetInputDef.name
|
||||
);
|
||||
if (iWidgetIdx === -1) continue;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
if (hasOuterOverride) remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
if (fallbackLiteral === undefined) fallbackLiteral = remappedNode.widgets_values[iWidgetIdx];
|
||||
}
|
||||
if (iWidgetIdx === -1) return;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
});
|
||||
if (fallbackLiteral === undefined) continue;
|
||||
|
||||
for (const target of targets) {
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) continue;
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef || targetInputDef.widget) continue;
|
||||
|
||||
// Not a widget-representable input — typically a required wildcard ("*") link slot
|
||||
// with no widget UI (e.g. a "Preview as Text" node used to render a width/height
|
||||
// value into a prompt template string). There is no upstream node to link it to
|
||||
// once flattened (the value only ever existed as this outer widget or a sibling
|
||||
// target's own default), so stash it for convertUiToApi() to inject as a plain
|
||||
// literal value instead of a link.
|
||||
if (!remappedNode._wfmLiteralInputs) remappedNode._wfmLiteralInputs = {};
|
||||
remappedNode._wfmLiteralInputs[targetInputDef.name] = fallbackLiteral;
|
||||
}
|
||||
}
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
for (const iLink of (sgDef.links || [])) {
|
||||
@@ -459,6 +618,10 @@ export const comfyWorkflow = {
|
||||
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
// Reroute: UI-only passthrough node, no backend node class — including it in the API
|
||||
// payload triggers a "missing_node_type" 400 from ComfyUI. Its wires are already
|
||||
// resolved through via _resolveBypassSource() below, same as a Bypassed node's.
|
||||
if (node.type === "Reroute") continue;
|
||||
// Skip note/display-only nodes that are unknown to ComfyUI backend
|
||||
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
|
||||
const nodeId = String(node.id);
|
||||
@@ -606,6 +769,15 @@ export const comfyWorkflow = {
|
||||
inputs["wildcard_text"] = widgets[0];
|
||||
}
|
||||
|
||||
// Literal values for non-widget (e.g. wildcard "*") inputs that a subgraph boundary
|
||||
// widget fanned out to — see the injection step in _flattenSubgraphs for why these
|
||||
// can't be represented as widgets_values entries or resolved as a link.
|
||||
if (node._wfmLiteralInputs) {
|
||||
for (const [k, v] of Object.entries(node._wfmLiteralInputs)) {
|
||||
if (!(k in inputs)) inputs[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
class_type: node.type,
|
||||
inputs,
|
||||
@@ -715,15 +887,48 @@ export const comfyWorkflow = {
|
||||
|
||||
// KSamplerAdvanced uses noise_seed instead of seed
|
||||
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
|
||||
const seedVal = inputs.seed ?? inputs.noise_seed;
|
||||
// seed/steps/cfg/denoise are sometimes wired to Primitive nodes (e.g. a shared
|
||||
// "steps" value switched between two LoRA presets) instead of holding a direct
|
||||
// number — leave those undefined so the settings-tab UI shows "linked" instead of
|
||||
// a link-reference array, and Apply doesn't clobber the link with a literal value.
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title,
|
||||
seed: inputs.seed ?? inputs.noise_seed,
|
||||
seedKey,
|
||||
steps: inputs.steps, cfg: inputs.cfg,
|
||||
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
|
||||
denoise: inputs.denoise,
|
||||
seed: typeof seedVal === "number" ? seedVal : undefined,
|
||||
seedKey, seedNodeId: id,
|
||||
steps: typeof inputs.steps === "number" ? inputs.steps : undefined, stepsNodeId: id,
|
||||
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined, cfgNodeId: id,
|
||||
sampler_name: inputs.sampler_name, samplerNodeId: id,
|
||||
scheduler: inputs.scheduler, schedulerNodeId: id,
|
||||
denoise: typeof inputs.denoise === "number" ? inputs.denoise : undefined, denoiseNodeId: id,
|
||||
});
|
||||
}
|
||||
|
||||
// CFGGuider / BasicGuider / DualCFGGuider — used by "Advanced Sampling" workflows
|
||||
// (Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.) in place of KSampler's
|
||||
// positive/negative inputs. These feed SamplerCustomAdvanced rather than driving a
|
||||
// sampler themselves, so role propagation must start here too, or CLIPTextEncode
|
||||
// nodes downstream stay "unknown" role.
|
||||
if (ct === "CFGGuider") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
if (ct === "BasicGuider") {
|
||||
if (Array.isArray(inputs.conditioning)) samplerPositiveRef[inputs.conditioning[0]] = true;
|
||||
}
|
||||
// DualCFGGuider (HiDream E1 instruct-pix2pix style edit): cond1/cond2 both derive from
|
||||
// the positive text (often via InstructPixToPixConditioning), negative is separate —
|
||||
// cond2 is left unmarked since it doesn't cleanly map to either role.
|
||||
if (ct === "DualCFGGuider") {
|
||||
if (Array.isArray(inputs.cond1)) samplerPositiveRef[inputs.cond1[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// SamplerCustom (older/simpler counterpart to SamplerCustomAdvanced): holds
|
||||
// positive/negative directly instead of going through a separate Guider node.
|
||||
if (ct === "SamplerCustom") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
|
||||
@@ -737,6 +942,7 @@ export const comfyWorkflow = {
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
"FluxGuidance", "FluxKontextMultiReferenceLatentMethod",
|
||||
]);
|
||||
|
||||
for (let iter = 0; iter < 5; iter++) {
|
||||
@@ -791,6 +997,29 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1 edit) — unlike COND_PASSTHROUGH nodes,
|
||||
// this has *separate* positive/negative inputs feeding separate outputs, so each
|
||||
// role must route only to its own input (not both) or the negative CLIPTextEncode
|
||||
// would incorrectly get marked positive too via the positive-output branch.
|
||||
if (ct === "InstructPixToPixConditioning") {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — used by Ernie
|
||||
// Image's prompt-enhancement toggle to pick between the raw PrimitiveStringMultiline
|
||||
// text and an LLM-generated (TextGenerate) rewrite. The rewrite can't be known
|
||||
// statically, so propagate the role through both branches — only the branch that
|
||||
// resolves to a literal string source (e.g. PrimitiveStringMultiline) will end up
|
||||
// producing a prompt_nodes entry in Pass 2.
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,6 +1041,72 @@ export const comfyWorkflow = {
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
// --- Advanced Sampler: SamplerCustomAdvanced ---
|
||||
// "Advanced Sampling" pattern used by Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.:
|
||||
// instead of one KSampler node holding seed/steps/cfg/sampler/scheduler, those values
|
||||
// are spread across RandomNoise (noise_seed) → a Guider node (cfg) → KSamplerSelect
|
||||
// (sampler_name) → a *Scheduler node (steps/denoise), all feeding SamplerCustomAdvanced.
|
||||
// Collect them into one composite entry so Settings-tab editing still works; each
|
||||
// value's *NodeId tracks which real node to write back to (null = no such control
|
||||
// exists on this workflow, e.g. Flux2Scheduler has no named "scheduler" combo).
|
||||
if (ct === "SamplerCustomAdvanced") {
|
||||
const noiseId = Array.isArray(inputs.noise) ? String(inputs.noise[0]) : null;
|
||||
const guiderId = Array.isArray(inputs.guider) ? String(inputs.guider[0]) : null;
|
||||
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
|
||||
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
|
||||
|
||||
const noiseInputs = (noiseId && workflow[noiseId]?.inputs) || {};
|
||||
const guiderNode = guiderId ? workflow[guiderId] : null;
|
||||
const guiderInputs = guiderNode?.inputs || {};
|
||||
// DualCFGGuider (HiDream E1 instruct-pix2pix edit) has no plain "cfg" input — its
|
||||
// two CFG knobs are cfg_conds/cfg_cond2_negative. Use cfg_conds as the
|
||||
// representative value so Settings still shows/edits *something* meaningful.
|
||||
const guiderCfgKey = guiderNode?.class_type === "DualCFGGuider" ? "cfg_conds" : "cfg";
|
||||
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
|
||||
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
|
||||
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title, advanced: true,
|
||||
seed: typeof noiseInputs.noise_seed === "number" ? noiseInputs.noise_seed : undefined,
|
||||
seedKey: "noise_seed", seedNodeId: noiseId,
|
||||
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
|
||||
stepsNodeId: schedulerId,
|
||||
cfg: typeof guiderInputs[guiderCfgKey] === "number" ? guiderInputs[guiderCfgKey] : undefined,
|
||||
cfgNodeId: guiderCfgKey in guiderInputs ? guiderId : null,
|
||||
cfgKey: guiderCfgKey,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
scheduler: undefined, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced Sampler: SamplerCustom ---
|
||||
// Older/simpler counterpart to SamplerCustomAdvanced: no RandomNoise/Guider
|
||||
// indirection — noise_seed and cfg are its own widgets, and positive/negative feed it
|
||||
// directly. sampler_name/steps/scheduler/denoise still come from separate
|
||||
// KSamplerSelect/*Scheduler nodes via its sampler/sigmas inputs.
|
||||
if (ct === "SamplerCustom") {
|
||||
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
|
||||
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
|
||||
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
|
||||
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
|
||||
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title, advanced: true,
|
||||
seed: typeof inputs.noise_seed === "number" ? inputs.noise_seed : undefined,
|
||||
seedKey: "noise_seed", seedNodeId: id,
|
||||
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
|
||||
stepsNodeId: schedulerId,
|
||||
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined,
|
||||
cfgNodeId: id,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
scheduler: schedulerInputs.scheduler, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Checkpoint nodes ---
|
||||
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
|
||||
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
|
||||
@@ -878,8 +1173,9 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen-based image+text encoder
|
||||
if (ct === "TextEncodeQwenImageEditPlus") {
|
||||
// Qwen-based image+text encoder (Plus variant takes up to 3 reference images;
|
||||
// the base variant takes one — both share the same "prompt" widget)
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: inputs.prompt || "",
|
||||
@@ -887,6 +1183,22 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// TextEncodeBooguEdit (Boogu image-edit model) — prompt + negative_prompt in one node
|
||||
if (ct === "TextEncodeBooguEdit") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
@@ -957,10 +1269,15 @@ export const comfyWorkflow = {
|
||||
}
|
||||
|
||||
// --- Latent nodes ---
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
|
||||
// EmptyFlux2LatentImage (Flux.2 templates) often has width/height wired to a
|
||||
// GetImageSize node (auto-follows the reference image) rather than a direct number —
|
||||
// leave those as undefined, same treatment as TextEncodeMageFlowEdit below, so the
|
||||
// settings-tab UI shows "linked" instead of a stale default and Apply doesn't clobber it.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: inputs.width, height: inputs.height,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,12 +163,28 @@ window._wfmReceiveLLMPromptRequest = async (context) => {
|
||||
// ComfyUI Comic Creater からiframe越しに画像プロンプト・サイズを受け取り、Generate UIで
|
||||
// テキストから画像を生成して結果URLを返す(半自動マンガ作成のコマ単位バッチ画像生成用)。
|
||||
// Comic Creater側のスクリプトタブ「画像を一括生成」から
|
||||
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height) として呼ばれる。
|
||||
// I2I/Inpaintと異なり画像アップロードは行わず(txt2img)、専用ワークフロー選択には未対応で
|
||||
// 常にGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveGenerateRequest = async (prompt, width, height) => {
|
||||
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height, negative, workflowData?, workflowFilename?)
|
||||
// として呼ばれる。I2I/Inpaintと異なり画像アップロードは行わない(txt2img)。
|
||||
// workflowData が渡された場合(Comic Creater側のT2I設定でデフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (!window._wfmGenerateTab?.generate) return { ok: false, message: "Generate UI is not ready yet" };
|
||||
|
||||
if (workflowData) {
|
||||
try {
|
||||
if (!comfyUI.connected) {
|
||||
await comfyUI.checkConnection();
|
||||
}
|
||||
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
|
||||
await comfyEditor.loadModelLists();
|
||||
}
|
||||
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
|
||||
} catch (e) {
|
||||
console.warn("[T2I] failed to load default workflow:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!comfyUI.currentWorkflow || !analysis) return { ok: false, message: "No workflow loaded in Generate UI" };
|
||||
|
||||
@@ -180,6 +196,7 @@ window._wfmReceiveGenerateRequest = async (prompt, width, height) => {
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negative) comfyEditor.setPromptText("negative", negative, { workflow, analysis });
|
||||
|
||||
await window._wfmGenerateTab.generate(workflow);
|
||||
|
||||
|
||||
@@ -9,50 +9,8 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
import { initLabTab } from "./lab-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Gallery Metadata - ワークフロー保存
|
||||
@@ -1961,7 +1919,7 @@ export async function initGenerateTab() {
|
||||
function moveRawJsonToTab(tabKey) {
|
||||
const widget = document.getElementById("wfm-gen-rawjson-widget");
|
||||
if (!widget) return;
|
||||
if (tabKey === "feeder" || tabKey === "batch") {
|
||||
if (tabKey === "feeder" || tabKey === "batch" || tabKey === "lab") {
|
||||
widget.style.display = "none";
|
||||
return;
|
||||
}
|
||||
@@ -1985,6 +1943,9 @@ export async function initGenerateTab() {
|
||||
if (target === "model" && comfyUI.currentAnalysis) {
|
||||
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
|
||||
}
|
||||
// Lab has its own Run/Results panel — hide the shared Generate/Seed/Batch column
|
||||
const rightCol = document.querySelector(".wfm-gen-right");
|
||||
if (rightCol) rightCol.style.display = (target === "lab") ? "none" : "";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2161,6 +2122,9 @@ export async function initGenerateTab() {
|
||||
// Feeder tab
|
||||
await initFeederTab();
|
||||
|
||||
// Lab tab (experimental I2I batch generation)
|
||||
initLabTab();
|
||||
|
||||
// Style dropdown
|
||||
await _loadStyles();
|
||||
|
||||
|
||||
+204
-9
@@ -217,6 +217,44 @@ const LANGUAGES = {
|
||||
batchStopped: (completed, failed) => `Batch stopped — ${completed} completed, ${failed} failed`,
|
||||
batchComplete: (completed, total, failed) => failed > 0 ? `Batch complete: ${completed}/${total} (${failed} failed)` : `Batch complete: ${completed}/${total}`,
|
||||
batchLoraMissing: (count, total, sample) => `${count}/${total} selected LoRAs are not recognized by ComfyUI yet (e.g. ${sample}). Click Refresh in ComfyUI or restart the server, then try again.`,
|
||||
|
||||
// -- Lab tab --
|
||||
labImageUploaded: (name) => `Image uploaded: ${name}`,
|
||||
labUploadFailed: "Upload failed",
|
||||
labBatchCountTooSmall: "Increase the Batch count first to add a keyframe beyond it",
|
||||
labUseWorkflowDefault: "(workflow default)",
|
||||
labAppliedAt: (n) => `Applied: ${n}`,
|
||||
labRevertToBase: "↩ back to #1",
|
||||
labApplyFromIteration: "Applies from iteration #",
|
||||
labRevertCheckboxLabel: "Revert to iteration #1's setting",
|
||||
labSeedEmptyRandom: "Seed (empty = random)",
|
||||
labPause: "Pause",
|
||||
labResume: "Resume",
|
||||
labNoWorkflowLoaded: "Load a workflow into GenerateUI first",
|
||||
labNoSourceImage: "Drop a source image first",
|
||||
labNoResultsYet: "No results yet",
|
||||
labResultPreview: "Preview",
|
||||
labEnterPlanName: "Plan name:",
|
||||
labPlanSaved: "Plan saved",
|
||||
labPlanSaveFailed: "Failed to save plan",
|
||||
labPlanLoaded: "Plan loaded",
|
||||
labPlanLoadFailed: "Failed to load plan",
|
||||
labPlanInvalidFile: "Drop a .json plan file or its .png index image",
|
||||
labConfirmClear: "Clear all Lab settings (image, columns, note, results)?",
|
||||
labGetFromGenUI: "Get from GenerateUI",
|
||||
labGetFromImage: "Get from Image",
|
||||
labStyle: "Style",
|
||||
labApply: "Apply",
|
||||
labNoStyles: "No styles",
|
||||
labEnableStyleFirst: "Enable the Style checkbox and pick a style at the top of the GenerateUI tab first",
|
||||
labPlanJsonRefreshed: "Refreshed from the current Setting tab",
|
||||
labPlanJsonApplied: "Plan JSON applied to Setting",
|
||||
labPlanJsonInvalid: "Invalid JSON",
|
||||
labStyleApplied: "Style applied",
|
||||
labPromptFetched: "Prompt loaded",
|
||||
labNoPromptInImage: "No prompt found in that file",
|
||||
labMetadataExtractFailed: "Failed to read metadata",
|
||||
|
||||
connectedToComfyUI: "Connected to ComfyUI",
|
||||
failedToConnect: "Failed to connect",
|
||||
modelListsRefreshed: "Model lists refreshed",
|
||||
@@ -645,7 +683,7 @@ const LANGUAGES = {
|
||||
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, send to canvas, load in GenerateUI, set as default, delete",
|
||||
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
|
||||
helpGen1: "Connect/disconnect ComfyUI and check connection status",
|
||||
helpGen2: "Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
|
||||
helpGen2: "Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
|
||||
helpGen3: "5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing",
|
||||
helpGen4: "Input tab: inner tabs — Prompt and Image (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a ↺ button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust its emphasis weight in (text:weight) syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter",
|
||||
@@ -684,6 +722,32 @@ const LANGUAGES = {
|
||||
helpFeederGal4: "Right pane — Preview: displays the currently selected input image; updates when a thumbnail is clicked",
|
||||
helpFeederGal5: "__Feeder__ group: reserved group pre-created automatically; cannot be renamed or deleted; add images to it from the Gallery tab using the F overlay button on each thumbnail",
|
||||
helpFeederGal6: "WFS_GalleryFeeder canvas controls: the node also has Run / Stop / After Gen widgets added directly on the ComfyUI canvas — these work the same as the WFM UI controls and allow running the gallery feeder loop without opening WFM",
|
||||
|
||||
helpLabTitle: "GenerateUI Tab — Lab subtab",
|
||||
helpLabDesc: "Experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration. Two subtabs: Setting and Results.",
|
||||
helpLabCardColumnsTitle: "Setting subtab — Image & Columns",
|
||||
helpLab1: "Left pane — Image: drop or click to select an I2I source image; it is uploaded to ComfyUI's input folder and used for every iteration unless \"Use generated image for next\" is checked",
|
||||
helpLab2: "Left pane — Use generated image for next: from iteration 2 onward, feeds the previous iteration's first output image back in as the I2I source instead of the original dropped image (iteration 1 always uses the original)",
|
||||
helpLab3: "Center pane — 4 independent columns: Checkpoint, VAE, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid",
|
||||
helpLab4: "+ / − per column: + appends a new keyframe at the next free iteration number (up to the Batch count) and opens its edit modal immediately; − removes the last keyframe (iteration #1 can never be removed)",
|
||||
helpLab5: "Click a cell to edit it: iteration #1 sets the baseline value (leave it blank to keep the loaded workflow's own setting); iteration #2+ also lets you choose which iteration it starts applying from, plus a \"revert to #1's setting\" checkbox",
|
||||
helpLab6: "Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; \"revert to #1\" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one",
|
||||
helpLab7: "Lab never edits the workflow shown in Input/Model/Settings — every iteration runs on a fresh clone of the loaded workflow, so keyframes here never affect the other subtabs",
|
||||
helpLab8: "Prompt cell editor extras: Get from GenerateUI fills Positive/Negative from the workflow currently loaded in GenerateUI; Get from Image extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); Style appends whichever style is currently selected in the Style checkbox/dropdown at the top of the GenerateUI toolbar to the Positive/Negative text below, comma-separated after any existing text (never clears it) — enable that checkbox and pick a style first, Lab doesn't have its own separate style picker",
|
||||
helpLabCardPlanTitle: "Setting subtab — Run & Plan files",
|
||||
helpLabPlan1: "Right pane — Batch: number of iterations to run; Note: free-text memo saved together with the plan",
|
||||
helpLabPlan2: "Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop",
|
||||
helpLabPlan3: "Plan Save vs Save As: Plan Save silently overwrites the currently loaded plan (or asks for a name the first time); Save As always asks for a new name, even while a plan is already loaded",
|
||||
helpLabPlan4: "Plan Load: drop a plan's .json file, or its .png index-image thumbnail, onto the PLAN box (or click it to browse) — a dropped .json is read directly; a dropped .png fetches the matching .json (same filename) from the server",
|
||||
helpLabPlan5: "Plan Clear resets the Setting tab to an empty plan (asks for confirmation first)",
|
||||
helpLabPlan6: "Plan files are stored in user/default/Workflow-Studio/lab_plan/ as <name>.json plus a <name>.png index image — a contact sheet of up to 9 result thumbnails (3 per row) generated automatically whenever a plan with results is saved",
|
||||
helpLabPlan7: "Eagle auto-save (configured in the Settings tab) applies to every image Lab generates, the same as GenerateUI's own Generate button",
|
||||
helpLabPlan8: "Plan JSON subtab: shows the current Setting tab's plan as raw JSON; Refresh re-reads it from the Setting tab (discarding any unapplied text edits), Apply to Setting parses your edits and writes them back into the Setting tab (columns, note, batch count, chain-image, source image, results) — the same effect as loading that JSON as a plan file, without needing to save it first",
|
||||
helpLabCardResultsTitle: "Results subtab",
|
||||
helpLabResults1: "Left pane shows the source image used for the run",
|
||||
helpLabResults2: "Center pane shows up to 9 generated images in generation order; click a thumbnail to view it enlarged",
|
||||
helpLabResults3: "Loading a saved plan restores its Results grid from the image list recorded in the plan file",
|
||||
|
||||
helpSearchPlaceholder: "Search help...",
|
||||
helpPrompt1: "AI assistant (Ollama / LM Studio / Lemonade): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
|
||||
helpPrompt2: "Apply AI responses to GenerateUI prompts",
|
||||
@@ -939,6 +1003,7 @@ const LANGUAGES = {
|
||||
helpTrouble6: "Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.",
|
||||
helpTrouble7: "Tagger tab shows no models: verify the model folder exists at ComfyUI/models/tagger/<model-name>/ and contains a .onnx or .h5 file; if onnxruntime is not installed, run: python_embeded\\python.exe -m pip install onnxruntime",
|
||||
helpTrouble8: "Model tab shows a checkpoint you didn't expect (e.g. always the same model regardless of the loaded workflow): the checkpoint saved in the workflow file is not installed, so it was silently replaced with another available model during loading — an error toast reports the original (missing) model name when this happens. Select the correct checkpoint manually in the Model tab, or enable \"Default Checkpoint\" in Settings to force a known-good model on every load.",
|
||||
helpTrouble9: "Generate runs a different workflow than the one just loaded: a Batch type checkbox (Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler) is left ON in the Batch subtab. While any Batch type is active, the Generate button runs the batch selection instead of the currently loaded workflow. Open the Batch subtab and turn off the active batch type checkbox.",
|
||||
helpSupportTitle: "Support",
|
||||
helpSupportDesc: "If you find this plugin useful, please consider supporting its development!",
|
||||
helpGithubDesc: "Report issues, request features, and contribute",
|
||||
@@ -1078,14 +1143,14 @@ const LANGUAGES = {
|
||||
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
|
||||
metaFmtSdwebui: "SD WebUI / SD Forge — PNG (parameters)",
|
||||
metaFmtFooocus: "Fooocus — PNG (fooocus_scheme)",
|
||||
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported",
|
||||
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image subgraph workflows supported",
|
||||
helpMetadataTitle: "Metadata Tab",
|
||||
helpMetadataDesc: "Drop a ComfyUI-generated PNG/WebP or workflow JSON to instantly extract and view the embedded model and prompt information.",
|
||||
helpMetadata1: "Left column: drop zone (image preview for PNG/WebP) and file info; click or drop to load",
|
||||
helpMetadata2: "Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow",
|
||||
helpMetadata3: "Right column: LoRA list (with strength values) and Prompt list (click to view full text below)",
|
||||
helpMetadata4: "Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative",
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus.",
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image subgraph workflows), SD WebUI, SD Forge, Fooocus.",
|
||||
},
|
||||
|
||||
ja: {
|
||||
@@ -1299,6 +1364,44 @@ const LANGUAGES = {
|
||||
batchStopped: (completed, failed) => `バッチ停止 — 完了${completed}件、失敗${failed}件`,
|
||||
batchComplete: (completed, total, failed) => failed > 0 ? `バッチ完了: ${completed}/${total}件 (${failed}件失敗)` : `バッチ完了: ${completed}/${total}件`,
|
||||
batchLoraMissing: (count, total, sample) => `選択したLoRAのうち${count}/${total}件がComfyUIに認識されていません(例: ${sample})。ComfyUIでRefreshするかサーバーを再起動してから再実行してください。`,
|
||||
|
||||
// -- Lab タブ --
|
||||
labImageUploaded: (name) => `画像をアップロードしました: ${name}`,
|
||||
labUploadFailed: "アップロードに失敗しました",
|
||||
labBatchCountTooSmall: "先にBatch数を増やしてからキーフレームを追加してください",
|
||||
labUseWorkflowDefault: "(ワークフローの設定のまま)",
|
||||
labAppliedAt: (n) => `適用:${n}`,
|
||||
labRevertToBase: "↩ 1回目に戻す",
|
||||
labApplyFromIteration: "何回目から適用するか",
|
||||
labRevertCheckboxLabel: "1回目の設定に戻す",
|
||||
labSeedEmptyRandom: "Seed(空欄でランダム)",
|
||||
labPause: "一時停止",
|
||||
labResume: "再開",
|
||||
labNoWorkflowLoaded: "先にGenerateUIでワークフローを読み込んでください",
|
||||
labNoSourceImage: "先に元になる画像をドロップしてください",
|
||||
labNoResultsYet: "まだ結果がありません",
|
||||
labResultPreview: "プレビュー",
|
||||
labEnterPlanName: "プラン名:",
|
||||
labPlanSaved: "プランを保存しました",
|
||||
labPlanSaveFailed: "プランの保存に失敗しました",
|
||||
labPlanLoaded: "プランを読み込みました",
|
||||
labPlanLoadFailed: "プランの読み込みに失敗しました",
|
||||
labPlanInvalidFile: "プランの.jsonファイル、またはそのインデックス画像(.png)をドロップしてください",
|
||||
labConfirmClear: "Labの設定(画像・列・メモ・結果)をすべてクリアしますか?",
|
||||
labGetFromGenUI: "GenerateUIから取得",
|
||||
labGetFromImage: "画像から取得",
|
||||
labStyle: "スタイル",
|
||||
labApply: "適用",
|
||||
labNoStyles: "スタイルがありません",
|
||||
labEnableStyleFirst: "先にGenerateUIタブ上部のStyleチェックボックスをオンにし、スタイルを選択してください",
|
||||
labPlanJsonRefreshed: "現在のSettingタブの内容で更新しました",
|
||||
labPlanJsonApplied: "Plan JSONをSettingに適用しました",
|
||||
labPlanJsonInvalid: "JSONが不正です",
|
||||
labStyleApplied: "スタイルを適用しました",
|
||||
labPromptFetched: "プロンプトを取得しました",
|
||||
labNoPromptInImage: "このファイルにはプロンプトが見つかりませんでした",
|
||||
labMetadataExtractFailed: "メタデータの読み取りに失敗しました",
|
||||
|
||||
connectedToComfyUI: "ComfyUIに接続しました",
|
||||
failedToConnect: "接続に失敗しました",
|
||||
modelListsRefreshed: "モデルリストを更新しました",
|
||||
@@ -1727,7 +1830,7 @@ const LANGUAGES = {
|
||||
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、バッジ付与、タグ、メモ、AI要約、キャンバスへ送る、生成UIに読み込み、デフォルト設定、削除",
|
||||
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
|
||||
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
|
||||
helpGen2: "Workflowタブからワークフローを読み込み(サブグラフを含むワークフローにも対応 — Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等のテンプレート含む;ComfyUIの簡略化App形式 [.app.json / Linear Mode] のみ非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
|
||||
helpGen2: "Workflowタブからワークフローを読み込み(サブグラフを含むワークフローにも対応 — Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 等のテンプレート含む;ComfyUIの簡略化App形式 [.app.json / Linear Mode] のみ非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
|
||||
helpGen3: "5タブ構成 — Input(プロンプト+画像)、Model、Settings、Feeder、Batch — Input/Model/SettingsタブにはRaw JSON列を常時表示",
|
||||
helpGen4: "Inputタブ:Prompt・Imageの内部タブ切り替え;PromptタブにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加);各Applyボタンの左隣の↺ボタンはテキストエリアをノードの現在の適用値に戻す;テキストを選択(または単語・括弧内にカーソルを置くだけでも可)してCtrl+↑/↓を押すと(text:weight)形式で強調weightを±0.05単位で調整(weightが1.0に戻ると括弧を自動解除)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA(Single/Stackタブ)/ControlNet/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)/Hypernetwork(Strength入力付き)のセレクタ(フィルタ検索付き)",
|
||||
@@ -1766,6 +1869,32 @@ const LANGUAGES = {
|
||||
helpFeederGal4: "右ペイン — プレビュー:現在選択中の入力画像を表示;サムネイルをクリックすると更新",
|
||||
helpFeederGal5: "__Feeder__ グループ:自動で作成される予約グループ;名前変更・削除不可;ギャラリータブの各サムネイル上の F ボタンでこのグループに画像を追加",
|
||||
helpFeederGal6: "WFS_GalleryFeeder キャンバスコントロール:このノードには ComfyUI キャンバス上に直接 Run / Stop / After Gen ウィジェットが追加されており、WFM を開かずにギャラリーフィーダーループを実行できます",
|
||||
|
||||
helpLabTitle: "生成UIタブ — Labサブタブ",
|
||||
helpLabDesc: "実験的なI2Iバッチ生成機能です。GenerateUIに現在読み込まれているワークフローをN回実行し、Checkpoint・VAE・Prompt・KSamplerをそれぞれ独立して任意の回数目から変更できます。Setting/Resultsの2つのサブタブがあります。",
|
||||
helpLabCardColumnsTitle: "Settingサブタブ — 画像と列",
|
||||
helpLab1: "左ペイン — Image:I2Iの元になる画像をドロップまたはクリックして選択します。ComfyUIのinputフォルダにアップロードされ、「Use generated image for next」がオフの間は毎回この画像が使われます",
|
||||
helpLab2: "左ペイン — Use generated image for next:オンにすると2回目以降、直前の回で生成された1枚目の画像を次のI2I入力として使用します(1回目は常に元画像を使用)",
|
||||
helpLab3: "中央ペイン — Checkpoint・VAE・Prompt・KSamplerの4列は独立しています。共通の行グリッドではなく、列ごとに個別のキーフレーム(設定変更点)のリストを持ちます",
|
||||
helpLab4: "各列の+/-:+は次に空いている回数目(Batch数まで)にキーフレームを追加し、すぐに編集モーダルを開きます。-は末尾のキーフレームを削除します(1回目は削除できません)",
|
||||
helpLab5: "セルをクリックすると編集できます。1回目は基準となる設定値です(空欄のままにするとワークフロー本来の設定を使用)。2回目以降はさらに「何回目から適用するか」の指定と「1回目の設定に戻す」チェックボックスがあります",
|
||||
helpLab6: "N回目時点での実効値は、N以下で最も大きい回数目のキーフレームが次のキーフレームまで引き継がれます。「1回目に戻す」を使うと、新しい値を指定する代わりに1回目の値(空欄ならワークフロー本来の設定)がそのまま使われます",
|
||||
helpLab7: "LabはInput/Model/Settingsに表示されているワークフローを直接編集しません。各回の実行はワークフローの複製に対して行われるため、Labでのキーフレーム設定が他のサブタブに影響することはありません",
|
||||
helpLab8: "Promptセル編集モーダルの追加機能:「Get from GenerateUI」で現在GenerateUIに読み込まれているワークフローからPositive/Negativeを取得、「Get from Image」でLab左ペインのIMAGEに現在読み込まれている画像からプロンプトを抽出(Metadataタブと同じ検出ロジック。別途ファイル選択は不要)、「Style」でGenerateUIツールバー上部のStyleチェックボックス/ドロップダウンで現在選択されているスタイルを、既存のPositive/Negativeテキストを消さずにカンマ区切りで追記(先に上部でチェックボックスをオンにしスタイルを選択しておく必要あり。Lab独自のスタイル選択はありません)",
|
||||
helpLabCardPlanTitle: "Settingサブタブ — 実行とプランファイル",
|
||||
helpLabPlan1: "右ペイン — Batch:実行する回数。Note:プランと一緒に保存される自由記述のメモ",
|
||||
helpLabPlan2: "Run/Pause/Cancel:Runでバッチループを開始、Pause/Resumeで回と回の間を一時停止・再開、Cancelで現在の生成を中断してループを止めます",
|
||||
helpLabPlan3: "Plan SaveとSave Asの違い:Plan Saveは読込済みのプランがあればダイアログなしで上書き保存します(未読込の場合のみ名前を確認)。Save Asはプラン読込中でも常に新しい名前を確認します",
|
||||
helpLabPlan4: "Plan Load:プランの.jsonファイル、またはそのインデックス画像(.png)をPLAN欄にドロップします(クリックでファイル選択も可)。.jsonをドロップした場合はそのまま読み込み、.pngをドロップした場合は同名の.jsonをサーバーから取得します",
|
||||
helpLabPlan5: "Plan ClearはSettingタブの内容を空のプランにリセットします(確認あり)",
|
||||
helpLabPlan6: "プランファイルは user/default/Workflow-Studio/lab_plan/ に <名前>.json として保存され、結果がある場合は最大9枚(1行3枚)のサムネイルを並べたインデックス画像 <名前>.png も自動生成されます",
|
||||
helpLabPlan7: "設定タブで構成したEagle自動保存は、GenerateUI本来のGenerateボタンと同様にLabで生成したすべての画像に適用されます",
|
||||
helpLabPlan8: "Plan JSONサブタブ:現在のSettingタブのプラン内容をそのままJSONで表示します。「Refresh」でSettingタブの現在の内容から再読込(未適用の編集は破棄)、「Apply to Setting」で編集内容を解析しSettingタブへ書き戻します(列・メモ・Batch数・画像連鎖・元画像・結果)。保存せずにそのままプランファイルとして読み込んだのと同じ効果があります",
|
||||
helpLabCardResultsTitle: "Resultsサブタブ",
|
||||
helpLabResults1: "左ペインには実行に使用した元画像が表示されます",
|
||||
helpLabResults2: "中央ペインには生成順に最大9枚の画像が表示されます。サムネイルをクリックすると拡大表示されます",
|
||||
helpLabResults3: "保存済みプランを読み込むと、プランファイルに記録された画像一覧からResultsの表示が復元されます",
|
||||
|
||||
helpSearchPlaceholder: "ヘルプを検索...",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio / Lemonade):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
|
||||
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
|
||||
@@ -2020,6 +2149,7 @@ const LANGUAGES = {
|
||||
helpTrouble6: "モデルの移動・削除後にモデル読み込みエラーが発生する:ComfyUIがファイルパスをキャッシュしており、古い場所を参照することがあります。ComfyUIメニューのHard Reset(Queue → Hard Reset)またはComfyUIを再起動してキャッシュをクリアしてください。",
|
||||
helpTrouble7: "Taggerタブにモデルが表示されない:ComfyUI/models/tagger/<モデル名>/ フォルダの存在と .onnx または .h5 ファイルの有無を確認;onnxruntimeが未インストールの場合は python_embeded\\python.exe -m pip install onnxruntime を実行",
|
||||
helpTrouble8: "ModelタブのCheckpointが想定と違うモデルになっている(どのワークフローを読み込んでも同じモデルになる等):ワークフローファイルに保存されたCheckpointが実際にはインストールされておらず、読み込み時に無警告で別のインストール済みモデルに差し替えられています。この場合、元の(見つからなかった)モデル名を含むエラートーストが表示されます。Modelタブで正しいCheckpointを手動選択するか、設定タブの「デフォルトCheckpoint」を有効にして、読み込み時に常に動作確認済みのモデルを強制適用してください。",
|
||||
helpTrouble9: "Generateを押すと、読み込んだワークフローとは別のワークフローで実行される:Batchサブタブでバッチタイプ(Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler)のチェックボックスがONのまま残っています。いずれかのバッチタイプが有効な間は、Generateボタンは現在読み込まれているワークフローではなくバッチ選択された内容を実行します。Batchサブタブを開き、有効になっているバッチタイプのチェックボックスをOFFにしてください。",
|
||||
helpSupportTitle: "サポート",
|
||||
helpSupportDesc: "このプラグインが役に立ったら、開発を支援していただけると嬉しいです!",
|
||||
helpGithubDesc: "バグ報告、機能リクエスト、コントリビュート",
|
||||
@@ -2159,14 +2289,14 @@ const LANGUAGES = {
|
||||
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
|
||||
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters チャンク)",
|
||||
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme チャンク)",
|
||||
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image サブグラフ形式に対応済み",
|
||||
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image サブグラフ形式に対応済み",
|
||||
helpMetadataTitle: "Metadata タブ",
|
||||
helpMetadataDesc: "ComfyUI で生成した PNG / WebP または ワークフロー JSON をドロップすると、埋め込まれたモデル・プロンプト情報を即座に抽出・表示します。",
|
||||
helpMetadata1: "左列: ドロップゾーン(PNG/WebP は画像プレビュー表示)+ファイル情報・対応フォーマット説明",
|
||||
helpMetadata2: "中列: ワークフローから抽出した Checkpoint・VAE・Diffusion Model・Text Encoder の一覧",
|
||||
helpMetadata3: "右列: LoRA 一覧(強度値付き)+プロンプト一覧(クリックで下部に全文表示)",
|
||||
helpMetadata4: "プロンプトアクション — Copy: クリップボードにコピー; GenUI:P/N: GenerateUI のポジティブ/ネガティブプロンプトにセット; Prompt:P/N: Prompt タブ Presets のポジティブ/ネガティブにセット",
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -2380,6 +2510,44 @@ const LANGUAGES = {
|
||||
batchStopped: (completed, failed) => `批处理已停止 — 完成${completed}个,失败${failed}个`,
|
||||
batchComplete: (completed, total, failed) => failed > 0 ? `批处理完成: ${completed}/${total}个 (${failed}个失败)` : `批处理完成: ${completed}/${total}个`,
|
||||
batchLoraMissing: (count, total, sample) => `所选LoRA中有${count}/${total}个尚未被ComfyUI识别(例如:${sample})。请在ComfyUI中点击Refresh或重启服务器后再试。`,
|
||||
|
||||
// -- Lab 标签页 --
|
||||
labImageUploaded: (name) => `图片已上传: ${name}`,
|
||||
labUploadFailed: "上传失败",
|
||||
labBatchCountTooSmall: "请先增加Batch数量再添加关键帧",
|
||||
labUseWorkflowDefault: "(保持工作流原设置)",
|
||||
labAppliedAt: (n) => `应用:${n}`,
|
||||
labRevertToBase: "↩ 恢复第1次的设置",
|
||||
labApplyFromIteration: "从第几次开始应用",
|
||||
labRevertCheckboxLabel: "恢复第1次的设置",
|
||||
labSeedEmptyRandom: "Seed(留空为随机)",
|
||||
labPause: "暂停",
|
||||
labResume: "继续",
|
||||
labNoWorkflowLoaded: "请先在GenerateUI中加载工作流",
|
||||
labNoSourceImage: "请先拖入源图片",
|
||||
labNoResultsYet: "暂无结果",
|
||||
labResultPreview: "预览",
|
||||
labEnterPlanName: "方案名称:",
|
||||
labPlanSaved: "方案已保存",
|
||||
labPlanSaveFailed: "方案保存失败",
|
||||
labPlanLoaded: "方案已加载",
|
||||
labPlanLoadFailed: "方案加载失败",
|
||||
labPlanInvalidFile: "请拖入方案的.json文件或其索引图片(.png)",
|
||||
labConfirmClear: "确定要清除所有Lab设置(图片、列、备注、结果)吗?",
|
||||
labGetFromGenUI: "从GenerateUI获取",
|
||||
labGetFromImage: "从图片获取",
|
||||
labStyle: "风格",
|
||||
labApply: "应用",
|
||||
labNoStyles: "暂无风格",
|
||||
labEnableStyleFirst: "请先在GenerateUI标签页顶部勾选Style复选框并选择一个风格",
|
||||
labPlanJsonRefreshed: "已根据当前Setting标签页内容刷新",
|
||||
labPlanJsonApplied: "已将Plan JSON应用到Setting",
|
||||
labPlanJsonInvalid: "JSON格式无效",
|
||||
labStyleApplied: "已应用风格",
|
||||
labPromptFetched: "已获取提示词",
|
||||
labNoPromptInImage: "该文件中未找到提示词",
|
||||
labMetadataExtractFailed: "读取元数据失败",
|
||||
|
||||
connectedToComfyUI: "已连接到ComfyUI",
|
||||
failedToConnect: "连接失败",
|
||||
modelListsRefreshed: "模型列表已刷新",
|
||||
@@ -2808,7 +2976,7 @@ const LANGUAGES = {
|
||||
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、徽章分配、标签、备注、AI摘要、发送到画布、加载到生成UI、设为默认、删除",
|
||||
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
|
||||
helpGen1: "连接/断开ComfyUI,检查连接状态",
|
||||
helpGen2: "从工作流标签页加载工作流(支持含子图的工作流,例如 Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等模板;仅不支持ComfyUI的简化App格式 [.app.json / Linear Mode])。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
|
||||
helpGen2: "从工作流标签页加载工作流(支持含子图的工作流,例如 Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 等模板;仅不支持ComfyUI的简化App格式 [.app.json / Linear Mode])。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
|
||||
helpGen3: "5标签布局 — Input(提示词+图像)、Model、Settings、Feeder、Batch — Input/Model/Settings各含实时预览和直接编辑的Raw JSON列",
|
||||
helpGen4: "Input标签:含Prompt和Image子标签(拖放上传);Prompt标签显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive);每个Apply按钮左侧的↺按钮可将文本框恢复为节点当前已应用的值;选中文字(或将光标置于单词/括号内也可)后按Ctrl+↑/↓,即可以(text:weight)语法以±0.05为单位调整强调权重(权重恢复为1.0时自动去除括号)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/LoRA(Single/Stack标签)/ControlNet/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)/Hypernetwork(带Strength输入)选择器(含筛选)",
|
||||
@@ -2847,6 +3015,32 @@ const LANGUAGES = {
|
||||
helpFeederGal4: "右侧面板 — 预览:显示当前选中的输入图片;点击缩略图时更新",
|
||||
helpFeederGal5: "__Feeder__ 分组:自动预先创建的保留分组;不可重命名或删除;在图库标签页中点击每个缩略图上的 F 按钮可将图片添加到此分组",
|
||||
helpFeederGal6: "WFS_GalleryFeeder 画布控件:该节点在 ComfyUI 画布上直接添加了 Run / Stop / After Gen 组件——与 WFM 界面中的控件功能相同,无需打开 WFM 即可运行图库供给循环",
|
||||
|
||||
helpLabTitle: "GenerateUI标签页 — Lab子标签",
|
||||
helpLabDesc: "实验性的I2I批量生成功能。将GenerateUI当前加载的工作流运行N次,可分别独立地从任意指定次数开始更改Checkpoint、VAE、Prompt、KSampler。包含Setting和Results两个子标签。",
|
||||
helpLabCardColumnsTitle: "Setting子标签 — 图片与列",
|
||||
helpLab1: "左侧面板 — Image:拖入或点击选择作为I2I源图片;会上传到ComfyUI的input文件夹,只要未勾选\"Use generated image for next\",每次迭代都会使用该图片",
|
||||
helpLab2: "左侧面板 — Use generated image for next:勾选后,从第2次开始使用上一次迭代生成的第1张图片作为I2I输入,而不是原始拖入的图片(第1次始终使用原始图片)",
|
||||
helpLab3: "中间面板 — Checkpoint、VAE、Prompt、KSampler这4列相互独立;每列各自维护自己的关键帧列表,而不是共享同一个行网格",
|
||||
helpLab4: "每列的+/-:+会在下一个空闲的迭代序号(不超过Batch数)添加关键帧并立即打开编辑弹窗;-会删除最后一个关键帧(第1个关键帧无法删除)",
|
||||
helpLab5: "点击单元格即可编辑:第1个关键帧设置基准值(留空则保持工作流本身的设置);第2个及以后的关键帧还可以选择从第几次开始应用,以及是否勾选\"恢复第1次的设置\"",
|
||||
helpLab6: "第N次迭代的实际生效值:使用迭代序号≤N中序号最大的关键帧,并一直延续到下一个关键帧改变它为止;勾选\"恢复第1次的设置\"时会直接复用第1次的值(若第1次为空则使用工作流默认值),而不是指定新值",
|
||||
helpLab7: "Lab不会修改Input/Model/Settings中显示的工作流——每次迭代都在加载工作流的一个副本上运行,因此这里的关键帧设置不会影响其他子标签",
|
||||
helpLab8: "Prompt单元格编辑弹窗的附加功能:「Get from GenerateUI」从GenerateUI当前加载的工作流中获取Positive/Negative;「Get from Image」从Lab左侧IMAGE区域当前加载的图片中提取提示词(与Metadata标签相同的检测逻辑,无需另外选择文件);「Style」会将GenerateUI工具栏顶部Style复选框/下拉菜单中当前选中的风格,以逗号分隔追加到下方的Positive/Negative文本之后(不会清空已有内容)——需先在顶部勾选复选框并选择风格,Lab没有自己独立的风格选择器",
|
||||
helpLabCardPlanTitle: "Setting子标签 — 运行与方案文件",
|
||||
helpLabPlan1: "右侧面板 — Batch:要运行的迭代次数;Note:与方案一起保存的自由备注",
|
||||
helpLabPlan2: "Run/Pause/Cancel:Run开始批量循环;Pause/Resume在各次迭代之间暂停/继续;Cancel会中断当前生成并停止循环",
|
||||
helpLabPlan3: "Plan Save与Save As的区别:Plan Save会在已加载方案时静默覆盖保存(未加载时才会询问名称);Save As无论是否已加载方案都会始终询问新名称",
|
||||
helpLabPlan4: "Plan Load:将方案的.json文件或其索引图片(.png)拖入PLAN区域(也可点击浏览选择)——拖入.json会直接读取;拖入.png会从服务器获取同名的.json",
|
||||
helpLabPlan5: "Plan Clear会将Setting标签重置为空方案(会先确认)",
|
||||
helpLabPlan6: "方案文件保存在 user/default/Workflow-Studio/lab_plan/ 下,命名为<名称>.json,若方案含有结果,还会自动生成<名称>.png索引图片——最多9张结果缩略图组成的联系表(每行3张)",
|
||||
helpLabPlan7: "设置标签页中配置的Eagle自动保存,会像GenerateUI自身的Generate按钮一样应用于Lab生成的每一张图片",
|
||||
helpLabPlan8: "Plan JSON子标签:以原始JSON形式显示当前Setting标签页的方案内容。「Refresh」会从Setting标签页重新读取(放弃尚未应用的编辑);「Apply to Setting」会解析你的编辑并写回Setting标签页(列、备注、Batch数量、图片链式生成、源图片、结果)——效果与直接将该JSON作为方案文件加载相同,无需先保存",
|
||||
helpLabCardResultsTitle: "Results子标签",
|
||||
helpLabResults1: "左侧面板显示本次运行使用的源图片",
|
||||
helpLabResults2: "中间面板按生成顺序显示最多9张生成图片;点击缩略图可放大查看",
|
||||
helpLabResults3: "加载已保存的方案时,会根据方案文件中记录的图片列表恢复Results网格",
|
||||
|
||||
helpSearchPlaceholder: "搜索帮助...",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio / Lemonade):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
|
||||
helpPrompt2: "将AI回复应用到生成UI的提示词",
|
||||
@@ -3101,6 +3295,7 @@ const LANGUAGES = {
|
||||
helpTrouble6: "移动或删除模型后出现模型加载错误:ComfyUI会缓存文件路径,可能仍引用旧位置。请通过ComfyUI菜单执行Hard Reset(Queue → Hard Reset)或重启ComfyUI以清除缓存。",
|
||||
helpTrouble7: "Tagger标签不显示模型:确认ComfyUI/models/tagger/<模型名>/文件夹存在且包含.onnx或.h5文件;如未安装onnxruntime,请运行:python_embeded\\python.exe -m pip install onnxruntime",
|
||||
helpTrouble8: "Model 标签页显示的 Checkpoint 与预期不符(例如无论加载哪个工作流都显示相同模型):工作流文件中保存的 Checkpoint 实际未安装,加载时已被静默替换为其他已安装模型,此时会弹出包含原始(未找到)模型名称的错误提示。请在 Model 标签页手动选择正确的 Checkpoint,或在设置标签页启用\"默认 Checkpoint\",在每次加载时强制应用一个已验证可用的模型。",
|
||||
helpTrouble9: "点击 Generate 后,执行的是与刚加载的工作流不同的工作流:Batch 子标签页中的某个批量类型(Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler)复选框仍处于开启状态。只要有任意批量类型处于启用状态,Generate 按钮执行的就是批量选择的内容,而不是当前加载的工作流。请打开 Batch 子标签页,关闭已启用的批量类型复选框。",
|
||||
helpSupportTitle: "支持",
|
||||
helpSupportDesc: "如果您觉得这个插件有用,请考虑支持它的开发!",
|
||||
helpGithubDesc: "报告问题、请求功能和贡献代码",
|
||||
@@ -3240,14 +3435,14 @@ const LANGUAGES = {
|
||||
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
|
||||
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters)",
|
||||
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme)",
|
||||
metaFormatTodo: "已支持 Flux.2 / Qwen-Image / Z-Image 子图工作流",
|
||||
metaFormatTodo: "已支持 Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 子图工作流",
|
||||
helpMetadataTitle: "Metadata 标签页",
|
||||
helpMetadataDesc: "拖放 ComfyUI 生成的 PNG/WebP 或工作流 JSON,即可提取并显示其中的模型和提示词信息。",
|
||||
helpMetadata1: "左列:拖放区(PNG/WebP 显示图片预览)及文件信息与支持格式说明",
|
||||
helpMetadata2: "中列:从工作流中提取的 Checkpoint、VAE、扩散模型、文本编码器列表",
|
||||
helpMetadata3: "右列:LoRA 列表(含强度值)和提示词列表(点击在下方显示全文)",
|
||||
helpMetadata4: "提示词操作 — Copy:复制到剪贴板;GenUI:P/N:设置 GenerateUI 正/负向提示;Prompt:P/N:设置 Prompt 标签页预设正/负向提示",
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow 子图工作流)、SD WebUI、SD Forge、Fooocus。",
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 子图工作流)、SD WebUI、SD Forge、Fooocus。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,957 @@
|
||||
/**
|
||||
* Lab Tab - Experimental I2I batch generation.
|
||||
*
|
||||
* Runs the currently loaded workflow N times, letting Checkpoint / VAE / Prompt /
|
||||
* KSampler each be overridden independently starting at a chosen iteration
|
||||
* ("keyframe"). Never mutates comfyUI.currentWorkflow — everything is applied to a
|
||||
* fresh deep clone per iteration so it can't interfere with the Input/Model/Settings/
|
||||
* Batch subtabs.
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { showToast, openModal, closeModal } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, getEagleSettings, saveToEagle, setupSearchClearBtn } from "./util.js";
|
||||
import { extractAllMetadata } from "./metadata-tab.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
|
||||
const COLUMN_KEYS = ["checkpoint", "vae", "prompt", "ksampler"];
|
||||
const MAX_RESULTS = 9;
|
||||
const RESULTS_GRID_COLS = 3;
|
||||
|
||||
function _defaultValueFor(col) {
|
||||
if (col === "prompt") return { positive: "", negative: "" };
|
||||
if (col === "ksampler") return { steps: null, cfg: null, sampler_name: "", scheduler: "", denoise: null, seed: null };
|
||||
return "";
|
||||
}
|
||||
|
||||
function _emptyColumns() {
|
||||
return {
|
||||
checkpoint: [{ atIteration: 1, value: "", revertToBase: false }],
|
||||
vae: [{ atIteration: 1, value: "", revertToBase: false }],
|
||||
prompt: [{ atIteration: 1, value: _defaultValueFor("prompt"), revertToBase: false }],
|
||||
ksampler: [{ atIteration: 1, value: _defaultValueFor("ksampler"), revertToBase: false }],
|
||||
};
|
||||
}
|
||||
|
||||
function _emptyLabState() {
|
||||
return {
|
||||
planFilename: null,
|
||||
note: "",
|
||||
batchCount: 4,
|
||||
chainImage: false,
|
||||
sourceImageFilename: null,
|
||||
columns: _emptyColumns(),
|
||||
results: { images: [] },
|
||||
};
|
||||
}
|
||||
|
||||
let _lab = _emptyLabState();
|
||||
let _paused = false;
|
||||
let _aborted = false;
|
||||
let _resumeResolve = null;
|
||||
let _lastGeneratedImageRef = null; // ComfyUI "name [type]" annotated ref of the previous iteration's first output
|
||||
|
||||
// ============================================
|
||||
// Init
|
||||
// ============================================
|
||||
|
||||
export function initLabTab() {
|
||||
_initDropZone();
|
||||
_initPlanDropZone();
|
||||
_initSubtabToggle();
|
||||
_initColumnButtons();
|
||||
_initRunControls();
|
||||
_initPlanButtons();
|
||||
_initPlanJsonTab();
|
||||
_renderAllColumns();
|
||||
_renderResultsGrid();
|
||||
}
|
||||
|
||||
function _initDropZone() {
|
||||
const dropZone = document.getElementById("wfm-lab-drop-zone");
|
||||
const fileInput = document.getElementById("wfm-lab-file-input");
|
||||
const previewWrap = document.getElementById("wfm-lab-preview-wrap");
|
||||
const previewImg = document.getElementById("wfm-lab-preview-img");
|
||||
|
||||
const applyFile = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
if (previewImg) previewImg.src = url;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
_lab.sourceImageFilename = result.name;
|
||||
_updateResultsSourceImage();
|
||||
showToast(t("labImageUploaded", result.name), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labUploadFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Plan Load — same drag&drop + click-to-browse UX as the Image drop zone.
|
||||
// Accepts a .json plan file (read directly) or a .png index-image thumbnail
|
||||
// (same basename as its .json on the server, fetched by filename).
|
||||
function _initPlanDropZone() {
|
||||
const dropZone = document.getElementById("wfm-lab-plan-drop-zone");
|
||||
const fileInput = document.getElementById("wfm-lab-plan-file-input");
|
||||
|
||||
const applyFile = (file) => {
|
||||
if (!file) return;
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith(".json")) {
|
||||
_loadPlanFromFile(file);
|
||||
} else if (name.endsWith(".png") || file.type.startsWith("image/")) {
|
||||
_loadPlanFromIndexImage(file);
|
||||
} else {
|
||||
showToast(t("labPlanInvalidFile"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
fileInput.value = ""; // allow re-selecting the same file next time
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _initSubtabToggle() {
|
||||
document.querySelectorAll(".wfm-lab-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.labSubtab;
|
||||
document.querySelectorAll(".wfm-lab-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById("wfm-lab-panel-setting")?.classList.toggle("active", target === "setting");
|
||||
document.getElementById("wfm-lab-panel-results")?.classList.toggle("active", target === "results");
|
||||
document.getElementById("wfm-lab-panel-planjson")?.classList.toggle("active", target === "planjson");
|
||||
if (target === "results") _updateResultsSourceImage();
|
||||
if (target === "planjson") _syncLabJsonView();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _initPlanJsonTab() {
|
||||
document.getElementById("wfm-lab-planjson-refresh")?.addEventListener("click", () => {
|
||||
_syncLabJsonView();
|
||||
showToast(t("labPlanJsonRefreshed"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-lab-planjson-apply")?.addEventListener("click", () => {
|
||||
const editor = document.getElementById("wfm-lab-planjson-editor");
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(editor?.value || "{}");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanJsonInvalid")}: ${err.message}`, "error");
|
||||
return;
|
||||
}
|
||||
_applyPlanData(_lab.planFilename, data);
|
||||
_syncLabJsonView();
|
||||
showToast(t("labPlanJsonApplied"), "success");
|
||||
});
|
||||
}
|
||||
|
||||
// Serializes the live Setting-tab state into the same shape saved to disk by
|
||||
// _savePlan(), for display/editing in the Plan JSON panel.
|
||||
function _buildPlanData(name) {
|
||||
return {
|
||||
name,
|
||||
note: _lab.note,
|
||||
batch_count: _lab.batchCount,
|
||||
chain_image: _lab.chainImage,
|
||||
source_image: _lab.sourceImageFilename,
|
||||
columns: _lab.columns,
|
||||
results: { images: _lab.results.images },
|
||||
};
|
||||
}
|
||||
|
||||
function _syncLabJsonView() {
|
||||
const editor = document.getElementById("wfm-lab-planjson-editor");
|
||||
const highlight = document.getElementById("wfm-lab-planjson-highlight");
|
||||
if (!editor) return;
|
||||
const name = _lab.planFilename ? _lab.planFilename.replace(/\.json$/i, "") : "";
|
||||
const json = JSON.stringify(_buildPlanData(name), null, 2);
|
||||
editor.value = json;
|
||||
syncJsonHighlight(highlight, json);
|
||||
}
|
||||
|
||||
function _initRunControls() {
|
||||
document.getElementById("wfm-lab-batch-count")?.addEventListener("change", (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
_lab.batchCount = (isNaN(v) || v < 1) ? 1 : v;
|
||||
e.target.value = _lab.batchCount;
|
||||
});
|
||||
document.getElementById("wfm-lab-note")?.addEventListener("input", (e) => { _lab.note = e.target.value; });
|
||||
document.getElementById("wfm-lab-chain-image")?.addEventListener("change", (e) => { _lab.chainImage = e.target.checked; });
|
||||
|
||||
document.getElementById("wfm-lab-run-btn")?.addEventListener("click", () => _runLabBatch());
|
||||
document.getElementById("wfm-lab-cancel-btn")?.addEventListener("click", async () => {
|
||||
_aborted = true;
|
||||
_paused = false;
|
||||
if (_resumeResolve) { _resumeResolve(); _resumeResolve = null; }
|
||||
await comfyUI.interrupt();
|
||||
showToast(t("interrupted"), "info");
|
||||
});
|
||||
document.getElementById("wfm-lab-pause-btn")?.addEventListener("click", (e) => {
|
||||
_paused = !_paused;
|
||||
e.target.textContent = _paused ? t("labResume") : t("labPause");
|
||||
if (!_paused && _resumeResolve) { _resumeResolve(); _resumeResolve = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function _initPlanButtons() {
|
||||
document.getElementById("wfm-lab-plan-save-btn")?.addEventListener("click", () => _savePlan());
|
||||
document.getElementById("wfm-lab-plan-saveas-btn")?.addEventListener("click", () => _savePlan(null, true));
|
||||
document.getElementById("wfm-lab-plan-clear-btn")?.addEventListener("click", () => _clearPlan());
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Column keyframes: render / add / remove / edit
|
||||
// ============================================
|
||||
|
||||
function _initColumnButtons() {
|
||||
document.querySelectorAll(".wfm-lab-col-add").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const kfs = _lab.columns[col];
|
||||
const lastIter = kfs[kfs.length - 1].atIteration;
|
||||
if (lastIter >= _lab.batchCount) {
|
||||
showToast(t("labBatchCountTooSmall"), "error");
|
||||
return;
|
||||
}
|
||||
kfs.push({ atIteration: lastIter + 1, value: _defaultValueFor(col), revertToBase: false });
|
||||
_renderColumn(col);
|
||||
_openCellModal(col, kfs.length - 1);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-lab-col-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const kfs = _lab.columns[col];
|
||||
if (kfs.length <= 1) return; // Row 1 (baseline) cannot be removed
|
||||
kfs.pop();
|
||||
_renderColumn(col);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _renderAllColumns() {
|
||||
COLUMN_KEYS.forEach(_renderColumn);
|
||||
}
|
||||
|
||||
function _renderColumn(col) {
|
||||
const container = document.getElementById(`wfm-lab-col-cells-${col}`);
|
||||
if (!container) return;
|
||||
const keyframes = _lab.columns[col];
|
||||
container.innerHTML = keyframes.map((kf, idx) => _cellHtml(col, kf, idx)).join("");
|
||||
container.querySelectorAll(".wfm-lab-cell").forEach((el) => {
|
||||
el.addEventListener("click", () => _openCellModal(col, parseInt(el.dataset.idx, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
function _cellHtml(col, kf, idx) {
|
||||
const revertClass = kf.revertToBase ? " wfm-lab-cell-revert" : "";
|
||||
if (idx === 0) {
|
||||
return `<div class="wfm-lab-cell${revertClass}" data-idx="${idx}">
|
||||
<span class="wfm-lab-cell-index">${kf.atIteration}:</span>
|
||||
<span class="wfm-lab-cell-value">${_cellLabel(col, kf)}</span>
|
||||
</div>`;
|
||||
}
|
||||
// Iteration #2 and beyond: value on its own line, applied-at iteration below it
|
||||
return `<div class="wfm-lab-cell${revertClass}" data-idx="${idx}">
|
||||
<div class="wfm-lab-cell-value">${_cellLabel(col, kf)}</div>
|
||||
<div class="wfm-lab-cell-applied">${t("labAppliedAt", kf.atIteration)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _cellLabel(col, kf) {
|
||||
if (kf.revertToBase) return t("labRevertToBase");
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
return kf.value ? escapeHtml(kf.value) : t("labUseWorkflowDefault");
|
||||
}
|
||||
if (col === "prompt") {
|
||||
const p = kf.value?.positive || "";
|
||||
const n = kf.value?.negative || "";
|
||||
if (!p && !n) return t("labUseWorkflowDefault");
|
||||
return `<span class="wfm-lab-cell-prompt-line">P: ${escapeHtml(p)}</span><span class="wfm-lab-cell-prompt-line">N: ${escapeHtml(n)}</span>`;
|
||||
}
|
||||
if (col === "ksampler") {
|
||||
const v = kf.value || {};
|
||||
if (v.steps == null && v.cfg == null && v.denoise == null) return t("labUseWorkflowDefault");
|
||||
const parts = [];
|
||||
if (v.steps != null) parts.push(`Step${v.steps}`);
|
||||
if (v.cfg != null) parts.push(`CFG${v.cfg}`);
|
||||
if (v.denoise != null) parts.push(`Denoise${v.denoise}`);
|
||||
return escapeHtml(parts.join(" / "));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function _isEmptyValue(col, value) {
|
||||
if (col === "checkpoint" || col === "vae") return !value;
|
||||
if (col === "prompt") return !value || (!value.positive && !value.negative);
|
||||
if (col === "ksampler") {
|
||||
return !value || (value.steps == null && value.cfg == null && value.denoise == null &&
|
||||
!value.sampler_name && !value.scheduler && value.seed == null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _openCellModal(col, idx) {
|
||||
const kf = _lab.columns[col][idx];
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === _lab.columns[col].length - 1;
|
||||
|
||||
let valueFieldsHtml = "";
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${col === "checkpoint" ? "Checkpoint" : "VAE"}</label>
|
||||
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
|
||||
<input type="text" id="wfm-lab-modal-value-filter" class="wfm-input wfm-search-input" placeholder="Filter...">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-value-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-lab-modal-value" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === kf.value ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
|
||||
</select>
|
||||
</div>`;
|
||||
} else if (col === "prompt") {
|
||||
const v = kf.value || {};
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-btnrow">
|
||||
<button type="button" id="wfm-lab-modal-get-genui" class="wfm-btn wfm-btn-sm">${t("labGetFromGenUI")}</button>
|
||||
<button type="button" id="wfm-lab-modal-get-image" class="wfm-btn wfm-btn-sm">${t("labGetFromImage")}</button>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>Positive</label>
|
||||
<textarea id="wfm-lab-modal-positive" class="wfm-input" rows="3">${escapeHtml(v.positive || "")}</textarea>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>Negative</label>
|
||||
<textarea id="wfm-lab-modal-negative" class="wfm-input" rows="3">${escapeHtml(v.negative || "")}</textarea>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${t("labStyle")}</label>
|
||||
<button type="button" id="wfm-lab-modal-style-apply" class="wfm-btn wfm-btn-sm">${t("labApply")}</button>
|
||||
</div>`;
|
||||
} else if (col === "ksampler") {
|
||||
const v = kf.value || {};
|
||||
const samplers = comfyEditor.models.samplers || [];
|
||||
const schedulers = comfyEditor.models.schedulers || [];
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-inline">
|
||||
<div><label>Steps</label><input type="number" id="wfm-lab-modal-steps" class="wfm-input" value="${v.steps ?? ""}"></div>
|
||||
<div><label>CFG</label><input type="number" step="0.1" id="wfm-lab-modal-cfg" class="wfm-input" value="${v.cfg ?? ""}"></div>
|
||||
<div><label>Denoise</label><input type="number" step="0.01" min="0" max="1" id="wfm-lab-modal-denoise" class="wfm-input" value="${v.denoise ?? ""}"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-inline">
|
||||
<div><label>Sampler</label>
|
||||
<select id="wfm-lab-modal-sampler" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
${samplers.map((s) => `<option value="${escapeHtml(s)}" ${s === v.sampler_name ? "selected" : ""}>${escapeHtml(s)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Scheduler</label>
|
||||
<select id="wfm-lab-modal-scheduler" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
${schedulers.map((s) => `<option value="${escapeHtml(s)}" ${s === v.scheduler ? "selected" : ""}>${escapeHtml(s)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${t("labSeedEmptyRandom")}</label>
|
||||
<input type="number" id="wfm-lab-modal-seed" class="wfm-input" value="${v.seed ?? ""}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const atIterRow = isFirst ? "" : `
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>${t("labApplyFromIteration")}</label>
|
||||
<input type="number" id="wfm-lab-modal-iter" class="wfm-input" value="${kf.atIteration}" min="2" max="${_lab.batchCount}">
|
||||
</div>`;
|
||||
|
||||
const revertRow = isFirst ? "" : `
|
||||
<div class="wfm-lab-modal-checkbox">
|
||||
<input type="checkbox" id="wfm-lab-modal-revert" ${kf.revertToBase ? "checked" : ""}>
|
||||
<label for="wfm-lab-modal-revert">${t("labRevertCheckboxLabel")}</label>
|
||||
</div>`;
|
||||
|
||||
const deleteBtn = (!isFirst && isLast)
|
||||
? `<button id="wfm-lab-modal-delete" class="wfm-btn wfm-btn-danger">${t("delete")}</button>` : "";
|
||||
|
||||
const html = `
|
||||
<div id="wfm-lab-modal-fields">${valueFieldsHtml}</div>
|
||||
${atIterRow}
|
||||
${revertRow}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;justify-content:flex-end;">
|
||||
${deleteBtn}
|
||||
<button id="wfm-lab-modal-save" class="wfm-btn wfm-btn-primary">${t("save")}</button>
|
||||
</div>`;
|
||||
openModal(`${col} #${idx + 1}`, html);
|
||||
if (col === "prompt") _wireLabPromptModalExtras();
|
||||
if (col === "checkpoint" || col === "vae") _wireLabValueFilter(col);
|
||||
|
||||
const revertCb = document.getElementById("wfm-lab-modal-revert");
|
||||
const fieldsWrap = document.getElementById("wfm-lab-modal-fields");
|
||||
const syncDisabled = () => {
|
||||
const disabled = !!revertCb?.checked;
|
||||
if (fieldsWrap) fieldsWrap.style.opacity = disabled ? "0.4" : "1";
|
||||
fieldsWrap?.querySelectorAll("input,select,textarea").forEach((el) => { el.disabled = disabled; });
|
||||
};
|
||||
revertCb?.addEventListener("change", syncDisabled);
|
||||
syncDisabled();
|
||||
|
||||
document.getElementById("wfm-lab-modal-delete")?.addEventListener("click", () => {
|
||||
_lab.columns[col].splice(idx, 1);
|
||||
_renderColumn(col);
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-lab-modal-save")?.addEventListener("click", () => {
|
||||
const revert = !!document.getElementById("wfm-lab-modal-revert")?.checked;
|
||||
let atIteration = kf.atIteration;
|
||||
if (!isFirst) {
|
||||
const parsed = parseInt(document.getElementById("wfm-lab-modal-iter")?.value, 10);
|
||||
if (!isNaN(parsed)) atIteration = Math.min(Math.max(parsed, 2), _lab.batchCount);
|
||||
}
|
||||
|
||||
let value = kf.value;
|
||||
if (!revert) {
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
value = document.getElementById("wfm-lab-modal-value")?.value || "";
|
||||
} else if (col === "prompt") {
|
||||
value = {
|
||||
positive: document.getElementById("wfm-lab-modal-positive")?.value || "",
|
||||
negative: document.getElementById("wfm-lab-modal-negative")?.value || "",
|
||||
};
|
||||
} else if (col === "ksampler") {
|
||||
const numOrNull = (id) => {
|
||||
const raw = document.getElementById(id)?.value;
|
||||
return (raw === "" || raw == null) ? null : Number(raw);
|
||||
};
|
||||
value = {
|
||||
steps: numOrNull("wfm-lab-modal-steps"),
|
||||
cfg: numOrNull("wfm-lab-modal-cfg"),
|
||||
denoise: numOrNull("wfm-lab-modal-denoise"),
|
||||
sampler_name: document.getElementById("wfm-lab-modal-sampler")?.value || "",
|
||||
scheduler: document.getElementById("wfm-lab-modal-scheduler")?.value || "",
|
||||
seed: numOrNull("wfm-lab-modal-seed"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_lab.columns[col][idx] = { atIteration, value, revertToBase: revert };
|
||||
_lab.columns[col].sort((a, b) => a.atIteration - b.atIteration);
|
||||
_renderColumn(col);
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Merges a style ({prompt, negative_prompt}, from Workflow-Studio/style/*.json) into
|
||||
// plain positive/negative text. Unlike generate-tab.js's _applyNamedStyle() (which
|
||||
// substitutes a {prompt} placeholder into the style template for the main Generate
|
||||
// flow), Lab always appends: existing text is never cleared or reordered, the style's
|
||||
// text is just added after a comma (or used as-is if the field was empty).
|
||||
function _applyStyleToText(positive, negative, style) {
|
||||
const append = (existing, addition) => {
|
||||
if (!addition) return existing;
|
||||
return existing ? `${existing}, ${addition}` : addition;
|
||||
};
|
||||
return {
|
||||
positive: append(positive, style.prompt),
|
||||
negative: append(negative, style.negative_prompt),
|
||||
};
|
||||
}
|
||||
|
||||
// Filter box above the Checkpoint/VAE modal's <select>, same behavior as the
|
||||
// Model tab's filter inputs (comfyui-editor.js renderModelTab): rebuild the
|
||||
// option list on every keystroke, always keeping the "workflow default" option.
|
||||
// The overlay ✕ clear button reuses the same setupSearchClearBtn() helper as the
|
||||
// Models/Nodes/Workflow/Gallery tab search boxes and the Raw JSON search bar.
|
||||
function _wireLabValueFilter(col) {
|
||||
const filterInput = document.getElementById("wfm-lab-modal-value-filter");
|
||||
const select = document.getElementById("wfm-lab-modal-value");
|
||||
if (!filterInput || !select) return;
|
||||
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
|
||||
|
||||
const applyFilter = () => {
|
||||
const filter = filterInput.value.toLowerCase();
|
||||
select.innerHTML = `<option value="">${t("labUseWorkflowDefault")}</option>` +
|
||||
options.filter((m) => m.toLowerCase().includes(filter))
|
||||
.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join("");
|
||||
};
|
||||
|
||||
filterInput.addEventListener("input", applyFilter);
|
||||
setupSearchClearBtn("wfm-lab-modal-value-filter", "wfm-lab-modal-value-filter-clear", applyFilter);
|
||||
}
|
||||
|
||||
// Wires the Prompt-column modal's extra actions: fetch the live GenerateUI prompt,
|
||||
// extract a prompt from a dropped image/workflow file, and apply a saved Style.
|
||||
function _wireLabPromptModalExtras() {
|
||||
// Reuses the Style checkbox/dropdown already at the top of the GenerateUI toolbar
|
||||
// (populated by generate-tab.js's _loadStyles()) instead of duplicating a second
|
||||
// style picker inside this modal.
|
||||
const styleApplyBtn = document.getElementById("wfm-lab-modal-style-apply");
|
||||
const topStyleName = document.getElementById("wfm-style-select")?.value;
|
||||
const topStyleEnabled = !!document.getElementById("wfm-style-enabled")?.checked;
|
||||
if (styleApplyBtn) {
|
||||
styleApplyBtn.textContent = (topStyleEnabled && topStyleName) ? `${t("labApply")}: ${topStyleName}` : t("labApply");
|
||||
}
|
||||
|
||||
styleApplyBtn?.addEventListener("click", async () => {
|
||||
const enabled = !!document.getElementById("wfm-style-enabled")?.checked;
|
||||
const styleName = document.getElementById("wfm-style-select")?.value;
|
||||
if (!enabled || !styleName) {
|
||||
showToast(t("labEnableStyleFirst"), "error");
|
||||
return;
|
||||
}
|
||||
let styles = [];
|
||||
try {
|
||||
const res = await fetch("/api/wfm/styles");
|
||||
styles = res.ok ? await res.json() : [];
|
||||
} catch { styles = []; }
|
||||
const style = styles.find((s) => s.name === styleName);
|
||||
if (!style) { showToast(t("labNoStyles"), "error"); return; }
|
||||
|
||||
const posEl = document.getElementById("wfm-lab-modal-positive");
|
||||
const negEl = document.getElementById("wfm-lab-modal-negative");
|
||||
const merged = _applyStyleToText(posEl?.value || "", negEl?.value || "", style);
|
||||
if (posEl) posEl.value = merged.positive;
|
||||
if (negEl) negEl.value = merged.negative;
|
||||
showToast(t("labStyleApplied"), "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-lab-modal-get-genui")?.addEventListener("click", () => {
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!analysis) { showToast(t("labNoWorkflowLoaded"), "error"); return; }
|
||||
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
|
||||
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
|
||||
const posEl = document.getElementById("wfm-lab-modal-positive");
|
||||
const negEl = document.getElementById("wfm-lab-modal-negative");
|
||||
if (posEl) posEl.value = posNode?.text ?? "";
|
||||
if (negEl) negEl.value = negNode?.text ?? "";
|
||||
showToast(t("labPromptFetched"), "success");
|
||||
});
|
||||
|
||||
// Reads metadata from the image already loaded in Lab's own Image drop zone
|
||||
// (_lab.sourceImageFilename, already uploaded to ComfyUI's input folder) rather
|
||||
// than asking the user to pick a separate file.
|
||||
document.getElementById("wfm-lab-modal-get-image")?.addEventListener("click", async () => {
|
||||
if (!_lab.sourceImageFilename) {
|
||||
showToast(t("labNoSourceImage"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/view?filename=${encodeURIComponent(_lab.sourceImageFilename)}&type=input`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], _lab.sourceImageFilename, { type: blob.type || "image/png" });
|
||||
|
||||
const meta = await extractAllMetadata(file);
|
||||
if (!meta || (!meta.positives?.length && !meta.negatives?.length)) {
|
||||
showToast(t("labNoPromptInImage"), "error");
|
||||
return;
|
||||
}
|
||||
const posEl = document.getElementById("wfm-lab-modal-positive");
|
||||
const negEl = document.getElementById("wfm-lab-modal-negative");
|
||||
if (posEl && meta.positives?.[0]) posEl.value = meta.positives[0];
|
||||
if (negEl && meta.negatives?.[0]) negEl.value = meta.negatives[0];
|
||||
showToast(t("labPromptFetched"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labMetadataExtractFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Effective-value resolution & workflow build
|
||||
// ============================================
|
||||
|
||||
function _resolveKeyframe(col, iteration) {
|
||||
const kfs = _lab.columns[col];
|
||||
let applicable = null;
|
||||
for (const kf of kfs) {
|
||||
if (kf.atIteration <= iteration) applicable = kf;
|
||||
else break;
|
||||
}
|
||||
if (!applicable) return null;
|
||||
if (applicable.revertToBase) {
|
||||
const row1 = kfs.find((k) => k.atIteration === 1);
|
||||
return (row1 && !_isEmptyValue(col, row1.value)) ? row1.value : null;
|
||||
}
|
||||
return _isEmptyValue(col, applicable.value) ? null : applicable.value;
|
||||
}
|
||||
|
||||
function _buildWorkflowForIteration(iteration, chainImageRef) {
|
||||
const wf = JSON.parse(JSON.stringify(comfyUI.currentWorkflow || {}));
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!analysis) return { workflow: wf, seed: null };
|
||||
|
||||
const write = (nodeId, key, val) => {
|
||||
if (nodeId != null && wf[nodeId]) wf[nodeId].inputs[key] = val;
|
||||
};
|
||||
|
||||
const ckpt = _resolveKeyframe("checkpoint", iteration);
|
||||
if (ckpt && analysis.checkpoint_nodes?.[0]) {
|
||||
write(analysis.checkpoint_nodes[0].id, "ckpt_name", ckpt);
|
||||
}
|
||||
|
||||
const vae = _resolveKeyframe("vae", iteration);
|
||||
if (vae && analysis.vae_nodes?.[0]) {
|
||||
write(analysis.vae_nodes[0].id, "vae_name", vae);
|
||||
}
|
||||
|
||||
const prompt = _resolveKeyframe("prompt", iteration);
|
||||
if (prompt) {
|
||||
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
|
||||
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
|
||||
if (posNode && prompt.positive) write(posNode.id, posNode.textKey || "text", prompt.positive);
|
||||
if (negNode && prompt.negative) write(negNode.id, negNode.textKey || "text", prompt.negative);
|
||||
}
|
||||
|
||||
let seed = null;
|
||||
const ks = _resolveKeyframe("ksampler", iteration);
|
||||
const sampler = analysis.sampler_nodes?.[0];
|
||||
if (ks && sampler) {
|
||||
if (ks.steps != null) write(sampler.stepsNodeId ?? sampler.id, "steps", ks.steps);
|
||||
if (ks.cfg != null) write(sampler.cfgNodeId ?? sampler.id, "cfg", ks.cfg);
|
||||
if (ks.sampler_name) write(sampler.samplerNodeId ?? sampler.id, "sampler_name", ks.sampler_name);
|
||||
if (ks.scheduler && sampler.schedulerNodeId) write(sampler.schedulerNodeId, "scheduler", ks.scheduler);
|
||||
if (ks.denoise != null && sampler.denoiseNodeId) write(sampler.denoiseNodeId, "denoise", ks.denoise);
|
||||
if (ks.seed != null) seed = ks.seed;
|
||||
}
|
||||
|
||||
const imageRef = chainImageRef || _lab.sourceImageFilename;
|
||||
if (imageRef && analysis.load_image_nodes?.[0]) {
|
||||
write(analysis.load_image_nodes[0].id, "image", imageRef);
|
||||
}
|
||||
|
||||
return { workflow: wf, seed };
|
||||
}
|
||||
|
||||
// ComfyUI's LoadImage accepts "name [type]" (optionally "subfolder/name [type]") to
|
||||
// reference a previously generated output/temp image without re-uploading it.
|
||||
function _annotatedImageRef(img) {
|
||||
const rel = img.subfolder ? `${img.subfolder}/${img.filename}` : img.filename;
|
||||
return `${rel} [${img.type || "output"}]`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Batch execution
|
||||
// ============================================
|
||||
|
||||
function _waitIfPaused() {
|
||||
if (!_paused) return Promise.resolve();
|
||||
return new Promise((resolve) => { _resumeResolve = resolve; });
|
||||
}
|
||||
|
||||
function _setRunUiState(running) {
|
||||
const runBtn = document.getElementById("wfm-lab-run-btn");
|
||||
const cancelBtn = document.getElementById("wfm-lab-cancel-btn");
|
||||
const pauseBtn = document.getElementById("wfm-lab-pause-btn");
|
||||
if (runBtn) runBtn.style.display = running ? "none" : "";
|
||||
if (cancelBtn) cancelBtn.style.display = running ? "" : "none";
|
||||
if (pauseBtn) {
|
||||
pauseBtn.disabled = !running;
|
||||
pauseBtn.textContent = t("labPause");
|
||||
}
|
||||
}
|
||||
|
||||
async function _runLabBatch() {
|
||||
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) {
|
||||
showToast(t("labNoWorkflowLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
if (!_lab.sourceImageFilename) {
|
||||
showToast(t("labNoSourceImage"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
_paused = false;
|
||||
_aborted = false;
|
||||
_lastGeneratedImageRef = null;
|
||||
_lab.results.images = [];
|
||||
_renderResultsGrid();
|
||||
_setRunUiState(true);
|
||||
|
||||
const progressBar = document.getElementById("wfm-lab-progress-bar");
|
||||
const progressText = document.getElementById("wfm-lab-progress-text");
|
||||
const eagleAutoSave = getEagleSettings().autoSave;
|
||||
|
||||
let completed = 0, failed = 0;
|
||||
try {
|
||||
for (let i = 1; i <= _lab.batchCount; i++) {
|
||||
if (_aborted) break;
|
||||
await _waitIfPaused();
|
||||
if (_aborted) break;
|
||||
|
||||
if (progressText) progressText.textContent = `[${i}/${_lab.batchCount}] ...`;
|
||||
const chainRef = (_lab.chainImage && i > 1) ? _lastGeneratedImageRef : null;
|
||||
const { workflow, seed } = _buildWorkflowForIteration(i, chainRef);
|
||||
|
||||
try {
|
||||
const { images } = await comfyUI.generate(workflow, {
|
||||
seedMode: seed != null ? "fixed" : "random",
|
||||
seedValue: seed ?? -1,
|
||||
onProgress: (pct) => {
|
||||
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
|
||||
if (progressText) progressText.textContent = `[${i}/${_lab.batchCount}] ${(pct * 100).toFixed(0)}%`;
|
||||
},
|
||||
});
|
||||
const outputImages = images.filter((img) => img.type !== "temp");
|
||||
if (outputImages.length > 0) {
|
||||
_lastGeneratedImageRef = _annotatedImageRef(outputImages[0]);
|
||||
}
|
||||
for (const img of outputImages) {
|
||||
if (_lab.results.images.length < MAX_RESULTS) {
|
||||
_lab.results.images.push({ ...img, iteration: i });
|
||||
}
|
||||
if (eagleAutoSave) {
|
||||
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
saveToEagle(viewUrl, img.filename, [], img);
|
||||
}
|
||||
}
|
||||
_renderResultsGrid();
|
||||
completed++;
|
||||
} catch (err) {
|
||||
if (_aborted) break;
|
||||
failed++;
|
||||
showToast(t("batchItemFailed", i, _lab.batchCount, err.message), "error");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_paused = false;
|
||||
if (_resumeResolve) { _resumeResolve(); _resumeResolve = null; }
|
||||
_setRunUiState(false);
|
||||
}
|
||||
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
if (_aborted) {
|
||||
showToast(t("batchStopped", completed, failed), "info");
|
||||
} else {
|
||||
showToast(t("batchComplete", completed, _lab.batchCount, failed), failed > 0 ? "error" : "success");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Results grid
|
||||
// ============================================
|
||||
|
||||
function _resultImageUrl(img) {
|
||||
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
}
|
||||
|
||||
function _renderResultsGrid() {
|
||||
const grid = document.getElementById("wfm-lab-results-grid");
|
||||
if (!grid) return;
|
||||
if (_lab.results.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("labNoResultsYet")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = _lab.results.images.map((img, i) => `
|
||||
<div class="wfm-lab-result-item">
|
||||
<span class="wfm-lab-result-index">${img.iteration}</span>
|
||||
<img class="wfm-lab-result-thumb" data-idx="${i}" src="${_resultImageUrl(img)}">
|
||||
</div>`).join("");
|
||||
grid.querySelectorAll(".wfm-lab-result-thumb").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
openModal(t("labResultPreview"), `<img src="${el.src}" style="max-width:100%;max-height:80vh;display:block;margin:0 auto;">`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _updateResultsSourceImage() {
|
||||
const img = document.getElementById("wfm-lab-results-source-img");
|
||||
if (!img) return;
|
||||
if (_lab.sourceImageFilename) {
|
||||
img.src = `/view?filename=${encodeURIComponent(_lab.sourceImageFilename)}&type=input`;
|
||||
img.style.display = "";
|
||||
} else {
|
||||
img.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Index (contact-sheet) thumbnail generation
|
||||
// ============================================
|
||||
|
||||
function _loadImageEl(src) {
|
||||
return new Promise((resolve) => {
|
||||
const im = new Image();
|
||||
im.onload = () => resolve(im);
|
||||
im.onerror = () => resolve(null);
|
||||
im.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function _buildIndexImageDataUrl() {
|
||||
const imgs = _lab.results.images.slice(0, MAX_RESULTS);
|
||||
if (imgs.length === 0) return null;
|
||||
|
||||
const cols = Math.min(RESULTS_GRID_COLS, imgs.length);
|
||||
const rows = Math.ceil(imgs.length / cols);
|
||||
const cellSize = 128;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cols * cellSize;
|
||||
canvas.height = rows * cellSize;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let i = 0; i < imgs.length; i++) {
|
||||
const im = await _loadImageEl(_resultImageUrl(imgs[i]));
|
||||
if (!im) continue;
|
||||
const col = i % cols, row = Math.floor(i / cols);
|
||||
const scale = Math.max(cellSize / im.width, cellSize / im.height);
|
||||
const w = im.width * scale, h = im.height * scale;
|
||||
ctx.drawImage(im, col * cellSize - (w - cellSize) / 2, row * cellSize - (h - cellSize) / 2, w, h);
|
||||
}
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Plan save / load / clear
|
||||
// ============================================
|
||||
|
||||
async function _savePlan(filenameOverride, forceNewName = false) {
|
||||
let filename = filenameOverride;
|
||||
if (!filename) {
|
||||
if (forceNewName || !_lab.planFilename) {
|
||||
filename = window.prompt(t("labEnterPlanName"), forceNewName ? "" : (_lab.planFilename || ""));
|
||||
if (!filename) return;
|
||||
} else {
|
||||
filename = _lab.planFilename;
|
||||
}
|
||||
}
|
||||
|
||||
const data = _buildPlanData(filename.replace(/\.json$/i, ""));
|
||||
|
||||
let indexImageBase64 = null;
|
||||
try {
|
||||
indexImageBase64 = await _buildIndexImageDataUrl();
|
||||
} catch { /* thumbnail generation is best-effort */ }
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/lab/plans/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, data, index_image_base64: indexImageBase64 }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
_lab.planFilename = json.filename;
|
||||
showToast(t("labPlanSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanSaveFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _applyPlanData(filename, data) {
|
||||
_lab.planFilename = filename;
|
||||
_lab.note = data.note || "";
|
||||
_lab.batchCount = data.batch_count || 1;
|
||||
_lab.chainImage = !!data.chain_image;
|
||||
_lab.sourceImageFilename = data.source_image || null;
|
||||
_lab.columns = data.columns || _emptyColumns();
|
||||
_lab.results = { images: (data.results?.images || []).slice(0, MAX_RESULTS) };
|
||||
|
||||
const noteEl = document.getElementById("wfm-lab-note");
|
||||
const batchEl = document.getElementById("wfm-lab-batch-count");
|
||||
const chainEl = document.getElementById("wfm-lab-chain-image");
|
||||
if (noteEl) noteEl.value = _lab.note;
|
||||
if (batchEl) batchEl.value = _lab.batchCount;
|
||||
if (chainEl) chainEl.checked = _lab.chainImage;
|
||||
|
||||
const previewWrap = document.getElementById("wfm-lab-preview-wrap");
|
||||
const previewImg = document.getElementById("wfm-lab-preview-img");
|
||||
if (_lab.sourceImageFilename && previewImg && previewWrap) {
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(_lab.sourceImageFilename)}&type=input`;
|
||||
previewWrap.style.display = "";
|
||||
} else if (previewWrap) {
|
||||
previewWrap.style.display = "none";
|
||||
}
|
||||
|
||||
_renderAllColumns();
|
||||
_renderResultsGrid();
|
||||
_updateResultsSourceImage();
|
||||
}
|
||||
|
||||
// Plan Load reads a dropped/picked .json plan file directly in the browser (no
|
||||
// server round-trip needed — the browser's native file picker can't be preset to
|
||||
// open in .../Workflow-Studio/lab_plan, but most browsers remember the folder
|
||||
// after the user browses there once).
|
||||
async function _loadPlanFromFile(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const filename = file.name.toLowerCase().endsWith(".json") ? file.name : `${file.name}.json`;
|
||||
_applyPlanData(filename, data);
|
||||
showToast(t("labPlanLoaded"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanLoadFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Dropping the index-image thumbnail (same basename as its .json, saved
|
||||
// alongside it on the server) fetches the actual plan JSON by that filename.
|
||||
async function _loadPlanFromIndexImage(file) {
|
||||
const stem = file.name.replace(/\.[^.]+$/, "");
|
||||
await _loadPlanFromServer(`${stem}.json`);
|
||||
}
|
||||
|
||||
async function _loadPlanFromServer(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/lab/plans/content?filename=${encodeURIComponent(filename)}`);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
_applyPlanData(filename, json.data || {});
|
||||
showToast(t("labPlanLoaded"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("labPlanLoadFailed")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function _clearPlan() {
|
||||
if (!window.confirm(t("labConfirmClear"))) return;
|
||||
_lab = _emptyLabState();
|
||||
|
||||
const noteEl = document.getElementById("wfm-lab-note");
|
||||
const batchEl = document.getElementById("wfm-lab-batch-count");
|
||||
const chainEl = document.getElementById("wfm-lab-chain-image");
|
||||
if (noteEl) noteEl.value = "";
|
||||
if (batchEl) batchEl.value = _lab.batchCount;
|
||||
if (chainEl) chainEl.checked = false;
|
||||
|
||||
const previewWrap = document.getElementById("wfm-lab-preview-wrap");
|
||||
if (previewWrap) previewWrap.style.display = "none";
|
||||
|
||||
_renderAllColumns();
|
||||
_renderResultsGrid();
|
||||
_updateResultsSourceImage();
|
||||
}
|
||||
+118
-30
@@ -193,6 +193,45 @@ function extractPrompts(wf) {
|
||||
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
|
||||
}
|
||||
|
||||
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
|
||||
function resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const srcNode = nodeMap.get(srcId);
|
||||
if (!srcNode) return null;
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[slot];
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
|
||||
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
|
||||
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
|
||||
for (const name of ["on_false", "on_true"]) {
|
||||
const inp = srcNode.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) continue;
|
||||
const text = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// WFS_PromptText, PrimitiveStringMultiline 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") return v;
|
||||
// さらにリンクされている場合(StringReplace等の中継ノード)は追跡する
|
||||
if (Array.isArray(srcNode.inputs)) {
|
||||
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string"].includes(i.name));
|
||||
if (nextInput?.link != null) {
|
||||
const originId = linkOrigin.get(nextInput.link);
|
||||
const originSlot = linkSlot.get(nextInput.link) ?? 0;
|
||||
if (originId != null) return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
|
||||
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
|
||||
function extractPromptsFromNodeSet(nodes, links) {
|
||||
@@ -221,18 +260,8 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const srcNode = originId != null ? nodeMap.get(originId) : null;
|
||||
if (srcNode) {
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[originSlot];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
} else {
|
||||
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
}
|
||||
}
|
||||
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,24 +270,46 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
for (const n of nodes) {
|
||||
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
|
||||
foundSampler = true;
|
||||
for (const inp of n.inputs) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role slots.
|
||||
let inputsToScan = n.inputs;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
const isPos = name === "positive" || name.startsWith("positive");
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
// negative_prompt directly, so textMap (single text per node) can't represent both
|
||||
// roles.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const text = srcNode.widgets_values?.[widgetIdx];
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(originId);
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
let resolvedOriginId = originId;
|
||||
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
|
||||
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
|
||||
if (innerInput?.link != null) {
|
||||
const innerId = linkOrigin.get(innerInput.link);
|
||||
if (innerId != null) resolvedOriginId = innerId;
|
||||
}
|
||||
}
|
||||
const text = textMap.get(resolvedOriginId);
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
@@ -312,7 +363,8 @@ function extractMarkdownNoteModels(wf) {
|
||||
}
|
||||
|
||||
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
|
||||
function resolveLinkedText(wf, srcId, slot) {
|
||||
function resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const src = wf[String(srcId)];
|
||||
if (!src || typeof src !== "object") return null;
|
||||
const ct = src.class_type ?? "";
|
||||
@@ -321,13 +373,32 @@ function resolveLinkedText(wf, srcId, slot) {
|
||||
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// 汎用テキストキー
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
|
||||
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
|
||||
// リテラルへ解決できた方を採用する。
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const v = src.inputs?.[key];
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
} else if (typeof v === "string" && v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 汎用テキストキー("value" は PrimitiveString/PrimitiveStringMultiline)
|
||||
const keys = slot === 0
|
||||
? ["text_positive", "text", "text_g", "prompt"]
|
||||
? ["text_positive", "text", "text_g", "prompt", "value"]
|
||||
: ["text_negative", "text_l"];
|
||||
for (const k of keys) {
|
||||
const v = src.inputs?.[k];
|
||||
if (v && typeof v === "string") return v;
|
||||
if (typeof v === "string" && v) return v;
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -342,7 +413,9 @@ function extractPromptsAPI(wf) {
|
||||
const textMap = new Map();
|
||||
for (const [id, n] of Object.entries(wf)) {
|
||||
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
|
||||
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
|
||||
// model text encoders use this key instead of "text"/"text_g".
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
|
||||
if (raw && typeof raw === "string") {
|
||||
textMap.set(id, raw);
|
||||
} else if (Array.isArray(raw)) {
|
||||
@@ -356,20 +429,35 @@ function extractPromptsAPI(wf) {
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || key.startsWith("positive");
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
// negative_prompt directly, so textMap (single text per node) can't represent both
|
||||
// roles.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
|
||||
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(String(val[0]));
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
|
||||
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
|
||||
: String(val[0]);
|
||||
const text = textMap.get(resolvedId);
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
@@ -468,7 +556,7 @@ function parseFooocusMetadata(raw) {
|
||||
}
|
||||
|
||||
// ── Master extractAllMetadata ─────────────────────────────────
|
||||
async function extractAllMetadata(file) {
|
||||
export async function extractAllMetadata(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
|
||||
@@ -38,6 +38,49 @@ export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
export function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="/wfm_static/css/gallery-tab.css">
|
||||
<link rel="stylesheet" href="/wfm_static/css/tagger-tab.css">
|
||||
<link rel="stylesheet" href="/wfm_static/css/image-edit-tab.css">
|
||||
<link rel="stylesheet" href="/wfm_static/css/lab-tab.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -310,6 +311,7 @@
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="settings">Settings</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="feeder">Feeder</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="batch">Batch</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="lab">Lab</button>
|
||||
<button id="wfm-gen-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" style="margin-left:auto;" title="Save current workflow to Workflow tab">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -501,6 +503,150 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Lab — experimental I2I batch generation with per-column keyframes -->
|
||||
<div id="wfm-gen-subtab-lab" class="wfm-gen-subtab-content">
|
||||
<div class="wfm-lab-subtab-nav">
|
||||
<button class="wfm-lab-subtab-btn active" data-lab-subtab="setting">Setting</button>
|
||||
<button class="wfm-lab-subtab-btn" data-lab-subtab="results">Results</button>
|
||||
<button class="wfm-lab-subtab-btn" data-lab-subtab="planjson">Plan JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Setting panel -->
|
||||
<div id="wfm-lab-panel-setting" class="wfm-lab-panel active">
|
||||
<div class="wfm-lab-setting-layout">
|
||||
<!-- Left: source image + plan load/clear -->
|
||||
<div class="wfm-lab-pane wfm-lab-left">
|
||||
<div class="wfm-lab-pane-header">Image</div>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-lab-preview-wrap" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-lab-preview-img" alt="">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-lab-drop-zone">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<input type="file" id="wfm-lab-file-input" accept="image/*" style="display:none;">
|
||||
Drop image here or click to select
|
||||
</label>
|
||||
</div>
|
||||
<label class="wfm-lab-checkbox-label">
|
||||
<input type="checkbox" id="wfm-lab-chain-image">
|
||||
Use generated image for next
|
||||
</label>
|
||||
|
||||
<div class="wfm-lab-pane-header" style="margin-top:10px;">Plan</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-lab-plan-drop-zone">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
<input type="file" id="wfm-lab-plan-file-input" accept="application/json,.json,image/png,.png" style="display:none;">
|
||||
Drop plan (.json/.png) here or click to select
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-lab-side-btns">
|
||||
<button id="wfm-lab-plan-clear-btn" class="wfm-btn">Plan Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: 4 keyframe columns -->
|
||||
<div class="wfm-lab-pane wfm-lab-center">
|
||||
<div class="wfm-lab-columns">
|
||||
<div class="wfm-lab-column" data-col="checkpoint">
|
||||
<div class="wfm-lab-col-header">
|
||||
<span>checkpoint</span>
|
||||
<div class="wfm-lab-col-btns">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="checkpoint" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="checkpoint" title="Remove last keyframe">-</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-checkpoint"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-column" data-col="vae">
|
||||
<div class="wfm-lab-col-header">
|
||||
<span>vae</span>
|
||||
<div class="wfm-lab-col-btns">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="vae" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="vae" title="Remove last keyframe">-</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-vae"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-column" data-col="prompt">
|
||||
<div class="wfm-lab-col-header">
|
||||
<span>プロンプト</span>
|
||||
<div class="wfm-lab-col-btns">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="prompt" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="prompt" title="Remove last keyframe">-</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-prompt"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-column" data-col="ksampler">
|
||||
<div class="wfm-lab-col-header">
|
||||
<span>Ksampler</span>
|
||||
<div class="wfm-lab-col-btns">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="ksampler" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="ksampler" title="Remove last keyframe">-</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-ksampler"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: run controls -->
|
||||
<div class="wfm-lab-pane wfm-lab-right">
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="wfm-lab-run-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Run</button>
|
||||
<button id="wfm-lab-cancel-btn" class="wfm-btn wfm-btn-danger" style="display:none;">Cancel</button>
|
||||
</div>
|
||||
<button id="wfm-lab-pause-btn" class="wfm-btn" style="width:100%;margin-top:6px;" disabled>Pause</button>
|
||||
|
||||
<div class="wfm-gen-progress-container" style="margin-top:8px;">
|
||||
<div class="wfm-gen-progress-track">
|
||||
<div id="wfm-lab-progress-bar" class="wfm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span id="wfm-lab-progress-text" class="wfm-gen-progress-text">Ready</span>
|
||||
</div>
|
||||
|
||||
<div class="wfm-lab-field-row">
|
||||
<label>Batch:</label>
|
||||
<input type="number" id="wfm-lab-batch-count" class="wfm-input" value="4" min="1" max="50">
|
||||
</div>
|
||||
|
||||
<div class="wfm-lab-pane-header" style="margin-top:12px;">Note</div>
|
||||
<textarea id="wfm-lab-note" class="wfm-input wfm-lab-note" rows="6"></textarea>
|
||||
|
||||
<button id="wfm-lab-plan-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:8px;">Plan Save</button>
|
||||
<button id="wfm-lab-plan-saveas-btn" class="wfm-btn" style="width:100%;margin-top:6px;">Save As</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results panel -->
|
||||
<div id="wfm-lab-panel-results" class="wfm-lab-panel">
|
||||
<div class="wfm-lab-results-layout">
|
||||
<div class="wfm-lab-pane wfm-lab-results-left">
|
||||
<div class="wfm-lab-pane-header">Image</div>
|
||||
<img id="wfm-lab-results-source-img" class="wfm-i2i-preview-img" style="display:none;" alt="">
|
||||
</div>
|
||||
<div class="wfm-lab-pane wfm-lab-results-center">
|
||||
<div class="wfm-lab-pane-header">Results</div>
|
||||
<div id="wfm-lab-results-grid" class="wfm-lab-results-grid">
|
||||
<p class="wfm-placeholder">No results yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan JSON panel -->
|
||||
<div id="wfm-lab-panel-planjson" class="wfm-lab-panel">
|
||||
<div class="wfm-lab-planjson-toolbar">
|
||||
<button type="button" id="wfm-lab-planjson-refresh" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button type="button" id="wfm-lab-planjson-apply" class="wfm-btn wfm-btn-primary wfm-btn-sm">Apply to Setting</button>
|
||||
</div>
|
||||
<div class="wfm-json-container">
|
||||
<pre id="wfm-lab-planjson-highlight" class="wfm-json-highlight"></pre>
|
||||
<textarea id="wfm-lab-planjson-editor" class="wfm-json-editor" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Feeder — full-width: settings(left) + library(center) -->
|
||||
<div id="wfm-gen-subtab-feeder" class="wfm-gen-subtab-content">
|
||||
<div class="wfm-feeder-layout">
|
||||
@@ -1036,6 +1182,7 @@
|
||||
<button class="wfm-help-nav-item" data-help-page="workflow">Workflow Tab</button>
|
||||
<button class="wfm-help-nav-item" data-help-page="generate">GenerateUI Tab</button>
|
||||
<button class="wfm-help-nav-item" data-help-page="feeder">Feeder subtab</button>
|
||||
<button class="wfm-help-nav-item" data-help-page="lab">Lab subtab</button>
|
||||
<button class="wfm-help-nav-item" data-help-page="prompt">Prompt Tab</button>
|
||||
<button class="wfm-help-nav-item" data-help-page="metadata">Metadata Tab</button>
|
||||
<button class="wfm-help-nav-item" data-help-page="settings">Settings Tab</button>
|
||||
@@ -1187,6 +1334,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-page" id="wfm-help-page-lab">
|
||||
<div class="wfm-help-article-header">
|
||||
<h2 id="wfm-help-lab-title">GenerateUI Tab — Lab subtab</h2>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-help-jump-btn" data-jump-tab="generate">Open tab →</button>
|
||||
</div>
|
||||
<p id="wfm-help-lab-desc" style="margin-bottom:12px;">Experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration. Two subtabs: Setting and Results.</p>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-lab-card-columns-title">Setting subtab — Image & Columns</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-lab-1">Left pane — Image: drop or click to select an I2I source image; it is uploaded to ComfyUI's input folder and used for every iteration unless "Use generated image for next" is checked</li>
|
||||
<li id="wfm-help-lab-2">Left pane — Use generated image for next: from iteration 2 onward, feeds the previous iteration's first output image back in as the I2I source instead of the original dropped image (iteration 1 always uses the original)</li>
|
||||
<li id="wfm-help-lab-3">Center pane — 4 independent columns: Checkpoint, VAE, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid</li>
|
||||
<li id="wfm-help-lab-4">+ / − per column: + appends a new keyframe at the next free iteration number (up to the Batch count) and opens its edit modal immediately; − removes the last keyframe (iteration #1 can never be removed)</li>
|
||||
<li id="wfm-help-lab-5">Click a cell to edit it: iteration #1 sets the baseline value (leave it blank to keep the loaded workflow's own setting); iteration #2+ also lets you choose which iteration it starts applying from, plus a "revert to #1's setting" checkbox</li>
|
||||
<li id="wfm-help-lab-6">Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; "revert to #1" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one</li>
|
||||
<li id="wfm-help-lab-7">Lab never edits the workflow shown in Input/Model/Settings — every iteration runs on a fresh clone of the loaded workflow, so keyframes here never affect the other subtabs</li>
|
||||
<li id="wfm-help-lab-8">Prompt cell editor extras: <b>Get from GenerateUI</b> fills Positive/Negative from the workflow currently loaded in GenerateUI; <b>Get from Image</b> extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); <b>Style</b> appends whichever style is currently selected in the Style checkbox/dropdown at the top of the GenerateUI toolbar to the Positive/Negative text below, comma-separated after any existing text (never clears it) — enable that checkbox and pick a style first, Lab doesn't have its own separate style picker</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-lab-card-plan-title">Setting subtab — Run & Plan files</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-lab-plan-1">Right pane — Batch: number of iterations to run; Note: free-text memo saved together with the plan</li>
|
||||
<li id="wfm-help-lab-plan-2">Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop</li>
|
||||
<li id="wfm-help-lab-plan-3">Plan Save vs Save As: Plan Save silently overwrites the currently loaded plan (or asks for a name the first time); Save As always asks for a new name, even while a plan is already loaded</li>
|
||||
<li id="wfm-help-lab-plan-4">Plan Load: drop a plan's .json file, or its .png index-image thumbnail, onto the PLAN box (or click it to browse) — a dropped .json is read directly; a dropped .png fetches the matching .json (same filename) from the server</li>
|
||||
<li id="wfm-help-lab-plan-5">Plan Clear resets the Setting tab to an empty plan (asks for confirmation first)</li>
|
||||
<li id="wfm-help-lab-plan-6">Plan files are stored in <code>user/default/Workflow-Studio/lab_plan/</code> as <code><name>.json</code> plus a <code><name>.png</code> index image — a contact sheet of up to 9 result thumbnails (3 per row) generated automatically whenever a plan with results is saved</li>
|
||||
<li id="wfm-help-lab-plan-7">Eagle auto-save (configured in the Settings tab) applies to every image Lab generates, the same as GenerateUI's own Generate button</li>
|
||||
<li id="wfm-help-lab-plan-8">Plan JSON subtab: shows the current Setting tab's plan as raw JSON; <b>Refresh</b> re-reads it from the Setting tab (discarding any unapplied text edits), <b>Apply to Setting</b> parses your edits and writes them back into the Setting tab (columns, note, batch count, chain-image, source image, results) — the same effect as loading that JSON as a plan file, without needing to save it first</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-lab-card-results-title">Results subtab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-lab-results-1">Left pane shows the source image used for the run</li>
|
||||
<li id="wfm-help-lab-results-2">Center pane shows up to 9 generated images in generation order; click a thumbnail to view it enlarged</li>
|
||||
<li id="wfm-help-lab-results-3">Loading a saved plan restores its Results grid from the image list recorded in the plan file</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-page" id="wfm-help-page-prompt">
|
||||
<div class="wfm-help-article-header">
|
||||
<h2 id="wfm-help-prompt-title">Prompt Tab</h2>
|
||||
@@ -1710,6 +1902,7 @@
|
||||
<li id="wfm-help-trouble-4">Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting</li>
|
||||
<li id="wfm-help-trouble-5">LoRA Stack fails with "No such file or directory" error: The LoRA may not be recognized by comfyui-lora-manager (known issue). Try a different LoRA or check if the file appears in Lora Manager's own UI</li>
|
||||
<li id="wfm-help-trouble-6">Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.</li>
|
||||
<li id="wfm-help-trouble-9">Generate runs a different workflow than the one just loaded: a Batch type checkbox (Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler) is left ON in the Batch subtab. While any Batch type is active, the Generate button runs the batch selection instead of the currently loaded workflow. Open the Batch subtab and turn off the active batch type checkbox.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
|
||||
+104
-22
@@ -2939,6 +2939,41 @@ function _isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes(
|
||||
function _isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function _isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
|
||||
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
|
||||
function _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const srcNode = nodeMap.get(srcId);
|
||||
if (!srcNode) return null;
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (_isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[slot];
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
|
||||
for (const name of ["on_false", "on_true"]) {
|
||||
const inp = srcNode.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) continue;
|
||||
const text = _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") return v;
|
||||
if (Array.isArray(srcNode.inputs)) {
|
||||
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string"].includes(i.name));
|
||||
if (nextInput?.link != null) {
|
||||
const originId = linkOrigin.get(nextInput.link);
|
||||
const originSlot = linkSlot.get(nextInput.link) ?? 0;
|
||||
if (originId != null) return _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _extractPromptsFromNodeSet(nodes, links) {
|
||||
const nodeMap = new Map();
|
||||
for (const n of nodes) nodeMap.set(n.id, n);
|
||||
@@ -2959,12 +2994,8 @@ function _extractPromptsFromNodeSet(nodes, links) {
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const srcNode = originId != null ? nodeMap.get(originId) : null;
|
||||
if (srcNode) {
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (_isPromptStylerNode(srcType)) { const v = srcNode.widgets_values?.[originSlot]; if (v && typeof v === "string") textMap.set(n.id, v); }
|
||||
else { const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0]; if (v && typeof v === "string") textMap.set(n.id, v); }
|
||||
}
|
||||
const text2 = _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2973,24 +3004,44 @@ function _extractPromptsFromNodeSet(nodes, links) {
|
||||
for (const n of nodes) {
|
||||
if (!_isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
|
||||
foundSampler = true;
|
||||
for (const inp of n.inputs) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — resolve through its
|
||||
// "guider" input (CFGGuider/DualCFGGuider/BasicGuider) instead.
|
||||
let inputsToScan = n.inputs;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
const isPos = name === "positive" || name.startsWith("positive");
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — 1ノードでprompt/negative_prompt両方を直接持つため、
|
||||
// textMap(1ノード1テキスト)では両ロールを表現できない
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 1ノードでprompt/negative_prompt両方を
|
||||
// 直接持つため、textMap(1ノード1テキスト)では両ロールを表現できない
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const txt = srcNode.widgets_values?.[widgetIdx];
|
||||
if (txt && typeof txt === "string") { if (isPos) pos.add(txt); else neg.add(txt); }
|
||||
continue;
|
||||
}
|
||||
const txt = textMap.get(originId);
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
let resolvedOriginId = originId;
|
||||
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
|
||||
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
|
||||
if (innerInput?.link != null) {
|
||||
const innerId = linkOrigin.get(innerInput.link);
|
||||
if (innerId != null) resolvedOriginId = innerId;
|
||||
}
|
||||
}
|
||||
const txt = textMap.get(resolvedOriginId);
|
||||
if (!txt) continue;
|
||||
if (isPos) pos.add(txt); else neg.add(txt);
|
||||
}
|
||||
@@ -3040,13 +3091,28 @@ function _extractMarkdownNoteModels(wf) {
|
||||
return hasAny ? result : null;
|
||||
}
|
||||
|
||||
function _resolveLinkedText(wf, srcId, slot) {
|
||||
function _resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const src = wf[String(srcId)];
|
||||
if (!src || typeof src !== "object") return null;
|
||||
const ct = src.class_type ?? "";
|
||||
if (_isPromptStylerNode(ct)) { const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative; return (v && typeof v === "string") ? v : null; }
|
||||
const keys = slot === 0 ? ["text_positive", "text", "text_g", "prompt"] : ["text_negative", "text_l"];
|
||||
for (const k of keys) { const v = src.inputs?.[k]; if (v && typeof v === "string") return v; }
|
||||
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
|
||||
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const v = src.inputs?.[key];
|
||||
if (Array.isArray(v)) { const text = _resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1); if (text) return text; }
|
||||
else if (typeof v === "string" && v) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const keys = slot === 0 ? ["text_positive", "text", "text_g", "prompt", "value"] : ["text_negative", "text_l"];
|
||||
for (const k of keys) {
|
||||
const v = src.inputs?.[k];
|
||||
if (typeof v === "string" && v) return v;
|
||||
if (Array.isArray(v)) { const text = _resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1); if (text) return text; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3060,7 +3126,9 @@ function _extractPromptsAPI(wf) {
|
||||
const textMap = new Map();
|
||||
for (const [id, n] of Object.entries(wf)) {
|
||||
if (!n || !_isTextEncoderNode(n.class_type ?? "")) continue;
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
|
||||
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
|
||||
// model text encoders use this key instead of "text"/"text_g".
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
|
||||
if (raw && typeof raw === "string") { textMap.set(id, raw); }
|
||||
else if (Array.isArray(raw)) { const txt = _resolveLinkedText(wf, raw[0], raw[1] ?? 0); if (txt) textMap.set(id, txt); }
|
||||
}
|
||||
@@ -3069,20 +3137,34 @@ function _extractPromptsAPI(wf) {
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || !_isSamplerNode(n.class_type ?? "")) continue;
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || key.startsWith("positive");
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — 1ノードでprompt/negative_prompt両方を直接持つため、
|
||||
// textMap(1ノード1テキスト)では両ロールを表現できない
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 1ノードでprompt/negative_prompt両方を
|
||||
// 直接持つため、textMap(1ノード1テキスト)では両ロールを表現できない
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
|
||||
const txt = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (txt && typeof txt === "string") { if (isPos) pos.add(txt); else neg.add(txt); }
|
||||
continue;
|
||||
}
|
||||
const txt = textMap.get(String(val[0]));
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
|
||||
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
|
||||
: String(val[0]);
|
||||
const txt = textMap.get(resolvedId);
|
||||
if (!txt) continue;
|
||||
if (isPos) pos.add(txt); else neg.add(txt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user