Compare commits
@@ -0,0 +1,11 @@
|
||||
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
|
||||
#
|
||||
# Unsloth Desktop requires an API key even for local access
|
||||
# (Settings -> API in the Unsloth app, shown only once when issued).
|
||||
#
|
||||
# 1. Copy this file to ".env" (same directory as this example file)
|
||||
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
|
||||
# line becomes a comment and the key will not be loaded)
|
||||
# 3. Restart ComfyUI
|
||||
|
||||
UNSLOTH_API_KEY=
|
||||
@@ -194,9 +194,8 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
|
||||
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
|
||||
2. `README.md` のバージョンバッジを更新(``)
|
||||
3. `README.md` の Changelog に新バージョンのエントリを追加
|
||||
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
5. 上記すべてを同一コミットにまとめてプッシュ
|
||||
6. `gh release create vX.Y.Z` でリリースを作成
|
||||
3. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
4. 上記すべてを同一コミットにまとめてプッシュ
|
||||
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
|
||||
|
||||
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
|
||||
|
||||
@@ -2,6 +2,505 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.3.99
|
||||
|
||||
### 機能追加: AI TOOLタブ + サイドパネルAタブにUnslothバックエンド追加(APIキーは.env管理、SSRF脆弱性修正、reasoning_content対応)
|
||||
|
||||
ユーザーから「作業計画のUnslothをAI TOOLタブ、サイドパネルAタブに導入したい。APIキーは.envファイルで設定したい」との依頼を受けて着手。v0.3.98時点で「今回は見送り」として`project_next_tasks`メモリに保留していたUnslothバックエンド追加を実装した一連のセッション。
|
||||
|
||||
**1. Unslothバックエンド追加**: Ollama/LM Studio/Lemonadeに続く4つ目のバックエンドとしてUnsloth(OpenAI互換API、既定`http://localhost:8888`)を追加。既存3バックエンドと異なりローカルアクセスでも常にAPIキー認証が必須(`Authorization: Bearer sk-unsloth-...`)なため、フロントエンドから直接fetchする既存方式ではなく、新設したサーバー側プロキシ(`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`)経由にした。CivitAI連携(`civitai_service.py`)のAPIキー管理パターン(環境変数優先)を踏襲しつつ、今回はユーザーの要望通りAPIキーを`.env`ファイル(`UNSLOTH_API_KEY`)で管理する方式にした。`python-dotenv`を新規依存として追加し、ノードインポート前に実行される唯一のタイミングである`prestartup_script.py`(新規作成)で`.env`を読み込み`os.environ`へ反映する。フロントエンドはSPA(`ai-tab.js`)・サイドパネル(`node_sets_menu.js`)の両方で`fetchModels`/`callLLM`/`callVLM`/`callChat`各関数に`backend === "unsloth"`分岐を追加(既存の重複実装パターンを踏襲)。
|
||||
|
||||
**2. セキュリティレビュー対応(SSRF修正)**: 実装直後の自動セキュリティレビューで、プロキシがクライアント指定の`baseUrl`をそのまま信頼しており、悪意のあるページが`baseUrl`に任意の外部URLを指定すれば`UNSLOTH_API_KEY`を外部へ流出させられる脆弱性(SSRF経由の資格情報窃取)が指摘された。`baseUrl`のホスト名を`localhost`/`127.0.0.1`/`::1`のみに限定するアローリスト検証を追加して修正(ポート番号は自由のままとし、Unslothのカスタムポート起動に対応)。
|
||||
|
||||
**3. `.env.example`のコメントアウトの罠**: 動作確認時、ユーザーが`.env.example`の`# UNSLOTH_API_KEY=sk-unsloth-...`という例示行をそのまま`.env`にコピーし、先頭の`#`を消し忘れてコメントアウトされたまま値を入力してしまい「UNSLOTH_API_KEY is not set」エラーが発生。テンプレート自体を「空の実行可能な行」(`UNSLOTH_API_KEY=`)+手順コメントの形に変更し、同じ罠が再発しないようにした。
|
||||
|
||||
**4. Unsloth Desktop側「モデル自動切り替え」設定の仕様判明**: 動作確認中、Unsloth側の「モデル自動切り替え (OpenAI API)」設定がOFFだと、Unsloth Studio UI上で事前にモデルを手動ロードしていない限りAPIリクエストがHTTP 404で失敗することが判明(公式ドキュメント調査で確認:未知のモデル名は安全に現在ロード中のモデルへフォールバックするが、通常は明示的にONにしてAPI経由のモデル切り替えを許可する必要がある仕様)。本プラグイン側の不具合ではなくUnsloth Desktop自体の仕様のため、ヘルプ(`ai-6`)とREADMEに「Unsloth Desktop側でこの設定をONにする必要がある」旨を追記した。
|
||||
|
||||
**5. `reasoning_content`未対応バグ修正**: 「モデル自動切り替え」をONにした後も、会話が数ターン進むとチャットの応答が空のバブルになる不具合が発生。Unsloth Studioのサーバーログ(`~/.unsloth/studio/logs/server/`)と、ブラウザ操作MCP(kapture)の`evaluate`経由でプロキシへ生のAPIリクエストを直接送って実レスポンスを検証した結果、Unslothのreasoningモデル(`gemma-4-12B-it-QAT-GGUF`)はOpenAI互換APIで思考過程を`message.content`ではなく別フィールド`message.reasoning_content`に返す仕様であることが判明した。Ollama/LM Studio/Lemonadeはインラインの`<think>...</think>`タグ方式のため既存のThinking mode処理で問題にならなかったが、Unslothは別フィールド方式のため、Max tokens不足(初期値512)で思考中にトークンを使い切ると(`finish_reason: "length"`)、`content`が常に空文字列のまま返り、チャットに中身のないバブルだけが残っていた。`reasoning_content`を`<think>...</think>`として`content`に合成してから既存のThinking mode ON/OFF処理(`stripThinkingTags`)に流すよう修正し、SPA・サイドパネル両方に適用。Max tokensが小さすぎると根本的に回答本文が生成されない制約自体は残るため、ヘルプに「Unslothのreasoningモデルを使う場合はMax tokensを2048以上に」という推奨値も追記した。
|
||||
|
||||
**ヘルプタブ更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMap)に従い、AI TOOL TabヘルプのSettings pane説明(`ai-6`)にUnsloth関連3項目(APIキーの`.env`設定・モデル自動切り替え要件・`reasoning_content`/Max tokens注意)を追記。`sidepanel-16`・`ai-2`にもバックエンド一覧へUnslothを追加。README.mdのBackend support・Settings pane等の該当箇所も同様に更新。
|
||||
|
||||
**検証**: 全JS/PythonファイルをそれぞれNode.jsの構文チェック(`node --check`)・`ast.parse()`で確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更の都度`diff`で意図した差分のみであることを確認してから両フォルダへ同期し、`cmp`でファイル同一性を検証。実行時フォルダの`__pycache__`もクリア。最終的にユーザー自身が実機(Unsloth Desktop + ComfyUI)で一通り動作確認済み。
|
||||
|
||||
**How to apply**:
|
||||
1. 外部APIキーが常に必須なバックエンドを追加する場合、既存バックエンドと同じ「フロントエンドから直接fetch」方式は使えない(APIキーがブラウザに露出する)。サーバー側プロキシ方式が必須になる。
|
||||
2. サーバー側プロキシでクライアント指定のURLをそのまま信頼してAuthorizationヘッダーを付与すると、SSRF経由の資格情報漏洩につながる。ホスト名のアローリスト検証は必須。
|
||||
3. `.env`テンプレートに「コメントアウトされた例」を書くと、ユーザーが`#`を消し忘れてハマる典型パターンになる。テンプレートは「空の実行可能な行」+説明コメントの形にする。
|
||||
4. サードパーティAPIサーバーの独自拡張(`reasoning_content`等)は、標準的なOpenAI Chat Completions仕様と食い違うことがある。空応答やトークン切れなど不可解な挙動が出たら、実際の生レスポンスをブラウザから直接fetchして中身を見るのが最も早い切り分け方法(kaptureの`evaluate`ツールでプロキシへ直接リクエストを送るのが有効だった)。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.98
|
||||
|
||||
### 機能追加・バグ修正: AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成(ハイライト色・折りたたみ・並び替え)+ Thinking mode/Max tokens設定
|
||||
|
||||
ユーザーから「AI TOOLタブの翻訳、英語から日本語がうまく動かない(英語のまま、完了で翻訳されない)」との報告を受けて着手。原因調査後、関連するGenerateUI Modelタブの表示改善、AI TOOLタブへのLLM生成パラメータ設定追加と、複数の要望を連続して実装した一連のセッション。
|
||||
|
||||
**1. AI TOOL翻訳の信頼性向上**: 調査の結果、翻訳処理がOllamaの`/api/generate`(system role無しの生プロンプト文字列)またはLM Studio/Lemonadeの`/v1/chat/completions`(user roleのみ、system message無し)でLLMへ丸投げしており、指示追従が弱い小型・非力なローカルモデルが「翻訳してください」という指示を無視して原文をそのまま返すことがあった。それでもHTTPレスポンス自体は正常なため、画面上は「完了」と表示されたまま実際には未翻訳、という状態が発生していた。既にChatペインで使われていた`callChat()`(system role対応のchat API)を翻訳にも流用し、system promptで「翻訳結果のみを出力し、説明・前置き・原文の繰り返しをしない」と明確に指示するよう変更。加えてモデルが前置き文("Here is the translation:"等)や引用符を付けて返す場合の後処理除去(`cleanTranslationOutput`)と、出力が原文とほぼ同一=未翻訳の疑いがある場合の警告トースト(`looksUntranslated`)を追加。SPA(`ai-tab.js`)とサイドパネルAタブ(`node_sets_menu.js`)は全く同じ不具合パターンの重複実装だったため、両方に同じ修正を適用(設定は同じ`localStorage`キー`wfm_ai_settings`を共有)。
|
||||
|
||||
**2. GenerateUI Modelタブ: ハイライト色機能**: ユーザーから「読み込んだワークフローに関係なく項目を並べているため該当する項目(Checkpointなど)の色を変更表示、デフォルトは明るい緑、設定タブに色変更設定を追加したい」との要望。既存の「RAW JSON Colors」機能(`<style>`タグ動的注入+`localStorage`保存+Resetボタン)と全く同じパターンを踏襲し、`applyModelTabActiveColor()`を新設。`renderModelTab()`で各セクション(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork)のラベルとLoRA列見出しに、対応するノードが実際にワークフローへ含まれる場合のみ`wfm-model-label-active`クラスを付与。既存CSS`.wfm-form-group label { color: var(--wfm-text-secondary); }`の方が詳細度が高く単純なクラス指定では上書きされなかったため、注入CSS側のセレクタを`.wfm-form-group label.wfm-model-label-active`(および後続の折りたたみ対応でsummary要素向けセレクタも追加)として詳細度を確実に上回るよう実装。
|
||||
|
||||
**3. ControlNet/Hypernetwork折りたたみ化 + Text Encoder並び替え**: 「使用頻度が低い、意図的に利用するControlNet、Hypernetworkは折りたたみにし、最新モデルで主流のText EncoderをControlNetの上にしたい」とのフィードバックを受け、表示順をCheckpoint→VAE→Diffusion Model→**Text Encoder**→ControlNet→Hypernetworkに変更。ControlNet/Hypernetworkは`Image Editタブのプレースホルダ設定`(v0.3.97)で既に使われていた`<details>/<summary>` + `.wfm-settings-section`パターンをそのまま再利用し、デフォルト折りたたみ状態に。折りたたんだ状態でもハイライト色(#2の機能)は見出し文字に残るため、展開しなくても「このワークフローはControlNetを使っているか」が一目で分かる設計とした。続けて「LoRAもハイライト可能にしたい」との追加要望で、`templates/index.html`のLoRA列見出し`<span>`にid付与、`renderLoraPane()`でワークフローにLoRAノードが1件でもあればハイライトクラスをtoggleするよう対応。
|
||||
|
||||
**4. AI TOOLタブ: Thinking mode / Max tokens設定**: 「Thinking mode ON/OFF、Max tokens設定を追加したい」との要望を受け実装。Ollamaは`think`パラメータ(bool)と`options.num_predict`で標準対応しているが、LM Studio/Lemonade(OpenAI互換API)には標準的なthinking切替パラメータが存在しないため`max_tokens`のみ送信し、代わりにThinking mode OFF時は出力から`<think>...</think>`(`<thinking>...</thinking>`も)タグをクライアント側で正規表現除去する方式を採用。これはOllama自身がパラメータを無視した場合の保険としても機能し、バックエンドを問わず一貫した挙動になる。SPA(`callLLM`/`callChat`/`callVLM`)とサイドパネル(`aiCallLLM`/`aiCallChat`/`aiCallVLM`)の両方に`_applyGenOptions()`ヘルパーとタグ除去処理を追加し、Translation/Chat/TOOLS(VLM)/Wildcard生成の全呼び出し経路に反映。
|
||||
- **付随調査(見送り)**: 「Unsloth Desktopアプリのバックエンド追加は可能か」との質問を受け、公式ドキュメントを調査。OpenAI/Anthropic互換APIを同一ポート(既定`localhost:8888`)で提供する点は好条件だが、既存3バックエンドと異なり**ローカルアクセスであってもAPIキー認証が常に必須**(`Authorization: Bearer sk-unsloth-...`、無いと401)と判明。実装には新規UI(APIキー入力欄)が必要になるためユーザー判断で今回は見送り、将来の作業候補として`project_next_tasks`メモリに記録。
|
||||
|
||||
**ヘルプタブ更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUI TabヘルプにModelタブの新しい表示順・折りたたみ挙動(`gen-5`更新)とハイライト色機能(新規`gen-28`)、AI TOOL TabヘルプにThinking mode/Max tokens(`ai-6`更新)、SettingsタブヘルプにModelタブハイライト色設定(新規`settings-14`)を追加。この作業中に、以前から`index.html`にだけ存在し`i18n.js`(英語含む全言語)に対応キーが用意されていなかったため実際には一度も表示されていなかった「LoRA Stack — Apply writes...」の説明文を発見・復旧(`app.js`の`el.textContent = t(key)`は未定義キーでもキー名をそのまま返してHTML側の内容を問答無用に上書きするため、i18n.js側の追加漏れは静かな表示欠落を引き起こす)。
|
||||
|
||||
**検証**: 全JSファイルについて`node --check`で構文確認。開発元git リポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更の都度`diff`で意図した差分のみであることを確認してから両フォルダへ同期、CRLF/LF不一致がないことも`file`コマンドで事前チェック。各機能追加後にユーザー自身が実機(ComfyUI再起動後)で動作確認し、フィードバックを都度反映する形で進行。
|
||||
|
||||
**How to apply**:
|
||||
1. SPA(`ai-tab.js`)とサイドパネル(`node_sets_menu.js`)のように、同じ機能が2箇所に重複実装されている場合(設定は共通の`localStorage`キーを共有していても、API呼び出しロジック自体は別ファイルにコピーされている)、片方だけ修正すると「サイドパネル版だけ直っていない」という見落としが起きる。ai-tab.js相当の修正が必要な要望が来たら、必ずnode_sets_menu.js側の対応箇所も同時に確認する。
|
||||
2. ローカルLLM APIへの指示は、user発話に埋め込んだ「〜だけ出力して」という指示より、system roleとして明示的に分離した方が小型モデルの指示追従率が上がる。それでも従わないモデルへの保険として、クライアント側での出力後処理(前置き除去、echo検知)を組み合わせるとより堅牢になる。
|
||||
3. 動的注入`<style>`タグでハイライト色のような「上書き用クラス」を追加する場合、既存CSSがelement+classなど複合セレクタで詳細度を持っていると単純なクラス指定では負ける。読み込み順に依存せず確実に上書きするには、注入側のセレクタも同等以上の詳細度(同じ複合セレクタ構造にクラスを足す)にする。
|
||||
4. 折りたたみ(`<details>`)と色ハイライトを組み合わせる設計は、「低頻度機能は隠したいが、関連情報があるかどうかは常に見えていてほしい」という要望に対する良い組み合わせになる——折りたたんだ見出し自体を色の表示場所として使う。
|
||||
5. i18n.jsの「3点セット」規約が守られていないと、HTML側にどれだけ内容を書いてもJS側の`t(key)`上書きで静かに消える。ヘルプ文言を追加・編集する際は、既存の該当キーが本当に全言語ブロックに存在するか(特に英語)を都度確認する。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.97
|
||||
|
||||
### 機能追加・バグ修正: KREA-2ワークフロー対応 + Labタブ拡張(Clear/Get from Previous/Bypass/ライブ反映)+ Input Imageプレースホルダ機能
|
||||
|
||||
ユーザーから「KREA2モデルワークフローを生成UIタブ、メタデータ、Workflow Studio LibraryのIタブ他対応させたい」との依頼を受けて着手。実機検証を進める過程で複数の既存バグを発見・修正し、続けてLabタブとInputタブImageサブパネルへの機能追加も行った一連のセッション。
|
||||
|
||||
**1. KREA-2ワークフロー対応で発覚した2件の既存バグ修正**: KREA-2 Turbo(`krea2_turbo_int8_convrot.safetensors`、Qwen3VLベースのLLMプロンプト拡張+サブグラフ構成)の2種類のワークフロー(T2I版・スタイル参照版)を実機で読み込み検証したところ、GenerateUIタブでPositive Promptが誤って`SaveImage`ノードを指し、プロンプト欄が空になる不具合を発見。原因は、ノードタイプ単位の役割伝播ロジック(`comfyui-workflow.js`の`analyzeWorkflow()`)が`PreviewAny`(値タップ中継)と`StringConcatenate`(LoRAトリガーワード連結)という中継ノードタイプを一切考慮しておらず、KREA-2のプロンプト強化トグル配線(`PrimitiveStringMultiline`→`ComfySwitchNode`→`PreviewAny`→`StringConcatenate`→`ComfySwitchNode`→`CLIPTextEncode`)の途中で役割伝播が途切れていたため。同様の理由でスタイル参照版では`TextEncodeQwenImageEditPlus`のリンク値(配列)がテキストとしてそのまま誤表示されるバグも発覚。「KREA2専用」ではなく`PreviewAny`/`StringConcatenate`/リンク経由`TextEncodeQwenImageEditPlus`という汎用ノードタイプ単位の役割伝播ルールとして`comfyui-workflow.js`に追加し、Metadataタブ(`metadata-tab.js`の`resolveLinkedText`系2関数)とサイドパネルIタブの重複実装(`node_sets_menu.js`の`_resolveLinkedText`系2関数)にも同じ汎用キー拡張(`source`/`string_a`)を反映。
|
||||
- 修正後、実際に生成を実行したところ**別の既存バグ**で`Prompt outputs failed validation`エラーが発生。調査の結果、`TextGenerate`ノードの`sampling_mode`(`COMFY_DYNAMICCOMBO_V3`型ウィジェット)が選択中オプションのサブフィールド(`temperature`/`top_k`等の必須6個+`presence_penalty`のoptional1個、計7スロット消費)を持つのに対し、サブグラフの境界値注入ロジック(`_getDynamicComboSubNames`・`_simulateWidgetValues`)がDynamicComboの複数スロット消費を全く理解しておらず単純に1スロットとして扱っていたため、`sampling_mode`より後に宣言されている`thinking`(LLM思考モード、サブグラフ境界に接続)の注入処理が実際には`sampling_mode.temperature`のスロットを誤って上書きし、`temperature`に境界値の`false`(数値比較で0.0扱い)が入り`min: 0.01`バリデーションに抵触していた。`_getDynamicComboSubNames`をrequired/optional両方のサブフィールドを返すよう修正し、`_simulateWidgetValues`にDynamicCombo展開に対応する`comboExpander`引数を追加してスロット数を正しく消費するよう修正。LLMプロンプト拡張(DynamicComboを持つノード)をサブグラフ経由で使うワークフロー全般に影響する汎用バグだったため、KREA-2以外の同様構成のワークフローにも恩恵がある。
|
||||
|
||||
**2. Labタブ: プロンプトモーダルにClear/Get from Previousボタン追加**: ユーザー要望「プロンプトモーダルにプロンプトクリアボタンと前の設定からプロンプトを取得ボタンを追加したい(#3で実行、#2のプロンプトをポジティブ、ネガティブ両方コピー)」を実装。Clearは両フィールドを空にするのみ(ワークフローノードには触れない)。Get from Previousは同じカラムの直前のキーフレーム(`idx-1`)からPositive/Negative両方をコピー。キーフレーム#1には「前」が存在しないためボタン自体を非表示。
|
||||
|
||||
**3. Labタブ: Setting 1(キーフレーム#1)のライブ反映**: ユーザー要望「設定1のそれぞれのモーダルに生成UIタブの内容を初期自動反映したい(ワークフローの設定まま)表示は編集された場合に表示を変更したい」を実装。各カラム(Checkpoint/VAE/Prompt/KSampler)のキーフレーム#1が、まだ明示的に上書き保存されていない間は、GenerateUIタブで現在読み込まれているワークフローの実際の値(`comfyUI.currentAnalysis`から取得)をグリッドセル(破線ボーダー)と編集モーダル(フィールド事前入力+ヒント文言)の両方に表示する。何も変更せず保存しただけでも実際の上書き値として確定し、以後はライブ反映されなくなる(`_isEmptyValue(col, kf.value)`が false になるため)。GenerateUIタブの他サブタブからLabサブタブへ切り替えるたびに`refreshLabLiveDefaults()`で再描画し、常に最新のGenerateUI状態を反映する。「Get from Previous」ボタンも`_effectiveDisplayValue()`経由でこのライブ反映値を透過的に扱う。
|
||||
|
||||
**4. Labタブ: Bypassチェックボックス追加**: ユーザーから「バイパスボタンを追加したい」との要望を受け、追加箇所(Labタブの各列 vs Model タブ vs Raw JSONパネル)と動作仕様(ComfyUIノードを実際にBypassモードにするか、Labタブ内部だけでキーフレーム行を無効化するか)の2点をAskUserQuestionで確認。KSamplerは入出力が同じLATENT型のため実際のBypass(mode:4)が構造的に成立するが、Checkpoint/VAE/Promptの各ローダー/エンコーダーは入力を持たないため下流が未接続になり生成エラーを誘発するリスクがあるとユーザーに説明した上で、「そのキーフレーム行を無効化(Labタブ内のみ)」を選択いただいた。各キーフレームに`bypassed`フラグを追加し、`_resolveKeyframe()`でbypassedな行を「存在しないもの」として完全スキップ(直前に有効だった設定がそのまま使われる)。キーフレーム#1自体もBypass可能で、その場合はライブ反映表示より優先される。BypassチェックはRevertチェックと排他(両方同時には使えない)。
|
||||
|
||||
**5. GenerateUI Inputタブ Imageサブパネル: Clearボタン + プレースホルダ画像機能**: ユーザー要望「クリアボタンと上部にプレースホルダ画像機能を追加したい。プレースホルダ画像機能は色と画像が選べ...LoadImageのプレースホルダボタンを押下した際、画像を作成し既存の画像を差し替えます...デフォルト画像の設定は記憶されます」を実装(`comfyui-editor.js`の`renderImageTab`)。各LoadImageカードにClear(保留中の選択/プレビューをリセット、ノードには触れない)とPlaceholderの2ボタンを追加。パネル上部に共有のPlaceholder Image設定:Colorモード(幅・高さ・色を指定、押下時にキャンバスでその場生成しアップロード)とImageモード(デフォルト画像を1枚アップロードし記憶、押下時は再アップロードせずファイル名を再利用)。設定は`localStorage`(`wfm_i2i_placeholder`)に永続化。各カード自身のPlaceholderボタンで個別に差し替え可能。
|
||||
- **フォローアップ(低頻度機能のため折りたたみ化)**: ユーザーから「使用頻度は高くないため。折りたたみにしたい」とのフィードバックを受け、Settingsタブと同じ`<details>/<summary>`アコーディオンパターン(`.wfm-settings-section`/`.wfm-settings-summary`)に変更し、デフォルトで折りたたみ状態に。
|
||||
|
||||
**ヘルプタブ更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUI Tabヘルプに Imageサブパネルのプレースホルダ機能を1項目追加(`gen-27`)、Lab subtabヘルプにClear/Get from Previous・ライブ反映・Bypassの3項目を追加(`lab-11`〜`lab-13`)。EN側は`app.js`の`helpIdMap`が`el.textContent = t(key)`でHTMLタグを問答無用に上書きするため、新規追加時はi18n.js側の全言語ブロック(EN含む)に対応キーを用意しないとリッチHTML(`<b>`/`<code>`タグ)がプレーンテキストで消えてしまう点に注意して実装。
|
||||
|
||||
**検証**: 各修正について、実際にComfyUI(127.0.0.1:8189)とWorkflow Studioの実行時ComfyUI_5インスタンスへPlaywrightで接続し、KREA-2両ワークフローの読み込み・GenerateUI/Metadata/IタブでのPositive Prompt検出・実際の生成実行(バリデーション通過・画像出力まで)・Labタブの新機能(Clear/Get from Previous/ライブ反映/Bypass)の各UI操作・Input ImageサブパネルのColor/Imageモード双方でのPlaceholder適用とリロード後の設定復元・ヘルプタブのEN/JA両言語表示、をすべて実機で確認。開発元リポジトリと実行時`custom_nodes`フォルダはハッシュ比較で同一性を検証。
|
||||
|
||||
**How to apply**:
|
||||
1. ノードタイプ単位の汎用ロジック(役割伝播、ウィジェット値のインデックス復元など)を持つコードベースでは、特定モデル対応の不具合であっても「そのモデル専用」の分岐を足すのではなく、原因となっているノードタイプ(今回は`PreviewAny`/`StringConcatenate`/`COMFY_DYNAMICCOMBO_V3`)を汎用ロジック側に正しく組み込む修正を優先する。結果として対象モデル以外の同構成ワークフローにも自動的に恩恵が及ぶ。
|
||||
2. ウィジェット値の「インデックスから名前への復元」ロジックが複数箇所(今回は`_flattenSubgraphs`内の境界値注入と、最終API変換ループ)に重複実装されている場合、片方だけがDynamicCombo等の可変長ウィジェットを正しく理解していても、もう片方が単純な1対1対応を仮定しているとデータ破損が起きる。両方の実装が同じスロット数計算ロジックを共有しているか必ず確認する。
|
||||
3. 「バイパス」のような一般的な語でも、対象ノードの入出力型構成によって実際に構造的なComfyUI Bypassが成立するかは列ごとに異なりうる(LATENT型のKSamplerは素通し可能、入力を持たないLoader/Encoderは不可)。曖昧な機能要望は、AskUserQuestionで実装方式の選択肢とその技術的トレードオフを具体的に提示してから着手する。
|
||||
4. 低頻度機能をUIに追加する際は、既存のアコーディオン/折りたたみパターン(今回は`<details>/<summary>`+ `.wfm-settings-section`)を新規CSSクラスを増やさず再利用できないか確認する。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.96
|
||||
|
||||
### バグ修正: カタログ作成バッチが特定件数で無言停止する問題の修正 + Style Catalog/Promptタブ機能拡張
|
||||
|
||||
ユーザーから「カタログ作成機能ですべてのスタイル(277)を選択して実行した149でバッチ処理が止まってしまった」との報告を受けて調査・修正した一連のセッション。合わせてStyle Catalogタブとカタログ作成機能、Promptタブへのスタイル管理機能追加も行った。
|
||||
|
||||
**1. バッチ処理が無言で停止するバグの修正(`comfyui-client.js`)**: 調査の結果、`trackProgress()`(WebSocket経由で生成完了を待つPromise)にタイムアウトが一切なく、`execution_interrupted`イベントも未処理だったことが根本原因と判明。ComfyUI側からの完了通知が何らかの理由で届かなくなると(WebSocket瞬断・生成中断など)、Promiseが永久に解決されないままバッチループの`await`が固まり、エラーも出さずに停止する状態になっていた。`_pendingTrackers`(promptId→reject関数のMap)を追加し、(a) `trackProgress()`に10分のタイムアウトを追加、(b) `execution_interrupted`イベントのハンドリングを追加、(c) WebSocket切断(`onclose`)時に待機中の全Promiseを即座にrejectする仕組みを実装。既存の`_runBatchLoop`(`generate-tab.js`)は元々「エラー時は次のアイテムへ進む」try/catch構造を備えていたが、`trackProgress()`が例外を投げないまま無限に待ち続けていたためこのcatchに到達できていなかった。今回の修正で、意図されていた挙動(1件失敗しても次へ進む)が正しく機能するようになった。停止した149件目(`MK shepard-fairey-style`)自体はJSON構文・エンコーディングとも正常で、スタイルデータ側に問題は無いことも確認済み。
|
||||
|
||||
**2. カタログ作成/バッチのスタイル処理順序の調査(コード変更なし)**: ユーザーからの追加質問(Batchタブの表示順とカタログ作成の生成順が異なる、149件目が停止する原因はファイルにあるか)に対して調査のみ実施。バックエンド`_load_styles()`(`settings_routes.py`)は`style/*.json`をファイル名のアルファベット順に読み込み、各ファイル内はJSON配列の記載順を維持する仕様であることを特定。Style Catalogギャラリーの表示順は生成順とは独立に常に`sort=name_asc`(ファイル名のアルファベット順)である仕様も確認。いずれも設計通りの挙動でありバグではないと結論。
|
||||
|
||||
**3. Style Catalogタブ: ワークフロー再利用ボタンの追加**: 「スタイルとして選択」ボタンの下に「Load in GenerateUI」「Open in Metadata Tab」の2ボタンを追加。当初「Outputフォルダの該当画像へ遷移するボタン」を検討したが、カタログ画像はOutput画像のバイト列そのままのコピー(`extra_pnginfo`のワークフローも含め完全に同一のPNGメタデータを保持)であるため、Output側の対応ファイルを探しに行かずカタログ画像自身から直接ワークフローを読み込む方式の方が、実装がシンプルな上に**既存の全カタログ画像に対しても即座に使える**(Output→カタログの対応を新たに記録する仕組みが不要)という利点があり、ユーザー確認の上でこちらを採用した。
|
||||
|
||||
**4. Style Catalogタブ: ダブルクリックでの拡大表示**: `gallery-tab.js`既存の`openLightbox()`と同じCSSクラスをそのまま流用し、右ペインのプレビュー画像に加えて中央ペインのサムネイルカード自体にも`dblclick`を追加(Output Galleryと同一の操作感になるよう、ユーザーからの「操作が異なるため都度、間違える」というフィードバックを受けて追加)。ESCで閉じる処理はgallery-tab.js側の既存グローバルリスナーがクラス名ベースで拾うため追加実装不要だった。
|
||||
|
||||
**5. Promptタブ右ペインにStyle管理サブタブを新設**: 右ペイン(旧Wildcard専用)をタブ化し、Wildcard/Styleの2モードを切り替えられるようにした。Wildcardファイルマネージャー(一覧→エディタ→保存/削除/キャンセル)と同一のUXパターンで、登録済みStyle(`DATA_DIR/style/*.json`)の作成・編集・削除を実装。
|
||||
- バックエンドに`POST /api/wfm/styles`(新規作成)・`PUT /api/wfm/styles/{name}`(更新・リネーム)・`DELETE /api/wfm/styles/{name}`(削除)を新設。リネーム時は元々定義されていたファイル内でその場を書き換え(他ファイルへ移動しない)、名前は全スタイル横断で一意になるよう検証。新規作成は既定で`style/custom.json`へ追記し、同梱のスタイルパックファイル(`sdxl_styles_*.json`)は直接変更しない設計とした。
|
||||
- 保存・削除の都度、`generate-tab.js`に新規exportした`refreshStylesList()`を呼び出し、GenerateUIのStyleドロップダウン・Batchタブのチェックリストをリロード無しで再同期する。
|
||||
- 一時ディレクトリを使った単体テスト(`_find_style`のファイル横断検索、リネーム時の位置保持、重複名の拒否など)で動作確認。
|
||||
- **ファイル名バッジ + 既存ファイルへの追加**(フォローアップ、ユーザーからの追加要望): `_load_styles()`が各エントリに定義元ファイル名を`file`として付与するように変更し、一覧の各項目にファイル名を小バッジ表示。既存スタイル編集中は「+ Add to this file」ボタンが現れ、クリックするとフォームが空になり同じファイルへ追加される新規エントリとして保存できる(`POST /api/wfm/styles`の`file`パラメータで対象ファイルを指定、実在するファイルのみ許可しパストラバーサルを防止)。
|
||||
|
||||
**6. カタログ作成: Outputフォルダにコピーを残さないオプション**: カタログ作成は元々、ワークフローのSave Imageノードが生成の都度Outputフォルダへ通常通り書き出した上で、その画像をカタログフォルダへコピー保存する仕組みだった。大量スタイル(200件超)を一括作成するとOutputフォルダがほぼ重複ファイルで溢れる問題があり、モーダルに「Don't keep a copy in the Output folder」チェックボックスを追加。カタログへのコピー保存が**成功した後にのみ**、新設の`POST /wfm/gallery/output-image/delete`(filename/subfolder/typeからComfyUI Output配下の実ファイルを削除、`type="output"`以外は拒否)を呼び出してOutput側の元ファイルを削除する。コピー保存に失敗した場合は削除せず元画像を保持するため、データを失う経路が無い設計。既存の`GalleryService.delete_images()`(`_check_path_allowed`によるパス検証済み)をそのまま再利用しており、Outputフォルダ外のファイルを誤って削除する経路は存在しない。
|
||||
|
||||
**ヘルプタブ更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、GenerateUI/Prompt/Galleryタブヘルプに今回の追加項目をそれぞれ反映。
|
||||
|
||||
**検証**: 各段階でNode `--check`(JS構文)・`python -c ast.parse`(Python構文)・一時ディレクトリでのバックエンドロジック単体テスト(スタイルCRUD、ファイル横断検索、削除経路の安全性)・開発元と実行時`custom_nodes`フォルダの`diff`比較を実施。ComfyUI再起動を要する変更(バックエンドAPI追加)の都度ユーザーへ明示。
|
||||
|
||||
**How to apply**:
|
||||
1. WebSocketやポーリングで「完了通知を待つ」Promiseには、必ずタイムアウトを設定する。通知が絶対に届く保証はネットワークにもサーバー実装にも無く、タイムアウト無しのPromiseはバッチ処理のような長時間ループで「無言のまま停止する」最悪のバグを生む。周辺のtry/catchが正しく書かれていても、例外が投げられなければ意味が無い。
|
||||
2. 画像のバイト列そのものをコピー保存する機能(今回のStyle Catalog)では、コピー先の画像自体に元のメタデータ(埋め込みワークフロー等)がそのまま残っていることを活用すれば、コピー元との対応関係を別途記録・追跡する仕組みを作らずに済む場合がある。「元データへの参照を持つ」設計と「元データの複製として自己完結させる」設計のどちらが適切かは、コピー後に元データが削除・移動されうるかどうかで判断するとよい。
|
||||
3. 「Outputフォルダにファイルを残さない」系のオプションを実装する際、生成自体を変更する(ワークフローのSaveImageノードをPreviewImageに差し替える等)よりも、「一旦生成させてから、後続処理が成功したのを確認した上で元ファイルを削除する」方が実装が単純で、かつ後続処理が失敗した場合にデータを失わない安全な設計になる。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.95
|
||||
|
||||
### 機能追加: Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー新設 + GenerateUI「カタログ作成」機能
|
||||
|
||||
ユーザーから「GenerateUIタブのスタイルを画像から決めるためのスタイルカタログ機能を追加したい」との要望を受けて実装。まず前提としてGalleryタブの既存Style/Promptサブタブを「ImagePrompt」に改名(Style_Catalogと名前が紛らわしくなるのを避けるため)、続けて新規Style_Catalogサブタブと、GenerateUI側のカタログ生成機能を追加した。ブランチ`feature/style-catalog-imageprompt`(前セッションで未コミットのまま残していた`fix/output-gallery-style-prompt-display`ブランチから分岐、そちらの修正も本エントリに含む)。
|
||||
|
||||
**1. Style/Prompt → ImagePrompt 全面リネーム**: ユーザーから「内部ID/関数名/ファイル名も含めて全面リネーム」の指示を受け、タブラベルだけでなく`STYLE_PROMPT_FOLDER_NAME`→`IMAGE_PROMPT_FOLDER_NAME`(値も`ws_style_prompt`→`ws_image_prompt`)、APIルート(`/wfm/gallery/style-prompt/root`→`/wfm/gallery/image-prompt/root`等)、ファイル名(`static/js/style-gallery-tab.js`→`image-prompt-tab.js`)、関数名(`initStyleGalleryTab`→`initImagePromptTab`等)、CSSクラス(`.wfm-style-*`→`.wfm-imageprompt-*`)、i18nキー(`styleGallery*`→`imagePrompt*`)まで一貫して変更。実データフォルダも`mv`でリネーム(既存データはそのまま保持)。GenerateUI Batchタブの「Style」バッチ機能(`_stylesData`/`_batchStyleSelected`等、`DATA_DIR/style/*.json`のFooocus形式スタイルプリセット)とは全く別の概念であるため、こちらは意図的にリネーム対象から除外した。
|
||||
|
||||
**2. 事前調査: 既存Style機能・バッチ生成ループの理解**: 実装前にExploreエージェントで調査し、以下を把握した。
|
||||
- GenerateUIの「Style」はプリセットJSON(`{name, prompt, negative_prompt}`)を`DATA_DIR/style/`から読み込み、`_applyNamedStyle(workflow, style)`(`generate-tab.js`)がワークフローの複製に対して適用する。Positiveは`{prompt}`プレースホルダ置換または末尾追記、Negativeは常に末尾追記。
|
||||
- Batchタブに既に「Style」バッチ種別(複数スタイル選択→順次生成)が存在し、`_runBatchLoop(items, applyFn, labelFn)`という汎用バッチループを使っていた。カタログ作成はこの既存インフラをそのまま流用できると判断(新規バッチシステムを作らない)。
|
||||
- Positive/Negativeプロンプトの構造化抽出ロジック(ComfySwitchNode・CLIPTextEncodeEditPlus等の特殊ノード対応込み)は`metadata-tab.js`の`extractAllMetadata(file)`が既にexport済みで、Gallery側からもそのまま呼び出し可能と判明(Python側に同等ロジックは存在しない)。
|
||||
|
||||
**3. 新規: Style_Catalogギャラリーサブタブ**: Galleryタブ3つ目のサブタブとして追加。バックエンドは`get_style_catalog_root()`(`get_image_prompt_root()`と同型)のみ新設し、フォルダツリー・画像一覧・作成の各APIは既存の汎用エンドポイント(`root`/`folder`パラメータ対応済み)をそのまま再利用。フロントエンドは新規`static/js/style-catalog-tab.js`で、`image-prompt-tab.js`と同じツリー/グリッド実装を土台にしつつ、右ペインはプロンプトビルダーではなく「選択画像をfetch→Blob→File化→`extractAllMetadata()`でPositive/Negativeを抽出→個別コピーボタン」という読み取り専用パネルにした。「Select as Style」ボタンは、画像ファイル名(拡張子除く)と同名の登録済みStyleを`generate-tab.js`に新規exportした`selectStyleByName(name)`で探し、GenerateUIのStyleドロップダウンを切り替える方式(埋め込みプロンプトを直接適用するのではなく、既存Styleシステムへの視覚的ショートカットとする — ユーザー確認済みの方針)。
|
||||
|
||||
**4. 新規: バックエンド `save_image_to_folder`**: 既存の`save_image_to_gallery`(画像保存ルート、常に固定のギャラリールート直下にのみ保存)を一般化し、任意フォルダ+任意ファイル名+上書き保存に対応する`GalleryService.save_image_to_folder(folder_path, filename, image_data)`を新設。data URLデコード・拡張子解決・ファイル名サニタイズのロジックは`_decode_image_data_url()`/`_sanitize_save_filename()`としてモジュール関数に切り出し、既存の`save_image_to_gallery`ハンドラもこちらを使うようリファクタ(重複コード解消、既存動作は不変)。新規ルート`POST /wfm/gallery/image/save-to-folder`。
|
||||
|
||||
**5. 新規: GenerateUI「カタログ作成」/「カタログ」ボタン**: Style選択欄の右隣に設置。
|
||||
- スタイル選択の対象は、当初「全スタイル/選択中の1件」のラジオ選択UIを新設する設計で計画していたが、ユーザーから「カタログ作成の選択をバッチタブのStyleタブで行えるようにしたい。これにより複数選択も可能にしたい」とのフィードバックを受け、**新規UIを作らずBatchタブStyleサブタブの既存チェックリスト(`_batchStyleSelected`)をそのまま使う**方式に変更(複数選択は元から対応済み)。カタログ作成モーダルは保存先フォルダの指定(新規作成/既存選択)のみを担当する、よりシンプルな設計になった。
|
||||
- `_runBatchLoop`に第4引数`onResultFn(item, result)`を追加(既存呼び出し元は省略時no-opのため無影響)。カタログ作成はこのコールバックで、生成結果を`comfyUI.getImageBlob()`→data URL化→`save_image_to_folder`へPOST(ファイル名=スタイル名、拡張子はバックエンド側でBlobの実MIMEタイプから解決)。
|
||||
- 「カタログ」ボタン(カタログ作成ボタンの右隣、ユーザーからの追加要望)は、Galleryタブ→Style_Catalogサブタブへワンクリックで遷移するショートカット。
|
||||
|
||||
**6. Output Galleryのプロンプト表示修正(前セッションからの持ち越し)**: v0.3.94リリース直後、ユーザーがKapture目視確認で「Output Galleryの詳細パネルにプロンプトが表示されていない」ことを発見(リリースノートには書いたが実装漏れだった)。`gallery-tab.js`の`loadImageDetail()`が`metaRes`(`image_prompt`フィールドを含む)を取得していながら使っていなかったバグで、`renderImagePromptSection()`を追加し表示欄(Info タブ、Tags欄の上)を新設して解消。ユーザーの指示で当時はコミット・リリースまでは行わず、今回のブランチに持ち越して一緒に含めた。
|
||||
|
||||
**ヘルプタブ更新**: `project_v0336_conventions`の慣習(index.html + i18n.js 3言語 + app.jsのhelpIdMapの3点セット)に従い、Galleryタブヘルプの「Style/Prompt Gallery」カードを「ImagePrompt Gallery」に改名・`ws_image_prompt`表記に修正、新規「Style_Catalog Gallery」カードを追加。GenerateUIタブヘルプにもCreate Catalog/Catalogボタンの説明を追加。
|
||||
|
||||
**検証**: 各段階でNode `--check`(JS構文)・`python -m py_compile`(Python構文)・開発元と実行時`custom_nodes`フォルダの`diff -rq`比較を実施。ユーザー自身が実機でGallery 3サブタブ化・ImagePromptの既存データ動作・カタログ作成→保存→Style_Catalogでの閲覧→Select as Style→カタログボタンでの遷移、を一通り確認済み。
|
||||
|
||||
**How to apply**:
|
||||
1. 新機能が既存のバッチ処理・生成ループと概念的に重なる場合、専用の新規実装を書く前に「既存のバッチループに小さなフック(コールバック引数など)を追加するだけで実現できないか」を検討する。今回`_runBatchLoop`への1引数追加で、進捗バー・Pause/Resume・エラーハンドリングを含む既存バッチUI全体をタダで手に入れられた。
|
||||
2. ユーザーから「選択はA画面で行い、B画面はC機能だけ担当させたい」といった役割分担の指摘を受けたら、独自の選択UIを増やさず既存の選択UI(この場合Batchタブのチェックリスト)を再利用できないか確認する — 実装量が減るだけでなく、ユーザーにとって覚える操作系統が1つで済む。
|
||||
3. 「ファイル名を画像から画面Aのデータに紐付ける」機能を作る際、画像に実際に埋め込まれた値をそのまま複製・適用するのか、それとも名前で元のマスターデータ(この場合Style JSON)を検索し直すのかは、ユーザーの意図次第で大きく設計が変わる(前者はスナップショット的、後者は常に最新のマスターデータを参照する)。曖昧なら実装前に確認する。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.94
|
||||
|
||||
### 機能追加: Gallery タブのサブタブ化 + Style/Prompt ギャラリー新設(視覚的プロンプトビルダー)
|
||||
|
||||
ユーザーから「Galleryタブをサブタブ化してスタイル・プロンプト用ギャラリーを追加したい。サムネイルによるスタイル/プロンプトの閲覧・プロンプト作成を行いたい」との要望を受けて新規実装。既存のGalleryタブの挙動は「Output」サブタブとしてそのまま残し、隣に「Style/Prompt」サブタブを新設した。参考として`comfyui_prompt_gallery`(CivitAI配布PonyXL wildcardパックをサムネイル一覧化しクリックでプロンプト挿入するツール)と、ユーザーの別プロジェクト`prompt_builder_proto`(CLIP埋め込み検索まで持つ高機能プロトタイプ)の2つが示されたが、後者の3D空間表示・埋め込み検索等は対象外とし、ユーザー指定の「左フォルダツリー/中央サムネイル/右にプロンプト表示+作成欄/下部コピーボタン」という素の画面構成のみを踏襲した。ブランチ`feature/style-prompt-gallery`で作業。
|
||||
|
||||
**1. データ設計判断: プロンプトは`.txt`サイドカーで保存し、`gallery_metadata.json`には触れない**: 既存の`GalleryMetadataStore`(`gallery_metadata.json`)は起動時にメモリへロードし保存時にファイル全体を書き戻す実装のため、ComfyUI実行中に外部スクリプトで数百件を直接書き込むと、サーバー側の別の保存操作で上書き・消失するリスクがあった。加えて`cleanup_stale_images()`はフォルダパスの**前方一致**でメタデータを判定しており、非再帰スキャンで親フォルダをlist_imagesすると、実際はサブフォルダに存在する画像のメタデータを「無くなった」と誤判定して削除してしまう潜在バグが既存コードにあることも判明(Output側のフラットな日付フォルダ構成ではほぼ顕在化しないが、深い階層を持つStyle/Promptギャラリーでは実害が出うる)。これらを踏まえ、プロンプトデータは画像と同名の`.txt`サイドカーファイル(例: `mobile_trooper.png` + `mobile_trooper.txt`)として画像フォルダに直接置く方式にした。実行中のJSONストアを一切触らないため競合リスクがゼロになり、かつ「画像+txtを置くだけ」でユーザー自身が将来データを追加できる最も簡単なフォーマットになる、という一石二鳥の設計。
|
||||
|
||||
**2. バックエンド (`gallery_service.py` / `gallery_routes.py`)**: `_read_sidecar_prompt()`/`save_style_prompt()`で`.txt`サイドカーの読み書きを実装し、`get_image_metadata()`の戻り値に`style_prompt`フィールドを追加(Outputギャラリー側からws_style_promptフォルダを閲覧した際にも同じ仕組みでプロンプトが表示される)。`list_images()`の検索フィルタにもサイドカー内容を追加。新規ルート`GET /wfm/gallery/style-prompt/root`・`POST /wfm/gallery/image/style-prompt`を追加。Settingsでユーザーが Output ギャラリーのルートを実outputフォルダ以外に変更していてもStyle/Promptギャラリー(常に実output直下)へアクセスできるよう、`_comfy_output_root`という不変ルートを`_allowed_root`とは別に保持し、`_check_path_allowed()`をいずれか一方の配下なら許可するよう拡張した。
|
||||
|
||||
**3. フロントエンド**: `templates/index.html`のGalleryタブ先頭に`lab-tab.js`の`.wfm-lab-subtab-*`と同一パターンの`.wfm-gallery-subtab-nav`を追加し、既存の3カラムmarkupを`#wfm-gallery-panel-output`でラップ、新規`#wfm-gallery-panel-style`を追加。既存`gallery-tab.js`(1994行、favorite/tags/groups/bulk操作等が密結合したシングルトン実装)への機能追加によるregression リスクを避けるため、Style/Prompt専用の新規ファイル`static/js/style-gallery-tab.js`を作成し、既存RESTエンドポイントを再利用しつつレンダリングのみ独自実装した。`i18n.js`はen/ja/zh 3言語に新規キーを追加(既存の`galleryFolders`/`gallerySelectFolder`/`galleryNoImages`/`gallerySearchPlaceholder`等、定義済みだが未使用だったキーをここで初めて実際に使用した)。ヘルプタブは`project_v0336_conventions`メモの「index.html + i18n.js(3言語) + app.jsのhelpIdMapの3点セット」の慣習に従い新規カードを追加。
|
||||
|
||||
**4. シードデータ投入**: `tools/import_style_prompt_seed.py`を新規作成(PyYAML不要、`comfyui_prompt_gallery/web/prompt_gallery.js`の`parseYamlForImages`と同じインデントベースの簡易パーサをPythonに移植)。ユーザーの`wildcard_data`フォルダ(`pack: set: category: leaf: [tag_string]`形式のYAML + `thumbnails/`配下の対応画像)のうち、サムネイル画像を伴う6カテゴリ(artstyle/body/expression/job/pose/scene)から、画像とタグの対が実際に揃っている**460枚**のみを`output/ws_style_prompt`へコピー+`.txt`サイドカー生成した(残り487枚は元データ側にタグ定義が無く「テキストのみ」相当のため、ユーザー指示通りスキップ)。CivitAI配布パックの再配布ライセンス問題を避けるため、実データ画像はリポジトリにコミットせず、書き込み先はユーザー個人のローカルComfyUI outputフォルダのみとした。
|
||||
|
||||
**5. フォローアップ改善(実機フィードバックを受けて)**:
|
||||
- **再帰的な画像表示**: 親フォルダを選択してもサブフォルダの画像が0件表示になっていた(インポートしたデータが深い階層に配置されるため)。`list_images()`に`recursive`パラメータを追加し`_scan_folder_recursive()`(`os.walk`ベース)を新設、Style/Promptギャラリー側は常に`recursive=true`で呼び出すよう変更。フォルダツリーの`list_folder_tree()`も`image_count`(直下のみ、Output側で従来通り使用)とは別に`image_count_total`(サブフォルダ込みの合計、後方互換で追加)を返すようにし、ツリーの件数バッジもStyle/Prompt側では合計値を表示するよう修正。
|
||||
- **右ペイン2カラム化**: 選択画像プレビュー/プロンプト/選択済みチップ(左)と、最終プロンプト(右、縦いっぱいに伸びる)に分割。詳細パネル幅を320px→520pxに拡大。
|
||||
- **選択済みプロンプトのチップ表示から拡張子を除去**: `chip.filename`表示時に`.replace(/\.[^.]+$/, "")`。
|
||||
- **ponyxlWildcardsVault形式のライブ(未インポート)対応**: ユーザーが`wildcard_data`のカテゴリフォルダ(yaml + thumbnails/、`thumbnails_option2/`の別プレビュー画像`{leaf}.preview3.ext`含む)を生のまま`ws_style_prompt`配下に置いて確認したところ「対応させたい」との要望。`_read_sidecar_prompt()`を「1. `.txt`サイドカーを優先 → 2. 無ければponyxlWildcardsVault形式としてその場でYAML解決」の2段構えに変更。`_find_vault_category_root()`で画像の祖先フォルダを最大8階層遡り`thumbnails`/`thumbnails_option2`を直下に持つフォルダ(=カテゴリルート)を探し、そこに隣接する`*.yaml`を都度パース(YAML群のmtimeシグネチャでインスタンス内キャッシュ)。二重拡張子ファイル名(`alchemist.preview3.jpeg`)にも対応するため、リーフ名候補は`ファイル名.split(".")[0]`で取る。同じロジックを`tools/import_style_prompt_seed.py`側にも移植し、`thumbnails_option2`もスキャン対象に含めた。
|
||||
|
||||
**6. フォルダ名タイポ修正**: ユーザーが当初指定したフォルダ名`ws_style_plompt`が実はスペルミス(正しくは`ws_style_prompt`)と判明。実データフォルダを`mv`でリネームし(460枚+vaultフォルダのデータはそのまま保持)、コード内の全参照(`STYLE_PROMPT_FOLDER_NAME`定数、`index.html`、`i18n.js`3言語のヘルプ文、`style-gallery-tab.js`コメント、`import_style_prompt_seed.py`)を一括修正。
|
||||
|
||||
**トラブルシューティング: 「ComfyUI再起動したのに404」の切り分け**: バックエンドのルート追加後、ユーザーが「再起動した」と報告したにもかかわらず新規エンドポイントが404/405を返す事象が発生。`curl -i`でヘッダを確認したところ、GETの404レスポンスが`Content-Type: application/octet-stream`+`Transfer-Encoding: chunked`という、自前のJSON APIハンドラ(常に`web.json_response`でapplication/json)とは異質なシグネチャだったことと、POSTが404ではなく`405 Method Not Allowed`(`Allow: GET,HEAD`)を返したことから、リクエストが自作ルートに全く到達せずaiohttpの静的ファイル系フォールバックに吸収されている=新しいルート登録が実際には有効化されていないと判断。`netstat`でポートを握っているプロセスを特定し、`Get-Process`でその起動時刻を確認したところ、コード変更よりずっと前から起動し続けているプロセスであることが分かり、「再起動」が実際にはポートを握るプロセスに反映されていなかったことを特定できた。
|
||||
|
||||
**How to apply**:
|
||||
1. 複数プロセス/外部スクリプトから同時に書き込まれうるJSONストア(`GalleryMetadataStore`のような「起動時ロード→都度書き戻し」実装)に、バッチ処理や別プロセスからデータを注入したい場合は、そのストアを経由せず**ファイルシステム上の独立したファイル**(サイドカー等)に逃がすことを検討する。競合の心配なく安全に外部から書き込める。
|
||||
2. `cleanup_stale_images()`のようなフォルダ配下メタデータの「前方一致による孤立削除」処理は、**呼び出し元が実際にスキャンした範囲**(非再帰なら直下のみ)を正しく反映した`existing_paths`を渡さないと、スキャンしていない孫階層のメタデータを誤って削除しうる。再帰スキャンに切り替える際は、この既存ロジックとの整合性を必ず確認すること。
|
||||
3. バックエンドAPIの新規エンドポイントが「再起動したのに反映されない」ように見える場合は、`curl -i`でレスポンスヘッダ(Content-Type、Transfer-Encoding、405のAllow等)を確認すると、リクエストが自作ハンドラに届いているか・別のフォールバック機構に吸収されているかの切り分けに使える。合わせて`netstat`でポートを握っているPIDを特定し`Get-Process`/`Get-CimInstance Win32_Process`でそのプロセスの起動時刻を見れば、「再起動」がそのプロセスに実際に反映されているかを客観的に確認できる。
|
||||
4. CivitAI等サードパーティ配布のデータセットをローカルで加工・利用する場合、生成物(画像そのもの)はリポジトリにコミットせず、変換・投入スクリプトのみをコミットしてユーザー自身のローカル環境に対して実行する方式にすることで、再配布ライセンス問題を回避できる。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.93
|
||||
|
||||
### バグ修正・機能改善: Labタブ VAEキーフレーム不発バグの修正+Style適用UI刷新+Plan JSONスクロール同期修正+キーフレーム個別削除+Batch数超過警告+ワイルドカード対応+Save index image デフォルトON
|
||||
|
||||
ユーザーからの実機フィードバックを受けて、Labタブに対して一連の修正・改善をまとめて実施した。
|
||||
|
||||
**1. VAEキーフレームが2回目・3回目以降に反映されないバグの修正**: ユーザーから「Labでチェックポイント焼き込みVAE構成のワークフローに対し、VAEキーフレームを2列目・3列目に追加して実行したが、生成画像のメタデータを確認するとVAEが変更されていなかった」との報告を受け調査。`_buildWorkflowForIteration()`(`lab-tab.js`)は`analysis.vae_nodes[0]`という「ワークフロー中の`VAELoader`ノードを機械的に拾っただけの最初の1件」に無条件で書き込む設計だったが、独立した`VAELoader`ノードを持たず、チェックポイントに焼き込まれたVAEをそのまま使う構成のワークフロー(実際にユーザーが使用した`simple_i2i_save.json`は`VAELoader`ノードが存在した通常構成だったため、このケース自体には該当しなかったが、独立VAELoaderが無いワークフロー全般に共通する潜在バグとして特定・修正)では`vae_nodes`が空になり、キーフレームで指定した値が警告なく黙って捨てられていた。`analysis.vae_nodes[0]`が存在しない場合、その回のワークフロー複製に`VAELoader`ノードを動的に追加し、`vae`入力を持つ全ノード(`VAEDecode`/`VAEEncode`等)の接続先をそのノードへ張り替えるフォールバック処理を追加した。`comfyUI.currentWorkflow`自体は変更せず、`_buildWorkflowForIteration()`が毎回作るクローンにのみ適用するため、他のGenerateUIサブタブには影響しない。
|
||||
|
||||
**2. Style適用UIの刷新**: これまでPromptキーフレームの「Style」欄は、上部GenerateUIツールバーの Style 有効化チェックボックスがONの場合のみ機能する「Apply」ボタン(クリックするたびに上部で選択中のスタイル名をボタンラベルに表示)という設計だった。ユーザー要望により、(a) 上部の Style 有効化チェックボックスの状態に関係なく動作するように変更、(b) モーダル内に専用のON/OFFチェックボックスを追加してキーフレームごとに適用状態を保持、(c) 適用したスタイル名はボタン上ではなく横の別要素に表示、(d) チェックボックスをOFFにした際はその名前表示のみクリアし、既にPositive/Negativeへ追記済みのテキストは自動削除しない(あくまでユーザーが手動管理)という仕様に変更した。実装の紆余曲折として、当初「チェックボックスをONにした瞬間に自動でスタイルを追記する」形で実装したが、ユーザーから「追加は(従来通り)Apply ボタンで行いたい」とフィードバックを受け、Apply ボタン押下時にのみ追記しチェックボックスを自動ON化する方式に修正した。適用状態(`styleApplied`/`styleName`)はキーフレームの`value`に永続化し、Plan保存/読込・モーダル再オープンでも復元される。
|
||||
|
||||
**3. Plan JSONタブのスクロール同期バグ修正**: Plan JSONタブのテキストエリアは、色付きシンタックスハイライト用の`<pre>`オーバーレイを背後に重ねる仕組みだが、他のRaw JSON表示(GenerateUIのRaw JSON等)と異なり`scroll`イベントリスナーの配線が漏れていたため、テキストエリアをスクロールしてもオーバーレイの表示位置が追従せず「スクロールしても表示が変わらない」ように見えるバグがあった。`json-highlight.js`の`syncScroll()`を`lab-tab.js`にimportし、`_initPlanJsonTab()`に`scroll`イベントリスナーを追加して解消した。
|
||||
|
||||
**4. キーフレームをモーダルから個別に削除可能に**: 各キーフレームの編集モーダルにある「削除」ボタンは、従来は列内の最後のキーフレームでのみ表示されていた。ユーザー要望により、1回目(baseline、常に固定)以外の全てのキーフレームで表示するよう変更(「−」ボタン自体は従来通り末尾のみ削除する仕様を維持)。途中のキーフレームを削除すると配列の`splice`により後続が自然に繰り上がる。
|
||||
|
||||
**5. Batch数超過時の警告トースト追加**: 「プランの適用回数よりBatch数が少なくなった場合はどうなるか」という質問を受けて挙動を調査したところ、Batch数を後から減らしてもキーフレームは削除・再採番されず、単に実行ループ(`for (let i = 1; i <= _lab.batchCount; i++)`)が届かなくなるだけで無警告だった(データは残るため、Batch数を再び増やせば復活する)。ユーザー要望を受け、`_maxKeyframeIteration()`で全列の最大`atIteration`を求め、現在のBatch数を超えていれば警告トーストを出す`_warnIfKeyframesExceedBatch()`を追加。Batch数入力欄の`change`イベントと、`_applyPlanData()`(Plan読込・Plan JSON Apply共通)の両方から呼び出す。
|
||||
|
||||
**6. Promptキーフレームでのワイルドカード対応**: 「プロンプトでワイルドカードを使いたい」との要望を受け、GenerateUIメインの生成フローが既に持っていたSPA側ワイルドカード展開処理(`generate-tab.js`の`_expandWildcardsInWorkflow()`、A1111形式`__name__`構文、`Workflow-Studio/wildcards/`配下の.txtから1行ランダム選択、ネストは最大5パス、`ImpactWildcardEncode`/`Processor`はサーバー側処理のため対象外)を`export`し、`lab-tab.js`の`_runLabBatch()`から各イテレーションのワークフロー構築直後・生成実行直前に呼び出すよう配線した。これによりPromptキーフレームのテキストに`__name__`を書いておけば、同じキーフレームを複数イテレーションで使い回していても毎回ランダムに再展開される。`generate-tab.js`は既に`lab-tab.js`から`initLabTab`をimportしており、今回の変更で双方向の循環importになるが、両関数とも関数宣言(hoisted)でトップレベル即時実行はしていないため問題なし(Node上での動的import検証でモジュールグラフの解決自体に問題がないことを確認済み)。
|
||||
|
||||
**7. 「Save index image to Output on Run」のデフォルトON化**: ユーザー要望により`_emptyLabState()`の初期値を`false`→`true`に変更。あわせて、修正過程で`_clearPlan()`が`saveIndexEl.checked`を無条件に`false`へ固定していた(内部状態とチェックボックス表示が食い違う潜在バグ)ことに気づき、`_lab.saveIndexOnRun`の実値を反映するよう修正した。既存の保存済みPlanファイルは`save_index_on_run`の値をそのまま保持するため影響を受けない。
|
||||
|
||||
**検証**: 各修正・機能追加ごとに`node --check`で構文検証、開発元リポジトリと実行時`custom_nodes`フォルダの`diff`比較で意図した差分のみであることを確認してから同期。VAE修正は`analyzeWorkflow()`の実データ(ユーザー提供の`simple_i2i_save.json`)に対するNode単体実行で`vae_nodes`検出ロジックを確認済み。循環import追加分はNode上で`import()`によるモジュール解決を検証済み(`window is not defined`はNode実行環境固有の話で、モジュールグラフ解決自体は正常に完了することを確認)。
|
||||
|
||||
**How to apply**: (1) `analysis.*_nodes[0]`のような「先頭要素決め打ち」パターンは、そのノード種別がワークフロー中に必ず1つ存在する保証がない限り、存在しないケースへのフォールバックを用意すること(VAELoaderのように「省略可能」なノードは特に注意)。(2) UIのON/OFFチェックボックスとその内部状態(`_lab.xxx`)を分離して持つ場合、リセット系の関数(`_clearPlan()`等)で値をハードコードせず、必ず内部状態から読み出すこと——デフォルト値を変更した際に無言でズレるバグの温床になる。(3) `generate-tab.js`と`lab-tab.js`は既に循環importの関係にあるため、新たに一方から他方の関数を使う場合はトップレベルで即時評価される値(`const`の即時代入等)を挟まないよう注意する(関数宣言はhoistされるため安全)。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.92
|
||||
|
||||
### バグ修正: CLIPTextEncodeEditPlus使用ワークフローがGenerateUI経由だと直接実行と異なる画像になる問題(根本原因はconvertUiToApiのForceInput 未考慮バグ)+LoRA Stack機能の改善一式
|
||||
|
||||
ユーザーから「`CLIPTextEncodeEditPlus`(CLIP Text Encode edit+、`model-and-prompt-from-metadata`由来)と`Lora Loader (LoraManager)`を組み合わせたワークフローを、ComfyUIで直接実行した場合とWorkflow StudioのGenerateUI経由で実行した場合とで生成画像が異なる。シードも固定しているのに一致しない」という報告を受け、一連の調査・修正を行った。
|
||||
|
||||
**1. Metadata/Infoタブでのプロンプト抽出漏れ**: `CLIPTextEncodeEditPlus`はPositiveプロンプトの実体を`text1`(リンク入力、例えばLoraManagerの`trigger_words`出力)と`text_edit`ウィジェットを`mode`(RAW/EDIT/front/back)に応じて結合する専用ノードだが、`metadata-tab.js`(およびその移植版`node_sets_menu.js`のIタブ側)の`extractPromptsAPI`/`extractPromptsFromNodeSet`は`widgets_values[0]`(=`text_edit`のみ)を素朴に読むか、API形式では`inputs.text`/`text_g`/`prompt`キーしか見ておらずこのノード自体を全く認識できていなかった(結果、生成画像のPNG内蔵メタデータを見るとPositiveが空、Negativeしか表示されない)。ノード本体のPython実装(`metadata_checkpoint_node.py`の`CLIPTextEncodeEditPlus.encode()`)と完全に同じ結合ルールを`resolveEditPlusText(mode, textEdit, text1, text2)`として両ファイルに実装し、LiteGraph形式・API形式それぞれのtextMap構築ロジックに組み込んだ。なお`text1`がLoraManagerの`trigger_words`のように実行時に動的計算される値の場合、ワークフローJSON自体にはそのテキストが保存されていないため静的解析では復元できない(この制約はユーザーに明示済み)。
|
||||
|
||||
**2. 根本原因: `convertUiToApi`の`_getWidgetInputNames`/`_getWidgetInputTypes`が`forceInput: true`を考慮していなかった**: 上記の表示バグを追っている過程で、実際にComfyUIサーバー(`http://127.0.0.1:8189/object_info/CLIPTextEncodeEditPlus`)から取得したobject_infoと突き合わせたところ、真の実行結果不一致の原因を発見した。`text1`/`text2`はComfyUI側で`["STRING", {"forceInput": true}]`(リンク専用・ウィジェット化されない入力)として定義されているが、`comfyui-workflow.js`の`_getWidgetInputNames`/`_getWidgetInputTypes`はこの`forceInput`フラグを一切チェックしておらず、型がSTRING/INT/FLOAT/BOOLEAN等であれば`optional`入力も無条件にウィジェット名列へ含めていた。そのため`text2`(未接続)が誤って「ウィジェット」として扱われ、`widgets_values`の3番目に入っていた本来どのウィジェットにも対応しない余りの空文字列`""`が`text2`の値として吸い込まれ、送信されるAPIワークフローに`text2: ""`が**明示的に**含まれてしまっていた。Python側の`encode()`は`insert = text2 if text2 is not None else text_edit`という判定をしており、`text2=""`は`None`ではないため`insert = ""`が採用され、`mode: "back"`の場合`selected = text1 + ", " + ""`——**`text_edit`の内容が完全に欠落**していた。ComfyUI本体から直接実行した場合は未接続のforceInput入力が送信データに一切含まれず`text2=None`となるため、この欠落は発生しない。これが「同じワークフローなのに画像が異なる」「LoRA強度を変えても結果が変わらない(そもそも肝心のプロンプト本文が消えていたため無関係だった)」の直接の原因。`_getWidgetInputNames`/`_getWidgetInputTypes`双方の必須・オプション入力ループに`if (Array.isArray(spec) && spec[1]?.forceInput) continue;`を追加して除外するよう修正。この修正は`forceInput`付きオプション入力を持つ他のノード全般にも同様に効く汎用修正。
|
||||
|
||||
**3. GenerateUI Model タブ — LoRA Stack機能の改善**: 上記の検証と並行して、LoRA周りのUI/UXも3点改善した。
|
||||
- **Use Stack Groupチェックボックス**: `Lora Loader (LoraManager)`ノードが存在するワークフローを読み込んだ際、従来はStackタブが常にModelsタブの登録済み「Stack」グループを参照しており、ワークフロー自体が保持している実際のLoRA構成とは無関係だった。新設したチェックボックスで、OFF(ワークフロー読み込み時の既定)はLoraManagerノードに実際に設定されているLoRAをそのまま表示・編集し、ON はModelsタブの「Stack」グループを表示・編集するよう切替可能にした。切替時は必ず対象ノードのLoRA一覧をクリアしてから新しいソースを読み込み、OFFへ戻すとワークフロー読み込み時点のLoRA一覧(読み込み時にスナップショットとして保存)へ正確に復元される。
|
||||
- **LORA SYNTAX表示が1件しか出ない不具合の修正**: SingleタブのLORA SYNTAXはLoraManagerノードの`loras[0]`(1件目)のみを見る設計(Single=1件専用のため仕様通り)だが、Stackタブ側もModelsタブ登録グループしか見ておらず、結果的に「ワークフローに実際設定されている複数LoRA」を表示する手段がどこにもなかった。上記のチェックボックスと合わせて、新規読み込み時にLoRAが2件以上あれば自動でStackタブを初期表示するようにし、正しく全件表示されるようにした。
|
||||
- **タブ切替でStackのON/OFF状態が意図せず戻る不具合の修正**: 「ワークフロー状態」表示モードでは、Modelタブへ戻るたびの単純な再描画のたびに各LoRAの有効/無効状態(`_stackActive`)をワークフロー本来のデータで毎回強制上書きしていたため、ユーザーがStackパネルのトグルオールチェックボックスをOFFにしても次の再描画で無条件にONへ戻ってしまっていた。ワークフロー実データからの同期を「新規読み込み時」と「Use Stack GroupチェックボックスをOFFに戻した直後」の2タイミングのみに限定し、それ以外の再描画では既にメモリ上にある値を維持するよう修正。ついでに`stackModels.every()`が空配列に対し空虚な真を返すことで、LoRAが0件のときトグルオールチェックボックスが誤ってON表示になる副次的な表示バグも修正した。
|
||||
|
||||
**検証**: ユーザー自身が実機で確認。(1) `lora_manager_normal_clip.json`(ネイティブCLIPTextEncode使用)でComfyUI直接実行とGenerateUI実行の生成画像が同一であることを確認。(2) `lora_m_editplus_comfyUI.json`(CLIPTextEncodeEditPlus使用、mode: back)でも同様に画像が同一であることを確認。(3) 残る3モード(RAW / EDIT / front)についても結果が同一であることを確認済み。実サーバーの`/object_info`を取得しての突き合わせ、および修正前後のconvertUiToApiロジックの単体シミュレーションで根本原因を再現・修正確認済み。
|
||||
|
||||
**How to apply**: (1) ComfyUIのカスタムノードで`optional`の`forceInput: true`入力を追加する場合、Workflow Studio側のUI→API変換ロジックはこのフラグを尊重する前提で書くこと(`_getWidgetInputNames`/`_getWidgetInputTypes`が該当)——見落とすと、未接続時に本来送信されるべきでない値が紛れ込み、Pythonの`if x is not None`判定を静かに壊す。(2) ワークフローJSON上に存在しない・実行時にしか計算されない値(今回のLoraManagerの`trigger_words`のように)は、静的なメタデータ抽出では原理的に復元できないことをドキュメント・ユーザー説明の両方で明示すること。(3) 複数箇所に同種のロジックが移植されている場合(`metadata-tab.js`と`node_sets_menu.js`など)、片方だけ直して終わりにせず両方に同じ修正を適用し、`node --check`+差分比較で開発元/ランタイム両フォルダの同一性を確認してから同期する。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.91
|
||||
|
||||
### 機能拡張: Labインデックス画像にPlanメタデータをPNG埋め込み+README構成整理(Changelogセクション廃止)
|
||||
|
||||
v0.3.90でLabタブに追加した「Run時インデックス画像Output保存」機能について、ユーザーから「インデックス画像にPlanのメタデータは埋め込まれていないのでは」と確認を受け調査したところ、実際に埋め込まれていないことが判明。`_buildIndexImageDataUrl()`は`canvas.toDataURL("image/png")`でピクセルデータのみのプレーンなPNGを生成しており、バックエンド側(`lab_plan_service.py`の`save_index_image()`/`save_index_image_to_output()`)も受け取ったbase64をそのままバイト列として書き込むだけだった。特にOutputフォルダ側のコピーは対になる`.json`が存在しないため、画像単体では「どのPlanで生成したか」を後から追跡する手段が一切なかった。「Planドロップエリアにドロップして読み込める形にしたい」という要望を受け対応。
|
||||
|
||||
**PNG埋め込みの実装**: `static/js/util.js`に`embedPngTextChunk(dataUrl, keyword, text)`を新規追加。PNGのIHDR直後に**iTXt**チャンクを挿入する(挿入位置・CRC計算ロジックは、ComfyUI本体が既に持つワークフロー埋め込み機能=`web/comfyui/top_menu_extension.js`の`embedWorkflowInPng`と同じ方式を踏襲)。**tEXtではなくiTXtを選んだ理由**: このコードベースの既存PNGチャンク読み取り(`metadata-tab.js`の`readAllPNGTextChunks`)は、tEXtのtextフィールドをLatin-1でデコードする実装になっている。一方書き込み側(`embedWorkflowInPng`含む)は`TextEncoder().encode()`でUTF-8エンコードしているため、非ASCII文字(日本語のNoteやプロンプトなど)を含むテキストをtEXt経由で埋め込むと、読み取り時にLatin-1誤変換で文字化けする。iTXtチャンクは仕様上UTF-8前提であり、`readAllPNGTextChunks`の`parseITXtChunk`は既に正しくUTF-8デコードを行っているため、新規キー`wfm_lab_plan`はiTXtで統一した(既存のtEXt/workflow埋め込みが同じ問題を抱えている可能性があるが、それは既存機能でありスコープ外として手を付けていない)。
|
||||
|
||||
**読み書きの配線**: `metadata-tab.js`の`readAllPNGTextChunks`を新たに`export`(元は非export、Lab側から再利用するため)。`lab-tab.js`に`LAB_PLAN_PNG_KEY = "wfm_lab_plan"`を追加し、`_buildIndexImageDataUrl(planData)`が省略可能引数`planData`を受け取ってJSON文字列化・埋め込むよう変更。呼び出し元の`_savePlan()`と`_maybeSaveIndexImageOnRun()`双方で、既に構築済みの`_buildPlanData()`の結果を渡すようにした——Plan Save側(lab_plan/内、対になる.jsonが既にある)にも同じ埋め込みを行い、画像単体を持ち出しても再現可能にする一貫性を優先。`_loadPlanFromIndexImage(file)`は、まず`readAllPNGTextChunks(file)`でチャンクを直接読み`wfm_lab_plan`があれば`JSON.parse`して`_applyPlanData()`へ渡す(サーバー通信不要)よう変更し、埋め込みが無い古いインデックス画像に対してのみ従来の「同名.jsonをサーバーから取得」にフォールバックする。
|
||||
|
||||
**検証**: (1) Node単体テストでembed→PNGバイト解析→JSON.parseの往復を検証(日本語+絵文字を含む文字列が完全一致することを確認)。(2) Kaptureで実際にComfyUIを操作し、T2Iモード+Save index on Run+日本語/絵文字入りNoteでRunを実行、実際に生成された`Lab_index_00003_.png`の実バイトを別のNodeスクリプトで直接読み取り、埋め込まれた`wfm_lab_plan`チャンクの内容が実際のPlan状態と完全一致することを確認。(3) ブラウザの`evaluate`経由で`fetch`+`File`+`DataTransfer`+合成`drop`イベントを構築し、サーバー側に対応する`.json`が一切存在しないこのOutputフォルダPNGを実際にPlanドロップゾーンへドロップ、T2Iモード・Save index on Runチェックボックス・Note(日本語+絵文字)・Batch数・ワークフロー記録(Open Workflowボタン)が全てサーバー通信なしで正しく復元されることをスクリーンショットで確認済み。
|
||||
|
||||
**README構成整理**: README.mdの`## Changelog`セクション(v0.1.1〜v0.3.90まで692行)を全削除。バージョンを重ねるごとに肥大化しており、GitHub Releaseのリリースノートと内容が重複していたため、ユーザーの意向で「今後はGitHub Releaseのリリースノートのみでバージョン履歴を管理する」方針に変更。あわせて`CLAUDE.md`のリリース手順から「READMEのChangelogにエントリを追加」の手順を削除し、代わりに`gh release create`のリリースノート本文に変更点を記載する旨を明記した。README.mdは692行分の削減(1248行→556行)。
|
||||
|
||||
**How to apply**: (1) 今後PNGに新しいカスタムメタデータを埋め込みたい場合は、必ずtEXtではなく**iTXt**を使うこと(このコードベースのtEXt読み取りはLatin-1decodeのため非ASCII文字が壊れる)。`util.js`の`embedPngTextChunk(dataUrl, keyword, text)`と`metadata-tab.js`の`readAllPNGTextChunks(file)`(export済み)がその書き込み/読み取りのペア。(2) リリース時のバージョン履歴はREADME.mdではなく`gh release create`のリリースノートに記載する(README.mdにChangelogセクションを復活させない)。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.90
|
||||
|
||||
### 機能拡張: Labタブの進化(ワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞)+Galleryタブのルート表示簡略化
|
||||
|
||||
v0.3.87〜v0.3.89で実装したLabタブ(GenerateUI)に対する、実際の運用を通じたユーザーからの一連の追加要望をまとめて対応。
|
||||
|
||||
**1. Plan JSONへのワークフロー記録+呼び出し(Open Workflowボタン)**: これまでLabのPlanには「どのワークフローに対して」実行したキーフレーム設定なのかが記録されておらず、後日Planを見返しても再現できない問題があった。`_buildPlanData()`(`lab-tab.js`)が`#wfm-gen-wf-name`の`dataset.filename`(GenerateUIに現在ロード中のワークフローファイル名)を読み取り`workflow_filename`としてPlan JSONに保存するようにし、Plan読込後にPLANボックス内へ表示される**Open Workflow**ボタンから、その記録されたワークフローファイルを`/api/wfm/workflows/raw`経由で取得し`loadWorkflowIntoEditor()`へ渡してGenerateUIへ読み込み直せるようにした。自動読込はせず、常に`window.confirm`で確認を挟む(現在編集中のワークフローを不意に上書きしない安全策、ユーザー指定)。`loadWorkflowIntoEditor`は`generate-tab.js`にあり、`generate-tab.js`は既に`lab-tab.js`から`initLabTab`をimportしているため、静的importすると循環import(`generate-tab.js`→`lab-tab.js`→`generate-tab.js`)になる。v0.3.87で`getEagleSettings`/`saveToEagle`を`util.js`へ移設して回避した前例があるが、`loadWorkflowIntoEditor`はgenerate-tab.js固有の依存が多く移設は非現実的なため、今回はボタンのクリックハンドラ内で`const { loadWorkflowIntoEditor } = await import("./generate-tab.js");`と**動的import**する方式を採用(`gallery-tab.js`が`tagger-tab.js`に対して既に使っている前例パターンを踏襲)。
|
||||
|
||||
**2. T2Iワークフロー対応**: LabはもともとI2Iバッチ生成専用で、ソース画像が無いと`_runLabBatch()`が`labNoSourceImage`エラーで実行をブロックしていた。Imageペイン最上部に**T2I workflow (no source image)**チェックボックスを追加し、ON時は(a) ソース画像必須チェックをスキップ、(b) `_buildWorkflowForIteration()`でLoadImageノードへの書き込みを完全にスキップ(`imageRef = null`)、(c) Imageドロップゾーンと「Use generated image for next」をグレーアウト(既存の画像は削除せず、I2Iに戻したときのため保持)。Resultsパネルのソース画像欄には代わりに「T2I workflow (no source image)」の注記を表示。設定は`t2i_mode`としてPlan JSONに保存。実機確認では、実際にT2Iワークフロー(`EmptyLatentImage`使用、LoadImageノード無し)で画像生成に成功し、Eagle自動保存も正常動作することを確認した(検証中、テスト対象ワークフローのCheckpointが存在しないモデル名だったことによる`/prompt` 400エラーに遭遇したが、これはT2I機能自体のバグではなくテスト環境のモデル設定の問題だった)。
|
||||
|
||||
**3. Run時にインデックス画像をOutputフォルダへ保存+Eagle連携**: 右ペインに**Save index image to Output on Run**チェックボックスを追加。ONの場合、Run完了ごとに`_buildIndexImageDataUrl()`で作った9枚コンタクトシートを新設バックエンドAPI `POST /api/wfm/lab/index-image/save-to-output`へ送信し、`py/services/lab_plan_service.py`の`save_index_image_to_output()`が`folder_paths.get_output_directory()` + `folder_paths.get_save_image_path("Lab_index", output_dir)`を使って、通常の生成画像と同じ自動採番規則で`Lab_index_00001_.png`のように保存する(上書きしない)。Plan Save側の動作(`lab_plan/`への保存)は完全に独立したまま変更していない。続けて「インデックス画像もEagleに保存したい」という追加要望を受け、Output保存が成功した後に既存のグローバル設定(Settingsタブの`eagleAutoSave`、生成画像のEagle保存と同じ仕組み)を流用して`saveToEagle()`を呼ぶだけの実装を追加——Lab専用の別トグルは増やさず、既存パターンにそのまま乗せた。設定は`save_index_on_run`としてPlan JSONに保存。バックエンドのルート登録はComfyUI起動時の1回のみのため、この機能だけはPythonファイル変更を伴い**ComfyUI再起動後に実機確認**(Outputフォルダへの`Lab_index_00001_.png`保存、コンソールの`[Eagle] Saved: Lab_index_00002_.png`ログの両方を確認)。StabilityMatrix環境では`custom_nodes/.../output`が実体`Images/Text2Img`へのsymlinkになっている点に留意(`folder_paths.get_output_directory()`はsymlink越しに正しく解決される)。
|
||||
|
||||
**4. プランファイル名への`ws_labplan_`接頭辞自動付与**: `_savePlan()`(Plan Save/Save As共通処理)で、プロンプトに入力された名前から`_stripLabPlanPrefix()`で既存の接頭辞を除去してから`ws_labplan_`を付け直す方式にし、二重接頭辞を防止(ユーザーが手動で接頭辞込みの名前を入力しても安全)。JSON内部の`"name"`フィールドは接頭辞なしのプレーンな名前のまま(表示用と実ファイル名を分離)。Plan JSONサブタブのプレビュー(`_syncLabJsonView`)も同じ接頭辞除去ロジックで統一。
|
||||
|
||||
**5. Galleryタブ — ルートフォルダ表示の簡略化**: フォルダツリーのルートエントリが実際のディレクトリ名(例: StabilityMatrix環境の`Text2Img` — outputフォルダが`Images/Text2Img`へのsymlinkになっているため)をそのまま付けて「[root] Text2Img」と表示していたが、ルートにはT2I以外の画像(Lab_indexなど各種生成物)も含まれるため実態と合わず紛らわしいという指摘を受け、`gallery-tab.js`の`renderTreeNode()`で単に「[root]」とだけ表示するよう変更(`label.textContent = isRoot ? "[root]" : node.name;`)。
|
||||
|
||||
**README構成の見直し**: Featuresセクションが17個の`### <タブ名>`セクションで非常に長くなっていたため、GitHub Flavored MarkdownのHTML `<details><summary>`要素で各タブセクションを折りたたみ可能にした(見出しレベルは`<summary><h3>...</h3></summary>`として維持)。Changelog等の他セクションは対象外(ユーザー指定は「タブの項目ごと」=Featuresセクションのみ)。
|
||||
|
||||
**検証**: 4機能とも実機(Kapture経由でComfyUIの`http://127.0.0.1:8189/wfm`を直接操作)で確認済み。ワークフロー呼び出しはPlan保存直後にファイル名とボタンが表示されること・クリックで確認ダイアログ→再読込を確認(当初Save経路でUI更新関数`_updatePlanWorkflowUI()`を呼び忘れる不具合があり、実機確認で発覚・修正)。T2Iモードは実際の画像生成成功、Output保存はComfyUI再起動後に`Lab_index_00001_.png`の生成、Eagle連携はコンソールログとファイル両方で確認、Galleryルート表示はスクリーンショットで確認。いずれもテスト用に生成したファイルは検証後に削除(Output画像の削除はユーザー側で実施)。
|
||||
|
||||
**How to apply**: (1) Lab関連でstateを更新する処理(Save/Load/Clear)を追加・変更する際は、対応するUI更新関数を**全ての状態変更経路**で呼び忘れていないか確認すること — 今回まさにSave経路で呼び忘れて実機確認で発覚した。(2) `lab-tab.js`から`generate-tab.js`の関数を使いたい場合、`util.js`への機能移設が大げさすぎるなら動的import(`await import("./generate-tab.js")`)を検討する。(3) 新しいLabのRun時副作用(Output保存等)を追加する際は、可能な限り既存のグローバル設定(Eagle自動保存など)に乗せ、機能ごとに新しいトグルを増やさないこと。(4) StabilityMatrix環境のようにComfyUIのoutputフォルダがsymlinkになっている場合でも`folder_paths.get_output_directory()`は正しく解決されるが、`ls -lt`等で直接調査する際はsymlinkの実体を辿る必要がある。
|
||||
|
||||
---
|
||||
|
||||
## 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プロンプト下書き・バッチ画像生成)
|
||||
|
||||
別カスタムノード`comfyui-comic-creator`が実装中の「半自動マンガ作成」機能(スクリプトのコマ割り・セリフからコマ単位でフキダシ・画像を自動生成するPhase 3)から、iframe越しに呼び出される新規ブリッジ2つを`gallery-tab.js`に追加。既存の`_wfmReceiveImageForI2I`/`_wfmReceiveInpaintRequest`/`_wfmReceiveI2IRunRequest`と同じ「`window._wfmReceiveXxx`をwindowへ直接代入し、Comic Creator側からiframe越しに呼ぶ」配置パターンを踏襲。
|
||||
|
||||
- **`_wfmReceiveLLMPromptRequest(context)`**: シーン・登場人物・セリフ・現在のプロンプト下書きをコンテキストとして受け取り、AIタブの翻訳機能が既に持つLLM接続ロジック(`ai-tab.js`の`callLLM`/`loadAiSettings`/`isValidBackendUrl`、Ollama/LM Studio対応、設定タブの接続設定をそのまま再利用)で画像生成プロンプト案を1本生成して返す。これらの関数は元々`ai-tab.js`内に閉じていたため、`export`に追加してモジュール間で再利用できるようにした。
|
||||
- **`_wfmReceiveGenerateRequest(prompt, width, height)`**: 現在Generate UIタブにロード中のワークフローでtxt2img生成を実行し、結果画像のURLを返す。専用ワークフロー選択(I2I/Inpaint連携にある`workflowData`引数)は今回は非対応。幅・高さの指定は、設定タブの「Latent Image」パネルが`analysis.latent_nodes[0]`(`EmptyLatentImage`/`EmptySD3LatentImage`ノードを検出)を使って`workflow[id].inputs.width/height`を直接書き換えているのと同じ手法をそのまま踏襲。結果URLの取得は`_runInpaintWithImages`等と同じ「`window._wfmGenerateTab.generate()`実行後に`#wfm-gen-result-img`の`src`を読む」パターンを採用。
|
||||
|
||||
**検証**: Comic Creator側からKaptureで実機E2E確認済み(Ollama接続でのLLM呼び出し、t2iワークフローでの画像生成・結果URL取得とも成功)。詳細はComic Creator側DEVLOG「半自動マンガ作成 Phase 3」の項を参照。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.79
|
||||
|
||||
### 追加機能: サイドパネル(Workflow Studio Library)IタブのMage-Flow等サブグラフワークフロー対応
|
||||
|
||||
v0.3.78でMetadataタブ(`metadata-tab.js`)に行ったMage-Flowサブグラフ対応と同様の対応を、「同様にサイドパネル(ComfyUIキャンバス上のIタブ)も更新したい」という依頼を受けて`node_sets_menu.js`にも適用。調査したところ、`node_sets_menu.js`のIタブは`metadata-tab.js`とほぼ同一の抽出関数群(`_extractCheckpoints`/`_extractPrompts`/`_collectAllNodes`等)を独立に重複実装しており、`web/comfyui/`は`static/js/`と配信URLが異なるためESモジュールをimportできない(既存のAI backend URL解決等と同じ制約)。そのためMetadataタブ側の対応がそのまま反映されない状態だった。
|
||||
|
||||
- `comfyui-workflow.js`の`convertUiToApi()`相当ロジック一式(`_flattenSubgraphs`による外側ウィジェット値の内部ノードへの注入、`_getWidgetInputNames`/`_getWidgetInputTypes`/`_getDynamicComboSubNames`によるobject_infoベースのwidgetマッピング、DynamicCombo型対応を含む、約450行)を`_wfm`接頭辞を付けて`node_sets_menu.js`にローカル複製。GenerateUIタブと異なりIタブは読み取り専用表示のため、Checkpoint代替やBypass/Muteノードの記録機能(`_lastCheckpointSubstitutions`等)は複製せず省略。
|
||||
- `fromWorkflow`をasync化し、サブグラフ(`definitions.subgraphs`)を検出した場合は複製版`_wfmConvertUiToApi()`でAPI形式に正規化してから抽出するよう変更(変換失敗時は元のUI形式のまま抽出する既存ロジックへフォールバック)。
|
||||
- `_extractPromptsFromNodeSet`/`_extractPromptsAPI`に`TextEncodeMageFlowEdit`(1ノードでprompt/negative_prompt両方を直接持つ複合ノード)専用の分岐を追加。既存のtextMap(1ノード1テキストしか保持できない)構造では両ロールを区別できず、同じテキストが両方に登録されてしまうバグの修正を複製。
|
||||
|
||||
**検証**: `node_sets_menu.js`は`../../scripts/app.js`をトップレベルでimportしモジュール自体をNode.jsで直接実行できないため、複製したロジックブロック(`_sanitizeJSON`〜`_extractAllMetadata`の自己完結範囲)を`sed`で抜き出しNode.jsでテスト。`mageflow_1.json`で`metadata-tab.js`側と完全に一致する結果(モデル名・プロンプトとも外側の実際の値)を確認。既存の他サブグラフワークフロー(Flux2/Qwen/Ernie/Krea2等)8件でもエラーなく動作しリグレッションがないことを確認。
|
||||
|
||||
**How to apply**: `web/comfyui/`はimport不可のためローカル複製パターンを踏襲する場合、複製元のロジックが今後も更新される可能性があるものは、複製箇所に「変更する場合は元ファイル側も同期すること」という注意コメントを残す。複製したコードの検証は、importが困難なファイルでも`sed`等で自己完結したコードブロックを抜き出しNode.jsで実行すれば、ブラウザなしでも複製元と出力を突き合わせられる。
|
||||
|
||||
### 追加機能: AI TOOL Chatにスキル機能を追加・SVG生成品質改善・ヘルプのカード形式再設計
|
||||
|
||||
「Chatで生成されるSVGの質を上げたい」という依頼から調査を開始したところ、Chat機能(`ai-tab.js`)にはsystem promptの仕組みが一切なく、SVG生成は「モデルが会話の流れで偶然SVGコードを書いた場合に正規表現で抽出するだけ」という後付け設計だったことが判明した(`_extractSvgCode`/`_appendSvgPreview`)。これを踏まえ、単発のSVG改善ではなく汎用的な「AIスキル」機構(選択式system promptライブラリ)を新設し、SVG生成品質改善をその第一弾スキルとして実装する方針に変更した。
|
||||
|
||||
- **AIスキル機構**: `.md`ファイル(frontmatterで`name`/`description`を定義)を`user/default/Workflow-Studio/ai_skills/`に保存し、Chatペイン上部のドロップダウンで選択すると、その内容がsystem promptとして会話履歴の先頭に(表示上のチャット履歴には含めず)注入される。バックエンドは既存の`wildcard_service.py`/`wildcard_routes.py`パターンを踏襲した`skill_service.py`/`skill_routes.py`(`GET/POST /api/wfm/skills`系)を新設。管理UI(一覧・追加・編集・削除)はワイルドカードエディタのUIパターンを再利用してChatペインに内蔵。
|
||||
- **SVG Icon Generatorスキル**: アイコンライブラリ(Feather/Heroicons等)の設計慣習(`viewBox`+`currentColor`+`stroke-width`)とSVGのXSS対策(`<script>`/イベント属性/`<foreignObject>`禁止)を組み込んだ初期スキルとして同梱。実機検証(gemma3系モデル等)でカモメ/ペンギン等の生成結果が「落書き」状態だったため追加調査したところ、LLMのSVG生成研究(Chat2SVG等)で言われる「Shape Decomposition」(対象を部位に分解してから図形を組み立てる)を明示的に指示していないことが原因と判明。「Composition process」セクション(部位分解→形状割当→組み立ての手順を明示指示)を追加し、有機的な輪郭には短い`<path>`曲線を許可するよう緩和した結果、体感で大幅に改善(ユーザー確認済み)。
|
||||
- **Skill Creator(対話式スキル作成)**: 「作りたいスキルを対話形式で聞き出し、完成品を返す」ためのメタスキルを第二弾として同梱。1問ずつ質問し、十分な情報が集まったら\`\`\`skill\`\`\`フェンスでfrontmatter付きの完成スキルを出力する設計。フロント側に\`\`\`skill\`\`\`ブロック検出+「スキルとして保存」ボタン(クリックでスキル管理パネルを開きfrontmatterの`name`から自動生成したファイル名とともにエディタへプリフィル)を追加し、実際にqwen3:4bで対話→保存までのフローを検証済み。SVGプレビュー機能と同じ理由でSPA(`ai-tab.js`)側のみの実装とし、サイドパネル(`node_sets_menu.js`)はスキル選択・system prompt注入のみ対応。
|
||||
- **ヘルプのカード形式再設計**: 別件で導入中の`comfyui-comic-creator`のヘルプUIが「セクションをカード形式で表示(workflow studio のヘルプ形式)」というコメント付きで実装されており、調査したところWFS自身のヘルプUI(サイドバーナビ+検索)を参考に作られたものだったが、1トピック=1カードという構造だけはWFS側になかったことが判明。AI TOOLページを皮切りに全16ヘルプページを「ページ見出し+タブへのジャンプボタン」+「トピックごとの複数カード(見出し+`<ul>`)」の構造に再構成した。個々の項目テキスト・id・i18n翻訳キーは変更せず、既存の`<li>`要素をどのカードの`<ul>`に含めるかという入れ物の再編成のみで実施(新規に追加したのはカード見出し用のキーのみ、約50個×3言語)。再構成の過程で、AI TOOLページの`helpAi5`(Settingsペインの説明)が`helpAi6`に完全に包含される重複項目だったこと、Taggerページと一部のTrouble項目がindex.html上は空の`<li>`(実テキストはi18n.js側にのみ存在)だったことも発見し、あわせて修正・記入した。
|
||||
|
||||
**検証**: 新規追加した`/api/wfm/skills`系エンドポイントをcurlで確認。ブラウザでSVGスキル・Skill Creatorスキルの選択→送信→system prompt注入をfetchフックで実データ検証、手書きの「Bird」few-shot例は実際にレンダリングして認識可能な形状であることを確認。ヘルプの再設計は全16ページを実ブラウザでクリックし、カード表示・ジャンプボタンの遷移・翻訳適用を確認。
|
||||
|
||||
**How to apply**: 新しい「選択式system promptライブラリ」的な機能を追加する際は、`skill_service.py`のfrontmatterパース(`_FRONTMATTER_RE`)とファイル一覧/読み書きAPIパターンがそのまま再利用できる。ヘルプページを新設・改修する際は、1ページ=1個の巨大な`<ul>`ではなく、トピックごとに`.wfm-help-card`で分割し、新規カード見出しはid+i18n 3言語キー+`app.js`の`helpIdMap`の3点セットで追加すること([[project_v0336_conventions]]のヘルプ更新規約を参照)。index.html上のテキストは`applyI18nToHtml()`がi18n.jsの内容で必ず上書きするため、実際に表示される文言はi18n.js側が正であり、index.html側は空にしたり古いままにしたりせず同期を保つこと。
|
||||
|
||||
## v0.3.78
|
||||
|
||||
### 追加機能: Eagle自動保存のSVG対応
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Runs before ComfyUI imports any custom node — the only place early enough
|
||||
to populate os.environ for code that reads env vars at import/request time.
|
||||
|
||||
Loads a .env file (if present) from this plugin's root directory so the
|
||||
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
|
||||
settings.json or exposing it to the frontend.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = PLUGIN_DIR / ".env"
|
||||
|
||||
if ENV_FILE.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(ENV_FILE)
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
|
||||
@@ -69,6 +69,8 @@ NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
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"
|
||||
|
||||
+99
-32
@@ -2,11 +2,9 @@
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
@@ -43,6 +41,16 @@ def _init_allowed_root() -> None:
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
|
||||
|
||||
# ImagePrompt/Style Catalogギャラリー用に実outputフォルダを常に別途保持する
|
||||
# (Settingsで_allowed_rootが別フォルダに変更されていても影響しないようにするため)
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir and Path(output_dir).is_dir():
|
||||
_service.set_comfy_output_root(output_dir)
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir for image-prompt root: %s", e)
|
||||
|
||||
|
||||
_init_allowed_root()
|
||||
|
||||
@@ -55,7 +63,12 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
|
||||
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
|
||||
app.router.add_get("/wfm/gallery/style-catalog/root", get_style_catalog_root)
|
||||
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
|
||||
app.router.add_post("/wfm/gallery/image/save-to-folder", save_image_to_folder_route)
|
||||
app.router.add_post("/wfm/gallery/output-image/delete", delete_output_image)
|
||||
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
|
||||
app.router.add_get("/wfm/gallery/groups", list_groups)
|
||||
app.router.add_post("/wfm/gallery/groups", create_group)
|
||||
@@ -104,6 +117,7 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
recursive = request.rel_url.query.get("recursive", "false") == "true"
|
||||
|
||||
if not folder:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
@@ -117,6 +131,7 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
recursive=recursive,
|
||||
)
|
||||
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
|
||||
if not search:
|
||||
@@ -248,6 +263,36 @@ async def save_image_meta(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_prompt_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image-prompt/root — ImagePromptギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_image_prompt_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def save_image_prompt(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/image-prompt — 画像の.txtサイドカーにプロンプトを保存"""
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
prompt = body.get("prompt", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
ok = _service.save_image_prompt(img_path, prompt)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_style_catalog_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/style-catalog/root — Style Catalogギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_style_catalog_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -260,15 +305,6 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
|
||||
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
|
||||
@@ -283,33 +319,64 @@ async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
if _service._allowed_root is None:
|
||||
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
|
||||
|
||||
# data URL から MIME タイプを判定し、拡張子を決定する
|
||||
raw = image_data
|
||||
mime = "image/png"
|
||||
if raw.startswith("data:"):
|
||||
header, _, raw = raw.partition(",")
|
||||
m = re.match(r"data:([^;]+)", header)
|
||||
if m:
|
||||
mime = m.group(1)
|
||||
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
|
||||
result = _service.save_image_to_folder(str(_service._allowed_root), filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
|
||||
# ファイル名サニタイズ(OSで使えない文字を除去)
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
|
||||
image_bytes = base64.b64decode(raw)
|
||||
|
||||
save_path = _service._allowed_root / safe
|
||||
save_path.write_bytes(image_bytes)
|
||||
|
||||
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
|
||||
return web.json_response({"ok": True, "path": str(save_path)})
|
||||
logger.info("save_image_to_gallery: saved %s", result["path"])
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_gallery error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_to_folder_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/save-to-folder — 任意フォルダへ画像を保存(同名なら上書き)。
|
||||
Style Catalogのカタログ作成で、スタイル名をファイル名として使う際に利用する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
folder = body.get("folder", "").strip()
|
||||
filename = body.get("filename", "").strip()
|
||||
image_data = body.get("imageData", "")
|
||||
|
||||
if not folder or not filename or not image_data:
|
||||
return web.json_response({"error": "folder, filename and imageData required"}, status=400)
|
||||
|
||||
result = _service.save_image_to_folder(folder, filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_folder_route error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_output_image(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/output-image/delete — ComfyUI標準の画像参照(filename/subfolder/type)から
|
||||
実ファイルを削除する。Style Catalogのカタログ作成で「Outputフォルダに画像を残さない」
|
||||
オプションが有効な時、カタログへコピー保存した直後の元画像を消すために使う。
|
||||
type="output" 以外は拒否する(temp/inputの誤削除を防止)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = (body.get("filename") or "").strip()
|
||||
subfolder = body.get("subfolder") or ""
|
||||
img_type = body.get("type") or ""
|
||||
if not filename or img_type != "output":
|
||||
return web.json_response({"error": "filename required and type must be 'output'"}, status=400)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
target = output_dir / subfolder / filename
|
||||
|
||||
result = await asyncio.to_thread(_service.delete_images, [str(target)])
|
||||
if result["deleted"]:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"error": "; ".join(result["errors"]) or "File not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("delete_output_image error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""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)
|
||||
app.router.add_post("/api/wfm/lab/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -38,6 +38,9 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
app.router.add_get("/api/wfm/styles", handle_get_styles)
|
||||
app.router.add_post("/api/wfm/styles", handle_create_style)
|
||||
app.router.add_put("/api/wfm/styles/{name}", handle_update_style)
|
||||
app.router.add_delete("/api/wfm/styles/{name}", handle_delete_style)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -245,18 +248,28 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory."""
|
||||
def _style_files() -> list:
|
||||
style_dir = DATA_DIR / "style"
|
||||
styles = []
|
||||
if not style_dir.is_dir():
|
||||
return styles
|
||||
for json_file in sorted(style_dir.glob("*.json")):
|
||||
return []
|
||||
return sorted(style_dir.glob("*.json"))
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory.
|
||||
各エントリに定義元ファイル名を "file" として付与する(Promptタブの一覧表示・
|
||||
「このファイルへ追加」機能で使用)。"""
|
||||
styles = []
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
styles.extend(data)
|
||||
for entry in data:
|
||||
if isinstance(entry, dict):
|
||||
entry = dict(entry)
|
||||
entry["file"] = json_file.name
|
||||
styles.append(entry)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
return styles
|
||||
@@ -270,3 +283,139 @@ async def handle_get_styles(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error loading styles: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# Promptタブ「Style」サブタブでの新規作成分の保存先。既存スタイルの編集は
|
||||
# 元々そのスタイルが記載されているファイルをそのまま書き換える(他ファイルへは移動しない)。
|
||||
_CUSTOM_STYLE_FILENAME = "custom.json"
|
||||
|
||||
|
||||
def _find_style(name: str):
|
||||
"""名前が一致する最初のスタイルを探し、(ファイルパス, そのファイルの全データ, インデックス) を返す。
|
||||
見つからなければ None。"""
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
continue
|
||||
if not isinstance(data, list):
|
||||
continue
|
||||
for i, entry in enumerate(data):
|
||||
if isinstance(entry, dict) and entry.get("name") == name:
|
||||
return json_file, data, i
|
||||
return None
|
||||
|
||||
|
||||
def _write_style_file(path: Path, data: list) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
async def handle_create_style(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/styles - 新規スタイルを作成する。
|
||||
body.file を指定すると既存のそのファイルへ追記する(Promptタブの「このファイルへ追加」用、
|
||||
ファイル名はstyleディレクトリに実在するもののみ許可しパストラバーサルを防ぐ)。
|
||||
未指定ならデフォルトのcustom.jsonへ追記する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
target_file = (body.get("file") or "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _create():
|
||||
if _find_style(name) is not None:
|
||||
return "duplicate"
|
||||
style_dir = DATA_DIR / "style"
|
||||
style_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target_file:
|
||||
path = next((f for f in _style_files() if f.name == target_file), None)
|
||||
if path is None:
|
||||
return "file_not_found"
|
||||
else:
|
||||
path = style_dir / _CUSTOM_STYLE_FILENAME
|
||||
|
||||
data = []
|
||||
if path.is_file():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, list):
|
||||
data = loaded
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load %s, recreating: %s", path.name, e)
|
||||
data.append({"name": name, "prompt": prompt, "negative_prompt": negative_prompt})
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_create)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{name}" already exists'}, status=400)
|
||||
if result == "file_not_found":
|
||||
return web.json_response({"error": f'File "{target_file}" not found'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error creating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_update_style(request: web.Request) -> web.Response:
|
||||
"""PUT /api/wfm/styles/{name} - 既存スタイルを更新する(記載元のファイルをそのまま書き換え、リネームも可)。"""
|
||||
try:
|
||||
original_name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
new_name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
if not new_name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _update():
|
||||
found = _find_style(original_name)
|
||||
if found is None:
|
||||
return "not_found"
|
||||
path, data, idx = found
|
||||
if new_name != original_name and _find_style(new_name) is not None:
|
||||
return "duplicate"
|
||||
data[idx] = {"name": new_name, "prompt": prompt, "negative_prompt": negative_prompt}
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_update)
|
||||
if result == "not_found":
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{new_name}" already exists'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error updating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_style(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/wfm/styles/{name} - スタイルを削除する(記載元のファイルから該当エントリのみ除去)。"""
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
|
||||
def _delete():
|
||||
found = _find_style(name)
|
||||
if found is None:
|
||||
return False
|
||||
path, data, idx = found
|
||||
del data[idx]
|
||||
_write_style_file(path, data)
|
||||
return True
|
||||
|
||||
ok = await asyncio.to_thread(_delete)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""AI skill (.md system prompt) file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import AI_SKILLS_DIR
|
||||
from ..services.skill_service import SkillService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SkillService(AI_SKILLS_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/skills", handle_list)
|
||||
app.router.add_get("/api/wfm/skills/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/skills/save", handle_save)
|
||||
app.router.add_post("/api/wfm/skills/delete", handle_delete)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_skills)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing skills: %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:
|
||||
content = await asyncio.to_thread(_service.get_content, filename)
|
||||
return web.json_response({"content": content})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("Error getting skill 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", "")
|
||||
content = body.get("content", "")
|
||||
result = await asyncio.to_thread(_service.save_file, filename, content)
|
||||
return web.json_response({"status": "ok", "file": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving skill: %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_file, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unsloth API proxy routes.
|
||||
|
||||
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
|
||||
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
|
||||
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
|
||||
never sent to the frontend — the frontend calls this proxy with the target
|
||||
path/method/payload, and the server attaches the Authorization header before
|
||||
relaying to Unsloth's OpenAI-compatible API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
|
||||
# baseUrl is client-supplied (so a custom Unsloth port works), but the
|
||||
# Authorization header carries a real secret — restrict the host to loopback
|
||||
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
|
||||
# server (SSRF). Port is unrestricted.
|
||||
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _get_api_key():
|
||||
"""Return the Unsloth API key from the environment (.env), or None."""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def _is_allowed_base_url(base_url):
|
||||
"""Only relay (and attach the API key) to a loopback host."""
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register Unsloth API routes."""
|
||||
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
body = await request.json()
|
||||
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
|
||||
path = body.get("path") or "/v1/models"
|
||||
method = (body.get("method") or "GET").upper()
|
||||
payload = body.get("payload")
|
||||
|
||||
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
|
||||
return web.json_response({"message": "Unsupported proxy target"}, status=400)
|
||||
|
||||
if not _is_allowed_base_url(base_url):
|
||||
return web.json_response({
|
||||
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
|
||||
}, status=400)
|
||||
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return web.json_response({
|
||||
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI.",
|
||||
}, status=401)
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
return web.json_response(data)
|
||||
|
||||
except HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
|
||||
except URLError as e:
|
||||
logger.warning("Unsloth proxy connection error: %s", e)
|
||||
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
|
||||
except Exception as e:
|
||||
logger.error("Unsloth proxy error: %s", e)
|
||||
return web.json_response({"message": 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)
|
||||
|
||||
+264
-11
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -18,6 +19,37 @@ logger = logging.getLogger(__name__)
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
|
||||
# data URL の MIME タイプ -> 拡張子(save_image_to_gallery / save_image_to_folder共用)
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _decode_image_data_url(image_data: str) -> tuple[bytes, str]:
|
||||
"""data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。
|
||||
ヘッダが無ければPNG扱い(旧互換)。"""
|
||||
raw = image_data
|
||||
mime = "image/png"
|
||||
if raw.startswith("data:"):
|
||||
header, _, raw = raw.partition(",")
|
||||
m = re.match(r"data:([^;]+)", header)
|
||||
if m:
|
||||
mime = m.group(1)
|
||||
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
|
||||
return base64.b64decode(raw), ext
|
||||
|
||||
|
||||
def _sanitize_save_filename(filename: str, ext: str) -> str:
|
||||
"""OSで使えない文字を除去し、拡張子が無ければ付与する"""
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
return safe
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
|
||||
@@ -30,6 +62,54 @@ _JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 60.0
|
||||
|
||||
# ImagePromptギャラリー: 画像と同名の.txtサイドカーに保存されたプロンプトテキストの最大読み込みサイズ
|
||||
_SIDECAR_PROMPT_MAX = 20_000
|
||||
|
||||
# ImagePromptギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
|
||||
IMAGE_PROMPT_FOLDER_NAME = "ws_image_prompt"
|
||||
|
||||
# Style Catalogギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
|
||||
STYLE_CATALOG_FOLDER_NAME = "ws_style_catalog"
|
||||
|
||||
# ponyxlWildcardsVault形式(.yaml + thumbnails/)を検出するための、画像の祖先フォルダ探索上限
|
||||
_VAULT_ROOT_SEARCH_DEPTH = 8
|
||||
_VAULT_THUMB_DIR_NAMES = ("thumbnails", "thumbnails_option2")
|
||||
|
||||
|
||||
def _clean_vault_tags(text: str) -> str:
|
||||
"""タグ文字列の前後の空白・カンマを整理する(tools/import_style_prompt_seed.py と同じ処理)"""
|
||||
text = text.strip()
|
||||
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
|
||||
text = re.sub(r"\s*,\s*", ", ", text)
|
||||
return text
|
||||
|
||||
|
||||
def _parse_vault_yaml_leaves(yaml_path: Path) -> dict[str, str]:
|
||||
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
|
||||
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
|
||||
戻り値: {leaf_name(小文字): タグ文字列}
|
||||
"""
|
||||
leaves: dict[str, str] = {}
|
||||
try:
|
||||
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
|
||||
except OSError:
|
||||
return leaves
|
||||
for i, line in enumerate(lines):
|
||||
trimmed = line.strip()
|
||||
if not trimmed or trimmed.startswith("#") or not trimmed.endswith(":"):
|
||||
continue
|
||||
if i + 1 >= len(lines):
|
||||
continue
|
||||
next_trimmed = lines[i + 1].strip()
|
||||
if not next_trimmed.startswith("-"):
|
||||
continue
|
||||
key = trimmed[:-1].strip()
|
||||
tags = next_trimmed[1:].strip()
|
||||
if not tags or key.lower() == "skip":
|
||||
continue
|
||||
leaves[key.lower()] = _clean_vault_tags(tags)
|
||||
return leaves
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
"""フォルダ単位の画像スキャン結果キャッシュ。
|
||||
@@ -73,15 +153,26 @@ class GalleryService:
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
# ComfyUI実outputフォルダの不変ルート。Settingsでユーザーが_allowed_rootを
|
||||
# 別フォルダに変更しても、ImagePrompt/Style Catalogギャラリー(常に実output配下)への
|
||||
# アクセスが塞がれないようにするため_allowed_rootとは別管理にする
|
||||
self._comfy_output_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (yamlシグネチャ, {leaf: tags})
|
||||
self._vault_leaf_cache: dict[str, tuple[tuple, dict]] = {}
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
def set_comfy_output_root(self, root_path: str) -> None:
|
||||
"""ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._comfy_output_root = p
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バックグラウンドインデックス
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -147,14 +238,23 @@ class GalleryService:
|
||||
logger.debug("BG index error: %s", e)
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
return False
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)。
|
||||
Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの
|
||||
_comfy_output_root のいずれか配下であれば許可する。"""
|
||||
resolved = path.resolve()
|
||||
if self._allowed_root is not None:
|
||||
try:
|
||||
resolved.relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if self._comfy_output_root is not None:
|
||||
try:
|
||||
resolved.relative_to(self._comfy_output_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
@@ -183,11 +283,13 @@ class GalleryService:
|
||||
except PermissionError:
|
||||
pass
|
||||
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
|
||||
total_count = image_count + sum(c["image_count_total"] for c in children_nodes)
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": rel if rel != "." else "",
|
||||
"abs_path": str(path).replace("\\", "/"),
|
||||
"image_count": image_count,
|
||||
"image_count_total": total_count,
|
||||
"children": children_nodes,
|
||||
}
|
||||
|
||||
@@ -230,6 +332,33 @@ class GalleryService:
|
||||
self._folder_cache.set(folder, entries)
|
||||
return entries
|
||||
|
||||
def _scan_folder_recursive(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
ImagePrompt/Style Catalogギャラリーのように深い階層に画像が置かれるケース用。
|
||||
キャッシュはしない(フォルダ数が少なく、都度スキャンしても軽い想定)。
|
||||
"""
|
||||
entries = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in files:
|
||||
if Path(name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
full = Path(root) / name
|
||||
try:
|
||||
st = full.stat()
|
||||
entries.append((
|
||||
name,
|
||||
str(full.resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
self,
|
||||
folder_path: str,
|
||||
@@ -238,8 +367,9 @@ class GalleryService:
|
||||
favorite_only: bool = False,
|
||||
tag_filter: str = "",
|
||||
group_filter: str = "",
|
||||
recursive: bool = False,
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
|
||||
"""指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
@@ -253,7 +383,7 @@ class GalleryService:
|
||||
group_member_set = self.metadata_store.get_group_member_set(group_filter)
|
||||
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
raw_entries = self._scan_folder_recursive(folder) if recursive else self._scan_folder(folder)
|
||||
|
||||
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
@@ -289,14 +419,16 @@ class GalleryService:
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト・.txtサイドカー)
|
||||
if search:
|
||||
s = search.lower()
|
||||
sidecar_prompt = self._read_sidecar_prompt(Path(abs_path)) or ""
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
or s in meta.get("prompt_cache", "").lower()
|
||||
or s in sidecar_prompt.lower()
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -314,6 +446,126 @@ class GalleryService:
|
||||
|
||||
return results
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# ImagePromptギャラリー: .txtサイドカーによるプロンプト保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_image_prompt_root(self) -> str | None:
|
||||
"""ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を
|
||||
返す。無ければ作成する。"""
|
||||
if self._comfy_output_root is None:
|
||||
return None
|
||||
root = self._comfy_output_root / IMAGE_PROMPT_FOLDER_NAME
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return str(root).replace("\\", "/")
|
||||
|
||||
def get_style_catalog_root(self) -> str | None:
|
||||
"""Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を
|
||||
返す。無ければ作成する。"""
|
||||
if self._comfy_output_root is None:
|
||||
return None
|
||||
root = self._comfy_output_root / STYLE_CATALOG_FOLDER_NAME
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return str(root).replace("\\", "/")
|
||||
|
||||
def save_image_to_folder(self, folder_path: str, filename: str, image_data: str) -> dict:
|
||||
"""任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。
|
||||
Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return {"ok": False, "error": "Destination folder not found"}
|
||||
if not self._check_path_allowed(folder):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
try:
|
||||
image_bytes, ext = _decode_image_data_url(image_data)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Invalid imageData: {e}"}
|
||||
safe = _sanitize_save_filename(filename, ext)
|
||||
if not safe:
|
||||
return {"ok": False, "error": "Invalid filename"}
|
||||
save_path = folder / safe
|
||||
try:
|
||||
save_path.write_bytes(image_bytes)
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"ok": True, "path": str(save_path).replace("\\", "/")}
|
||||
|
||||
def _sidecar_path(self, image_path: Path) -> Path:
|
||||
return image_path.with_suffix(".txt")
|
||||
|
||||
def _read_sidecar_prompt(self, image_path: Path) -> str | None:
|
||||
"""画像のプロンプトテキストを返す。
|
||||
1. 画像と同名の .txt サイドカーがあればそれを使う
|
||||
2. 無ければ ponyxlWildcardsVault 形式 (*.yaml + thumbnails/) をフォールバックとして
|
||||
その場で解決する(インポート未実施のフォルダをそのまま置いた場合に対応するため)
|
||||
"""
|
||||
sidecar = self._sidecar_path(image_path)
|
||||
try:
|
||||
if sidecar.is_file() and sidecar.stat().st_size <= _SIDECAR_PROMPT_MAX:
|
||||
text = sidecar.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if text:
|
||||
return text
|
||||
except OSError:
|
||||
pass
|
||||
return self._read_vault_prompt(image_path)
|
||||
|
||||
def _find_vault_category_root(self, image_path: Path) -> Path | None:
|
||||
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す"""
|
||||
cur = image_path.parent
|
||||
for _ in range(_VAULT_ROOT_SEARCH_DEPTH):
|
||||
if any((cur / name).is_dir() for name in _VAULT_THUMB_DIR_NAMES):
|
||||
return cur
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
return None
|
||||
|
||||
def _load_vault_leaves(self, category_root: Path) -> dict[str, str]:
|
||||
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
|
||||
if not yaml_files:
|
||||
return {}
|
||||
try:
|
||||
signature = tuple(sorted((str(f), f.stat().st_mtime) for f in yaml_files))
|
||||
except OSError:
|
||||
signature = ()
|
||||
cache_key = str(category_root)
|
||||
cached = self._vault_leaf_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return cached[1]
|
||||
leaves: dict[str, str] = {}
|
||||
for yf in yaml_files:
|
||||
leaves.update(_parse_vault_yaml_leaves(yf))
|
||||
self._vault_leaf_cache[cache_key] = (signature, leaves)
|
||||
return leaves
|
||||
|
||||
def _read_vault_prompt(self, image_path: Path) -> str | None:
|
||||
"""ponyxlWildcardsVault形式のフォールバック解決。
|
||||
thumbnails_option2 の "{leaf}.preview3.ext" のような二重拡張子にも対応するため、
|
||||
ファイル名の最初の "." より前をリーフ名候補として使う。"""
|
||||
category_root = self._find_vault_category_root(image_path)
|
||||
if category_root is None:
|
||||
return None
|
||||
leaves = self._load_vault_leaves(category_root)
|
||||
if not leaves:
|
||||
return None
|
||||
leaf_candidate = image_path.name.split(".")[0].lower()
|
||||
return leaves.get(leaf_candidate)
|
||||
|
||||
def save_image_prompt(self, image_path: str, text: str) -> bool:
|
||||
"""画像と同名の.txtサイドカーにプロンプトテキストを保存する"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return False
|
||||
if not self._check_path_allowed(p):
|
||||
return False
|
||||
try:
|
||||
self._sidecar_path(p).write_text((text or "").strip(), encoding="utf-8")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("save_image_prompt: failed for %s: %s", p, e)
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -355,6 +607,7 @@ class GalleryService:
|
||||
"tags": saved.get("tags", []),
|
||||
"memo": saved.get("memo", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
"image_prompt": self._read_sidecar_prompt(path),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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 save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Lab_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the images generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""AI skill (.md system prompt) file management service."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"md"}
|
||||
|
||||
DEFAULT_SKILL_FILENAME = "svg-icon.md"
|
||||
|
||||
DEFAULT_SKILL_CONTENT = """---
|
||||
name: SVG Icon Generator
|
||||
description: Produces clean, minimal, valid SVG icons and simple illustrations.
|
||||
---
|
||||
|
||||
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
|
||||
|
||||
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
|
||||
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
|
||||
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
|
||||
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
|
||||
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
|
||||
|
||||
## Output rules
|
||||
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
|
||||
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
|
||||
- Output exactly one `<svg>...</svg>` element per code block.
|
||||
|
||||
## Structure rules
|
||||
- Always include a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
|
||||
- Use a small, round coordinate system (0-24 or 0-100).
|
||||
- Round coordinate values to at most 2 decimal places.
|
||||
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
|
||||
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
|
||||
|
||||
## Color rules
|
||||
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
|
||||
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
|
||||
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
|
||||
|
||||
## Accessibility
|
||||
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
|
||||
|
||||
## Security (mandatory - do not violate)
|
||||
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
|
||||
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
|
||||
|
||||
## Example 1 - simple geometric icon (single color, primitives only)
|
||||
|
||||
Request: "Sun icon"
|
||||
|
||||
Parts: disc (circle), 8 rays (lines).
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sun</title>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="1" x2="12" y2="4"/>
|
||||
<line x1="12" y1="20" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="4" y2="12"/>
|
||||
<line x1="20" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</g>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Example 2 - organic subject (multi-color, decomposed into parts)
|
||||
|
||||
Request: "Bird icon"
|
||||
|
||||
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Bird</title>
|
||||
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
|
||||
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
|
||||
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
|
||||
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
|
||||
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
|
||||
"""
|
||||
|
||||
SKILL_CREATOR_FILENAME = "skill-creator.md"
|
||||
|
||||
SKILL_CREATOR_CONTENT = """---
|
||||
name: Skill Creator
|
||||
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
|
||||
---
|
||||
|
||||
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter (`name`, `description`) followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
|
||||
|
||||
## How to conduct the interview
|
||||
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
|
||||
- Cover, in this order, only what is not already clear from context:
|
||||
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
|
||||
2. What output format is expected - plain prose, a specific fenced code block language, a fixed structure, a length limit?
|
||||
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
|
||||
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
|
||||
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
|
||||
|
||||
## Output format for the finished skill
|
||||
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
|
||||
- The block must start with frontmatter exactly in this shape:
|
||||
---
|
||||
name: <short display name, shown in the skill dropdown>
|
||||
description: <one sentence, shown as a tooltip in the dropdown>
|
||||
---
|
||||
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
|
||||
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
|
||||
"""
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
"""Extract a shallow `key: value` frontmatter block, if present."""
|
||||
m = _FRONTMATTER_RE.match(content)
|
||||
if not m:
|
||||
return {}
|
||||
fields: dict = {}
|
||||
for line in m.group(1).splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
class SkillService:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills_dir = skills_dir
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_default_skill()
|
||||
|
||||
def _seed_default_skill(self) -> None:
|
||||
"""Seed a starter skill file the first time the directory is empty."""
|
||||
try:
|
||||
has_any = any(
|
||||
p.is_file() and p.suffix.lstrip(".").lower() in ALLOWED_EXTS
|
||||
for p in self.skills_dir.iterdir()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
has_any = False
|
||||
if not has_any:
|
||||
(self.skills_dir / DEFAULT_SKILL_FILENAME).write_text(
|
||||
DEFAULT_SKILL_CONTENT, encoding="utf-8"
|
||||
)
|
||||
(self.skills_dir / SKILL_CREATOR_FILENAME).write_text(
|
||||
SKILL_CREATOR_CONTENT, encoding="utf-8"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> list[dict]:
|
||||
"""Return sorted list of skill files with parsed frontmatter metadata."""
|
||||
if not self.skills_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.skills_dir.glob("*")):
|
||||
if not (path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS):
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
content = ""
|
||||
meta = _parse_frontmatter(content)
|
||||
files.append({
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def save_file(self, filename: str, content: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
return {
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
def delete_file(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()
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
# Skills are a flat directory - reject any path separator (no subfolders).
|
||||
name = filename.strip()
|
||||
if "/" in name or "\\" in name or name in (".", ".."):
|
||||
return None
|
||||
ext = Path(name).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.skills_dir / name
|
||||
@@ -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,15 +73,19 @@ 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
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_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)
|
||||
ollama_routes.setup_routes(app)
|
||||
unsloth_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
@@ -74,5 +94,7 @@ class WorkflowStudio:
|
||||
wildcard_routes.setup_routes(app)
|
||||
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.78"
|
||||
version = "0.3.99"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -19,3 +19,6 @@ piexif>=1.1.3
|
||||
# Omit if you do not use DeepDanbooru models.
|
||||
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
|
||||
# tensorflow>=2.10
|
||||
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
+160
-1
@@ -2,11 +2,45 @@
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブ (Output / Style-Prompt) ──────────────────────── */
|
||||
|
||||
.wfm-gallery-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-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-gallery-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-gallery-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-gallery-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-gallery-panel.active { display: flex; }
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
@@ -499,6 +533,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-image-prompt-text {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -833,3 +881,114 @@
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ImagePrompt / Style_Catalog ギャラリー (右ペイン)
|
||||
============================================================ */
|
||||
|
||||
.wfm-imageprompt-detail-panel {
|
||||
width: 520px;
|
||||
min-width: 520px;
|
||||
max-width: 520px;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-left {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-prompt-box {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row .wfm-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove:hover { opacity: 1; }
|
||||
|
||||
.wfm-imageprompt-final-section {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-final-textarea {
|
||||
margin-top: 4px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/* 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-plan-workflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-secondary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Setting 1, not yet explicitly overridden: showing the live GenerateUI value instead of
|
||||
its own stored one (see _isLiveDisplay in lab-tab.js). Dashed border distinguishes it
|
||||
from a saved override (solid border) at a glance. */
|
||||
.wfm-lab-cell.wfm-lab-cell-live {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-live .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* This keyframe row is skipped entirely (see _resolveKeyframe's bypassed handling) —
|
||||
amber to read as "temporarily disabled", distinct from the neutral gray used for
|
||||
revert/live. Takes priority over both of those in _cellHtml. */
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed {
|
||||
border-color: var(--wfm-warning);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed .wfm-lab-cell-value {
|
||||
color: var(--wfm-warning);
|
||||
}
|
||||
|
||||
.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-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
margin: 0 0 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);
|
||||
}
|
||||
@@ -2342,6 +2342,38 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Col3 (Wildcard/Style) タブ切り替え */
|
||||
.wfm-prompt-col3-tabs {
|
||||
display: flex;
|
||||
background: var(--wfm-surface);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-prompt-col3-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wfm-prompt-col3-tab:hover { color: var(--wfm-text); }
|
||||
.wfm-prompt-col3-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-prompt-col3-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ollama Assistant */
|
||||
.wfm-ollama-assistant {
|
||||
display: flex;
|
||||
@@ -2885,6 +2917,29 @@ body {
|
||||
Image Input (I2I) Slots
|
||||
============================================ */
|
||||
|
||||
/* Container/header styling comes from .wfm-settings-section / .wfm-settings-summary
|
||||
(shared details/summary accordion used by the Settings tab) — collapsed by default
|
||||
since this is a low-frequency feature. */
|
||||
|
||||
.wfm-i2i-placeholder-mode {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-i2i-placeholder-mode label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-i2i-placeholder-fields input[type="color"] {
|
||||
padding: 2px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.wfm-i2i-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -2948,6 +3003,12 @@ body {
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.wfm-i2i-drop-zone.wfm-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-i2i-drop-label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
@@ -3182,6 +3243,21 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-help-article-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wfm-help-article-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--wfm-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfm-help-card {
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
@@ -5714,3 +5790,42 @@ body {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-skill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-skill-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-skill-panel {
|
||||
margin: 6px 8px 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
background: var(--wfm-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-skill-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wfm-ai-skill-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
+416
-31
@@ -5,7 +5,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, escapeHtml } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
@@ -50,33 +50,102 @@ function fileToBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
// ============================================
|
||||
// Thinking mode / Max tokens (shared by callLLM / callChat / callVLM)
|
||||
// ============================================
|
||||
|
||||
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
|
||||
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
|
||||
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
|
||||
// <think>タグ除去でも担保する(stripThinkingTagsを参照)。
|
||||
function _applyGenOptions(body, backend, settings) {
|
||||
const maxTokens = parseInt(settings?.maxTokens, 10);
|
||||
if (backend === "ollama") {
|
||||
body.think = !!settings?.thinkingMode;
|
||||
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
|
||||
} else if (maxTokens > 0) {
|
||||
body.max_tokens = maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function stripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).response || "";
|
||||
const text = (await res.json()).response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await res.json()).choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Unsloth proxy (server relays the request, attaching the API key from .env
|
||||
// so it never reaches the frontend — see py/routes/unsloth_routes.py)
|
||||
// ============================================
|
||||
|
||||
async function unslothProxy(baseUrl, path, method, payload) {
|
||||
const res = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
|
||||
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
|
||||
// `reasoning_content` field. Fold it back into a <think> block so the
|
||||
// existing Thinking-mode show/strip logic (_applyGenOptions/stripThinkingTags)
|
||||
// still applies uniformly. Without this, a low max_tokens can make the model
|
||||
// spend its whole budget reasoning and return an empty `content` with no
|
||||
// visible explanation.
|
||||
function _unslothContent(message) {
|
||||
const reasoning = message?.reasoning_content;
|
||||
const content = message?.content || "";
|
||||
return reasoning ? `<think>${reasoning}</think>${content}` : content;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// URL validation
|
||||
// ============================================
|
||||
@@ -100,6 +169,9 @@ async function fetchModels(url, backend) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models || []).map((m) => m.name);
|
||||
} else if (backend === "unsloth") {
|
||||
const data = await unslothProxy(url, "/v1/models", "GET");
|
||||
return (data.data || []).map((m) => m.id);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
@@ -114,30 +186,37 @@ async function testConnection(url, backend) {
|
||||
return models.length;
|
||||
}
|
||||
|
||||
async function callLLM(url, backend, model, prompt) {
|
||||
async function callLLM(url, backend, model, prompt, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.response || "";
|
||||
const text = data.response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content || "";
|
||||
const text = data.choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,11 +224,41 @@ async function callLLM(url, backend, model, prompt) {
|
||||
// Translation prompt builder
|
||||
// ============================================
|
||||
|
||||
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
|
||||
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
|
||||
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
|
||||
// Uses a system role (via callChat) rather than a single raw prompt string —
|
||||
// local models follow "translate only, no commentary" far more reliably
|
||||
// when it's a system instruction instead of buried inside the user text.
|
||||
function buildTranslationMessages(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "").trim() : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English").trim() || "English" : LANG_NAMES[dstLang];
|
||||
const fromPart = srcName ? ` from ${srcName}` : "";
|
||||
const system = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
|
||||
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
|
||||
+ `no surrounding quotes, and do not repeat or echo the original text.`;
|
||||
return [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: text },
|
||||
];
|
||||
}
|
||||
|
||||
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
|
||||
function cleanTranslationOutput(raw) {
|
||||
let s = (raw || "").trim();
|
||||
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
|
||||
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
|
||||
if (s.length >= 2) {
|
||||
const first = s[0], last = s[s.length - 1];
|
||||
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
|
||||
if (pairs[first] === last) s = s.slice(1, -1).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Heuristic: if the cleaned output is (near-)identical to the input, the
|
||||
// model most likely echoed the source instead of translating it.
|
||||
function looksUntranslated(input, output) {
|
||||
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
|
||||
const a = normalize(input), b = normalize(output);
|
||||
return a.length > 0 && a === b;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -217,11 +326,19 @@ function initTranslateTab() {
|
||||
outputEl.value = "";
|
||||
|
||||
try {
|
||||
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
outputEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
const messages = buildTranslationMessages(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const { content } = await callChat(url, backend, model, messages, undefined, settings);
|
||||
const cleaned = cleanTranslationOutput(content);
|
||||
outputEl.value = cleaned;
|
||||
|
||||
if (srcLangSel.value !== dstLangSel.value && looksUntranslated(text, cleaned)) {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastMaybeNotTranslated"), "error");
|
||||
} else {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
@@ -288,6 +405,11 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
const thinkingModeCheckbox = document.getElementById("wfm-ai-thinking-mode-checkbox");
|
||||
if (thinkingModeCheckbox) thinkingModeCheckbox.checked = !!saved.thinkingMode;
|
||||
const maxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (maxTokensInput && saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
@@ -395,6 +517,8 @@ function initSettingsTab() {
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
const thinkingMode = !!thinkingModeCheckbox?.checked;
|
||||
const maxTokens = Math.max(0, parseInt(maxTokensInput?.value, 10) || 0);
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -403,6 +527,7 @@ function initSettingsTab() {
|
||||
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
thinkingMode, maxTokens,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
@@ -453,10 +578,10 @@ function _formatMessagesForBackend(messages, backend) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
async function callChat(url, backend, model, messages, tools, settings = {}) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
@@ -465,9 +590,17 @@ async function callChat(url, backend, model, messages, tools) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const message = data.choices?.[0]?.message || {};
|
||||
const content = _unslothContent(message);
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
@@ -476,7 +609,8 @@ async function callChat(url, backend, model, messages, tools) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +717,245 @@ function _appendSvgPreview(container, svgCode) {
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// アシスタント応答から ```skill フェンスを抽出する(Skill Creator用スキルが出力する完成品ブロック)
|
||||
function _extractSkillBlock(text) {
|
||||
if (!text) return null;
|
||||
const m = text.match(/```skill\s*([\s\S]*?)```/i);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// frontmatterの name から保存用ファイル名を推測する(未設定時は my-skill.md)
|
||||
function _slugifySkillName(content) {
|
||||
const m = content.match(/^---\r?\n[\s\S]*?name:\s*(.+?)\r?\n/);
|
||||
const name = (m ? m[1] : "").trim();
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return `${slug || "my-skill"}.md`;
|
||||
}
|
||||
|
||||
// 応答内にスキル定義ブロックがあれば「スキルとして保存」ボタンを追加する。
|
||||
// クリックすると管理パネルを開き、新規スキルとしてエディタに内容を流し込む(保存前にレビュー可能)。
|
||||
function _appendSkillSaveButton(container, skillContent) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-btn wfm-btn-sm";
|
||||
btn.textContent = t("aiChatSaveAsSkill");
|
||||
btn.addEventListener("click", () => {
|
||||
const panel = document.getElementById("wfm-ai-skill-panel");
|
||||
if (panel) panel.style.display = "";
|
||||
skillOpenEditor(null, skillContent);
|
||||
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
|
||||
if (nameInput) nameInput.value = _slugifySkillName(skillContent);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI skills (Chat system prompt library)
|
||||
// ============================================
|
||||
|
||||
async function skillFetchFiles() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/skills");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function skillFetchContent(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/skills/content?filename=${encodeURIComponent(filename)}`);
|
||||
const data = await res.json();
|
||||
return data.content ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function skillSaveFile(filename, content) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/skills/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, content }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.status === "ok" ? data.file : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function skillDeleteFile(filename) {
|
||||
try {
|
||||
await fetch("/api/wfm/skills/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// system prompt として送る前に frontmatter (---...---) を取り除く
|
||||
function _stripSkillFrontmatter(content) {
|
||||
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
||||
return (m ? content.slice(m[0].length) : content).trim();
|
||||
}
|
||||
|
||||
let skillFiles = [];
|
||||
let skillEditingFilename = null; // null = new file
|
||||
let _activeSkillCache = null; // { filename, content } キャッシュ(送信毎の再フェッチを避ける)
|
||||
|
||||
function skillRenderList() {
|
||||
const list = document.getElementById("wfm-ai-skill-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (skillFiles.length === 0) {
|
||||
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">${t("aiSkillNoFiles")}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const f of skillFiles) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span class="wfm-wc-file-name" title="${escapeHtml(f.description || "")}">${escapeHtml(f.name)}</span>
|
||||
<span class="wfm-wc-file-ext">.md</span>
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-ai-skill-edit-btn" title="Edit">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-ai-skill-edit-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const content = await skillFetchContent(f.filename);
|
||||
skillOpenEditor(f.filename, content ?? "");
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function skillOpenEditor(filename, content) {
|
||||
skillEditingFilename = filename || null;
|
||||
|
||||
const editor = document.getElementById("wfm-ai-skill-editor");
|
||||
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
|
||||
const contentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
const deleteBtn = document.getElementById("wfm-ai-skill-editor-delete-btn");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = filename || "";
|
||||
contentTA.value = content || "";
|
||||
deleteBtn.style.display = filename ? "" : "none";
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function skillCloseEditor() {
|
||||
skillEditingFilename = null;
|
||||
const editor = document.getElementById("wfm-ai-skill-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
function skillPopulateSelect() {
|
||||
const sel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
if (!sel) return;
|
||||
const settings = loadAiSettings();
|
||||
const wanted = sel.value || settings.activeSkillFilename || "";
|
||||
|
||||
sel.innerHTML = `<option value="">-- ${t("aiSkillNone")} --</option>` +
|
||||
skillFiles.map((f) => `<option value="${escapeHtml(f.filename)}" title="${escapeHtml(f.description || "")}">${escapeHtml(f.name)}</option>`).join("");
|
||||
|
||||
sel.value = skillFiles.some((f) => f.filename === wanted) ? wanted : "";
|
||||
_activeSkillCache = null;
|
||||
}
|
||||
|
||||
async function skillRefreshFiles() {
|
||||
skillFiles = await skillFetchFiles();
|
||||
skillRenderList();
|
||||
skillPopulateSelect();
|
||||
}
|
||||
|
||||
// 選択中スキルの本文を取得(system prompt注入用、選択が変わるまでキャッシュ)
|
||||
async function _getActiveSkillSystemPrompt() {
|
||||
const sel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
const filename = sel?.value || "";
|
||||
if (!filename) return null;
|
||||
if (_activeSkillCache?.filename === filename) return _activeSkillCache.content;
|
||||
const raw = await skillFetchContent(filename);
|
||||
const content = _stripSkillFrontmatter(raw || "");
|
||||
_activeSkillCache = { filename, content };
|
||||
return content;
|
||||
}
|
||||
|
||||
function initSkillManager() {
|
||||
const selectEl = document.getElementById("wfm-ai-chat-skill-select");
|
||||
const manageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
|
||||
const panel = document.getElementById("wfm-ai-skill-panel");
|
||||
const closeBtn = document.getElementById("wfm-ai-skill-close-btn");
|
||||
const newBtn = document.getElementById("wfm-ai-skill-new-btn");
|
||||
const saveBtn = document.getElementById("wfm-ai-skill-editor-save-btn");
|
||||
const deleteBtn = document.getElementById("wfm-ai-skill-editor-delete-btn");
|
||||
const cancelBtn = document.getElementById("wfm-ai-skill-editor-cancel-btn");
|
||||
|
||||
if (!selectEl) return;
|
||||
|
||||
skillRefreshFiles();
|
||||
|
||||
selectEl.addEventListener("change", () => {
|
||||
saveAiSettings({ activeSkillFilename: selectEl.value });
|
||||
_activeSkillCache = null;
|
||||
});
|
||||
|
||||
manageBtn?.addEventListener("click", () => {
|
||||
if (!panel) return;
|
||||
const show = panel.style.display === "none";
|
||||
panel.style.display = show ? "" : "none";
|
||||
if (show) skillRefreshFiles();
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener("click", () => {
|
||||
if (panel) panel.style.display = "none";
|
||||
});
|
||||
|
||||
newBtn?.addEventListener("click", () => {
|
||||
skillOpenEditor(null, "---\nname: \ndescription: \n---\n\n");
|
||||
});
|
||||
|
||||
saveBtn?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
|
||||
const contentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
let filename = (nameInput?.value || "").trim();
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!filename) { showToast(t("pleaseEnterFilename"), "error"); return; }
|
||||
if (!/\.md$/i.test(filename)) filename += ".md";
|
||||
if (!/^[\w\-. ]+\.md$/i.test(filename)) { showToast(t("invalidPathFormat"), "error"); return; }
|
||||
|
||||
const saved = await skillSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(t("savedAs", filename), "success");
|
||||
skillCloseEditor();
|
||||
await skillRefreshFiles();
|
||||
selectEl.value = filename;
|
||||
saveAiSettings({ activeSkillFilename: filename });
|
||||
_activeSkillCache = null;
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
deleteBtn?.addEventListener("click", async () => {
|
||||
if (!skillEditingFilename) return;
|
||||
if (!confirm(`Delete "${skillEditingFilename}"?`)) return;
|
||||
await skillDeleteFile(skillEditingFilename);
|
||||
showToast(t("deletedName", skillEditingFilename), "success");
|
||||
if (selectEl.value === skillEditingFilename) {
|
||||
saveAiSettings({ activeSkillFilename: "" });
|
||||
_activeSkillCache = null;
|
||||
}
|
||||
skillCloseEditor();
|
||||
await skillRefreshFiles();
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener("click", () => skillCloseEditor());
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
|
||||
@@ -602,6 +975,8 @@ function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
if (role === "assistant" && content) {
|
||||
const svgCode = _extractSvgCode(content);
|
||||
if (svgCode) _appendSvgPreview(div, svgCode);
|
||||
const skillBlock = _extractSkillBlock(content);
|
||||
if (skillBlock) _appendSkillSaveButton(div, skillBlock);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
@@ -630,6 +1005,8 @@ function initChatTab() {
|
||||
_clearToolsImage();
|
||||
});
|
||||
|
||||
initSkillManager();
|
||||
|
||||
let chatHistory = [];
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -662,7 +1039,11 @@ function initChatTab() {
|
||||
|
||||
try {
|
||||
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
|
||||
const reply = await callChat(url, backend, model, chatHistory, tools);
|
||||
const skillPrompt = await _getActiveSkillSystemPrompt();
|
||||
const messagesToSend = skillPrompt
|
||||
? [{ role: "system", content: skillPrompt }, ...chatHistory]
|
||||
: chatHistory;
|
||||
const reply = await callChat(url, backend, model, messagesToSend, tools, settings);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
@@ -822,7 +1203,7 @@ function initVlmTab() {
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
|
||||
try {
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
const result = await callLLM(url, backend, model, prompt, settings);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
@@ -848,7 +1229,7 @@ function initVlmTab() {
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType, settings);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
@@ -878,3 +1259,7 @@ export function initAiTab() {
|
||||
initVlmTab();
|
||||
initSettingsTab();
|
||||
}
|
||||
|
||||
// gallery-tab.js の外部(Comic Creator)向けブリッジ(半自動マンガ作成の画像プロンプト
|
||||
// 下書き機能)が、設定タブと同じAI接続設定・LLM呼び出しロジックを再利用するためのexport。
|
||||
export { callLLM, loadAiSettings, isValidBackendUrl };
|
||||
|
||||
+200
-3
@@ -222,6 +222,68 @@ function applyI18nToHtml() {
|
||||
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
|
||||
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
|
||||
|
||||
// Gallery サブタブ (Output / ImagePrompt / Style_Catalog)
|
||||
const galleryOutputSubtab = document.getElementById("wfm-gallery-subtab-output");
|
||||
if (galleryOutputSubtab) galleryOutputSubtab.textContent = t("galleryTabOutput");
|
||||
const galleryImagePromptSubtab = document.getElementById("wfm-gallery-subtab-imageprompt");
|
||||
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
|
||||
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
|
||||
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
|
||||
|
||||
// ImagePrompt ギャラリーパネル
|
||||
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
|
||||
if (imagePromptSearch) imagePromptSearch.placeholder = t("gallerySearchPlaceholder");
|
||||
const imagePromptRefreshBtn = document.getElementById("wfm-imageprompt-refresh-btn");
|
||||
if (imagePromptRefreshBtn) imagePromptRefreshBtn.textContent = t("refresh");
|
||||
const imagePromptTreeTitle = document.getElementById("wfm-imageprompt-tree-title");
|
||||
if (imagePromptTreeTitle) imagePromptTreeTitle.textContent = t("galleryFolders");
|
||||
const imagePromptListPlaceholder = document.getElementById("wfm-imageprompt-list-placeholder");
|
||||
if (imagePromptListPlaceholder) imagePromptListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const imagePromptNoSelection = document.getElementById("wfm-imageprompt-no-selection");
|
||||
if (imagePromptNoSelection) imagePromptNoSelection.textContent = t("galleryNoSelection");
|
||||
const imagePromptSelectedPromptTitle = document.getElementById("wfm-imageprompt-selected-prompt-title");
|
||||
if (imagePromptSelectedPromptTitle) imagePromptSelectedPromptTitle.textContent = t("imagePromptSelectedPrompt");
|
||||
const imagePromptSelectedPrompt = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
if (imagePromptSelectedPrompt) imagePromptSelectedPrompt.placeholder = t("imagePromptNoPrompt");
|
||||
const imagePromptSavePromptBtn = document.getElementById("wfm-imageprompt-save-prompt-btn");
|
||||
if (imagePromptSavePromptBtn) imagePromptSavePromptBtn.textContent = t("save");
|
||||
const imagePromptAddBtn = document.getElementById("wfm-imageprompt-add-btn");
|
||||
if (imagePromptAddBtn) imagePromptAddBtn.textContent = t("imagePromptAddBtn");
|
||||
const imagePromptChipsTitle = document.getElementById("wfm-imageprompt-chips-title");
|
||||
if (imagePromptChipsTitle) imagePromptChipsTitle.textContent = t("imagePromptChipsTitle");
|
||||
const imagePromptClearBtn = document.getElementById("wfm-imageprompt-clear-btn");
|
||||
if (imagePromptClearBtn) imagePromptClearBtn.textContent = t("imagePromptClearBtn");
|
||||
const imagePromptFinalPromptTitle = document.getElementById("wfm-imageprompt-final-prompt-title");
|
||||
if (imagePromptFinalPromptTitle) imagePromptFinalPromptTitle.textContent = t("imagePromptFinalPromptTitle");
|
||||
const imagePromptCopyBtn = document.getElementById("wfm-imageprompt-copy-btn");
|
||||
if (imagePromptCopyBtn) imagePromptCopyBtn.textContent = t("copy");
|
||||
|
||||
// Style_Catalog ギャラリーパネル
|
||||
const scSearch = document.getElementById("wfm-stylecatalog-search");
|
||||
if (scSearch) scSearch.placeholder = t("stylecatalogSearchPlaceholder");
|
||||
const scRefreshBtn = document.getElementById("wfm-stylecatalog-refresh-btn");
|
||||
if (scRefreshBtn) scRefreshBtn.textContent = t("refresh");
|
||||
const scTreeTitle = document.getElementById("wfm-stylecatalog-tree-title");
|
||||
if (scTreeTitle) scTreeTitle.textContent = t("galleryFolders");
|
||||
const scListPlaceholder = document.getElementById("wfm-stylecatalog-list-placeholder");
|
||||
if (scListPlaceholder) scListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const scNoSelection = document.getElementById("wfm-stylecatalog-no-selection");
|
||||
if (scNoSelection) scNoSelection.textContent = t("galleryNoSelection");
|
||||
const scPositiveTitle = document.getElementById("wfm-stylecatalog-positive-title");
|
||||
if (scPositiveTitle) scPositiveTitle.textContent = t("stylecatalogPositive");
|
||||
const scCopyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
if (scCopyPositiveBtn) scCopyPositiveBtn.textContent = t("copy");
|
||||
const scNegativeTitle = document.getElementById("wfm-stylecatalog-negative-title");
|
||||
if (scNegativeTitle) scNegativeTitle.textContent = t("stylecatalogNegative");
|
||||
const scCopyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
if (scCopyNegativeBtn) scCopyNegativeBtn.textContent = t("copy");
|
||||
const scSelectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
if (scSelectStyleBtn) scSelectStyleBtn.textContent = t("stylecatalogSelectStyleBtn");
|
||||
const scLoadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
if (scLoadGenuiBtn) scLoadGenuiBtn.textContent = t("stylecatalogLoadGenuiBtn");
|
||||
const scOpenMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
if (scOpenMetadataBtn) scOpenMetadataBtn.textContent = t("stylecatalogOpenMetadataBtn");
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
@@ -302,6 +364,14 @@ function applyI18nToHtml() {
|
||||
}
|
||||
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
|
||||
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
|
||||
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
|
||||
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
|
||||
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
|
||||
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
|
||||
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
|
||||
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
|
||||
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
|
||||
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
|
||||
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
|
||||
@@ -320,6 +390,19 @@ function applyI18nToHtml() {
|
||||
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
|
||||
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
|
||||
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiSkillSel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
if (aiSkillSel) {
|
||||
const firstOpt = aiSkillSel.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = `-- ${t("aiSkillNone")} --`;
|
||||
}
|
||||
const aiSkillManageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
|
||||
if (aiSkillManageBtn) aiSkillManageBtn.title = t("aiSkillManageBtnTitle");
|
||||
const aiSkillPanelTitle = document.querySelector(".wfm-ai-skill-panel-title");
|
||||
if (aiSkillPanelTitle) aiSkillPanelTitle.textContent = t("aiSkillPanelTitle");
|
||||
const aiSkillNewBtn = document.getElementById("wfm-ai-skill-new-btn");
|
||||
if (aiSkillNewBtn) aiSkillNewBtn.textContent = t("aiSkillNewBtn");
|
||||
const aiSkillContentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
if (aiSkillContentTA) aiSkillContentTA.placeholder = t("aiSkillContentPlaceholder");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -367,6 +450,11 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
|
||||
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
|
||||
"wfm-help-gen-19": "helpGen19",
|
||||
"wfm-help-gen-20": "helpGen20", "wfm-help-gen-21": "helpGen21",
|
||||
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
|
||||
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
|
||||
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
|
||||
"wfm-help-gen-28": "helpGen28",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
@@ -381,18 +469,42 @@ 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-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
|
||||
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
|
||||
"wfm-help-lab-13": "helpLab13",
|
||||
"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-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
|
||||
"wfm-help-lab-plan-11": "helpLabPlan11",
|
||||
"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",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-prompt-card-style-title": "helpPromptCardStyleTitle",
|
||||
"wfm-help-prompt-10": "helpPrompt10", "wfm-help-prompt-11": "helpPrompt11",
|
||||
"wfm-help-prompt-12": "helpPrompt12", "wfm-help-prompt-13": "helpPrompt13",
|
||||
"wfm-help-prompt-14": "helpPrompt14",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13",
|
||||
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
|
||||
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
|
||||
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
|
||||
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
|
||||
@@ -413,6 +525,12 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gallery-13": "helpGallery13", "wfm-help-gallery-14": "helpGallery14",
|
||||
"wfm-help-gallery-15": "helpGallery15", "wfm-help-gallery-16": "helpGallery16",
|
||||
"wfm-help-gallery-17": "helpGallery17",
|
||||
"wfm-help-gallery-18": "helpGallery18", "wfm-help-gallery-19": "helpGallery19",
|
||||
"wfm-help-gallery-20": "helpGallery20", "wfm-help-gallery-21": "helpGallery21",
|
||||
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
|
||||
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
|
||||
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
|
||||
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
|
||||
@@ -432,8 +550,18 @@ function applyI18nToHtml() {
|
||||
"wfm-help-sidepanel-17": "helpSidepanel17",
|
||||
"wfm-help-ai-title": "helpAiTitle",
|
||||
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-ai-8": "helpAi8",
|
||||
"wfm-help-ai-9": "helpAi9",
|
||||
"wfm-help-ai-card-layout-title": "helpAiCardLayout",
|
||||
"wfm-help-ai-card-translate-title": "helpAiCardTranslate",
|
||||
"wfm-help-ai-card-chat-title": "helpAiCardChat",
|
||||
"wfm-help-ai-card-svg-title": "helpAiCardSvg",
|
||||
"wfm-help-ai-card-skills-title": "helpAiCardSkills",
|
||||
"wfm-help-ai-card-skilltips-title": "helpAiCardSkillTips",
|
||||
"wfm-help-ai-card-tools-title": "helpAiCardTools",
|
||||
"wfm-help-ai-card-settings-title": "helpAiCardSettings",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
@@ -494,11 +622,67 @@ 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",
|
||||
"wfm-help-kofi-desc": "helpKofiDesc",
|
||||
"wfm-help-thanks": "helpThanks",
|
||||
// -- Help card headings (card-based redesign) --
|
||||
"wfm-help-about-card-getting-started-title": "helpAboutCardGettingStarted",
|
||||
"wfm-help-wf-title": "helpWfTitle",
|
||||
"wfm-help-wf-card-browse-title": "helpWfCardBrowse",
|
||||
"wfm-help-wf-card-groups-title": "helpWfCardGroups",
|
||||
"wfm-help-wf-card-panel-title": "helpWfCardPanel",
|
||||
"wfm-help-gen-title": "helpGenTitle",
|
||||
"wfm-help-gen-card-connect-title": "helpGenCardConnect",
|
||||
"wfm-help-gen-card-tabs-title": "helpGenCardTabs",
|
||||
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
|
||||
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
|
||||
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
|
||||
"wfm-help-gen-card-lora-title": "helpGenCardLora",
|
||||
"wfm-help-prompt-title": "helpPromptTitle",
|
||||
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
|
||||
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
|
||||
"wfm-help-prompt-card-wildcards-title": "helpPromptCardWildcards",
|
||||
"wfm-help-metadata-card-overview-title": "helpMetadataCardOverview",
|
||||
"wfm-help-metadata-card-extracted-title": "helpMetadataCardExtracted",
|
||||
"wfm-help-metadata-card-formats-title": "helpMetadataCardFormats",
|
||||
"wfm-help-settings-title": "helpSettingsTitle",
|
||||
"wfm-help-settings-card-general-title": "helpSettingsCardGeneral",
|
||||
"wfm-help-settings-card-connections-title": "helpSettingsCardConnections",
|
||||
"wfm-help-settings-card-workflow-title": "helpSettingsCardWorkflow",
|
||||
"wfm-help-settings-card-editor-title": "helpSettingsCardEditor",
|
||||
"wfm-help-settings-card-misc-title": "helpSettingsCardMisc",
|
||||
"wfm-help-nodes-title": "helpNodesTitle",
|
||||
"wfm-help-nodes-card-browse-title": "helpNodesCardBrowse",
|
||||
"wfm-help-nodes-card-groups-title": "helpNodesCardGroups",
|
||||
"wfm-help-nodes-card-panel-title": "helpNodesCardPanel",
|
||||
"wfm-help-gallery-card-browse-title": "helpGalleryCardBrowse",
|
||||
"wfm-help-gallery-card-folder-title": "helpGalleryCardFolder",
|
||||
"wfm-help-gallery-card-detail-title": "helpGalleryCardDetail",
|
||||
"wfm-help-gallery-card-actions-title": "helpGalleryCardActions",
|
||||
"wfm-help-gallery-card-formats-title": "helpGalleryCardFormats",
|
||||
"wfm-help-models-title": "helpModelsTitle",
|
||||
"wfm-help-models-card-browse-title": "helpModelsCardBrowse",
|
||||
"wfm-help-models-card-civitai-title": "helpModelsCardCivitai",
|
||||
"wfm-help-models-card-apply-title": "helpModelsCardApply",
|
||||
"wfm-help-models-card-groups-title": "helpModelsCardGroups",
|
||||
"wfm-help-models-card-bulk-title": "helpModelsCardBulk",
|
||||
"wfm-help-sidepanel-card-tabs-title": "helpSidepanelCardTabs",
|
||||
"wfm-help-sidepanel-card-dnd-title": "helpSidepanelCardDnd",
|
||||
"wfm-help-sidepanel-card-info-title": "helpSidepanelCardInfo",
|
||||
"wfm-help-sidepanel-card-ai-title": "helpSidepanelCardAi",
|
||||
"wfm-help-sidepanel-card-nav-title": "helpSidepanelCardNav",
|
||||
"wfm-help-tagger-card-overview-title": "helpTaggerCardOverview",
|
||||
"wfm-help-tagger-card-single-title": "helpTaggerCardSingle",
|
||||
"wfm-help-tagger-card-sending-title": "helpTaggerCardSending",
|
||||
"wfm-help-tagger-card-batch-title": "helpTaggerCardBatch",
|
||||
"wfm-help-imageedit-card-overview-title": "helpImageEditCardOverview",
|
||||
"wfm-help-shortcuts-card-general-title": "helpShortcutsCardGeneral",
|
||||
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
|
||||
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
|
||||
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -511,6 +695,11 @@ function applyI18nToHtml() {
|
||||
helpSearch.placeholder = t("helpSearchPlaceholder");
|
||||
helpSearch.addEventListener("input", _onHelpSearch);
|
||||
}
|
||||
|
||||
// Jump-to-tab buttons
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.textContent = t("helpJumpToTab");
|
||||
});
|
||||
}
|
||||
|
||||
function _onHelpSearch(e) {
|
||||
@@ -576,7 +765,7 @@ import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
import { initMetadataTab } from "./metadata-tab.js";
|
||||
@@ -592,6 +781,7 @@ try {
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
applyJsonColors(_s.jsonColors);
|
||||
applyModelTabActiveColor(_s.modelTabActiveColor);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
@@ -604,6 +794,13 @@ function initHelpTab() {
|
||||
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Jump-to-tab buttons: switch to the real feature tab this help page documents
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelector(`.wfm-tab[data-tab="${btn.dataset.jumpTab}"]`)?.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
@@ -18,8 +18,15 @@ export const comfyUI = {
|
||||
connected: false,
|
||||
generating: false,
|
||||
currentPromptId: null,
|
||||
// trackProgress() 実行中の Promise を promptId -> reject関数 で保持。
|
||||
// WebSocket切断時に、待機中のPromiseを解決させずに放置してバッチループが
|
||||
// 無言のまま停止する(例: カタログ作成バッチが特定件数で止まる)のを防ぐ。
|
||||
_pendingTrackers: new Map(),
|
||||
currentWorkflow: null,
|
||||
currentAnalysis: null,
|
||||
// ワークフロー読み込み時点のLora Loader (LoraManager)ノードの loras/text 状態。
|
||||
// Stackグループチェックボックスのトグル時に、OFF側の「読み込み時の状態へ戻す」用に使う。
|
||||
loraManagerSnapshots: {},
|
||||
|
||||
updateUrl(url) {
|
||||
if (!url) {
|
||||
@@ -66,10 +73,18 @@ export const comfyUI = {
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.onopen = () => resolve(true);
|
||||
this.socket.onerror = () => resolve(false);
|
||||
this.socket.onclose = () => { this.socket = null; };
|
||||
this.socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this._rejectPendingTrackers(new Error("WebSocket disconnected"));
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_rejectPendingTrackers(err) {
|
||||
for (const rejectFn of this._pendingTrackers.values()) rejectFn(err);
|
||||
this._pendingTrackers.clear();
|
||||
},
|
||||
|
||||
// Node info fetching
|
||||
async fetchObjectInfo(nodeClass) {
|
||||
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
|
||||
@@ -196,12 +211,29 @@ export const comfyUI = {
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
trackProgress(promptId, progressCallback) {
|
||||
// timeoutMs: この時間内に完了/エラー/中断通知が来なければ強制的にreject。
|
||||
// WebSocketの取りこぼしやexecution_interrupted未処理でPromiseが永久に
|
||||
// ぶら下がり続け、バッチループが無言で停止するのを防ぐための安全弁。
|
||||
trackProgress(promptId, progressCallback, timeoutMs = 10 * 60 * 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error("WebSocket not connected"));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (isResolve, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket?.removeEventListener("message", handler);
|
||||
clearTimeout(timer);
|
||||
this._pendingTrackers.delete(promptId);
|
||||
if (isResolve) resolve(value); else reject(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(false, new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s (prompt_id=${promptId})`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
@@ -211,17 +243,19 @@ export const comfyUI = {
|
||||
}
|
||||
if (msg.type === "executing" && msg.data?.prompt_id === promptId) {
|
||||
if (msg.data.node === null) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
resolve();
|
||||
finish(true);
|
||||
}
|
||||
}
|
||||
if (msg.type === "execution_error" && msg.data?.prompt_id === promptId) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
reject(new Error(msg.data.exception_message || "Execution error"));
|
||||
finish(false, new Error(msg.data.exception_message || "Execution error"));
|
||||
}
|
||||
if (msg.type === "execution_interrupted" && msg.data?.prompt_id === promptId) {
|
||||
finish(false, new Error("Execution interrupted"));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
this.socket.addEventListener("message", handler);
|
||||
this._pendingTrackers.set(promptId, (err) => finish(false, err));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -276,7 +310,7 @@ export const comfyUI = {
|
||||
|
||||
// High-level generate
|
||||
async generate(workflow, options = {}) {
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError } = options;
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError, timeoutMs } = options;
|
||||
|
||||
this.generating = true;
|
||||
try {
|
||||
@@ -298,7 +332,7 @@ export const comfyUI = {
|
||||
this.currentPromptId = result.prompt_id;
|
||||
|
||||
// Track progress
|
||||
await this.trackProgress(result.prompt_id, onProgress);
|
||||
await this.trackProgress(result.prompt_id, onProgress, timeoutMs);
|
||||
|
||||
// Get history/results
|
||||
const history = await this.getHistory(result.prompt_id);
|
||||
|
||||
+449
-85
@@ -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";
|
||||
@@ -27,6 +27,114 @@ function _saveLatentPresets(list) {
|
||||
localStorage.setItem(_LATENT_PRESET_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
// ── I2I placeholder image (Input tab / Image sub-panel) ──────────────────
|
||||
// One shared default (color-generated or a fixed uploaded image), remembered across
|
||||
// sessions. Each LoadImage slot's own "Placeholder" button applies this same default
|
||||
// to just that slot — see renderImageTab.
|
||||
const _I2I_PLACEHOLDER_KEY = "wfm_i2i_placeholder";
|
||||
const _I2I_PLACEHOLDER_DEFAULT = { mode: "color", color: "#808080", width: 512, height: 512, imageFilename: "" };
|
||||
|
||||
function _loadI2IPlaceholderConfig() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_I2I_PLACEHOLDER_KEY);
|
||||
return raw ? { ..._I2I_PLACEHOLDER_DEFAULT, ...JSON.parse(raw) } : { ..._I2I_PLACEHOLDER_DEFAULT };
|
||||
} catch { return { ..._I2I_PLACEHOLDER_DEFAULT }; }
|
||||
}
|
||||
|
||||
function _saveI2IPlaceholderConfig(cfg) {
|
||||
localStorage.setItem(_I2I_PLACEHOLDER_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
// Renders a solid-color PNG on an offscreen canvas and returns it as a File, ready to
|
||||
// upload through comfyUI.uploadImage() the same way a picked/dropped file would be.
|
||||
function _generateColorImageFile(width, height, color) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, width || 512);
|
||||
canvas.height = Math.max(1, height || 512);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = color || "#808080";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { reject(new Error("Failed to render placeholder image")); return; }
|
||||
resolve(new File([blob], `placeholder_${canvas.width}x${canvas.height}.png`, { type: "image/png" }));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
// Wires the placeholder config panel at the top of the Image sub-panel (mode switch,
|
||||
// color/size fields, default-image drop zone). Every change is persisted immediately via
|
||||
// _saveI2IPlaceholderConfig so it's remembered next time the panel is rendered — this
|
||||
// panel is display-only config, it doesn't touch any workflow node itself; only each
|
||||
// slot's own Placeholder button (see renderImageTab) does that.
|
||||
function _wireI2IPlaceholderConfig(scopeEl) {
|
||||
const colorModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-color");
|
||||
const imageModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-image");
|
||||
const colorFields = scopeEl.querySelector("#wfm-i2i-ph-color-fields");
|
||||
const imageFields = scopeEl.querySelector("#wfm-i2i-ph-image-fields");
|
||||
const widthInput = scopeEl.querySelector("#wfm-i2i-ph-width");
|
||||
const heightInput = scopeEl.querySelector("#wfm-i2i-ph-height");
|
||||
const colorInput = scopeEl.querySelector("#wfm-i2i-ph-color");
|
||||
const dropZone = scopeEl.querySelector("#wfm-i2i-ph-drop");
|
||||
const fileInput = scopeEl.querySelector("#wfm-i2i-ph-file");
|
||||
const previewWrap = scopeEl.querySelector("#wfm-i2i-ph-preview-wrap");
|
||||
const previewImg = scopeEl.querySelector("#wfm-i2i-ph-preview-img");
|
||||
const statusEl = scopeEl.querySelector("#wfm-i2i-ph-status");
|
||||
if (!colorModeRadio) return; // no LoadImage slots rendered — panel doesn't exist
|
||||
|
||||
const setMode = (mode) => {
|
||||
if (colorFields) colorFields.style.display = mode === "color" ? "" : "none";
|
||||
if (imageFields) imageFields.style.display = mode === "image" ? "" : "none";
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), mode });
|
||||
};
|
||||
colorModeRadio.addEventListener("change", () => { if (colorModeRadio.checked) setMode("color"); });
|
||||
imageModeRadio?.addEventListener("change", () => { if (imageModeRadio.checked) setMode("image"); });
|
||||
|
||||
widthInput?.addEventListener("change", () => {
|
||||
const width = Math.max(1, parseInt(widthInput.value, 10) || 512);
|
||||
widthInput.value = width;
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), width });
|
||||
});
|
||||
heightInput?.addEventListener("change", () => {
|
||||
const height = Math.max(1, parseInt(heightInput.value, 10) || 512);
|
||||
heightInput.value = height;
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), height });
|
||||
});
|
||||
colorInput?.addEventListener("change", () => {
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), color: colorInput.value });
|
||||
});
|
||||
|
||||
// Default image: uploaded to ComfyUI immediately (its filename is what actually gets
|
||||
// reused later, not the raw file) so every slot's Placeholder button can apply it
|
||||
// without re-uploading.
|
||||
const applyDefaultImageFile = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
if (statusEl) statusEl.textContent = t("i2iPlaceholderUploading");
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), imageFilename: result.name });
|
||||
if (previewImg) previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
};
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyDefaultImageFile(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) applyDefaultImageFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _buildPresetOptions(customPresets) {
|
||||
const customs = customPresets.map((p) =>
|
||||
`<option value="${p.w}x${p.h}">${p.w}x${p.h}</option>`
|
||||
@@ -344,11 +452,11 @@ export const comfyEditor = {
|
||||
return found || name;
|
||||
},
|
||||
|
||||
renderAll(analysis, workflow) {
|
||||
renderAll(analysis, workflow, opts = {}) {
|
||||
this.renderPromptTab(analysis, "wfm-gen-prompt-fields");
|
||||
this.renderImageTab(analysis, "wfm-gen-image-fields");
|
||||
this.renderModelTab(analysis, "wfm-gen-model-fields");
|
||||
this.renderLoraPane(analysis, "wfm-gen-lora-fields"); // async, fires independently
|
||||
this.renderLoraPane(analysis, "wfm-gen-lora-fields", opts); // async, fires independently
|
||||
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
|
||||
_syncRawJson();
|
||||
},
|
||||
@@ -367,7 +475,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 +487,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>
|
||||
@@ -508,10 +616,14 @@ export const comfyEditor = {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
const sections = [
|
||||
// 使用頻度の高い順: Checkpoint/VAE/Diffusion Model/Text Encoder は常時表示。
|
||||
// ControlNet/Hypernetwork は意図的に使う場面が限られるため折りたたみにする。
|
||||
const mainSections = [
|
||||
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
|
||||
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
|
||||
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
|
||||
];
|
||||
const collapsibleSections = [
|
||||
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
|
||||
{
|
||||
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
|
||||
@@ -519,48 +631,70 @@ export const comfyEditor = {
|
||||
},
|
||||
];
|
||||
|
||||
el.innerHTML = sections
|
||||
.map((s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
const renderSectionBody = (s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
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;">
|
||||
<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("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
return `
|
||||
<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("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
};
|
||||
|
||||
const mainHtml = mainSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label class="${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</label>
|
||||
${renderSectionBody(s)}
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const teHtml = '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
|
||||
const collapsibleHtml = collapsibleSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary ${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</summary>
|
||||
<div class="wfm-form-group" style="margin-bottom:0;">
|
||||
${renderSectionBody(s)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join("");
|
||||
|
||||
el.innerHTML = mainHtml + teHtml + collapsibleHtml;
|
||||
|
||||
// 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 +704,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
|
||||
@@ -606,13 +742,34 @@ export const comfyEditor = {
|
||||
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
|
||||
},
|
||||
|
||||
async renderLoraPane(analysis, containerId) {
|
||||
async renderLoraPane(analysis, containerId, opts = {}) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
const loraNodes = analysis.lora_nodes || [];
|
||||
document.getElementById("wfm-gen-lora-col-title")?.classList.toggle("wfm-model-label-active", loraNodes.length > 0);
|
||||
const loras = this.models.loras || [];
|
||||
const defaultStackTarget = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
|
||||
const hasLoraManager = loraNodes.some((n) => n.is_lora_manager);
|
||||
// Use Stack Group チェックボックスの現在状態を先読み(Stack表示の取得元判定に使う)。
|
||||
// resetStackMode(新規読み込み)時は常にOFF扱い。
|
||||
const stackModeChecked = hasLoraManager && !opts.resetStackMode && (el.querySelector("#wfm-lora-stack-mode-toggle")?.checked ?? false);
|
||||
|
||||
// ワークフロー新規読み込み時のみ: LoraManagerノードのloras/text状態をスナップショットし、
|
||||
// Stackグループ使用チェックボックスをOFFへリセットする(既存のGenUI状態への影響を避けるため
|
||||
// タブ切替やStack再読込などの単なる再描画では行わない)。
|
||||
if (opts.resetStackMode) {
|
||||
comfyUI.loraManagerSnapshots = {};
|
||||
for (const n of loraNodes) {
|
||||
if (!n.is_lora_manager) continue;
|
||||
const wfNode = comfyUI.currentWorkflow?.[n.id];
|
||||
if (!wfNode?.inputs) continue;
|
||||
comfyUI.loraManagerSnapshots[n.id] = {
|
||||
loras: Array.isArray(wfNode.inputs.loras?.__value__) ? JSON.parse(JSON.stringify(wfNode.inputs.loras.__value__)) : [],
|
||||
text: wfNode.inputs.text ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
const nodeOpts = loraNodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
@@ -637,26 +794,53 @@ export const comfyEditor = {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Stack group, metadata, CivitAI cache in parallel
|
||||
// Stack表示の取得元: ON(Use Stack Group)ならModelsタブの登録済みStackグループ、
|
||||
// OFF(既定)ならLoraManagerノードに実際に設定されているLoRAをそのまま表示する。
|
||||
let stackModels = [];
|
||||
let metadata = {};
|
||||
let civitaiCache = {};
|
||||
const useWorkflowLoraState = hasLoraManager && !stackModeChecked;
|
||||
try {
|
||||
const [grpRes, metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/groups?type=lora"),
|
||||
const [metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/metadata"),
|
||||
fetch("/api/wfm/models/civitai/cache"),
|
||||
]);
|
||||
const groups = grpRes.ok ? await grpRes.json() : {};
|
||||
stackModels = groups["Stack"] || [];
|
||||
metadata = metaRes.ok ? await metaRes.json() : {};
|
||||
civitaiCache = civRes.ok ? await civRes.json() : {};
|
||||
} catch { /* ignore */ }
|
||||
|
||||
stackModels.forEach((m) => {
|
||||
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
|
||||
if (_stackActive[m] === undefined) _stackActive[m] = true;
|
||||
});
|
||||
if (stackModeChecked) {
|
||||
try {
|
||||
const grpRes = await fetch("/api/wfm/models/groups?type=lora");
|
||||
const groups = grpRes.ok ? await grpRes.json() : {};
|
||||
stackModels = groups["Stack"] || [];
|
||||
} catch { /* ignore */ }
|
||||
stackModels.forEach((m) => {
|
||||
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
|
||||
if (_stackActive[m] === undefined) _stackActive[m] = true;
|
||||
});
|
||||
} else if (useWorkflowLoraState) {
|
||||
const wfNode = comfyUI.currentWorkflow?.[defaultStackTarget];
|
||||
const wfLoras = wfNode?.inputs?.loras?.__value__;
|
||||
// resetStackMode(新規読み込み)/syncFromWorkflow(チェックボックスOFF切替直後)の時だけ
|
||||
// ワークフローの実データで強制上書きする。タブ切替等の単なる再描画では、
|
||||
// 既にメモリ上に値があればそれを維持し、Stackパネルでの一時的なON/OFF・強度編集を
|
||||
// 再描画のたびに消してしまわないようにする。
|
||||
const shouldSync = opts.resetStackMode || opts.syncFromWorkflow;
|
||||
if (Array.isArray(wfLoras)) {
|
||||
stackModels = wfLoras.map((l) => {
|
||||
const found = (this.models.loras || []).find((m) => _loraBasename(m) === l.name);
|
||||
const full = found || l.name;
|
||||
if (shouldSync || _stackStrengths[full] === undefined) {
|
||||
_stackStrengths[full] = { m: parseFloat(l.strength) || 1.0, c: parseFloat(l.clipStrength ?? l.strength) || 1.0 };
|
||||
}
|
||||
if (shouldSync || _stackActive[full] === undefined) {
|
||||
_stackActive[full] = l.active !== false;
|
||||
}
|
||||
return full;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build Stack trigger words
|
||||
const activeTriggerWords = [];
|
||||
@@ -673,7 +857,9 @@ export const comfyEditor = {
|
||||
? activeTriggerWords.map((w) => `<span class="wfm-lora-trigger-word">${w}</span>`).join(" ")
|
||||
: `<span style="color:var(--wfm-text-secondary);font-size:12px;">—</span>`;
|
||||
const stackLoraSyntax = _buildLoraSyntax(stackModels);
|
||||
const allActive = stackModels.every((m) => _stackActive[m] !== false);
|
||||
// stackModelsが空だと every() が空配列に対し true を返す(空虚な真)ため、
|
||||
// 何も無いのに「トグルオール」チェックボックスがON表示になるのを防ぐ
|
||||
const allActive = stackModels.length > 0 && stackModels.every((m) => _stackActive[m] !== false);
|
||||
const anyActive = stackModels.some((m) => _stackActive[m] !== false);
|
||||
|
||||
const stackModelRows = stackModels.map((m) => {
|
||||
@@ -691,7 +877,10 @@ export const comfyEditor = {
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
|
||||
let _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
|
||||
// 新規読み込み時、LoraManagerノードに実際に複数LoRAが設定されている場合は
|
||||
// Single(1件しか表示できない)ではなくStackタブを初期表示にする
|
||||
if (opts.resetStackMode && useWorkflowLoraState && stackModels.length > 1) _prevActiveTab = "stack";
|
||||
// applyToGenUIで設定したSingle表示の状態を保存(再描画後に復元)
|
||||
const _prevSingleSyntax = document.getElementById("wfm-lora-single-syntax")?.textContent || "";
|
||||
const _prevSingleTriggers = document.getElementById("wfm-lora-single-triggers")?.innerHTML || "";
|
||||
@@ -700,11 +889,19 @@ export const comfyEditor = {
|
||||
<div class="wfm-lora-tab-header">
|
||||
<button class="wfm-lora-tab-btn active" data-tab="single">Single</button>
|
||||
<button class="wfm-lora-tab-btn" data-tab="stack">Stack</button>
|
||||
${hasLoraManager ? `
|
||||
<label class="wfm-lora-stack-mode-label" style="margin-left:auto;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;" title="ON: 登録済みStackグループを読み込む / OFF: ワークフローに実際に設定されているLoRAを表示・編集する">
|
||||
<input type="checkbox" id="wfm-lora-stack-mode-toggle" ${stackModeChecked ? "checked" : ""}>
|
||||
Use Stack Group
|
||||
</label>` : ""}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@@ -731,6 +928,7 @@ export const comfyEditor = {
|
||||
|
||||
<!-- Stack tab -->
|
||||
<div class="wfm-lora-tab-content" id="wfm-lora-panel-stack" style="display:none;">
|
||||
${hasLoraManager ? `<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:6px;">${useWorkflowLoraState ? "Showing: LoRAs currently in workflow" : "Showing: registered Stack group"}</div>` : ""}
|
||||
<div class="wfm-lora-stack-header">
|
||||
<select class="wfm-select" id="wfm-lora-stack-target" style="flex:1;min-width:0;">${stackTargetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-lora-stack-apply" title="Apply Stack to node and sync to Positive prompt">Apply</button>
|
||||
@@ -765,7 +963,7 @@ export const comfyEditor = {
|
||||
<div id="wfm-lora-stack-triggers" class="wfm-lora-stack-triggers">${stackTriggerHtml}</div>
|
||||
</div>
|
||||
<div class="wfm-lora-stack-models">
|
||||
${stackModelRows || `<p class="wfm-placeholder">No models in Stack group</p>`}
|
||||
${stackModelRows || `<p class="wfm-placeholder">${useWorkflowLoraState ? "No LoRAs set on this node" : "No models in Stack group"}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -803,9 +1001,63 @@ export const comfyEditor = {
|
||||
document.getElementById("wfm-lora-panel-stack").style.display = "";
|
||||
}
|
||||
|
||||
// ── Stackグループ使用チェックボックス ─────────────────
|
||||
// ON: LoraManagerノードへStackグループを(クリアしてから)読み込む(既存のStack Applyを流用)
|
||||
// OFF: ワークフロー読み込み時点のloras/text状態へ(クリアしてから)戻す
|
||||
document.getElementById("wfm-lora-stack-mode-toggle")?.addEventListener("change", async (e) => {
|
||||
const nodeId = document.getElementById("wfm-lora-stack-target")?.value || defaultStackTarget;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
|
||||
if (e.target.checked) {
|
||||
// stackModelsはこの時点では「ワークフロー state」を指したままのため、
|
||||
// 先に再描画してStackグループを取得させてからApplyする
|
||||
await this.renderLoraPane(analysis, containerId);
|
||||
document.getElementById("wfm-lora-stack-apply")?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const snap = comfyUI.loraManagerSnapshots?.[nodeId] || { loras: [], text: "" };
|
||||
comfyUI.currentWorkflow[nodeId].inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras)) };
|
||||
comfyUI.currentWorkflow[nodeId].inputs.text = snap.text;
|
||||
_syncRawJson();
|
||||
|
||||
// ON時にPositiveプロンプトへ挿入されたStack由来の<lora:...>構文とトリガーワードを除去する
|
||||
// (新規に追加はしない。ワークフロー本来のプロンプトへ戻すだけ)
|
||||
const posTextarea = document.getElementById("wfm-prompt-pos-text");
|
||||
if (posTextarea && stackModels.length > 0) {
|
||||
let cleaned = posTextarea.value;
|
||||
for (const m of stackModels) {
|
||||
const stem = _loraBasename(m).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
cleaned = cleaned.replace(new RegExp(`,?\\s*<lora:${stem}:[^>]*>`, "gi"), "");
|
||||
}
|
||||
const stackWords = new Set();
|
||||
stackModels.forEach((m) => {
|
||||
const sha = (metadata[m] || {}).sha256;
|
||||
const civInfo = sha && civitaiCache[sha];
|
||||
civInfo?.trainedWords?.forEach((w) => stackWords.add(w.trim().toLowerCase()));
|
||||
});
|
||||
if (stackWords.size > 0) {
|
||||
cleaned = cleaned.split(",").map((p) => p.trim()).filter((p) => p && !stackWords.has(p.toLowerCase())).join(", ");
|
||||
}
|
||||
posTextarea.value = cleaned.replace(/,\s*$/, "").trim();
|
||||
|
||||
const posNodeId = document.getElementById("wfm-prompt-pos-target")?.value;
|
||||
if (posNodeId && comfyUI.currentWorkflow?.[posNodeId]) {
|
||||
const promptNode = comfyUI.currentAnalysis?.prompt_nodes?.find((n) => String(n.id) === String(posNodeId));
|
||||
comfyUI.currentWorkflow[posNodeId].inputs[promptNode?.textKey || "text"] = posTextarea.value;
|
||||
_syncRawJson();
|
||||
}
|
||||
}
|
||||
|
||||
// syncFromWorkflow: OFFへ戻した直後は、たった今書き戻したloras/text状態と
|
||||
// Stackパネルの強度/有効状態表示を一致させるため強制同期する
|
||||
this.renderLoraPane(analysis, containerId, { syncFromWorkflow: true });
|
||||
});
|
||||
|
||||
// ── 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 +1065,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 +1284,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 +1326,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 +1345,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 +1382,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 +1405,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();
|
||||
});
|
||||
@@ -1212,8 +1492,34 @@ export const comfyEditor = {
|
||||
const slots = loadNodes.slice(0, 4);
|
||||
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
|
||||
const meoSlots = meoNodes.slice(0, 2);
|
||||
const phCfg = _loadI2IPlaceholderConfig();
|
||||
const isColorMode = phCfg.mode !== "image";
|
||||
el.innerHTML = `
|
||||
${slots.length === 0 ? "" : `
|
||||
<details class="wfm-settings-section wfm-i2i-placeholder-config">
|
||||
<summary class="wfm-settings-summary">${t("i2iPlaceholderTitle")}</summary>
|
||||
<div class="wfm-i2i-placeholder-mode">
|
||||
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-color" value="color" ${isColorMode ? "checked" : ""}> ${t("i2iPlaceholderColor")}</label>
|
||||
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-image" value="image" ${isColorMode ? "" : "checked"}> ${t("i2iPlaceholderImage")}</label>
|
||||
</div>
|
||||
<div id="wfm-i2i-ph-color-fields" class="wfm-i2i-placeholder-fields wfm-lab-modal-inline" style="${isColorMode ? "" : "display:none;"}">
|
||||
<div><label>${t("i2iPlaceholderWidth")}</label><input type="number" min="1" id="wfm-i2i-ph-width" class="wfm-input" value="${Number(phCfg.width) || 512}"></div>
|
||||
<div><label>${t("i2iPlaceholderHeight")}</label><input type="number" min="1" id="wfm-i2i-ph-height" class="wfm-input" value="${Number(phCfg.height) || 512}"></div>
|
||||
<div><label>${t("i2iPlaceholderColorLabel")}</label><input type="color" id="wfm-i2i-ph-color" class="wfm-input" value="${escapeHtml(/^#[0-9a-fA-F]{6}$/.test(phCfg.color) ? phCfg.color : "#808080")}"></div>
|
||||
</div>
|
||||
<div id="wfm-i2i-ph-image-fields" class="wfm-i2i-placeholder-fields" style="${isColorMode ? "display:none;" : ""}">
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-i2i-ph-preview-wrap" style="${phCfg.imageFilename ? "" : "display:none;"}">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-i2i-ph-preview-img" src="${phCfg.imageFilename ? `/view?filename=${encodeURIComponent(phCfg.imageFilename)}&type=input` : ""}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-i2i-ph-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
${t("i2iPlaceholderDropImage")}
|
||||
<input type="file" accept="image/*" id="wfm-i2i-ph-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-ph-status"></span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="wfm-i2i-grid">
|
||||
${slots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
|
||||
@@ -1247,8 +1553,10 @@ export const comfyEditor = {
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-placeholder-btn" data-slot="${i}">${t("i2iPlaceholderApply")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-clear" data-slot="${i}">${t("clear")}</button>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1409,8 +1717,64 @@ export const comfyEditor = {
|
||||
applyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear: reset this slot's staged/preview state back to empty. Doesn't touch
|
||||
// the workflow node itself — LoadImage always needs *some* valid filename, so
|
||||
// there's nothing meaningful to "unset" there; this just lets the user cancel
|
||||
// a pending pick and start over.
|
||||
const clearBtn = el.querySelector(`.wfm-i2i-clear[data-slot="${i}"]`);
|
||||
clearBtn?.addEventListener("click", () => {
|
||||
pendingFile = null;
|
||||
previewImg.src = "";
|
||||
previewWrap.style.display = "none";
|
||||
filenameEl.textContent = "";
|
||||
if (fileInput) fileInput.value = "";
|
||||
applyBtn.disabled = !pendingMaskFile;
|
||||
statusEl.textContent = "";
|
||||
});
|
||||
|
||||
// Placeholder: generate (color mode) or reuse (image mode) the shared default
|
||||
// configured at the top of the panel, and replace this slot's image
|
||||
// immediately — no separate Apply click needed, matching the "one button,
|
||||
// one slot" behavior described for this feature.
|
||||
const placeholderBtn = el.querySelector(`.wfm-i2i-placeholder-btn[data-slot="${i}"]`);
|
||||
placeholderBtn?.addEventListener("click", async () => {
|
||||
const cfg = _loadI2IPlaceholderConfig();
|
||||
placeholderBtn.disabled = true;
|
||||
statusEl.textContent = t("i2iPlaceholderGenerating");
|
||||
try {
|
||||
let filename;
|
||||
if (cfg.mode === "image") {
|
||||
if (!cfg.imageFilename) throw new Error(t("i2iPlaceholderNoDefaultImage"));
|
||||
filename = cfg.imageFilename;
|
||||
} else {
|
||||
const file = await _generateColorImageFile(cfg.width, cfg.height, cfg.color);
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
filename = result.name;
|
||||
}
|
||||
if (comfyUI.currentWorkflow?.[node.id]) {
|
||||
comfyUI.currentWorkflow[node.id].inputs.image = filename;
|
||||
}
|
||||
pendingFile = null;
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
previewWrap.style.display = "";
|
||||
filenameEl.textContent = filename;
|
||||
applyBtn.disabled = !pendingMaskFile;
|
||||
statusEl.textContent = `✓ ${filename}`;
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
_syncRawJson();
|
||||
} catch (err) {
|
||||
statusEl.textContent = `✗ ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
} finally {
|
||||
placeholderBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_wireI2IPlaceholderConfig(el);
|
||||
|
||||
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
|
||||
meoSlots.forEach((node, i) => {
|
||||
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
|
||||
@@ -1752,7 +2116,7 @@ async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
|
||||
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
|
||||
|
||||
section.innerHTML = `
|
||||
<label>Text Encoder</label>
|
||||
<label class="wfm-model-label-active">Text Encoder</label>
|
||||
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
|
||||
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
|
||||
|
||||
+470
-86
@@ -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;
|
||||
@@ -74,6 +85,9 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// Required inputs: skip types that are linked (have uppercase first letter = node output types)
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
// forceInput: true — link-only socket even though its type is a widget type
|
||||
// (e.g. CLIPTextEncodeEditPlus's text1/text2). Never has a widgets_values entry.
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
// Link types are strings like "MODEL", "CLIP", "CONDITIONING", "LATENT", "IMAGE", etc.
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", or an array of choices
|
||||
if (Array.isArray(type)) {
|
||||
@@ -91,6 +105,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// Optional inputs that are widgets
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
@@ -117,6 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
@@ -128,6 +144,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
}
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
@@ -143,6 +160,13 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
/**
|
||||
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
|
||||
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
|
||||
*
|
||||
* Includes both required AND optional sub-fields: ComfyUI serializes every sub-field of the
|
||||
* selected option into widgets_values regardless of which bucket it's declared in (e.g.
|
||||
* TextGenerate's "on" option puts temperature/top_k/.../seed under required but
|
||||
* presence_penalty under optional — all 7 still occupy a widgets_values slot). Dropping the
|
||||
* optional ones here under-counts how many slots the combo actually consumed, misaligning
|
||||
* every widget declared after it in the node's schema.
|
||||
*/
|
||||
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
|
||||
const info = objectInfo[classType];
|
||||
@@ -150,7 +174,10 @@ function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey
|
||||
const options = spec?.[1]?.options;
|
||||
if (!Array.isArray(options)) return [];
|
||||
const opt = options.find((o) => o.key === selectedKey) || options[0];
|
||||
return Object.keys(opt?.inputs?.required || {});
|
||||
return [
|
||||
...Object.keys(opt?.inputs?.required || {}),
|
||||
...Object.keys(opt?.inputs?.optional || {}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +197,140 @@ 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.
|
||||
*
|
||||
* comboExpander(name, selectedKey) — when a widget's type is COMFY_DYNAMICCOMBO_V3, its own turn
|
||||
* only consumes the *selector* slot (the chosen option's key); the selected option's sub-fields
|
||||
* (e.g. TextGenerate's sampling_mode → temperature/top_k/.../seed/presence_penalty) each occupy
|
||||
* one more widgets_values slot right after it. Without expanding those here too, every widget
|
||||
* declared later in the node's schema (e.g. TextGenerate's "thinking") gets matched against a
|
||||
* slot that's actually still inside the combo's sub-field span, corrupting both values.
|
||||
*/
|
||||
function _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, skipLinked, comboExpander) {
|
||||
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++;
|
||||
}
|
||||
if (widgetTypes[nIdx] === "COMFY_DYNAMICCOMBO_V3" && comboExpander) {
|
||||
const subNames = comboExpander(name, widgetsValues[p]);
|
||||
for (let s = 0; s < subNames.length && idx < widgetsValues.length; s++) {
|
||||
consumed.push(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);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
|
||||
// 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, comboExpander);
|
||||
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, comboExpander);
|
||||
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 comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
const sim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
return sim.positions[targetName] ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a subgraph-containing UI workflow into a plain UI workflow.
|
||||
*
|
||||
@@ -226,16 +387,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 +435,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 +458,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 +650,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 +801,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 +919,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 +974,7 @@ export const comfyWorkflow = {
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
"FluxGuidance", "FluxKontextMultiReferenceLatentMethod",
|
||||
]);
|
||||
|
||||
for (let iter = 0; iter < 5; iter++) {
|
||||
@@ -791,6 +1029,60 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// TextEncodeQwenImageEditPlus/TextEncodeQwenImageEdit — propagate role through its
|
||||
// linked "prompt" input when prompt-enhancement wiring feeds it via a switch chain
|
||||
// instead of a literal string (e.g. Krea-2's image-reference workflow).
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
const tv = inputs.prompt;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
// PreviewAny — generic value tap/relay used mid-chain in prompt-enhancement wiring
|
||||
// (e.g. Krea-2's "Refine Prompt?" switch output tap); pass role through its
|
||||
// "source" input unchanged.
|
||||
if (ct === "PreviewAny") {
|
||||
const sv = inputs.source;
|
||||
if (Array.isArray(sv)) {
|
||||
if (isPos) samplerPositiveRef[sv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[sv[0]] = true;
|
||||
}
|
||||
}
|
||||
// StringConcatenate — appends e.g. a LoRA trigger word onto the prompt text
|
||||
// (Krea-2); propagate role through both operands so the upstream literal is found.
|
||||
if (ct === "StringConcatenate") {
|
||||
for (const key of ["string_a", "string_b"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,6 +1104,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,13 +1236,34 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen-based image+text encoder
|
||||
if (ct === "TextEncodeQwenImageEditPlus") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: inputs.prompt || "",
|
||||
textKey: "prompt",
|
||||
});
|
||||
// Qwen-based image+text encoder (Plus variant takes up to 3 reference images;
|
||||
// the base variant takes one — both share the same "prompt" widget). Only add when
|
||||
// "prompt" is a direct string; if linked (e.g. Krea-2's prompt-enhancement wiring),
|
||||
// the upstream literal node is resolved instead via the role propagation above.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
const textVal = inputs.prompt;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -957,10 +1336,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,
|
||||
});
|
||||
}
|
||||
|
||||
+120
-2
@@ -7,10 +7,13 @@ import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
|
||||
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
|
||||
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,6 +129,87 @@ window._wfmReceiveI2IRunRequest = async (imageBlob, params, workflowData, workfl
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しにコマの設定(シーン・要素・セリフ等)を受け取り、
|
||||
// AIタブの設定タブと同じLLM接続設定(Ollama/LM Studio)で画像生成プロンプトの下書きを
|
||||
// 生成して返す(半自動マンガ作成の「L」ボタン連携用)。
|
||||
// Comic Creater側のスクリプトタブ「L」ボタンから
|
||||
// iframe.contentWindow._wfmReceiveLLMPromptRequest(context) として呼ばれる。
|
||||
// context: { scene, elements, dialogues, existingPrompt }(いずれも文字列、空文字可)
|
||||
window._wfmReceiveLLMPromptRequest = async (context) => {
|
||||
try {
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
if (!isValidBackendUrl(url)) return { ok: false, message: "AI settings: invalid backend URL (configure it in the Settings tab)" };
|
||||
if (!model) return { ok: false, message: "AI settings: no model selected (configure it in the Settings tab)" };
|
||||
|
||||
const { scene = "", elements = "", dialogues = "", existingPrompt = "" } = context || {};
|
||||
const prompt = [
|
||||
"Create a concise Stable Diffusion image generation prompt (English, comma-separated tags/phrases describing scene, composition, character appearance and action) for one manga panel, based on the following context.",
|
||||
"Output only the prompt text, nothing else.",
|
||||
scene ? `Scene: ${scene}` : null,
|
||||
elements ? `Characters/elements: ${elements}` : null,
|
||||
dialogues ? `Dialogue: ${dialogues}` : null,
|
||||
existingPrompt ? `Current draft prompt (revise/improve it): ${existingPrompt}` : null,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
const text = (result || "").trim();
|
||||
if (!text) return { ok: false, message: "LLM returned an empty response" };
|
||||
return { ok: true, text };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像プロンプト・サイズを受け取り、Generate UIで
|
||||
// テキストから画像を生成して結果URLを返す(半自動マンガ作成のコマ単位バッチ画像生成用)。
|
||||
// Comic Creater側のスクリプトタブ「画像を一括生成」から
|
||||
// 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" };
|
||||
|
||||
const workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
|
||||
const latent = analysis.latent_nodes?.[0];
|
||||
if (latent && width && height && workflow[latent.id]) {
|
||||
workflow[latent.id].inputs.width = width;
|
||||
workflow[latent.id].inputs.height = height;
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negative) comfyEditor.setPromptText("negative", negative, { workflow, analysis });
|
||||
|
||||
await window._wfmGenerateTab.generate(workflow);
|
||||
|
||||
const resultUrl = document.getElementById("wfm-gen-result-img")?.src;
|
||||
if (!resultUrl) return { ok: false, message: "No result image produced" };
|
||||
return { ok: true, url: resultUrl };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ── ページング ────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
@@ -319,7 +403,7 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
// フォルダ名
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? `[root] ${node.name}` : node.name;
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
if (node.image_count > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
@@ -829,15 +913,29 @@ async function loadImageDetail(img) {
|
||||
state.embeddedWorkflow = wfRes.has_workflow ? wfRes.workflow : null;
|
||||
renderWorkflowJson(state.embeddedWorkflow);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(metaRes.image_prompt);
|
||||
} catch (e) {
|
||||
renderWorkflowJson(null);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(null);
|
||||
}
|
||||
|
||||
// グループタブ更新
|
||||
renderDetailGroup(img);
|
||||
}
|
||||
|
||||
function renderImagePromptSection(imagePrompt) {
|
||||
const section = document.getElementById("wfm-gallery-image-prompt-section");
|
||||
const text = document.getElementById("wfm-gallery-image-prompt-text");
|
||||
if (!section || !text) return;
|
||||
if (imagePrompt) {
|
||||
text.textContent = imagePrompt;
|
||||
section.style.display = "";
|
||||
} else {
|
||||
section.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagsDisplay(tags) {
|
||||
const container = document.getElementById("wfm-gallery-tags-display");
|
||||
container.innerHTML = "";
|
||||
@@ -1524,6 +1622,9 @@ export function initGalleryTab() {
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
_initGallerySubtabToggle();
|
||||
initImagePromptTab();
|
||||
initStyleCatalogTab();
|
||||
}
|
||||
|
||||
let _initialized = false;
|
||||
@@ -1535,6 +1636,23 @@ function onGalleryTabActivated() {
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog) ────────────────────────
|
||||
|
||||
function _initGallerySubtabToggle() {
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.gallerySubtab;
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById("wfm-gallery-panel-output")?.classList.toggle("active", target === "output");
|
||||
document.getElementById("wfm-gallery-panel-imageprompt")?.classList.toggle("active", target === "imageprompt");
|
||||
document.getElementById("wfm-gallery-panel-styleCatalog")?.classList.toggle("active", target === "styleCatalog");
|
||||
if (target === "imageprompt") activateImagePromptTab();
|
||||
if (target === "styleCatalog") activateStyleCatalogTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// リフレッシュ
|
||||
document.getElementById("wfm-gallery-refresh-btn")?.addEventListener("click", () => {
|
||||
|
||||
+213
-49
@@ -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, refreshLabLiveDefaults } from "./lab-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Gallery Metadata - ワークフロー保存
|
||||
@@ -273,7 +231,9 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
|
||||
|
||||
// Render editor tabs
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow);
|
||||
// resetStackMode: 新規ワークフロー読み込み時のみ、LoraManagerノードのloras/text状態を
|
||||
// スナップショットし、Stackグループ使用チェックボックスをOFFへリセットする
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow, { resetStackMode: true });
|
||||
|
||||
// Update raw JSON with highlight
|
||||
const rawTextarea = document.getElementById("wfm-gen-raw-json");
|
||||
@@ -1305,6 +1265,12 @@ async function _loadStyles() {
|
||||
_rebuildStyleList();
|
||||
}
|
||||
|
||||
// Promptタブ「Style」サブタブでスタイルを作成・編集・削除した後に呼ばれる。
|
||||
// GenerateUIのStyleドロップダウン・Batchタブのチェックリストを最新の状態へ再同期する。
|
||||
export async function refreshStylesList() {
|
||||
await _loadStyles();
|
||||
}
|
||||
|
||||
function _renderStyleDropdown() {
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (!sel) return;
|
||||
@@ -1333,6 +1299,21 @@ function _rebuildStyleList() {
|
||||
);
|
||||
}
|
||||
|
||||
// Style Catalogギャラリーの「Select as Style」から呼ばれる。名前が一致する登録済み
|
||||
// Styleを探し、GenerateUIのStyleドロップダウンをそれに切り替えてタブへ移動する。
|
||||
// 画像に埋め込まれたプロンプトを直接適用するのではなく、既存Styleシステムへの
|
||||
// 視覚的なショートカットとして機能する(元のStyle JSONが変更・削除されても影響を受けない)。
|
||||
export function selectStyleByName(name) {
|
||||
const style = _stylesData.find((s) => s.name === name);
|
||||
if (!style) return false;
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (sel) sel.value = name;
|
||||
const enabled = document.getElementById("wfm-style-enabled");
|
||||
if (enabled) enabled.checked = true;
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _applyNamedStyle(workflow, style) {
|
||||
if (!style) return workflow;
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
@@ -1433,7 +1414,7 @@ async function _expandWildcardText(text) {
|
||||
|
||||
const _IMPACT_WC_TYPES = new Set(["ImpactWildcardEncode", "ImpactWildcardProcessor"]);
|
||||
|
||||
async function _expandWildcardsInWorkflow(workflow) {
|
||||
export async function _expandWildcardsInWorkflow(workflow) {
|
||||
let hasWildcard = false;
|
||||
outer: for (const node of Object.values(workflow)) {
|
||||
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
|
||||
@@ -1581,7 +1562,9 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
// ============================================
|
||||
|
||||
// 汎用バッチループ: items を順に applyFn(item) → _coreGenerate() で処理する
|
||||
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
// onResultFn(item, {images, seed}) が渡された場合、各アイテムの生成結果ごとに呼び出す
|
||||
// (Style Catalogのカタログ作成が、生成画像をスタイル名で保存するために使用)
|
||||
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x), onResultFn = null) {
|
||||
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
|
||||
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
|
||||
const batchCount = document.getElementById("wfm-ckpt-batch-count");
|
||||
@@ -1616,7 +1599,8 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
|
||||
try {
|
||||
await applyFn(item);
|
||||
await _coreGenerate(true);
|
||||
const result = await _coreGenerate(true);
|
||||
if (onResultFn) await onResultFn(item, result);
|
||||
completed++;
|
||||
} catch (err) {
|
||||
if (_ckptBatch.aborted) break;
|
||||
@@ -1645,6 +1629,157 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Style Catalog 作成
|
||||
// ============================================
|
||||
// Batchタブ「Style」サブタブで選択済みのスタイル(_batchStyleSelected)を対象に、
|
||||
// 現在ロード中のワークフローでそれぞれ生成し、結果をスタイル名でws_style_catalog
|
||||
// 配下の指定フォルダへ保存する(Style Catalogギャラリーからの視覚的スタイル選択に使う)。
|
||||
|
||||
function _blobToDataUrl(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function _flattenFolderTree(node, acc = [], isRoot = true) {
|
||||
acc.push({ path: isRoot ? "[root]" : node.path, abs_path: node.abs_path });
|
||||
(node.children || []).forEach((c) => _flattenFolderTree(c, acc, false));
|
||||
return acc;
|
||||
}
|
||||
|
||||
async function openCatalogCreateModal() {
|
||||
const list = _stylesData.filter((s) => _batchStyleSelected.has(s.name));
|
||||
if (list.length === 0) {
|
||||
showToast(t("catalogCreateNoStyles"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let catalogRoot = null;
|
||||
let folderTree = null;
|
||||
try {
|
||||
const rootRes = await fetch("/wfm/gallery/style-catalog/root");
|
||||
const rootData = await rootRes.json();
|
||||
catalogRoot = rootData.root;
|
||||
if (!catalogRoot) throw new Error(rootData.error || "Style Catalog root not resolved");
|
||||
const treeRes = await fetch(`/wfm/gallery/folders?root=${encodeURIComponent(catalogRoot)}`);
|
||||
folderTree = await treeRes.json();
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderOptions = folderTree && !folderTree.error ? _flattenFolderTree(folderTree) : [];
|
||||
|
||||
const html = `
|
||||
<p style="margin:0 0 10px;font-size:13px;color:var(--wfm-text-secondary);">${t("catalogCreateSelectedCount", list.length)}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="radio" name="wfm-catalog-dest" value="new" checked>
|
||||
${t("catalogCreateNewFolder")}
|
||||
</label>
|
||||
<input type="text" id="wfm-catalog-new-folder-name" class="wfm-input" placeholder="folder name" style="margin-left:22px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="radio" name="wfm-catalog-dest" value="existing" ${folderOptions.length === 0 ? "disabled" : ""}>
|
||||
${t("catalogCreateExistingFolder")}
|
||||
</label>
|
||||
<select id="wfm-catalog-existing-folder" class="wfm-input" style="margin-left:22px;" ${folderOptions.length === 0 ? "disabled" : ""}>
|
||||
${folderOptions.map((f) => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.path)}</option>`).join("")}
|
||||
</select>
|
||||
<hr class="wfm-group-divider" style="margin:4px 0;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="wfm-catalog-skip-output">
|
||||
${t("catalogCreateSkipOutput")}
|
||||
</label>
|
||||
</div>
|
||||
<button id="wfm-catalog-run-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:14px;">${t("catalogCreateRun")}</button>
|
||||
`;
|
||||
openModal(t("catalogCreateTitle"), html);
|
||||
|
||||
document.getElementById("wfm-catalog-run-btn")?.addEventListener("click", async () => {
|
||||
const destType = document.querySelector('input[name="wfm-catalog-dest"]:checked')?.value;
|
||||
let destFolder = null;
|
||||
|
||||
if (destType === "new") {
|
||||
const name = document.getElementById("wfm-catalog-new-folder-name")?.value.trim();
|
||||
if (!name) {
|
||||
showToast(t("catalogCreateFolderNameRequired"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent: catalogRoot, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Failed to create folder");
|
||||
destFolder = data.path;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
destFolder = document.getElementById("wfm-catalog-existing-folder")?.value || null;
|
||||
if (!destFolder) {
|
||||
showToast(t("catalogCreateFolderRequired"), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const skipOutput = document.getElementById("wfm-catalog-skip-output")?.checked || false;
|
||||
|
||||
closeModal();
|
||||
await _runCatalogCreate(list, destFolder, skipOutput);
|
||||
});
|
||||
}
|
||||
|
||||
async function _runCatalogCreate(list, destFolder, skipOutput) {
|
||||
try {
|
||||
await _runBatchLoop(
|
||||
list,
|
||||
(style) => { _batchStyleOverride = style; },
|
||||
(s) => s.name,
|
||||
async (style, result) => {
|
||||
const outputImages = (result?.images || []).filter((img) => img.type !== "temp");
|
||||
if (outputImages.length === 0) return;
|
||||
const srcImage = outputImages[0];
|
||||
const blob = await comfyUI.getImageBlob(srcImage);
|
||||
const dataUrl = await _blobToDataUrl(blob);
|
||||
const res = await fetch("/wfm/gallery/image/save-to-folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder: destFolder, filename: style.name, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Failed to save catalog image");
|
||||
|
||||
// Outputフォルダに元画像を残さないオプション: カタログへのコピー保存が
|
||||
// 成功した後にのみ、ComfyUIのOutputフォルダ側の元ファイルを削除する
|
||||
// (コピー失敗時は削除せず、元画像を保持したままにする)
|
||||
if (skipOutput) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/output-image/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: srcImage.filename,
|
||||
subfolder: srcImage.subfolder || "",
|
||||
type: srcImage.type || "output",
|
||||
}),
|
||||
});
|
||||
} catch { /* 削除失敗はカタログ作成自体を止めない */ }
|
||||
}
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
_batchStyleOverride = null;
|
||||
}
|
||||
}
|
||||
|
||||
// アクティブなバッチタイプに応じてバッチを実行するディスパッチャ
|
||||
async function _runBatchGenerate() {
|
||||
switch (_activeBatchType) {
|
||||
@@ -1933,6 +2068,25 @@ export async function initGenerateTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Style Catalog 作成ボタン
|
||||
const catalogCreateBtn = document.getElementById("wfm-gen-catalog-create-btn");
|
||||
if (catalogCreateBtn) {
|
||||
catalogCreateBtn.textContent = t("catalogCreateBtn");
|
||||
catalogCreateBtn.addEventListener("click", () => {
|
||||
openCatalogCreateModal().catch((e) => showToast(t("errorWithMsg", e.message), "error"));
|
||||
});
|
||||
}
|
||||
|
||||
// Style Catalog を開くボタン(Gallery タブ → Style_Catalog サブタブへ遷移)
|
||||
const catalogOpenBtn = document.getElementById("wfm-gen-catalog-open-btn");
|
||||
if (catalogOpenBtn) {
|
||||
catalogOpenBtn.textContent = t("catalogOpenBtn");
|
||||
catalogOpenBtn.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="styleCatalog"]')?.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Generate button
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
|
||||
handleGenerate().catch(() => {});
|
||||
@@ -1961,7 +2115,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 +2139,13 @@ export async function initGenerateTab() {
|
||||
if (target === "model" && comfyUI.currentAnalysis) {
|
||||
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
|
||||
}
|
||||
// Re-render Lab's column grid so Setting 1's live-reflected cells (checkpoint/
|
||||
// VAE/prompt/KSampler) pick up whatever is currently loaded in GenerateUI, not
|
||||
// whatever was loaded the last time Lab was rendered.
|
||||
if (target === "lab") refreshLabLiveDefaults();
|
||||
// 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 +2322,9 @@ export async function initGenerateTab() {
|
||||
// Feeder tab
|
||||
await initFeederTab();
|
||||
|
||||
// Lab tab (experimental I2I batch generation)
|
||||
initLabTab();
|
||||
|
||||
// Style dropdown
|
||||
await _loadStyles();
|
||||
|
||||
|
||||
+749
-24
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* ImagePrompt Gallery — Gallery タブのサブタブ。
|
||||
* ws_image_prompt フォルダ配下の画像+.txtサイドカーを閲覧し、
|
||||
* クリックで選択→「追加」でプロンプトを組み立てて最終的にコピーする。
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
const API = {
|
||||
root: () => `/wfm/gallery/image-prompt/root`,
|
||||
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
|
||||
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
|
||||
imageMeta: (path) => `/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`,
|
||||
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
savePrompt: `/wfm/gallery/image/image-prompt`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
root: null,
|
||||
currentFolder: null,
|
||||
search: "",
|
||||
images: [],
|
||||
selectedImage: null,
|
||||
chips: [], // [{ path, filename, prompt }]
|
||||
};
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────────────────────
|
||||
|
||||
export function initImagePromptTab() {
|
||||
document.getElementById("wfm-imageprompt-refresh-btn")?.addEventListener("click", () => {
|
||||
loadFolderTree();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-imageprompt-search", "wfm-imageprompt-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById("wfm-imageprompt-search");
|
||||
searchInput?.addEventListener("input", debounce(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadImages();
|
||||
}, 300));
|
||||
|
||||
document.getElementById("wfm-imageprompt-add-btn")?.addEventListener("click", addSelectedToChips);
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn")?.addEventListener("click", saveSelectedPrompt);
|
||||
document.getElementById("wfm-imageprompt-clear-btn")?.addEventListener("click", clearAllChips);
|
||||
document.getElementById("wfm-imageprompt-copy-btn")?.addEventListener("click", copyFinalPrompt);
|
||||
}
|
||||
|
||||
export async function activateImagePromptTab() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
const data = await apiFetch(API.root());
|
||||
state.root = data.root;
|
||||
await loadFolderTree();
|
||||
} catch (e) {
|
||||
const tree = document.getElementById("wfm-imageprompt-tree");
|
||||
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
async function loadFolderTree() {
|
||||
if (!state.root) return;
|
||||
const tree = document.getElementById("wfm-imageprompt-tree");
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
try {
|
||||
const data = await apiFetch(API.folders(state.root));
|
||||
if (data.error) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
|
||||
return;
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTreeNode(node, container, depth, isRoot) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-gallery-tree-item";
|
||||
item.style.paddingLeft = `${depth * 12}px`;
|
||||
item.dataset.path = node.abs_path;
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const absPath = node.abs_path;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wfm-gallery-tree-arrow";
|
||||
if (hasChildren) {
|
||||
arrow.textContent = "▶";
|
||||
arrow.dataset.expanded = "false";
|
||||
} else {
|
||||
arrow.textContent = " ";
|
||||
arrow.style.visibility = "hidden";
|
||||
}
|
||||
item.appendChild(arrow);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
// サブフォルダ内も含めた合計件数(クリックで再帰的に全画像を表示するため)
|
||||
const totalCount = node.image_count_total ?? node.image_count;
|
||||
if (totalCount > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
badge.textContent = totalCount;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
item.appendChild(label);
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
document.querySelectorAll("#wfm-imageprompt-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
|
||||
item.classList.add("selected");
|
||||
state.currentFolder = absPath;
|
||||
loadImages();
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "wfm-gallery-tree-children";
|
||||
childContainer.style.display = "none";
|
||||
|
||||
arrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const expanded = arrow.dataset.expanded === "true";
|
||||
if (!expanded) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
if (childContainer.children.length === 0) {
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
arrow.dataset.expanded = "false";
|
||||
arrow.textContent = "▶";
|
||||
childContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画像一覧 ─────────────────────────────────────────────────
|
||||
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
const grid = document.getElementById("wfm-imageprompt-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
|
||||
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
|
||||
if (state.search) params.search = state.search;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(API.images(params));
|
||||
state.images = data.images || [];
|
||||
document.getElementById("wfm-imageprompt-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const grid = document.getElementById("wfm-imageprompt-grid");
|
||||
if (state.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createThumbCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-gallery-thumb-card";
|
||||
card.title = img.filename;
|
||||
card.dataset.path = img.path;
|
||||
if (state.selectedImage && state.selectedImage.path === img.path) {
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "wfm-gallery-thumb-img";
|
||||
imgEl.loading = "lazy";
|
||||
imgEl.src = API.thumb(img.path);
|
||||
imgEl.alt = img.filename;
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
card.addEventListener("click", () => selectImage(img));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 選択画像 / プロンプト表示 ─────────────────────────────────
|
||||
|
||||
async function selectImage(img) {
|
||||
state.selectedImage = img;
|
||||
document.querySelectorAll("#wfm-imageprompt-grid .wfm-gallery-thumb-card").forEach(el => {
|
||||
el.classList.toggle("selected", el.dataset.path === img.path);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-imageprompt-selected-name").textContent = img.filename;
|
||||
const preview = document.getElementById("wfm-imageprompt-selected-preview");
|
||||
preview.innerHTML = "";
|
||||
const previewImg = document.createElement("img");
|
||||
previewImg.src = API.serveImage(img.path);
|
||||
previewImg.alt = img.filename;
|
||||
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;";
|
||||
preview.appendChild(previewImg);
|
||||
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
promptBox.value = "";
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = true;
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = true;
|
||||
|
||||
try {
|
||||
const meta = await apiFetch(API.imageMeta(img.path));
|
||||
promptBox.value = meta.image_prompt || "";
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = false;
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = !meta.image_prompt;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedPrompt() {
|
||||
if (!state.selectedImage) return;
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
try {
|
||||
const res = await apiFetch(API.savePrompt, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.selectedImage.path, prompt: promptBox.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(t("imagePromptSaved"), "success");
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = !promptBox.value.trim();
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── プロンプトビルダー (チップ / 最終プロンプト) ─────────────────
|
||||
|
||||
function cleanPromptText(text) {
|
||||
return String(text || "")
|
||||
.replace(/^[,\s]+|[,\s]+$/g, "")
|
||||
.replace(/\s*,\s*/g, ", ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function addSelectedToChips() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
const prompt = cleanPromptText(promptBox.value);
|
||||
if (!prompt) return;
|
||||
if (state.chips.some(c => c.path === img.path)) {
|
||||
showToast(t("imagePromptAlreadyAdded"), "info");
|
||||
return;
|
||||
}
|
||||
state.chips.push({ path: img.path, filename: img.filename, prompt });
|
||||
renderChips();
|
||||
rebuildFinalPrompt();
|
||||
}
|
||||
|
||||
function removeChip(path) {
|
||||
state.chips = state.chips.filter(c => c.path !== path);
|
||||
renderChips();
|
||||
rebuildFinalPrompt();
|
||||
}
|
||||
|
||||
function clearAllChips() {
|
||||
state.chips = [];
|
||||
renderChips();
|
||||
document.getElementById("wfm-imageprompt-final-prompt").value = "";
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const container = document.getElementById("wfm-imageprompt-chips");
|
||||
container.innerHTML = "";
|
||||
state.chips.forEach(chip => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "wfm-imageprompt-chip";
|
||||
el.title = chip.prompt;
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "wfm-imageprompt-chip-text";
|
||||
text.textContent = chip.filename.replace(/\.[^.]+$/, "");
|
||||
el.appendChild(text);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "wfm-imageprompt-chip-remove";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => removeChip(chip.path));
|
||||
el.appendChild(removeBtn);
|
||||
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildFinalPrompt() {
|
||||
const combined = state.chips.map(c => c.prompt).join(", ");
|
||||
document.getElementById("wfm-imageprompt-final-prompt").value = cleanPromptText(combined);
|
||||
}
|
||||
|
||||
async function copyFinalPrompt() {
|
||||
const text = document.getElementById("wfm-imageprompt-final-prompt").value;
|
||||
if (!text.trim()) {
|
||||
showToast(t("imagePromptNothingToCopy"), "info");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(t("imagePromptCopiedToast"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+169
-34
@@ -75,7 +75,7 @@ function parseITXtChunk(data, latin1, utf8) {
|
||||
pos = findNull(data, pos); if (pos === -1) return null; pos++;
|
||||
return { keyword, text: utf8.decode(data.slice(pos)) };
|
||||
}
|
||||
async function readAllPNGTextChunks(file) {
|
||||
export async function readAllPNGTextChunks(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
@@ -188,11 +188,67 @@ 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"); }
|
||||
|
||||
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
|
||||
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
|
||||
function resolveEditPlusText(mode, textEdit, text1, text2) {
|
||||
const t1 = typeof text1 === "string" ? text1 : "";
|
||||
const edit = typeof textEdit === "string" ? textEdit : "";
|
||||
const insert = typeof text2 === "string" && text2 !== "" ? text2 : edit;
|
||||
switch (mode) {
|
||||
case "RAW": return t1;
|
||||
case "EDIT": return edit;
|
||||
case "front": return t1 ? `${insert}, ${t1}` : insert;
|
||||
case "back": return t1 ? `${t1}, ${insert}` : insert;
|
||||
default: return t1 || edit;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPrompts(wf) {
|
||||
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
|
||||
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等の中継ノード)は追跡する
|
||||
// "source" = PreviewAny(値タップ中継), "string_a" = StringConcatenate(LoRAトリガーワード
|
||||
// 連結等)— Krea-2のプロンプト強化配線で使われる中継ノード
|
||||
if (Array.isArray(srcNode.inputs)) {
|
||||
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string", "source", "string_a"].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) {
|
||||
@@ -212,7 +268,22 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const n of nodes) {
|
||||
if (!isTextEncoderNode(n.type ?? "")) continue;
|
||||
const type = n.type ?? "";
|
||||
if (type === "CLIPTextEncodeEditPlus") {
|
||||
const resolveNamedInput = (name) => {
|
||||
if (!Array.isArray(n.inputs)) return null;
|
||||
const inp = n.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) return null;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) return null;
|
||||
return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
};
|
||||
const combined = resolveEditPlusText(n.widgets_values?.[1], n.widgets_values?.[0], resolveNamedInput("text1"), resolveNamedInput("text2"));
|
||||
if (combined) textMap.set(n.id, combined);
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(type)) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") {
|
||||
textMap.set(n.id, text);
|
||||
@@ -221,18 +292,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 +302,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 +395,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 +405,34 @@ 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、"source" は
|
||||
// PreviewAny の値タップ中継、"string_a" は StringConcatenate の主オペランド — いずれも
|
||||
// Krea-2のプロンプト強化配線で使われる中継ノード)
|
||||
const keys = slot === 0
|
||||
? ["text_positive", "text", "text_g", "prompt"]
|
||||
: ["text_negative", "text_l"];
|
||||
? ["text_positive", "text", "text_g", "prompt", "value", "source", "string_a"]
|
||||
: ["text_negative", "text_l", "source", "string_a"];
|
||||
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;
|
||||
}
|
||||
@@ -341,8 +446,23 @@ 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;
|
||||
if (!n) continue;
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
const resolveField = (key) => {
|
||||
const v = n.inputs?.[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v)) return resolveLinkedText(wf, v[0], v[1] ?? 0);
|
||||
return null;
|
||||
};
|
||||
const combined = resolveEditPlusText(n.inputs?.mode, n.inputs?.text_edit, resolveField("text1"), resolveField("text2"));
|
||||
if (combined) textMap.set(id, combined);
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(ct)) continue;
|
||||
// "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 +476,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 +603,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");
|
||||
|
||||
+224
-1
@@ -4,7 +4,8 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, setupSearchClearBtn } from "./util.js";
|
||||
import { refreshStylesList } from "./generate-tab.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -903,6 +904,144 @@ function wcUpdateFilePicker() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Style manager (Col3 "Style" タブ)
|
||||
// ============================================
|
||||
// Wildcardファイルマネージャーと同じCRUDパターン(一覧→エディタ→保存/削除/キャンセル)。
|
||||
// 保存・削除後は generate-tab.js の refreshStylesList() を呼び、GenerateUIのStyleドロップダウンや
|
||||
// Batchタブのチェックリストを再読み込み無しで最新状態へ同期する。
|
||||
|
||||
let styleListData = [];
|
||||
let styleSearchText = "";
|
||||
let styleEditingOriginalName = null; // null = 新規作成
|
||||
let styleEditingFile = null; // 編集中/追加先スタイルの定義元ファイル名(style/配下、null = デフォルトのcustom.json)
|
||||
|
||||
async function styleFetchList() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/styles");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function styleApiCreate(name, prompt, negativePrompt, targetFile) {
|
||||
try {
|
||||
const body = { name, prompt, negative_prompt: negativePrompt };
|
||||
if (targetFile) body.file = targetFile;
|
||||
const res = await fetch("/api/wfm/styles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function styleApiUpdate(originalName, name, prompt, negativePrompt) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(originalName)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, prompt, negative_prompt: negativePrompt }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function styleApiDelete(name) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function styleRenderList() {
|
||||
const list = document.getElementById("wfm-style-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
const q = styleSearchText.trim().toLowerCase();
|
||||
const filtered = q ? styleListData.filter(s => s.name.toLowerCase().includes(q)) : styleListData;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = styleListData.length === 0
|
||||
? `<div class="wfm-pm-empty" style="font-size:12px;">No styles yet.<br><small>Click "+ New" to create one.</small></div>`
|
||||
: `<div class="wfm-pm-empty" style="font-size:12px;">No matching styles.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of filtered) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.name)}</span>
|
||||
${s.file ? `<span class="wfm-wc-file-ext" title="${esc(s.file)}">${esc(s.file)}</span>` : ""}
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-style-edit-btn" title="Edit style">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-style-edit-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
styleOpenEditor(s);
|
||||
});
|
||||
item.addEventListener("click", () => styleOpenEditor(s));
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// forcedFile: 「+ Add to this file」から呼ばれた場合に、既存スタイルではなく
|
||||
// 特定ファイルを追加先として指定するために使う(styleは渡さずnullにする)
|
||||
function styleOpenEditor(style, forcedFile) {
|
||||
styleEditingOriginalName = style ? style.name : null;
|
||||
styleEditingFile = style ? (style.file || null) : (forcedFile || null);
|
||||
|
||||
const editor = document.getElementById("wfm-style-editor");
|
||||
const nameInput = document.getElementById("wfm-style-editor-name");
|
||||
const promptTA = document.getElementById("wfm-style-editor-prompt");
|
||||
const negativeTA = document.getElementById("wfm-style-editor-negative");
|
||||
const deleteBtn = document.getElementById("wfm-style-editor-delete-btn");
|
||||
const addFileBtn = document.getElementById("wfm-style-editor-addfile-btn");
|
||||
const fileLabel = document.getElementById("wfm-style-editor-file-label");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = style?.name || "";
|
||||
promptTA.value = style?.prompt || "";
|
||||
negativeTA.value = style?.negative_prompt || "";
|
||||
deleteBtn.style.display = style ? "" : "none";
|
||||
// 「このファイルへ追加」は既存スタイルを編集中(=対象ファイルが分かっている)時だけ表示
|
||||
addFileBtn.style.display = style ? "" : "none";
|
||||
if (styleEditingFile) {
|
||||
fileLabel.textContent = `File: ${styleEditingFile}`;
|
||||
fileLabel.style.display = "";
|
||||
} else {
|
||||
fileLabel.style.display = "none";
|
||||
}
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function styleCloseEditor() {
|
||||
styleEditingOriginalName = null;
|
||||
styleEditingFile = null;
|
||||
const editor = document.getElementById("wfm-style-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
async function styleRefreshList() {
|
||||
styleListData = await styleFetchList();
|
||||
styleRenderList();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialize
|
||||
// ============================================
|
||||
@@ -1319,4 +1458,88 @@ export function initPromptTab() {
|
||||
|
||||
// Initial wildcard file load
|
||||
wcRefreshFiles();
|
||||
|
||||
// ── Col3タブ切り替え (Wildcard / Style) ────────────────────
|
||||
document.querySelectorAll(".wfm-prompt-col3-tab[data-col3tab]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".wfm-prompt-col3-tab[data-col3tab]").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const tabId = btn.dataset.col3tab;
|
||||
document.querySelectorAll(".wfm-prompt-col3-tab-content").forEach(c => { c.style.display = "none"; });
|
||||
const content = document.getElementById(`wfm-col3-tab-${tabId}`);
|
||||
if (content) content.style.display = "";
|
||||
});
|
||||
});
|
||||
|
||||
// ── Style manager ────────────────────────────────
|
||||
document.getElementById("wfm-style-new-btn")?.addEventListener("click", () => {
|
||||
styleOpenEditor(null);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-style-refresh-btn")?.addEventListener("click", () => {
|
||||
styleRefreshList();
|
||||
});
|
||||
|
||||
// 編集中の既存スタイルと同じファイルへ、新しい空のスタイルを追加する
|
||||
document.getElementById("wfm-style-editor-addfile-btn")?.addEventListener("click", () => {
|
||||
if (!styleEditingFile) return;
|
||||
styleOpenEditor(null, styleEditingFile);
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-style-search", "wfm-style-search-clear-btn", () => {
|
||||
styleSearchText = "";
|
||||
styleRenderList();
|
||||
});
|
||||
document.getElementById("wfm-style-search")?.addEventListener("input", (e) => {
|
||||
styleSearchText = e.target.value;
|
||||
styleRenderList();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-style-editor-save-btn")?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-style-editor-name");
|
||||
const promptTA = document.getElementById("wfm-style-editor-prompt");
|
||||
const negativeTA = document.getElementById("wfm-style-editor-negative");
|
||||
|
||||
const name = (nameInput?.value || "").trim();
|
||||
const prompt = promptTA?.value || "";
|
||||
const negativePrompt = negativeTA?.value || "";
|
||||
|
||||
if (!name) { showToast(t("pleaseEnterStyleName"), "error"); return; }
|
||||
|
||||
const result = styleEditingOriginalName
|
||||
? await styleApiUpdate(styleEditingOriginalName, name, prompt, negativePrompt)
|
||||
: await styleApiCreate(name, prompt, negativePrompt, styleEditingFile);
|
||||
|
||||
if (result.ok) {
|
||||
showToast(t("savedAs", name), "success");
|
||||
styleCloseEditor();
|
||||
await styleRefreshList();
|
||||
await refreshStylesList();
|
||||
} else {
|
||||
showToast(t("errorWithMsg", result.error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: delete
|
||||
document.getElementById("wfm-style-editor-delete-btn")?.addEventListener("click", async () => {
|
||||
if (!styleEditingOriginalName) return;
|
||||
if (!confirm(`Delete "${styleEditingOriginalName}"?`)) return;
|
||||
const result = await styleApiDelete(styleEditingOriginalName);
|
||||
if (result.ok) {
|
||||
showToast(t("deletedName", styleEditingOriginalName), "success");
|
||||
styleCloseEditor();
|
||||
await styleRefreshList();
|
||||
await refreshStylesList();
|
||||
} else {
|
||||
showToast(t("errorWithMsg", result.error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: cancel
|
||||
document.getElementById("wfm-style-editor-cancel-btn")?.addEventListener("click", () => {
|
||||
styleCloseEditor();
|
||||
});
|
||||
|
||||
// Initial style list load
|
||||
styleRefreshList();
|
||||
}
|
||||
|
||||
@@ -366,6 +366,21 @@ export function applyJsonColors(colors) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// GenerateUI Model tab: highlight color for section labels that match the loaded workflow
|
||||
const MODEL_ACTIVE_COLOR_STYLE_ID = "wfm-model-active-color-style";
|
||||
const MODEL_ACTIVE_COLOR_DEFAULT = "#4ade80";
|
||||
|
||||
export function applyModelTabActiveColor(color) {
|
||||
const val = color || MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
let el = document.getElementById(MODEL_ACTIVE_COLOR_STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
@@ -577,6 +592,21 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- GenerateUI Model Tab Highlight Color -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("modelTabColorSection") || "GenerateUI Modelタブ ハイライト色"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("modelTabColorLabel") || "ワークフローに含まれる項目の色"}</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="color" id="wfm-model-active-color" value="${settings.modelTabActiveColor || MODEL_ACTIVE_COLOR_DEFAULT}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-model-active-color-reset">${t("jsonColorReset") || "デフォルトに戻す"}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("modelTabColorHint") || "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
@@ -1333,6 +1363,22 @@ export async function initSettingsTab() {
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// --- GenerateUI Model tab highlight color ---
|
||||
document.getElementById("wfm-model-active-color")?.addEventListener("input", (e) => {
|
||||
saveLocalSettings({ modelTabActiveColor: e.target.value });
|
||||
applyModelTabActiveColor(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-model-active-color-reset")?.addEventListener("click", () => {
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.modelTabActiveColor;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyModelTabActiveColor(undefined);
|
||||
const inp = document.getElementById("wfm-model-active-color");
|
||||
if (inp) inp.value = MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Save (local settings)
|
||||
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
|
||||
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Style Catalog Gallery — Gallery タブのサブタブ。
|
||||
* ws_style_catalog フォルダ配下の画像(GenerateUIの「カタログ作成」で生成した、
|
||||
* ファイル名=スタイル名のプレビュー画像)を閲覧し、埋め込みPositive/Negative
|
||||
* プロンプトを表示・コピーする。「Select as Style」でGenerateUIの登録済み
|
||||
* Styleドロップダウンをファイル名と同名のStyleに切り替える。
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { extractAllMetadata, loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { selectStyleByName, loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
|
||||
const API = {
|
||||
root: () => `/wfm/gallery/style-catalog/root`,
|
||||
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
|
||||
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
|
||||
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
workflow: (path) => `/wfm/gallery/image/workflow?path=${encodeURIComponent(path)}`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
root: null,
|
||||
currentFolder: null,
|
||||
search: "",
|
||||
images: [],
|
||||
selectedImage: null,
|
||||
};
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────────────────────
|
||||
|
||||
export function initStyleCatalogTab() {
|
||||
document.getElementById("wfm-stylecatalog-refresh-btn")?.addEventListener("click", () => {
|
||||
loadFolderTree();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-stylecatalog-search", "wfm-stylecatalog-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById("wfm-stylecatalog-search");
|
||||
searchInput?.addEventListener("input", debounce(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadImages();
|
||||
}, 300));
|
||||
|
||||
document.getElementById("wfm-stylecatalog-copy-positive-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-positive-text"));
|
||||
document.getElementById("wfm-stylecatalog-copy-negative-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-negative-text"));
|
||||
document.getElementById("wfm-stylecatalog-select-style-btn")?.addEventListener("click", onSelectStyleClick);
|
||||
document.getElementById("wfm-stylecatalog-load-genui-btn")?.addEventListener("click", onLoadGenUIClick);
|
||||
document.getElementById("wfm-stylecatalog-open-metadata-btn")?.addEventListener("click", onOpenMetadataClick);
|
||||
}
|
||||
|
||||
export async function activateStyleCatalogTab() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
const data = await apiFetch(API.root());
|
||||
state.root = data.root;
|
||||
await loadFolderTree();
|
||||
} catch (e) {
|
||||
const tree = document.getElementById("wfm-stylecatalog-tree");
|
||||
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
async function loadFolderTree() {
|
||||
if (!state.root) return;
|
||||
const tree = document.getElementById("wfm-stylecatalog-tree");
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
try {
|
||||
const data = await apiFetch(API.folders(state.root));
|
||||
if (data.error) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
|
||||
return;
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTreeNode(node, container, depth, isRoot) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-gallery-tree-item";
|
||||
item.style.paddingLeft = `${depth * 12}px`;
|
||||
item.dataset.path = node.abs_path;
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const absPath = node.abs_path;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wfm-gallery-tree-arrow";
|
||||
if (hasChildren) {
|
||||
arrow.textContent = "▶";
|
||||
arrow.dataset.expanded = "false";
|
||||
} else {
|
||||
arrow.textContent = " ";
|
||||
arrow.style.visibility = "hidden";
|
||||
}
|
||||
item.appendChild(arrow);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
const totalCount = node.image_count_total ?? node.image_count;
|
||||
if (totalCount > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
badge.textContent = totalCount;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
item.appendChild(label);
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
document.querySelectorAll("#wfm-stylecatalog-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
|
||||
item.classList.add("selected");
|
||||
state.currentFolder = absPath;
|
||||
loadImages();
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "wfm-gallery-tree-children";
|
||||
childContainer.style.display = "none";
|
||||
|
||||
arrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const expanded = arrow.dataset.expanded === "true";
|
||||
if (!expanded) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
if (childContainer.children.length === 0) {
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
arrow.dataset.expanded = "false";
|
||||
arrow.textContent = "▶";
|
||||
childContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画像一覧 ─────────────────────────────────────────────────
|
||||
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
const grid = document.getElementById("wfm-stylecatalog-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
|
||||
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
|
||||
if (state.search) params.search = state.search;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(API.images(params));
|
||||
state.images = data.images || [];
|
||||
document.getElementById("wfm-stylecatalog-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const grid = document.getElementById("wfm-stylecatalog-grid");
|
||||
if (state.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createThumbCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-gallery-thumb-card";
|
||||
card.title = img.filename;
|
||||
card.dataset.path = img.path;
|
||||
if (state.selectedImage && state.selectedImage.path === img.path) {
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "wfm-gallery-thumb-img";
|
||||
imgEl.loading = "lazy";
|
||||
imgEl.src = API.thumb(img.path);
|
||||
imgEl.alt = img.filename;
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
card.addEventListener("click", () => selectImage(img));
|
||||
card.addEventListener("dblclick", () => openLightbox(img));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 選択画像 / Positive・Negative表示 ─────────────────────────
|
||||
|
||||
function styleNameFromFilename(filename) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
async function selectImage(img) {
|
||||
state.selectedImage = img;
|
||||
document.querySelectorAll("#wfm-stylecatalog-grid .wfm-gallery-thumb-card").forEach(el => {
|
||||
el.classList.toggle("selected", el.dataset.path === img.path);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-stylecatalog-selected-name").textContent = img.filename;
|
||||
const preview = document.getElementById("wfm-stylecatalog-selected-preview");
|
||||
preview.innerHTML = "";
|
||||
const previewImg = document.createElement("img");
|
||||
previewImg.src = API.serveImage(img.path);
|
||||
previewImg.alt = img.filename;
|
||||
previewImg.title = "Double-click to enlarge";
|
||||
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;cursor:zoom-in;";
|
||||
previewImg.addEventListener("dblclick", () => openLightbox(img));
|
||||
preview.appendChild(previewImg);
|
||||
|
||||
const positiveText = document.getElementById("wfm-stylecatalog-positive-text");
|
||||
const negativeText = document.getElementById("wfm-stylecatalog-negative-text");
|
||||
const copyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
const copyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
const selectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
const loadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
const openMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
|
||||
positiveText.textContent = "";
|
||||
negativeText.textContent = "";
|
||||
copyPositiveBtn.disabled = true;
|
||||
copyNegativeBtn.disabled = true;
|
||||
selectStyleBtn.disabled = false; // ファイル名からのStyle選択はメタデータ抽出に依存しない
|
||||
loadGenuiBtn.disabled = false;
|
||||
openMetadataBtn.disabled = false;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
const meta = await extractAllMetadata(file);
|
||||
|
||||
const positive = meta?.positives?.[0] || "";
|
||||
const negative = meta?.negatives?.[0] || "";
|
||||
|
||||
if (!positive && !negative) {
|
||||
positiveText.textContent = t("stylecatalogNoPromptFound");
|
||||
} else {
|
||||
positiveText.textContent = positive;
|
||||
negativeText.textContent = negative;
|
||||
copyPositiveBtn.disabled = !positive;
|
||||
copyNegativeBtn.disabled = !negative;
|
||||
}
|
||||
} catch (e) {
|
||||
positiveText.textContent = t("stylecatalogNoPromptFound");
|
||||
}
|
||||
}
|
||||
|
||||
// gallery-tab.jsのライトボックスと同じCSSクラスを使う(ESCで閉じる処理もgallery-tab.js側の
|
||||
// グローバルkeydownリスナーが共通で担う)。
|
||||
function openLightbox(img) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wfm-gallery-lightbox";
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-lightbox-inner">
|
||||
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
|
||||
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
|
||||
<button class="wfm-gallery-lightbox-close">×</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay || e.target.classList.contains("wfm-gallery-lightbox-close")) {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function copyText(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = el?.textContent || "";
|
||||
if (!text.trim()) return;
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => showToast(t("imagePromptCopiedToast"), "success"))
|
||||
.catch((e) => showToast(t("errorWithMsg", e.message), "error"));
|
||||
}
|
||||
|
||||
function onSelectStyleClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
const name = styleNameFromFilename(img.filename);
|
||||
const ok = selectStyleByName(name);
|
||||
if (ok) {
|
||||
showToast(t("stylecatalogStyleSelected", name), "success");
|
||||
} else {
|
||||
showToast(t("stylecatalogNoMatch"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// カタログ画像は生成元の出力画像と同一バイト列で保存されているため、埋め込みワークフローも
|
||||
// そのまま残っている。Outputフォルダ内の元画像を探す必要なく、この画像から直接読み込める。
|
||||
async function onLoadGenUIClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
try {
|
||||
const data = await apiFetch(API.workflow(img.path));
|
||||
if (!data.has_workflow) {
|
||||
showToast(t("galleryNoEmbeddedWorkflow"), "warning");
|
||||
return;
|
||||
}
|
||||
const loaded = await loadWorkflowIntoEditor(data.workflow, img.filename);
|
||||
if (loaded !== false) {
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function onOpenMetadataClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await loadFileIntoMetadataTab(file);
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,76 @@
|
||||
* Shared utilities
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// PNG metadata embedding (iTXt chunk)
|
||||
// ============================================
|
||||
// Embeds an arbitrary key/UTF-8-text pair into a canvas-generated PNG data URL as
|
||||
// an iTXt chunk, inserted right after IHDR — same insertion point/CRC approach as
|
||||
// ComfyUI's own workflow-embedding (web/comfyui/top_menu_extension.js's
|
||||
// embedWorkflowInPng), but iTXt instead of tEXt so non-ASCII text (Japanese notes,
|
||||
// prompts, etc.) round-trips correctly through this codebase's PNG chunk reader
|
||||
// (metadata-tab.js's readAllPNGTextChunks already decodes iTXt text as UTF-8;
|
||||
// tEXt text there is decoded as Latin-1, which would mangle non-ASCII payloads).
|
||||
|
||||
function _n2b(n) {
|
||||
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
function _joinBytes(...arrs) {
|
||||
const result = new Uint8Array(arrs.reduce((total, a) => total + a.byteLength, 0));
|
||||
arrs.reduce((offset, a) => { result.set(a, offset); return offset + a.byteLength; }, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
let _pngCrcTable = null;
|
||||
function _pngCrc32(data) {
|
||||
if (!_pngCrcTable) {
|
||||
_pngCrcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
_pngCrcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) crc = (crc >>> 8) ^ _pngCrcTable[(crc ^ data[i]) & 0xff];
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
function _dataUrlToBytes(dataUrl) {
|
||||
const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function _bytesToDataUrl(bytes, mime) {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return `data:${mime};base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
export function embedPngTextChunk(dataUrl, keyword, text) {
|
||||
const bytes = _dataUrlToBytes(dataUrl);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
const data = _joinBytes(
|
||||
new TextEncoder().encode(`iTXt${keyword}`),
|
||||
new Uint8Array([0, 0, 0, 0, 0]), // keyword-NUL, compression flag(0), compression method(0), empty lang tag+NUL, empty translated keyword+NUL
|
||||
new TextEncoder().encode(text),
|
||||
);
|
||||
const chunk = _joinBytes(_n2b(data.byteLength - 4), data, _n2b(_pngCrc32(data)));
|
||||
|
||||
// Insert after IHDR (8-byte signature + 4-byte length + 4-byte type + IHDR data + 4-byte CRC)
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const insertAt = ihdrDataLen + 20;
|
||||
const result = _joinBytes(bytes.subarray(0, insertAt), chunk, bytes.subarray(insertAt));
|
||||
return _bytesToDataUrl(result, "image/png");
|
||||
}
|
||||
|
||||
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
@@ -32,12 +102,56 @@ export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
unsloth: "http://localhost:8888",
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
+812
-60
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Style/Prompt ギャラリー用シードデータ投入スクリプト。
|
||||
|
||||
comfyui_prompt_gallery / prompt_builder_proto 形式の wildcard データ
|
||||
(pack: set: category: leaf: [tag_string] という4階層YAML + thumbnails/ 配下の対応画像)
|
||||
から、画像とタグ文字列の組が揃っているエントリだけを ws_style_prompt 用フォルダへコピーする。
|
||||
|
||||
- テキストのみのワイルドカード(画像が無いもの)は対象外
|
||||
- サムネイル画像はあってもYAML側にタグが無いもの(プレースホルダー画像等)も対象外
|
||||
- コピー先には画像と同名の .txt サイドカー(タグ文字列そのまま)を書き込む
|
||||
|
||||
Usage:
|
||||
python tools/import_style_prompt_seed.py --source <wildcard_dataディレクトリ> --dest <ws_style_promptディレクトリ>
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
|
||||
def clean_tags(text: str) -> str:
|
||||
text = text.strip()
|
||||
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
|
||||
text = re.sub(r"\s*,\s*", ", ", text)
|
||||
return text
|
||||
|
||||
|
||||
def parse_yaml_leaves(yaml_path: Path) -> dict[str, str]:
|
||||
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
|
||||
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
|
||||
戻り値: {leaf_name(小文字): タグ文字列}
|
||||
"""
|
||||
leaves: dict[str, str] = {}
|
||||
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
trimmed = line.strip()
|
||||
if not trimmed or trimmed.startswith("#"):
|
||||
continue
|
||||
if not trimmed.endswith(":"):
|
||||
continue
|
||||
if i + 1 >= len(lines):
|
||||
continue
|
||||
next_trimmed = lines[i + 1].strip()
|
||||
if not next_trimmed.startswith("-"):
|
||||
continue
|
||||
key = trimmed[:-1].strip()
|
||||
tags = next_trimmed[1:].strip()
|
||||
if not tags or key.lower() == "skip":
|
||||
continue
|
||||
leaves[key.lower()] = clean_tags(tags)
|
||||
return leaves
|
||||
|
||||
|
||||
def import_category(cat_dir: Path, dest_root: Path) -> tuple[int, int, int]:
|
||||
"""1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)"""
|
||||
category = cat_dir.name
|
||||
yaml_files = sorted(cat_dir.glob("*.yaml")) + sorted(cat_dir.glob("*.yml"))
|
||||
|
||||
leaves: dict[str, str] = {}
|
||||
for yf in yaml_files:
|
||||
leaves.update(parse_yaml_leaves(yf))
|
||||
|
||||
copied = 0
|
||||
images_without_prompt = 0
|
||||
|
||||
# "thumbnails" と、preview違いの "thumbnails_option2"(例: alchemist.preview3.jpeg)の両方を対象にする
|
||||
for thumb_dir_name in ("thumbnails", "thumbnails_option2"):
|
||||
thumbnails_dir = cat_dir / thumb_dir_name
|
||||
if not thumbnails_dir.is_dir():
|
||||
continue
|
||||
for img_path in sorted(thumbnails_dir.rglob("*")):
|
||||
if not img_path.is_file() or img_path.suffix.lower() not in IMAGE_EXTS:
|
||||
continue
|
||||
# "alchemist.preview3.jpeg" のような二重拡張子にも対応するため、最初の "." より前をリーフ名候補にする
|
||||
leaf_candidate = img_path.name.split(".")[0].lower()
|
||||
tags = leaves.get(leaf_candidate)
|
||||
if tags is None:
|
||||
images_without_prompt += 1
|
||||
continue
|
||||
|
||||
rel_parts = img_path.relative_to(thumbnails_dir).parts
|
||||
# 先頭1階層(パック名。例: "ponyxl")は全カテゴリ共通で冗長なため省く
|
||||
dest_rel = Path(*rel_parts[1:]) if len(rel_parts) > 1 else Path(rel_parts[0])
|
||||
dest_path = dest_root / category / dest_rel
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.copy2(img_path, dest_path)
|
||||
dest_path.with_suffix(".txt").write_text(tags, encoding="utf-8")
|
||||
copied += 1
|
||||
|
||||
return copied, images_without_prompt, len(leaves)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", required=True, help="wildcard_data ディレクトリ")
|
||||
parser.add_argument("--dest", required=True, help="投入先 ws_style_prompt ディレクトリ")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = Path(args.source)
|
||||
dest = Path(args.dest)
|
||||
if not source.is_dir():
|
||||
raise SystemExit(f"source not found: {source}")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
categories = [
|
||||
d for d in sorted(source.iterdir())
|
||||
if d.is_dir() and (d / "thumbnails").is_dir()
|
||||
]
|
||||
if not categories:
|
||||
raise SystemExit(f"No category with a thumbnails/ subfolder found under {source}")
|
||||
|
||||
total_copied = 0
|
||||
total_skipped = 0
|
||||
for cat_dir in categories:
|
||||
copied, skipped, leaf_total = import_category(cat_dir, dest)
|
||||
print(f"[{cat_dir.name}] copied={copied} images_without_prompt={skipped} yaml_leaves={leaf_total}")
|
||||
total_copied += copied
|
||||
total_skipped += skipped
|
||||
|
||||
print(f"\nDone. {len(categories)} categories, {total_copied} images copied, {total_skipped} skipped (no matching prompt).")
|
||||
print(f"Destination: {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1051
-53
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user