Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ffadbdca | ||
|
|
65ef71b4ad | ||
|
|
49019dfb05 | ||
|
|
78d9bba904 | ||
|
|
f8435ed770 | ||
|
|
b2ef333dfe | ||
|
|
dfb33e831d | ||
|
|
64c21b1224 | ||
|
|
8f43412e96 | ||
|
|
b1e665b46b |
@@ -2,6 +2,352 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.3.78
|
||||
|
||||
### 追加機能: Eagle自動保存のSVG対応
|
||||
|
||||
「画像管理ソフトEagleとの連携機能(Eagleへ自動保存)をSVGに対応させたい」という依頼を受けて実装。既存のEagle自動保存(`generate-tab.js`のEagle送信ループ)は`history.outputs`の`images`キーのみを走査しており、SVG出力ノードには2系統あってそれぞれ挙動が異なることが判明した。
|
||||
|
||||
- **標準`SaveSVGNode`(ComfyUI本体)**: 通常の`images`キー(`{filename, subfolder, type}`)で結果を返すため既存の収集ロジックに拾われるが、ComfyUIの`/view`エンドポイントはSVGをXSS対策として`Content-Type: application/octet-stream` + `Content-Disposition: attachment`で強制返却するため、`<img>`表示にもEagleの`addFromURL`にも正しく使えない。`comfyui-client.js`の`getImageBlob()`で拡張子`.svg`を検出し`blob.slice(0, blob.size, "image/svg+xml")`でMIMEタイプを付け替えることで`<img>`表示を解決し、Eagle送信は`eagle_routes.py`側でローカルファイルパスを解決して`addFromPath`を使うルートに切り替えた。
|
||||
- **`comfyui-tosvg`の`Save SVG String`ノード**: `images`キーではなく独自の`saved_svg`(ファイル名)/`path`(絶対パス)キーで返す。さらに、ComfyUIの実行エンジン(`execution.py`の`get_output_from_returns`)は`ui`辞書の各値をイテラブルとして連結する仕様のため、このノードが単一文字列を返すと1文字ずつ分解された配列になる(`"a.svg"` → `["a",".","s","v","g"]`)。`comfyui-client.js`側でこの文字配列を検出し`join("")`で復元する対応を追加。絶対パスが既知なため、Eagle送信は`_resolve_absolute_svg_path()`(output/temp/inputディレクトリ配下限定の検証つき)経由で直接`addFromPath`する。
|
||||
- **プレビュー用一時画像の除外**: `SVG String Preview`や`PreviewImage`など`type: "temp"`の画像は、Eagle自動保存からもGenerateUI結果表示からも除外(`outputImages = images.filter(img => img.type !== "temp")`に統一)。SVG出力があるワークフローでは、GenerateUIの結果プレビューを実際に保存されたSVGファイルに切り替え、`/view`が使えないためGalleryタブの配信エンドポイント(`/wfm/gallery/image/serve?path=...`、絶対パス指定・正しいContent-Type)を再利用した。
|
||||
|
||||
**セキュリティ**: `_resolve_absolute_svg_path()`は任意の絶対パスをそのまま信頼せず、ComfyUIのoutput/temp/inputディレクトリ配下の実在するファイルのみ許可する(外部からこのAPIを叩かれてローカル任意ファイルを送信させられるリスクの回避)。Windowsのパス比較は大文字小文字を区別しないため`os.path.normcase`で正規化してから比較。
|
||||
|
||||
**検証**: Node.jsスクリプトで`comfyui-client.js`/`eagle_routes.py`のロジックを直接テストし、両ノードタイプでファイルが正しい形式でEagleへ送信されることを確認。ユーザーが実際にComfyUI上で`svg_1.json`/`svg_5.json`/`svg_7.json`の3種類のワークフローを実行し、Eagle保存・プレビュー表示それぞれで動作確認。
|
||||
|
||||
**How to apply**: [[project_v0342_gallery_feeder]]のSVG関連対応と合わせ、SVGはComfyUI本体がXSS対策で`/view`から生配信しない前提で設計する必要がある。新しいSVG出力ノードに対応する際は、まず`ui`辞書がどのキー名で結果を返すか(`images` vs 独自キー)、絶対パスが取得できるか、を確認してから送信経路(`addFromURL` vs `addFromPath`)を選ぶ。
|
||||
|
||||
### 追加機能: GenerateUI/MetadataタブでComfyUIサブグラフワークフロー対応(Mage-Flow等)
|
||||
|
||||
「mageflow_1.jsonをGenerateUIタブ、メタデータタブに対応させたい」という依頼を受けて調査したところ、`comfyui-workflow.js`の`detectFormat()`が`definitions.subgraphs`を持つワークフロー全般を一律「App形式」と判定し、GenerateUIタブが読み込みを拒否する設計になっていたことが判明した(`generate-tab.js`の`loadWorkflowIntoEditor()`が`format === "app"`で即エラー)。サブグラフ展開自体は`_flattenSubgraphs()`に既に実装済みで`convertUiToApi()`から呼ばれていたが、GenerateUIの通常フローには到達しない状態だった。
|
||||
|
||||
- **`detectFormat()`の判定基準を訂正**: 「サブグラフを含む」ことと「真のApp形式(ComfyUIの簡略化Webアプリ風UI)」は別軸であるべきところ、後者の判定条件に前者が混入していた。`extra.linearMode === true`または`.app.json`拡張子の場合のみ"app"、それ以外(サブグラフを含むだけの通常ノードグラフ)は"ui"として扱うよう修正。`workflow_analyzer.py`(Python側、サイドパネルのAPI/APPバッジ表示に使われる)にも同じ判定基準の食い違いがあり、揃えて修正。
|
||||
- **サブグラフ外側ウィジェット値の内部ノードへの注入(`_flattenSubgraphs()`)**: 最大の落とし穴だった。サブグラフの「外側ノード」(折りたたみ表示、ユーザーが実際に編集する値)のwidgets_valuesと、`definitions.subgraphs[].nodes`内の「内部ノード」(サブグラフ編集画面を開いた時のテンプレート初期値)のwidgets_valuesは、ComfyUIが保存時に同期しない場合があり、実データで検証すると別内容のプロンプトが入っていた。既存の`_flattenSubgraphs()`はリンクのリダイレクトのみ行い、ウィジェット値の注入は一切していなかったため、そのままではGenerateUIが古いテンプレートのデフォルト値で生成してしまう。外側ノードのinputs配列(`widget`かつ`link`なしの要素)を走査し、サブグラフ入力ポート経由で対応する内部ノードのwidgets_values配列へ値を上書きするロジックを新設。内部ノードのwidgets_values配列がリンク済み入力の分もエントリを持つ「legacy full形式」の場合があり、`convertUiToApi()`本体が前提とする「リンク済みwidgetにはエントリがない」形式との食い違いでインデックスがずれるバグも合わせて修正(リダイレクトされた実リンク先スロットのみをエントリから除去する形に変更)。
|
||||
- **`analyzeWorkflow()`に`TextEncodeMageFlowEdit`対応**: 1ノードでprompt/negative_prompt両方を直接持つ複合ノードのため、SDXLPromptStylerパターンを参考にrole固定(positive/negative)で個別登録。width/height/batch_sizeも内包しているため`latent_nodes`にも登録(EmptyLatentImage相当)。
|
||||
- **DynamicCombo型(`COMFY_DYNAMICCOMBO_V3`)対応**: `SaveImageAdvanced`の`format`入力(選択値に応じて`bit_depth`/`input_color_space`等のサブフィールドが動的に変わる新しいComfyUI V3 API機能)が未対応で、`inputs`から丸ごと欠落し`TypeError: missing 1 required positional argument: 'format'`が発生した。最初は`{"format": "png", "bit_depth": ..., ...}`という単一辞書にまとめて渡す実装を試みたが実機で同じエラーが再現し、ComfyUI本体の`comfy_api/latest/_io.py`の`DynamicCombo._expand_schema_for_dynamic`を読んだところ、バックエンドは`"format"`/`"format.bit_depth"`のような**フラットなドット区切りキー**を`inputs`辞書に個別に期待しており、実行エンジン自身(`build_nested_inputs()`)が実行時にネストした辞書へ自動組み立てる設計だったため、辞書化をやめてフラットキーをそのまま`inputs`へ設定する実装に修正。
|
||||
- **`metadata-tab.js`のサブグラフ対応強化**: 既存の`collectAllNodes()`ベースの抽出(トップレベル+サブグラフ内nodesを単純結合)はモデル名・プロンプンとも「内部テンプレートの古い値」を拾ってしまう同じ問題を抱えていたため、サブグラフを検出した場合は`comfyWorkflow.convertUiToApi()`を経由してAPI形式(外側ウィジェット値が注入済み)に正規化してから抽出するよう変更。`extractPromptsAPI`/`extractPromptsFromNodeSet`双方に`TextEncodeMageFlowEdit`のpositive/negative個別抽出(1ノードにつき2つのwidgets_valuesスロット)対応を追加。
|
||||
|
||||
**検証**: Node.jsで`comfyui-workflow.js`をブラウザ実行と同一ロジックのままimportし(`/object_info`はローカルComfyUIサーバーへのfetchで取得)、`mageflow_1.json`の変換結果を全項目突き合わせ。外側と内部でテキストが異なる実データを使い、正しく外側の値が採用されることを確認。既存のFlux2/Qwen/Ernie/Krea2等、サブグラフを持つ8ワークフローで`git stash`によるビフォーアフター比較を実施し回帰なしを確認(変更前は全て"app"判定で一律ブロックされていたため、影響は純増)。ユーザーが実機でGenerateUI実行・Eagle送信・Metadata抽出それぞれ確認。
|
||||
|
||||
**How to apply**: ComfyUIのサブグラフは「外側の折りたたみ表示ウィジェット値」と「内部テンプレート定義のwidgets_values」が別データであり、保存時に同期される保証がないという前提を忘れないこと。サブグラフを扱うロジックを新設・修正する際は、実データ(必ずしもテンプレートそのままでない、ユーザーが値を変更済みのワークフローファイル)で外側/内部の値を突き合わせて検証する。またComfyUIの新しいV3 API機能(DynamicCombo等)に遭遇したら、まず`comfy_api/latest/_io.py`の該当実装を読んでバックエンドが実際に何を期待しているかを確認してから実装する方が、推測で実装してハマるより早い。
|
||||
|
||||
### 改善: Workflowタブ Summarize機能を構造化データベースに変更
|
||||
|
||||
上記のサブグラフ対応の流れで、「ワークフロータブの要約、解析ができるようにしたい」という依頼を受けた。調査の結果、既存の「AI要約」ボタン(`workflow-tab.js`)は生ワークフローJSON全体を`JSON.stringify(wf).substring(0, 4000)`で先頭4000文字に切り詰めてLLM(Ollama)へ渡すだけの実装で、`MarkdownNote`の長文説明やUI座標情報が先頭を占有し、複雑なワークフロー(特にサブグラフ入り)では肝心のノード構成情報が切り捨てられて有効な要約ができない状態だった。「要約機能の修正」「解析結果表示の新規UI追加」のどちらを優先するか確認し、前者を選択。
|
||||
|
||||
- `_buildSummarySourceText()`を新設。既存の`comfyWorkflow.detectFormat()`/`convertUiToApi()`/`analyzeWorkflow()`(上記のMage-Flow対応で強化済み)を再利用し、Checkpoint/Diffusion Model/Text Encoder/VAE/LoRA/ControlNet/Hypernetwork/Positive・Negative Prompt/Sampler設定/使用ノード種別一覧をコンパクトなテキストに整形してからLLMへ渡すよう変更。変換・解析に失敗した場合は従来の生JSON切り詰め方式へ自動フォールバック。
|
||||
|
||||
**検証**: Node.jsで`_buildStructuredSummaryText()`相当のロジックを`analyzeWorkflow()`の実出力に対して実行し、`mageflow_1.json`(1183文字)・`qwen-image-edit_controlnet.json`(694文字)など、旧実装の4000文字切り詰めより大幅にコンパクトかつ正確な出力になることを確認。サブグラフを持たない通常ワークフロー3件でも正しく動作することを確認。
|
||||
|
||||
**How to apply**: LLMへワークフロー情報を渡す用途では、生JSONダンプではなく既存の解析結果(`analyzeWorkflow()`)を再利用してテキスト化する方が、トークン数を抑えつつ精度も上がる。生JSONは`MarkdownNote`やUI座標などLLMにとってノイズになる情報を大量に含む。
|
||||
|
||||
### バグ修正: Workflowタブ詳細パネルのP:/I:/→カウントとフォーマットバッジ
|
||||
|
||||
上記のMage-Flow対応の実機確認中、Workflowタブのサイドパネルで新しいノードタイプを使うワークフローの入出力カウントが「P:0 I:0 → 0」と表示される問題が見つかった。原因は`workflow_analyzer.py`の`analyze_workflow()`がノードタイプ名の完全一致(`ntype == "CLIPTextEncode"`、`ntype == "SaveImage"`)でカウントしており、`TextEncodeMageFlowEdit`(プロンプト)や`SaveImageAdvanced`(画像出力)のような新しいノードタイプを認識できていなかったため。
|
||||
|
||||
- プロンプト検出を`_PROMPT_NODE_TYPES`集合(`TextEncodeMageFlowEdit`/`TextEncodeQwenImageEditPlus`/`SDXLPromptStyler`等)に、出力画像検出を`_OUTPUT_IMAGE_NODE_TYPES`集合(`SaveImageAdvanced`追加)にそれぞれ拡張。
|
||||
- 副次的に、この関数のフォーマット判定が上記で修正したJS側`detectFormat()`と食い違っていること(`"definitions" in workflow_data`だけで"app"と判定する古い基準のまま)を発見し、同じ基準(`linearMode`のみ"app")に統一。これはComfyUIキャンバスのサイドパネル(`node_sets_menu.js`)のAPI/APPバッジ表示に使われており、放置するとMage-Flow系ワークフローに誤って「APP」バッジが付き続ける状態だった。
|
||||
|
||||
**検証**: Pythonで`analyze_workflow()`を直接呼び出し、`mageflow_1.json`で`prompts: 1`/`outputs.images: 1`/`format: "ui"`が正しく出ることを確認。ユーザーからの追加確認依頼を受け、真にlinearModeを持つワークフロー(`wf_20260322113657.json`等、サブグラフを含むケースも含む)では引き続き"app"、Mage-Flow等のサブグラフのみのワークフローは"ui"と、両者が正しく区別されることをJS/Python両実装で突き合わせ検証。既存の複数ワークフローでリグレッションがないことも確認。
|
||||
|
||||
### 改善: Send to Canvasトーストメッセージの区別
|
||||
|
||||
「ワークフロータブでUI形式のワークフローをキャンバスへ送るボタン押下時、『ワークフロー準備完了 タイトルをキャンバスにドラッグ』のトーストは現在ドラッグの必要がないためメッセージを変えたい」という依頼。`workflow-tab.js`/`gallery-tab.js`の「キャンバスへ送る」処理は、`window.opener`経由で直接ロードされる場合(ドラッグ不要)と、`localStorage`+タイトルドラッグのフォールバック(ドラッグ必要)の2経路があるが、両方が同じ`workflowSentToCanvas`(「タイトルをドラッグ」)トーストを共有していたため、直接ロードされた場合にも実際には不要なドラッグ手順が案内されていた。
|
||||
|
||||
- 新しいi18nキー`workflowSentToCanvasDirect`(「ワークフローをキャンバスへ送信しました」)を英日中3言語で追加し、`window.opener`経由の成功時のみこちらを使うよう変更。ドラッグが実際に必要なフォールバック側は既存メッセージのまま維持。
|
||||
|
||||
**検証**: `node --check`で構文チェック。ユーザーがComfyUIツールバーから開いた状態で実機確認。
|
||||
|
||||
## v0.3.76
|
||||
|
||||
### 追加機能: Comic Creator向けI2I実行ブリッジ`_wfmReceiveI2IRunRequest`
|
||||
|
||||
Comic Creator(`comfyui-comic-creator`、本アプリをiframe埋め込みで連携する別アプリ)から、「マスク不要のI2I実行をその場で行い結果を受け取りたい」という依頼を受けて追加。既存のInpaint連携ブリッジ(`gallery-tab.js`の`_wfmReceiveInpaintRequest` → `image-edit-tab.js`の`runInpaintExternal`/`_runInpaintWithImages`)と対称的な、マスク版の無いI2I専用ルートを新設した。
|
||||
|
||||
- `image-edit-tab.js`: `_runI2IWithImage(imageBlob, {positive, negative, denoise, overrideOpts, onStatus})`を新規実装。`comfyEditor.applyImageToSlot(file, 0)`(マスクなし版アップロード)→`comfyEditor.setPromptText("positive"/"negative", ...)`→`comfyEditor.setInpaintParams({denoise, ...overrideOpts})`→`window._wfmGenerateTab.generate()`→`#wfm-gen-result-img`のsrcを返す、という`_runInpaintWithImages()`と同型の流れ。`setInpaintParams()`は`growMaskBy`未指定時に自動でスキップする既存実装だったため、denoise単体用の新規セッターは追加せずそのまま流用できた。外部エントリポイント`runI2IExternal(imageBlob, {positive, negative, denoise})`(多重実行ガード`_i2iExternalRunning`付き)を追加。
|
||||
- `gallery-tab.js`: `window._wfmReceiveI2IRunRequest = async (imageBlob, params, workflowData, workflowFilename) => {...}`を追加。`_wfmReceiveInpaintRequest`と同じく、`workflowData`が渡されれば実行前に`loadWorkflowIntoEditor()`でロードしてから`window._wfmImageEditTab.runI2IExternal(imageBlob, params)`を呼び、結果`{ok, url}`をそのまま返す。既存の`_wfmReceiveImageForI2I`(画像をI2Iスロットへセットするだけ、Comic Creatorのレイアウトタブが使用)は無改造。
|
||||
|
||||
**検証**: 変更した2ファイルを`node --check`で構文チェック。Comic Creator側(Imageタブの新規「Select I2I」パネル)からKapture経由で実際に呼び出し、Positive/Negative Prompt・Denoiseがワークフローへ反映された上でキュー実行され、結果画像URLが返ることを確認(Comic Creator側DEVLOG参照)。
|
||||
|
||||
**How to apply**: 「画像をComfyUIのワークフローに自動セットしてキュー実行し、結果を返す」という外部連携パターンを新設する際は、`comfyEditor.applyImageToSlot`/`setPromptText`/`setInpaintParams`/`window._wfmGenerateTab.generate`が既にマスクなし用途にも流用できる設計になっている(オプション引数は未指定なら書き込みをスキップする)。Inpaint版と重複する新規プリミティブをWorkflow Studio側に増やす必要は薄く、`_runInpaintWithImages`と同型の薄いラッパーを追加するだけで足りる。
|
||||
|
||||
## v0.3.77
|
||||
|
||||
### 追加機能: GalleryタブでSVGファイルの表示・管理に対応
|
||||
|
||||
「ギャラリータブでSVGファイル表示対応させたい」という依頼を受けて調査したところ、`gallery_service.py`の`IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}`にSVGが含まれておらず、フォルダスキャン・一覧取得・削除・移動・配信のすべてで一律に除外されていたことが原因だった。フロントエンド(`gallery-tab.js`)側は拡張子フィルタを一切持たず、サムネイル・詳細プレビュー・ライトボックスすべて`<img>`タグで描画する設計だったため、バックエンドの許可リストにSVGを追加するだけで表示系は無改造で対応できた。
|
||||
|
||||
- `gallery_service.py`: `IMAGE_EXTENSIONS`に`.svg`を追加。`serve_thumbnail()`にSVG分岐を新設し、GIF(アニメーション保持のため元ファイルを返す既存分岐)と同じ扱いで元ファイルをそのまま返すようにした。SVGはベクター画像でPillowが`Image.open()`できないため、そもそもラスタライズ縮小の意味がなく、`<img>`タグ側でブラウザが任意サイズにスケーリングして表示する前提。
|
||||
- `gallery_routes.py`: `serve_image`/`serve_thumb`のContent-Type判定は変更不要だった。Pythonの`mimetypes.guess_type()`が標準で`.svg`→`image/svg+xml`を解決するため、既存の`mimetypes.guess_type()`フォールバック分岐がそのまま機能する。
|
||||
|
||||
**セキュリティ**: `<img src="...">`によるSVG表示は、ブラウザが`<script>`要素の実行や外部リソース読み込みを無効化する仕様のため、悪意あるSVG(埋め込みJS等)が混入していてもXSSリスクはない。`<object>`/`<iframe>`/`iframe.srcdoc`のような埋め込み方式(スクリプトが実行され得る)は使っていない。
|
||||
|
||||
**検証**: 変更した`gallery_service.py`を`ast.parse()`で構文チェック。開発リポジトリと実行時`custom_nodes`フォルダへ同期し`__pycache__`をクリアした上でComfyUIを再起動し、実際にoutputフォルダへ配置したSVGファイルがGalleryタブでサムネイル・詳細プレビューとも正しく表示されることをユーザーが確認。
|
||||
|
||||
**How to apply**: [[project_next_release_docs]]のような「フォーマット追加」系の変更は、フロントエンドがフォーマット非依存の`<img>`/`<video>`等で描画する設計になっている場合、バックエンドの許可拡張子リスト(`IMAGE_EXTENSIONS`)に追加するだけで大半のUIパスに波及する。逆に言えば、新形式を追加する際はこのリストが唯一のゲートになっている前提を崩さないよう注意する。
|
||||
|
||||
### 追加機能: AI TOOL Chatでワークフロー不要のSVG生成・表示・Gallery保存
|
||||
|
||||
Gallery SVG対応の直後、「AI TOOLのチャット機能でワークフローを使用せずにSVGを作成することは可能か」と質問された。既存のChat画像生成(`generate_image`ツールコール→ComfyUIのT2I/I2Iワークフロー実行)とは別に、SVGはXMLテキストに過ぎずLLMのテキスト生成だけで完結するため、ComfyUIワークフローもTool Calling機能も不要である旨を回答した上で、「実装する(表示+Gallery保存)」の合意を得て実装した。
|
||||
|
||||
- `ai-tab.js`: `_extractSvgCode(text)`を新設。アシスタント応答からマークダウンのコードフェンスで囲まれたSVGコードブロック、または生の`<svg>...</svg>`タグを正規表現で抽出する。`appendChatBubble()`の`role === "assistant"`分岐に組み込み、検出時は`_appendSvgPreview()`を呼ぶ。抽出ロジックは`reply.content`(プレーンテキスト)のみを見ており、`reply.toolCalls`は一切参照しないため、モデルがtool calling(function calling)に対応していなくても動作する——「画像生成を許可する」トグルがONの場合のみ送信される`tools`パラメータとは完全に独立した経路。
|
||||
- `_appendSvgPreview()`: 抽出したSVGコードを`btoa(unescape(encodeURIComponent(svgCode)))`でBase64化し、`<img src="data:image/svg+xml;base64,...">`としてプレビュー表示(`<img>`経由のためスクリプト実行はブラウザ側で無効化され安全)。「Galleryに保存」ボタンを併設し、クリックで`/wfm/gallery/image/save`へ`{filename: "chat_svg_<timestamp>.svg", imageData: dataUrl}`をPOSTする。
|
||||
- `gallery_routes.py`の`save_image_to_gallery`: 従来は`imageData`を無条件にPNGとして扱い拡張子`.png`を強制していたが、data URLのヘッダ(`data:<mime>;base64,`)からMIMEタイプを判定し`_SAVE_EXT_BY_MIME`(png/jpeg/webp/gif/svg+xml)で拡張子を決定する汎用処理に変更。Image Edit Tabの既存呼び出し(`canvas.toDataURL("image/png")`)は引き続き`.png`として保存され後方互換。
|
||||
- CSS(`main.css`)に`.wfm-ai-chat-svg-wrapper`/`.wfm-ai-chat-svg-img`を追加(白背景パディング付きプレビュー枠、透過SVGでも視認できるように)。
|
||||
- i18n(`i18n.js`)に`aiChatSaveSvgToGallery`/`aiChatSvgSaved`/`aiChatSvgSaveFailed`を英日中3言語で追加。
|
||||
- ヘルプ文言3点セット(`templates/index.html`の`wfm-help-ai-7`、`i18n.js`の`helpAi7`英日中3言語、`app.js`の`helpIdMap`)でこの機能を追記。
|
||||
|
||||
**検証**: 変更した`ai-tab.js`/`i18n.js`/`app.js`を`node --check`で構文チェック、`gallery_routes.py`を`ast.parse()`で構文チェック。開発リポジトリと実行時`custom_nodes`フォルダへ全ファイル同期し`__pycache__`をクリア。ユーザーがComfyUI再起動後、Chatで実際にSVGを生成させてプレビュー表示とGallery保存を確認済み。
|
||||
|
||||
**How to apply**: LLMチャット機能に新しい出力形式(SVG、Mermaid図など、テキストとして表現できるフォーマット)を追加検討する際は、まず「ラスター画像生成のようにComfyUIワークフロー実行が本当に必要か、それともLLMのテキスト生成だけで完結するか」を切り分ける。後者であれば`reply.content`に対する検出・抽出ロジックのみで実装でき、Tool Calling対応可否に依存しない機能として提供できる。
|
||||
|
||||
## v0.3.75
|
||||
|
||||
### Lemonadeバックエンド追加(3つ目のAIバックエンド)
|
||||
|
||||
「LLMのバックエンドにlemonadeも追加したい」という依頼を受け、lemonade-server.ai のAPIドキュメント(OpenAI互換API・Ollama互換API等)を確認した上で実装。既存のバックエンド切替は`ai-tab.js`(SPA AI TOOLタブ)・`prompt-tab.js`/`settings-tab.js`(Promptタブ設定)・`node_sets_menu.js`(サイドパネル)の3箇所に独立実装されており、いずれも`if (backend === "ollama") {...} else {...}`という2値決め打ちで、elseがLM Studio(OpenAI互換API: `/v1/models`, `/v1/chat/completions`)を指す構造だった。Lemonadeのデフォルト(OpenAI互換モード)は`http://localhost:13305`でLM Studioと全く同じエンドポイント形状のため、fetch系関数(`fetchModels`/`callLLM`/`callChat`/`callVLM`等)は変更不要で、ラジオボタンの選択肢追加とデフォルトURL解決部分の3値対応のみで済んだ。
|
||||
|
||||
**対象範囲・接続方式は事前にユーザーに確認:** 3箇所すべてに追加するか/SPA AI TOOLタブのみ先行導入するか、接続方式はOpenAI互換モード既定かOllama互換モードも選択肢に含めるかを質問し、「3箇所すべて」「OpenAI互換モード既定」で合意して実装した。
|
||||
|
||||
**デフォルトURL解決の共通化:**
|
||||
- `static/js/util.js`に`AI_BACKEND_DEFAULT_URLS`(ollama/lmstudio/lemonade)と`getAiBackendDefaultUrl(backend)`を新設し、`ai-tab.js`・`prompt-tab.js`・`settings-tab.js`(いずれもESモジュールで`util.js`をimport済み)の計8箇所の三項演算子を置き換え
|
||||
- `web/comfyui/node_sets_menu.js`(ComfyUI拡張側、`static/js`とは別スコープのESモジュールでimport不可)は同名のヘルパーをローカルに複製(既存の重複実装パターンを踏襲)
|
||||
|
||||
**UI・ヘルプ更新:**
|
||||
- ラジオボタン`value="lemonade"`を`templates/index.html`(SPA)・`static/js/settings-tab.js`(Promptタブ設定)・`web/comfyui/node_sets_menu.js`(サイドパネル)の3箇所に追加
|
||||
- ヘルプ文言3点セット(`index.html`/`i18n.js`のEN・JA・ZH/`app.js`の`helpIdMap`)のうち、`helpIdMap`は既存キーへの追記のみで変更不要。「Ollama or/または/或 LM Studio」を列挙している9キー(`helpPrompt1`, `helpSettings5`, `helpSidepanel16`, `helpAi2`, `helpAi5`, `helpAi6`, `helpTrouble2` の各ロケール)すべてにLemonadeを追記
|
||||
- README.mdの現行機能セクション(Changelogではなくpresent-tense説明部分)とRequirementsセクションにLemonade関連の記載を追加。過去バージョンのChangelogエントリ(v0.3.40〜v0.3.74)は当時の実装を正しく記述しているため変更していない
|
||||
|
||||
**検証:** 変更した5つのJSファイル(`util.js`, `ai-tab.js`, `prompt-tab.js`, `settings-tab.js`, `node_sets_menu.js`)を`node --check`で構文チェック(全て成功)。実行中のComfyUIインスタンス(Kapture経由)で3箇所すべてを確認 — SPA AI TOOLタブSettings・SPA 設定タブ(Prompt tab AI Assistant)・サイドパネルAIタブのBackendラジオがいずれも`["ollama","lmstudio","lemonade"]`の3値で表示されることを確認、`getAiBackendDefaultUrl()`をブラウザ上で直接呼び出しollama/lmstudio/lemonade/未知の値それぞれで正しいデフォルトURL(`lemonade`→`http://localhost:13305`)を返すことを確認した。
|
||||
|
||||
### 追加修正: バックエンド切替時にURLを常にデフォルト値へ切り替え
|
||||
|
||||
上記の初回実装では、URL欄は「未保存の場合のみ」デフォルト値を補完する仕様(ラジオ切替時に`if (!saved.backendUrl) urlInput.value = defaultUrl`)だったため、一度どれかのURLを保存済みのユーザーがバックエンドを切り替えても欄が変わらなかった。「LMStudio、Lemonadeに切り替えた際それぞれデフォルトのURLに変更したい」というフィードバックを受け、3箇所すべてで無条件にデフォルトURLへ切り替える仕様に変更:
|
||||
|
||||
- `ai-tab.js`: `change`ハンドラの`if (!saved.backendUrl)`ガードを削除し、常に`urlInput.value = getAiBackendDefaultUrl(r.value)`
|
||||
- `settings-tab.js`: 従来`change`時に`placeholder`のみ更新していた(欄に既存の値があると効果が見えない実質バグ)のを、`value`を直接書き換えるよう修正
|
||||
- `node_sets_menu.js`: `change`リスナー自体が存在しなかった(バックエンド切替時にURLを更新する仕組みがそもそも未実装)ため新規追加
|
||||
|
||||
いずれもDOM上の表示値を書き換えるのみで、実際の永続化は各画面の「Save」ボタン押下時のまま変更なし。Kaptureで3箇所(SPA AI TOOLタブ・SPA 設定タブ・サイドパネル)すべて、ollama⇄lmstudio⇄lemonadeの切替でURL欄がそれぞれ`http://localhost:11434`/`http://localhost:1234`/`http://localhost:13305`に変わることを確認。テスト中に実際の保存設定(`wfm_ai_settings`)を上書きしないよう、Saveボタンは押さずページリロードで検証用の一時入力を破棄した。
|
||||
|
||||
### 検討事項: Lemonadeの画像生成・音声機能への対応可否(質問への回答のみ、実装保留)
|
||||
|
||||
ユーザーから「lemonadeでは画像生成や音声出力、テキスト読み上げなどに対応しているが、これに対応させることは可能か」と質問(実装依頼ではなく質問のみと明言)された。Lemonadeは`/v1/images/generations`(画像生成)・`/v1/audio/speech`(読み上げ)・`/v1/audio/transcriptions`(音声認識)をOpenAI互換の独立エンドポイントとして提供しており技術的には対応可能だが、以下の理由で「既存バックエンド切替の自然な延長」ではなく別枠の機能追加になる旨を回答した:
|
||||
|
||||
- **画像生成**: 本プラグインの画像生成はChatのTool Calling(`generate_image`)からComfyUIワークフロー自体を実行する設計であり、Lemonade側の画像生成APIを追加すると生成経路が2系統になり紛らわしい
|
||||
- **音声読み上げ/文字起こし**: AI TOOLタブに該当UI(再生プレイヤー、マイク入力等)が現状一切なく、既存キーの拡張ではなく新規機能追加になる
|
||||
|
||||
ユーザーは「検討したいと思います」として今回は実装せず保留。README.mdの「Backend support」直後に「Lemonade's other endpoints (not integrated)」として上記を明記し、将来検討時の起点として残した。
|
||||
|
||||
### バグ修正: Bypass(バイパス)ノードを含むワークフローの生成失敗
|
||||
|
||||
ユーザーがミュートしているノードを含むワークフロー(`chat_i2i_anima.json`、ComfyUI保存済み)をGenerateUIタブで読み込んで生成したところ、以下のエラーで失敗した:
|
||||
|
||||
```
|
||||
[ERROR] Failed to validate prompt for output 46:
|
||||
[ERROR] * CLIPTextEncode 73:
|
||||
[ERROR] - Exception when validating inner node: '74'
|
||||
[ERROR] * KSampler 66:
|
||||
[ERROR] - Exception when validating inner node: '74'
|
||||
```
|
||||
|
||||
**原因調査:** 実際のワークフローjsonを確認したところ、ノード74(LoraLoader)は`mode: 4`(ComfyUI独自のBypassモード。litegraph標準の`mode: 2`=Never/Muteとは別物)だった。`static/js/comfyui-workflow.js`の`convertUiToApi()`は`if (node.mode === 4) continue;`でBypassノードをAPI出力から単純に除外するだけで、そのノードを経由していた配線(CLIPLoader→LoraLoader.clip→CLIPTextEncode(73).clip、UNETLoader→LoraLoader.model→KSampler(66).model)を繋ぎ直しておらず、73番・66番が存在しないノード74を参照したまま出力されてバリデーションエラーになっていた。ComfyUI本体はBypassノードを「入力と出力を同じ型のスロット同士でパススルー接続してから除去」する仕様のため、この動作の再現が必要と判断。
|
||||
|
||||
**実装:**
|
||||
- `_resolveBypassSource(nodeById, linkMap, nodeId, slot, visited)`(新規ヘルパー)を追加。リンクの参照先がBypassノードの場合、その出力スロットと同じ型の入力スロットを探し(同インデックス優先、なければ型一致する最初の入力)、その入力の配線元を再帰的に辿る(連鎖バイパスにも対応、循環ガード付き)。マッチする入力が無ければ`null`を返しリンク無し扱いにする
|
||||
- `convertUiToApi()`内の全リンク解決箇所をこの関数経由に変更。バイパスされていないノードが参照先の場合は即座にそのまま返るため、既存の非バイパス経路への影響はない
|
||||
- ついでに`mode: 2`(真のMute)も除外対象に追加(従来は一切スキップされておらず、ミュートしても実行時は普通に動いていた)。ただしMuteはパススルーせず単純除外 — ComfyUI本体でも「必須の下流依存があるノードをMuteすると壊れる」のが仕様通りの挙動のため、あえて救済しない
|
||||
|
||||
**検証:** `chat_i2i_anima.json`を実際のComfyUIサーバー(`/object_info`)に接続したNodeスクリプトで`convertUiToApi()`に直接通し、KSampler(66).model→`["68",0]`(UNETLoader直結)、CLIPTextEncode(73).clip→`["61",0]`(CLIPLoader直結)、ノード74自体は出力に存在しない、ことを確認。さらに変換結果を実際に`/prompt`へPOSTし`node_errors: {}`(バリデーション成功)を確認。キューに入った時点で`/interrupt`により即座に中断し、無駄なGPU生成は行っていない。
|
||||
|
||||
### 追加機能: GenerateUIのRaw JSON上部にBypass/Muteノード表示
|
||||
|
||||
ユーザーから「生成UIタブのInput、Model、SettingsのRAW JSONペインの上部にバイパスノードの存在を示す表示を行いたい」と依頼。API形式のJSONには`mode`情報が残らないため、Raw JSON表示だけではBypass/Muteの有無が分からなくなる点への対応。
|
||||
|
||||
- `comfyui-workflow.js`に`_lastBypassedNodes`/`_lastMutedNodes`(モジュールスコープ、`_lastCheckpointSubstitutions`と同じパターン)を追加し、`convertUiToApi()`実行中にBypass/Muteで除外したノードの`{nodeId, title}`を記録。`getLastBypassedNodes()`/`getLastMutedNodes()`で呼び出し元から取得可能にした
|
||||
- `templates/index.html`の共有Raw JSONウィジェット(`#wfm-gen-rawjson-widget`、Input/Model/Settingsタブ間をJSで移動する仕組み — 既存のsearch barと同じ場所)に`#wfm-gen-rawjson-bypass-note`を追加。3タブ共通のヘッダーに置くことで「Input/Model/Settingsそれぞれの上部」という要望を1箇所の実装で満たせる
|
||||
- `generate-tab.js`の`loadWorkflowIntoEditor()`で、UI形式からの変換時(`format === "ui"`)のみ上記2つのgetterを読み、該当ノードがあれば`t("rawJsonBypassNote", list)`/`t("rawJsonMutedNote", list)`(新規i18nキー、EN/JA/ZH追加)を`escapeHtml`経由でnote要素に表示、無ければ`display:none`
|
||||
- CSS: `--wfm-warning`色のアンバー背景バーとして`main.css`に追加
|
||||
- ヘルプ: `helpGen7`(index.html + i18n.js全ロケール、既存キーへの追記のため`app.js`のhelpIdMapは変更不要)にBypass/Mute注記の説明を追加
|
||||
|
||||
**検証**(Kapture、実機ブラウザ): Workflowタブから`chat_i2i_anima`を検索→「生成UIに読み込み」ボタンで読み込み、Input/Model/Settingsいずれのサブタブでも`#wfm-gen-rawjson-bypass-note`に「バイパス中(除外・配線は再接続済み): LoraLoader (74)」が表示されることを確認。Bypass/Muteノードを含まない別ワークフロー(`04031-lora.json`)を読み込んだ場合は`display: none`で非表示になることも確認。
|
||||
|
||||
## v0.3.74
|
||||
|
||||
### AI TOOL Chatペインの画像添付対応 + I2I連携
|
||||
|
||||
v0.3.73でChatペインのT2I画像生成(Tool Calling)を実装した直後の依頼。「TOOLSのファイルドロップエリアを使ってChatに画像添付対応させたい、I2Iワークフローとの連携(画像の差し替え)も行いたい」という要望を受けて着手。実装前に設計上の分岐点(添付画像をLLMにも見せるか/I2I用ワークフローをT2Iと共用するか/添付の持続性)をユーザーに確認し、それぞれ「LLMにも見せる(マルチモーダル)」「T2I専用とは別にI2I専用ワークフローを新設」「明示的にクリアするまで保持」で合意して実装した。
|
||||
|
||||
**対象ファイル:** `static/js/ai-tab.js`, `static/js/comfyui-editor.js`, `static/css/main.css`, `static/js/i18n.js`, `static/js/app.js`, `templates/index.html`
|
||||
|
||||
**TOOLS↔Chat 共有の添付画像:**
|
||||
- 新しいドロップゾーンを作らず、TOOLSペインの既存ドロップエリア(`wfm-ai-vlm-drop`、VLMタスク用)をChatの添付機構としても共用する設計。モジュールレベルの共有state `_toolsImage = { file, base64, mimeType }` を導入し、`initVlmTab()`のローカル変数`vlmImage`を置き換えた
|
||||
- `_syncAttachmentUI()`(新規): TOOLS側プレビューの表示/非表示とChat側の添付インジケーター(サムネイル+ラベル+✕)を1箇所で同期。`document.getElementById`による都度参照のため、`initVlmTab`/`initChatTab`どちらが先に呼ばれても安全
|
||||
- クリアは共通の`_clearToolsImage()`。TOOLSプレビュー上の✕ボタン、Chat添付インジケーターの✕ボタンのどちらからでも同じ状態を更新する
|
||||
- 添付は合意事項通り、送信後も自動クリアされず明示的にクリアするまで保持される
|
||||
|
||||
**マルチモーダルチャット送信:**
|
||||
- `chatHistory`のエントリを内部共通形式 `{role, content, images?: [{base64, mimeType}]}` に拡張
|
||||
- `callChat()`に`_formatMessagesForBackend(messages, backend)`を追加し、送信直前にバックエンド別のワイヤーフォーマットへ変換(Ollama: `images:[base64...]`、LM Studio: `content`をテキスト+`image_url`のコンテンツブロック配列に変換)。`callVLM()`で既に使われている規約を踏襲
|
||||
- `generate_image`ツールの説明文を更新し、画像添付時はT2IでなくI2Iとして動作することをLLMに伝えるようにした(引数スキーマ自体は`prompt`/`negative_prompt`のまま変更なし)
|
||||
|
||||
**I2I生成ブリッジ:**
|
||||
- `comfyui-editor.js`の`applyImageToSlot(file, slotIndex, opts)`に`opts.workflow`/`opts.analysis`対応を追加(`applyImageAndMaskToSlot`と同一パターン)。渡された場合はDOM更新を行わずアップロード後のファイル名のみ返す非破壊的変更で、既存呼び出し元(GenerateUI Imageタブ)への影響なし
|
||||
- `generateImageFromChat(prompt, negativePrompt)`を拡張し、`_toolsImage`がセットされていればI2I分岐へ:対象ワークフロー(GenerateUIの読み込み中ワークフロー、またはI2I専用ワークフロー)に`load_image_nodes`が無ければ明確なエラーを投げ、あればslot 0のLoadImageノードへ添付画像を差し替えてから通常のプロンプト設定・生成へ進む
|
||||
|
||||
**SETTINGSペイン — Chat I2I Generation:**
|
||||
- 既存の「Chat Image Generation」(T2I専用ワークフロー)セクションと全く同じ構造で「Chat I2I Generation」セクションを新設。`chatGenI2IDedicatedEnabled`/`chatGenI2IDedicatedFilename`として`wfm_ai_settings`に独立して永続化。ワークフロー一覧の取得(`/api/wfm/workflows`)はT2I側のfetchを流用し2つのselectへ同時反映
|
||||
|
||||
**表示調整(ユーザーフィードバックにより追加修正):**
|
||||
- Chatバブル内の添付画像エコー(送信したユーザーメッセージに表示される添付プレビュー)が大きすぎたため、`appendChatBubble`に`opts.attachThumb`(新設、max 120px)を追加し、生成結果画像用の`opts.imageUrl`(`.wfm-ai-chat-img`)とスタイルを分離
|
||||
- 生成結果画像自体も表示が大きすぎるとのフィードバックを受け、`.wfm-ai-chat-img`の`max-width`を`100%`→`20%`に変更(約80%サイズダウン)
|
||||
|
||||
**推奨モデル:** 動作確認の結果、Tool CallingとVision機能の両方が良好に動作したモデルとして Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)を確認。ヘルプ(`helpAi3`)とREADMEに記載
|
||||
|
||||
**ヘルプ更新(3点セット):** `templates/index.html`/`static/js/i18n.js`(EN/JA/ZH)/`static/js/app.js`の`helpIdMap` — `helpAi3`(添付画像・I2I・推奨モデルの追記)、`helpAi4`(TOOLSドロップ画像がChatとも共有される旨)、`helpAi6`(Chat I2I Generation設定セクションの追記)。いずれも既存キーへの追記のため`helpIdMap`自体の変更は不要
|
||||
|
||||
**検証**(Kapture、実機ブラウザ、Ollama): TOOLSへの画像ドロップ→Chat添付インジケーター表示、Chatでの画像編集依頼→I2I生成の実行、TOOLS/Chat双方の✕による添付クリア、Chatバブルの画像サイズ調整(添付エコー120px、生成結果`max-width:20%`)を確認。ブラウザ接続(Kapture)が一時的に切断される場面があり、再接続後に検証を継続した。
|
||||
|
||||
## v0.3.73
|
||||
|
||||
### AI TOOL Chatペインからの対話式画像生成(Tool Calling)+ Checkpoint関連の安全策
|
||||
|
||||
ユーザーからの「OpenWebUIのような対話式画像生成をこのプラグインで実現できないか」という相談から着手。既存のOllama/LM Studioチャット機能(`static/js/ai-tab.js`)とGenerateUIタブの生成機能(`static/js/generate-tab.js`)は別々に存在していたが、両者をLLM Tool Calling経由で接続した。実装・検証の過程で、GenerateUIのModelタブに以前から存在した「保存済みワークフローのCheckpointが実在しない場合、無警告で別モデルに差し替わる」という表示バグも発見・修正した。
|
||||
|
||||
**対象ファイル:** `static/js/ai-tab.js`, `static/js/generate-tab.js`, `static/js/settings-tab.js`, `static/js/comfyui-editor.js`, `static/js/comfyui-workflow.js`, `static/css/main.css`, `static/js/i18n.js`, `static/js/app.js`, `templates/index.html`
|
||||
|
||||
**Chatペイン画像生成(Tool Calling):**
|
||||
- `generate_image`ツール(`prompt`/`negative_prompt`引数)をOllama/LM Studio双方のchat APIに渡し、呼び出すかどうかはLLM自身が判断する設計(合意事項: 画像生成後、結果はLLMに送り返さず会話は一旦区切る/SPA版のみ対応、サイドパネル版は対象外)
|
||||
- `callChat()`の戻り値を`string`から`{content, toolCalls}`へ変更。Ollamaの`tool_calls[].function.arguments`はオブジェクト、LM Studio(OpenAI互換)はJSON文字列という差異は`_parseToolArgs()`で吸収
|
||||
- 生成処理は既存の`window._wfmGenerateTab.generate(workflowOverride)`(Image Edit「Inpaint」の外部呼び出しと同じ橋渡しパターン)をそのまま再利用。ワークフローはディープクローンしてから`comfyEditor.setPromptText()`で書き込むため、GenerateUIタブの表示状態は一切汚染しない
|
||||
- **既存コードの欠落を修正:** `_coreGenerate`/`handleGenerate`(`generate-tab.js`)が生成結果`{images, seed}`を計算していながら`return`していなかったため、戻り値を追加(既存呼び出し元は戻り値を使っていないため影響なし)
|
||||
- Chatペイン下部(Send/Clearボタンの隣)に「画像生成を許可する」チェックボックス(デフォルトON、`wfm_ai_settings`に永続化)。OFF時は`callChat()`に`tools`を渡さずLLMには一切提示しない
|
||||
|
||||
**SETTINGSペイン — Chat画像生成の専用ワークフロー:**
|
||||
- Image Edit「Inpaint」の「Use dedicated workflow」と同一パターンを踏襲。OFF(デフォルト)はGenerateUIの読み込み中ワークフロー、ONは選択した保存済みワークフローを`/api/wfm/workflows/raw`経由でその都度ロード・変換(`comfyWorkflow.detectFormat`/`convertUiToApi`/`analyzeWorkflow`)して使用
|
||||
|
||||
**バグ修正: Checkpoint未一致時の無警告差し替え(`comfyui-workflow.js`):**
|
||||
- `convertUiToApi()`のCOMBO値フォールバック処理(Impact Packのワイルドカード等、動的な選択肢がインスタンス間でズレて生成エラーになるのを防ぐため既存)が、`ckpt_name`にも無差別に適用されており、保存されたCheckpointパスが現在の環境に存在しない場合、無警告でCombo選択肢の先頭(たまたまアルファベット順で最初のFluxモデル)に差し替えられていた。この製品の「なぜかいつもFluxが表示される」という混乱の直接的な原因だった
|
||||
- `getLastCheckpointSubstitutions()`を追加し、`ckpt_name`の差し替えが発生した場合のみ元の値・ノードIDを記録。`loadWorkflowIntoEditor`(`generate-tab.js`)で検知し、通常の「読み込み完了」トーストの代わりに、差し替え前のモデル名を含む警告トースト(`error`スタイル)を表示する
|
||||
- GenerateUI Modelタブ(`comfyui-editor.js`の`renderModelTab`)にも、保存値が現在のモデル一覧に存在しない場合に警告付きの選択肢を先頭表示する対応を追加(API形式で保存され`convertUiToApi`を経由しないワークフローに対して有効。UI形式ワークフローは上記の差し替えにより「一致している」ように見えるため、この表示だけでは検知できないケースがある)
|
||||
|
||||
**設定タブ — デフォルトCheckpoint:**
|
||||
- チェックボックスで有効化すると、GenerateUIへのワークフロー読み込み時に全Checkpointローダー系ノード(`CheckpointLoaderSimple`/`CheckpointLoader`/`ImageMetadataPromptLoader`)の`ckpt_name`を指定モデルへ強制上書き。サーバー側`/api/wfm/settings`に永続化(複数ブラウザ間で共有)
|
||||
- **実装中に判明した順序依存の不具合:** 上書き処理を`analyzeWorkflow()`実行後に行っていたため、Raw JSON上のデータは正しく上書きされてもModelタブの表示(`analysis.checkpoint_nodes`由来)が古い値のまま残る不具合があった。`analyzeWorkflow()`実行前に上書きする順序に修正
|
||||
|
||||
**ヘルプ更新(3点セット):** `templates/index.html`/`static/js/i18n.js`(EN/JA/ZH)/`static/js/app.js`の`helpIdMap` — `helpAi3`(Chat画像生成機能+許可トグルの説明を追記)、`helpAi6`(SETTINGSペインの専用ワークフロー設定を追記)、`helpTrouble8`(新規: 「ModelタブのCheckpointが想定と違う」症状の原因と対処法)
|
||||
|
||||
**検証**(Kapture、実機ブラウザ + Ollama qwen3.5:9b):
|
||||
- Chatペインから「〜の画像を生成して」で`generate_image`が呼ばれ、実際に画像が生成されチャット内に表示されることを確認(初回は無関係のワークフロー設定に起因するVAEチャンネル数不一致エラーが発生したが、ワークフロー側の修正後に正常動作を確認)
|
||||
- SETTINGSペインの専用ワークフロー機能: 選択したワークフロー(UI形式、`ws_sdxl_t2i_basic.json`)が正しくロード・変換され、Chat経由の生成に使われることを確認
|
||||
- 「画像生成を許可する」チェックボックスの位置変更(ヘッダー→下部の操作行)を反映
|
||||
- デフォルトCheckpointのON/OFF切り替えでModelタブの表示が正しく変わることを確認(ON: 指定モデル、OFF: 元の壊れた表示に復帰)
|
||||
- 実際に不一致のあるワークフロー(`op_sdxl_ill_lora.json`、保存値`anime\animozalyca_v10.safetensors`が未インストール)で、差し替え警告トーストが元のモデル名を含めて正しく表示されることを確認
|
||||
|
||||
## v0.3.72
|
||||
|
||||
### Comic Creator連携用の外部Inpaintエントリポイントを追加
|
||||
|
||||
Comic Creator(`comfyui-comic-creator`)のImageタブから、既存のI2I連携(`window._wfmReceiveImageForI2I`、iframe越しの直接関数呼び出し)と同じ方式でInpaintも呼び出せるようにする依頼を受けて対応。
|
||||
|
||||
**対象ファイル:** `static/js/image-edit-tab.js`, `static/js/gallery-tab.js`, `static/js/comfyui-editor.js`
|
||||
|
||||
- `image-edit-tab.js`: `_runInpaint()`(UI駆動)から共通処理(LoadImage/MaskEditorOneノードへの反映〜Generate実行〜結果URL取得)を`_runInpaintWithImages()`として切り出し、外部呼び出し専用の`runInpaintExternal(imageBlob, maskBlob, params)`を新設(専用ワークフロー選択は扱わず、常にGenerateUIの現在ロード中ワークフローが対象)。マスクレイヤー選択に`.visible`チェックを追加し、非表示のマスクレイヤーはUI駆動・外部呼び出し双方で対象から除外されるよう修正。
|
||||
- `gallery-tab.js`: `window._wfmReceiveImageForI2I`と同じ場所に`window._wfmReceiveInpaintRequest(imageBlob, maskBlob, params, workflowData?, workflowFilename?)`を新設。`workflowData`が渡されればI2Iと同じ手順(接続確認→モデルリスト確保→`loadWorkflowIntoEditor`)でロードしてから`runInpaintExternal`を呼ぶ。
|
||||
- **バグ修正(`comfyui-editor.js`):** `_loadImageElement()`内の`src instanceof Blob`判定が、別ウィンドウ(Comic Creator)で生成されたBlob/Fileに対しては別レルムのコンストラクタ参照になるため常に`false`を返し、`img.src`にBlobオブジェクトがそのまま代入されて`[object Blob]`という文字列がURLとして解釈され404になるバグを発見・修正(`typeof src !== "string"`による実体判定に変更)。Blob自体は`URL.createObjectURL`等でレルムをまたいで問題なく使えるため、チェック方法のみが原因だった。同一オリジンiframe越しに他ウィンドウ生成のBlob/Fileを直接受け渡す連携を実装する際は、受け取り側で`instanceof`ベースの判定を避けること。
|
||||
|
||||
**検証**(Kapture、Comic Creator実機 `http://127.0.0.1:8189/ccc`): Comic Creator側からInpaint実行→本ノードのInpaintが正常に動作し結果が返ることを確認。非表示マスクレイヤーの除外もComic Creator側UIから確認。
|
||||
|
||||
## v0.3.71
|
||||
|
||||
### Inpaint機能の追加(GenerateUI Imageタブ + Image Edit「Inpaint」タブ)
|
||||
|
||||
ユーザー要望「インペイント機能を追加したい」。参考ワークフロー(SDXL + VAEEncodeForInpaint構成)の解析から着手し、複数セッションにわたり段階的に実装。
|
||||
|
||||
**対象ファイル:** `static/js/comfyui-workflow.js`, `static/js/comfyui-editor.js`, `static/js/image-edit-tab.js`, `static/js/generate-tab.js`, `static/js/i18n.js`, `static/css/main.css`, `templates/index.html`, `static/js/app.js`
|
||||
|
||||
**ワークフロー解析(`analyzeWorkflow`):**
|
||||
- `load_image_nodes` に `mask_used` フラグを追加。`LoadImage` ノードのMASK出力(output slot 1)が他ノードの入力(`["<id>", 1]` 参照)として使われているかを走査して判定。`MaskEditorNode` 等の中継ノードを経由していても、参照元のLoadImageまで正しく特定できる
|
||||
- `inpaint_encode_nodes`(`VAEEncodeForInpaint` の `grow_mask_by`)、`mask_editor_one_nodes`(`MaskEditorOne` ノード)を新規検出
|
||||
|
||||
**生成UI Input/Imageタブ — マスクスロット:**
|
||||
- `mask_used` なLoadImageスロットにのみ「Mask」ドロップゾーンを追加表示
|
||||
- Apply時、マスク指定があれば画像+マスクをRGBA合成(マスク白領域→アルファ0)して1枚のPNGとしてアップロード。ComfyUIネイティブの `mask = 1 - alpha` 抽出にそのまま準拠するため追加ノード不要
|
||||
- Mask Editor One ノード用の専用スロットも追加(画像・マスクの両方が揃って初めてApply可能)
|
||||
|
||||
**Image Editタブ — Inpaintツール(🩹):**
|
||||
- プロパティパネルにポジ/ネガプロンプト、Grow Mask By、Denoise、Runボタン、結果画像プレビューを追加
|
||||
- Run押下で: 非マスクレイヤー合成画像 + アクティブなマスクレイヤーを書き出し → 生成UIのインペイント対応スロットへRGBA合成アップロード → プロンプト/grow_mask_by/denoiseを反映 → GenerateUIの生成を呼び出し(画面はImage Editタブに留まる)→ 完了後の結果画像をプロパティパネルに表示
|
||||
- **バグ修正:** ツールバーの `<button data-tool="inpaint">` を `templates/index.html` に追加し忘れており、`TOOL_DEFS` にエントリを追加しただけではボタンが表示されない不具合があった(ツールバーは静的HTML、`TOOL_DEFS` は `ready` 判定とラベル参照専用)
|
||||
|
||||
**Mask Editor One(`comfyui-mask-editor-one`)対応:**
|
||||
- 実装(`nodes.py`/`server.py`)を調査した結果、この custom node は `LoadImage` を介さず、画像はサーバー側インメモリキャッシュ(`node_id` キー、`bg_image_b64`、`/mask_editor/store_image` でPOST)から、マスクは `layer_data`(JSON文字列ウィジェット)の各レイヤーの**アルファチャンネル**から合成されると判明(`LoadImage` の `1 - alpha` とは逆で、alpha値がそのままmask値になる規約)
|
||||
- `comfyEditor.applyImageAndMaskToMaskEditorOneNode()` を新規実装し、この2つの経路(画像キャッシュPOST + layer_data書き込み)を実装
|
||||
- Inpaint Runは `mask_used` なLoadImageスロットを優先し、無ければMask Editor Oneノードへ自動フォールバック
|
||||
|
||||
**インペイント専用ワークフロー:**
|
||||
- Inpaintプロパティ上部に「Use dedicated workflow」チェックボックス + 保存済みワークフロー選択ドロップダウンを追加。OFF時は従来通りGenerateUIの読み込み中ワークフローを使用、ON時は選択したワークフローをその場でロード・解析し、**GenerateUIタブの表示状態には一切触れずに**画像/マスク/プロンプト/パラメータを適用して生成
|
||||
- `generate-tab.js`: `_coreGenerate`/`handleGenerate` に `workflowOverride` 引数を追加(省略時は従来通り `comfyUI.currentWorkflow` を使用)。オーバーライド時はバッチモードの影響を受けない単発生成に固定し、GenerateUI側のプロンプト同期(`syncToWorkflow`)もスキップ
|
||||
- `comfyui-editor.js`: `applyImageAndMaskToSlot` / `applyImageAndMaskToMaskEditorOneNode` / `setPromptText` / `setInpaintParams` に `{workflow, analysis}` オプションを追加し、指定時はそちらへ直接書き込む方式に統一
|
||||
|
||||
**サンプルワークフロー追加:** `workflows/ws_inpaint_basic.json`(LoadImage直結)、`workflows/ws_inpaint_mask_editor_one.json`(Mask Editor One使用)とプレビュー画像
|
||||
|
||||
**ヘルプ更新(3点セット):** `templates/index.html`、`static/js/i18n.js`(EN/JA/ZH `helpImageEdit7e` 新規)、`static/js/app.js`(`helpIdMap` に `wfm-help-imageedit-7e` 追加)
|
||||
|
||||
**既知の簡略化点:** Grow Mask By / Denoiseのプロパティパネル初期表示値は、専用ワークフロー使用時も常にGenerateUI側の読み込み中ワークフローから算出される(Run実行時に書き込まれる値自体は専用ワークフロー側に正しく反映される)
|
||||
|
||||
## v0.3.70
|
||||
|
||||
### Workflow AI要約: Ollama 404エラー修正
|
||||
|
||||
ユーザー報告「ワークフロー詳細モーダルの要約機能でエラー `[ERROR] Ollama chat error: HTTP Error 404: Not Found`」の調査から発生。
|
||||
|
||||
**原因:** `static/js/workflow-tab.js` の要約ボタンはモデル名を指定せずサーバー側 `/api/wfm/ollama/chat` を呼んでおり、サーバー側デフォルト(`py/routes/ollama_routes.py` の `OLLAMA_DEFAULTS.model = "llava"`、タグ無し)が使われていた。実環境には `llava:7b` のようにタグ付きの名前でしか入っておらず、Ollamaは完全一致でしかモデルを解決しないため404になっていた。加えて、設定タブの「AI Assistant Settings (Prompt Tab)」(`localStorage` の `wfm_prompt_ai_settings`)にOllamaのURL・モデル選択UIがすでに存在していたが、Workflowタブの要約機能はこれを一切参照していなかった。
|
||||
|
||||
**対象ファイル:** `static/js/workflow-tab.js`, `py/routes/ollama_routes.py`, `static/js/i18n.js`
|
||||
|
||||
**変更内容:**
|
||||
- `workflow-tab.js`: 要約ボタンが `readJsonStorage("wfm_prompt_ai_settings", {})` で設定タブ保存済みのbackend/backendUrl/modelを読み込み、`/api/wfm/ollama/chat` へ `url` / `model` を渡すように変更。backendが `ollama` 以外(LM Studio)の場合は明示的にエラートースト
|
||||
- `ollama_routes.py`: `handle_chat` がリクエストbodyの `url` も受け付けるように対応(従来は `model` のみ)
|
||||
- i18n: `summarizeOllamaOnly` をEN/JA/ZHに追加
|
||||
|
||||
### Image Edit: New button で Layer 1 自動追加
|
||||
|
||||
ユーザー要望「New button 画像作成時に自動でレイヤー1を追加してほしい」。既存の画像ロード時(`hasLayers` が偽の新規キャンバス経路)は既に「Layer 1」を自動生成していたが、`_newCanvas()`(Newボタン)だけは空の `LayerManager` のままだった。
|
||||
|
||||
**対象ファイル:** `static/js/image-edit-tab.js`
|
||||
|
||||
**変更内容:** `_newCanvas()` で `this._layerMgr.addLayer("draw", "Layer 1")` を追加し、アクティブレイヤー・SelectToolに設定。
|
||||
|
||||
### Image Edit: マスクレイヤーのオーバーレイをアクティブなマスクのみに表示
|
||||
|
||||
ユーザー要望「マスクレイヤーのオーバーレイ表示をマスクレイヤー以外は非表示にしたい」。従来は `layer.visible` のみを条件にオーバーレイ(半透明の色付き表示)を描画しており、非アクティブなマスクレイヤーも常に表示され続けていた。
|
||||
|
||||
**対象ファイル:** `static/js/image-edit-tab.js`
|
||||
|
||||
**変更内容:**
|
||||
- `_renderMaskLayerOverlay(ctx, maskLayer)` の先頭に `if (this._layerMgr?.activeLayer !== maskLayer) return;` を追加。全ての呼び出し経路(単独マスク/マスクグループ)を1箇所でカバー
|
||||
- **副次修正:** レイヤーパネルでSelectツール使用中に通常レイヤーへ切り替えた際に `_updateCompositeView()` が呼ばれていない分岐があり、上記の変更後は「マスク選択→通常レイヤー選択」でオーバーレイが画面に残ったままになる不具合が顕在化するため、当該分岐にも再描画呼び出しを追加
|
||||
|
||||
### GenerateUI Input: プロンプトの「戻す」ボタン + 強調weight編集(Ctrl+↑/↓)
|
||||
|
||||
ユーザー要望2件を同じセッションでまとめて実装。
|
||||
|
||||
**対象ファイル:** `static/js/comfyui-editor.js`, `static/js/i18n.js`
|
||||
|
||||
**変更内容:**
|
||||
- **戻すボタン(↺)** — Positive/Negative各Applyボタンの左隣に追加。クリックで、選択中ノードIDに対応する**ワークフロー上の現在値**(=直前にApply/ロードされた値)をテキストエリアへ再読み込みする、Applyの逆操作。ノード選択セレクトに `min-width:0` を追加し、ボタン増加によるレイアウト圧迫に対応
|
||||
- **強調weight編集(Ctrl+↑/↓)** — A1111 / ComfyUIネイティブのCLIP Text Encodeと同様の操作感。選択テキスト(無ければカーソル位置の括弧ブロック、次点で単語)を `(text:weight)` 構文に変換し、0.05刻みで増減。weightがちょうど1.0に戻ったら括弧を自動除去。汎用ヘルパー `_attachPromptWeightControl` / `_selectSurroundingParenBlock` / `_selectSurroundingWord` を新規追加し、Positive/Negative両テキストエリアにアタッチ
|
||||
- **バグ修正(同セッション内)** — weightがマイナスに達した状態(例: `(watermark:-0.05)`)でさらにCtrl+↓を押すと `((watermark:-0.05):0.95)` のような二重括弧になる不具合を確認。原因は数値抽出の正規表現 `([\d.]+)` が負符号にマッチせず、マッチ全体が失敗して選択テキスト全体を新しい単語として扱っていたため。`(-?[\d.]+)` に修正。下限は設けない仕様(ComfyUIネイティブのCLIP Text Encodeも同様に無制限のため)
|
||||
- i18n: `revertPromptTitle` をEN/JA/ZHに追加
|
||||
|
||||
**ヘルプ更新(3点セット):**
|
||||
- `templates/index.html`: `wfm-help-imageedit-3`(New button説明にLayer 1自動追加を追記)、`wfm-help-imageedit-mask-3`(アクティブなマスクのみオーバーレイ表示である旨を追記)、`wfm-help-gen-4`(戻すボタン・Ctrl+↑/↓ weight編集の説明を追記)
|
||||
- `static/js/i18n.js`: `helpImageEdit3` / `helpImageEditMask3` / `helpGen4` をEN/JA/ZHで更新(新規キー追加ではなく既存キーへの追記のため `app.js` の `helpIdMap` は変更不要)
|
||||
|
||||
**教訓(デプロイ同期、再掲):** 開発リポジトリと `custom_nodes/comfyui-workflow-studio`(StabilityMatrix配下の実行時ディレクトリ)は別実体のコピーであり自動同期しない。今回変更した全ファイル(`workflow-tab.js`, `ollama_routes.py`, `image-edit-tab.js`, `comfyui-editor.js`, `i18n.js`, `templates/index.html`)を都度両方へ適用して対応した。
|
||||
|
||||
## v0.3.69
|
||||
|
||||
### Gallery → ComfyUI Comic Creator 連携(Send CC ボタン)
|
||||
@@ -21,6 +367,25 @@ ComfyUI Comic Creator(別カスタムノード、連携先名称は「Comic Cr
|
||||
|
||||
**ハマりどころ(デプロイ同期忘れ):** 本リポジトリは開発用ディレクトリと実行時の `custom_nodes/comfyui-workflow-studio`(StabilityMatrix配下)が別実体のコピーであり、symlinkではない。上記3ファイルを開発リポジトリのみ編集した状態でComfyUIを起動していたため、「Send CCボタンが表示されない」という報告を受けた。実行時ディレクトリへ3ファイルを手動コピーして解決(差分は今回の追加分のみで他の乖離なし)。今後この種の変更をした際は、都度 `custom_nodes/` 側への同期を忘れないこと(本体のコミット前チェックリスト「全ファイルを custom_nodes/ に同期」を参照)。
|
||||
|
||||
### I2I連携(Comic Creater送信画像の受信・デフォルトワークフロー自動読込)
|
||||
|
||||
ComfyUI Comic Creater の「I2Iへ送る」ボタンから、iframe越しに画像をGenerate UIのImage入力スロットへ直接セットする受信用グローバル関数`window._wfmReceiveImageForI2I(blob, name, workflowData?, workflowFilename?)`を新規追加。上記のSend CCと対になる、逆方向(Comic Creater→Workflow Studio)の連携。既存の「Send GenUI Image」(Galleryの選択画像を送る版)とロジックを共有。
|
||||
|
||||
**対象ファイル:** `static/js/gallery-tab.js`
|
||||
|
||||
**変更内容:**
|
||||
- `window._wfmReceiveImageForI2I`をモジュールトップレベルに追加。`comfyEditor.applyImageToSlot(file, 0)`を呼び、Generate UI → Input → Imageタブへ自動切替した上で画像をLoadImageスロットにセットする
|
||||
- `workflowData`(ワークフローJSON)が渡された場合、画像をセットする前に`loadWorkflowIntoEditor(workflowData, workflowFilename)`(generate-tab.js既存export)でワークフローを差し替える。Comic Creater側の設定タブで「デフォルトI2Iワークフロー」が有効な場合のみ渡される(`user/default/workflows/`配下のファイルをComic Creater側が`GET /api/wfm/workflows/raw?filename=X`で取得して渡す設計、本リポジトリのAPI自体は無改修)
|
||||
- 既存の「Send CC」ボタンのクリックハンドラを拡張し、`window.parent._ccI2ITargetMode`(Comic Creater側が送信元をレイアウトタブ/Imageタブのどちらか記録するフラグ)を参照して、`window.parent.insertImageFromUrl(url)`(レイアウトタブ挿入、従来通り)と`window.parent._ccImageTab.loadFromUrl(url, name)`(Imageタブ読込、新規)を分岐
|
||||
|
||||
**ハマりどころ1(プレビューのURLキャッシュ):** Comic Creater側が同一ファイル名で連続送信すると、`applyImageToSlot`が`previewImg.src`に設定するURL文字列(`/view?filename=...`)が変わらないため、サーバー側は正しく上書きされているのにブラウザがキャッシュ画像を表示し続けた(「編集内容が反映されていないように見えるが、実際に生成すると反映されている」という診断が難しい症状として報告された)。対策はComic Creater側でファイル名をタイムスタンプ付きでユニーク化する方式とし、本リポジトリ側は無改修で解決。
|
||||
|
||||
**ハマりどころ2(モデルリスト未取得):** iframe埋め込み直後など`comfyEditor.models`(Checkpoint等一覧、`init()`時に1回だけ非同期取得)が空のタイミングで`loadWorkflowIntoEditor`を呼ぶと、Model/生成UIタブのCheckpoint等ドロップダウンが空のままレンダリングされ、選択もできなくなる不具合が発生した(Comic Creater再起動でも再現するとの報告)。`_wfmReceiveImageForI2I`内でワークフローロード前に`comfyUI.checkConnection()`→(`comfyEditor.models.checkpoints`が空なら)`comfyEditor.loadModelLists()`を保証するよう修正して解決。
|
||||
|
||||
**ComfyUI Comic Creator 側の実装:** レイアウトタブ・Imageタブの「I2Iへ送る」ボタン、送信共通関数`sendImageToWorkflowStudioI2I`、設定タブの「I2I設定」ブロック(デフォルトワークフローのON/OFF・ファイル名指定)はすべてComic Creater側に実装(本リポジトリからは見えない)。
|
||||
|
||||
**教訓(デプロイ同期忘れ、再掲):** 前回のSend CC実装時と同じ理由で、開発リポジトリと`custom_nodes/comfyui-workflow-studio`(StabilityMatrix配下の実行時ディレクトリ)は別実体のコピーであり自動同期しない。今回は`gallery-tab.js`の変更を都度両方へ適用して対応した。
|
||||
|
||||
### Loraバッチ生成のバリデーションエラー修正 + プロンプト自動反映
|
||||
|
||||
ユーザー報告「生成UIタブのBatchタブでLoraのバッチ機能を使用してIllustriousグループ(128モデル)でバッチ生成を開始したがエラー(`Prompt outputs failed validation`)となった」の調査から一連の修正を実施。
|
||||
@@ -703,7 +1068,7 @@ v0.3.60 で実装した G'MIC-Qt 連携の動作修正・セキュリティ強
|
||||
Windows でバックグラウンドプロセス(ComfyUI)からGUIウィンドウを表示するために必須のフラグ。一度削除したが eagle_comic_creater_web の参考実装を確認して再追加した。
|
||||
|
||||
#### 設定パスのプレースホルダー修正
|
||||
`settings-tab.js` の G'MIC-Qt パス入力フィールドのデフォルト値とプレースホルダーからユーザー固有のパス(`statsu-11`)を削除し、汎用的な `C:\path\to\gmic_qt.exe` に変更。`gmic_routes.py` のフォールバック値も同様に空文字に変更。
|
||||
`settings-tab.js` の G'MIC-Qt パス入力フィールドのデフォルト値とプレースホルダーからユーザー固有のパスを削除し、汎用的な `C:\path\to\gmic_qt.exe` に変更。`gmic_routes.py` のフォールバック値も同様に空文字に変更。
|
||||
|
||||
---
|
||||
|
||||
@@ -771,7 +1136,7 @@ G'MIC-Qt 連携用の API ルートを新規作成。
|
||||
| `GET /api/wfm/gmic/status/{job_id}` | ジョブの進行状況(`pending` / `processing` / `completed` / `failed`)を返す |
|
||||
| `POST /api/wfm/gmic/result` | `result_path` を受け取り、処理済み画像を base64 エンコードして返す |
|
||||
|
||||
- G'MIC-Qt の実行パスは Settings に保存された `gmic_qt_path` を参照。未設定の場合はデフォルトパス `C:\Users\statsu-11\gmic-3.6.5-qt-win64\gmic_qt.exe` にフォールバック。
|
||||
- G'MIC-Qt の実行パスは Settings に保存された `gmic_qt_path` を参照。未設定の場合はデフォルトパス `C:\path\to\gmic_qt.exe` にフォールバック。
|
||||
- プロセスは `subprocess.Popen` + `CREATE_NEW_CONSOLE` でノンブロッキングに起動し、スレッド内でポーリングして完了を検出。
|
||||
|
||||
#### `py/wfm.py`
|
||||
|
||||
@@ -21,7 +21,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -83,9 +83,13 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **5-tab layout** — Input / Model / Settings / Feeder / Batch tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Save button** — located at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API
|
||||
- **Input tab** — Prompt and Image inner tabs (drag-and-drop upload); Prompt tab shows Positive Prompt and Negative Prompt textareas, plus an **Embeddings selector** at the bottom (Filter + Select + Weight input + Paste button); Paste inserts `(embedding:Name:weight)` at the cursor position of the last focused textarea (defaults to Positive when neither is focused); Raw JSON (540px) in the right column
|
||||
- **Revert button** (v0.3.70) — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value (the reverse of Apply)
|
||||
- **Emphasis weight editing** (v0.3.70) — select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05, A1111/native-ComfyUI style; parentheses are removed automatically when the weight returns to 1.0
|
||||
- **Mask slot for inpainting** (v0.3.71) — LoadImage slots whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) show an additional Mask drop zone; when a mask is set, image + mask are composited into a single RGBA upload (mask baked into the alpha channel) matching ComfyUI's native alpha→MASK extraction — no extra nodes required
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device), **Hypernetwork** (with Strength field) selectors with filter; Raw JSON on the right
|
||||
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow; built-in **search bar** (always shown) finds all matches as you type with count display (`3/12`); navigate with ↑/↓ buttons or Enter / Shift+Enter; Escape or ✕ clears; current match highlighted in orange, other matches in yellow
|
||||
- **Bypass/Mute node handling** (v0.3.75) — loading a UI-format workflow with Bypass-mode nodes now reroutes their wires to the upstream source (matching same-type input/output slots, recursively through chained bypasses) instead of leaving a dangling reference that failed validation; Mute-mode nodes are excluded without rerouting, same as ComfyUI itself; when either is present, a note listing the affected nodes appears above the Raw JSON search bar in the Input/Model/Settings tabs
|
||||
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
|
||||
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
|
||||
- **Style selector** — checkbox and dropdown next to the Reset Workflow button; enable to apply a Fooocus-style JSON to positive and negative prompts at generation time; style files (`*.json`) are loaded from `user/default/Workflow-Studio/style/`; the style's `{prompt}` placeholder is replaced with the original prompt text, or the style is appended if no placeholder exists; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation copy and do not modify the loaded workflow
|
||||
@@ -161,6 +165,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **CivitAI Host** — choose which site opens when clicking a model link: `civitai.com` (SFW only) or `civitai.red` (unrestricted); saved to `settings.json` and synced to `localStorage` for the Models tab to use without extra fetches
|
||||
- **CivitAI API Key** — optional Bearer token for authenticated CivitAI access; stored in `settings.json` (excluded from data exports); environment variable `CIVITAI_API_KEY` takes priority if set
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Default Checkpoint** (v0.3.73) — optional checkbox to force every checkpoint loader node's model to a chosen default whenever a workflow is loaded into GenerateUI; a safeguard against generation errors or garbled images from a stale/mismatched checkpoint saved in the workflow file
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
@@ -192,6 +197,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, tag filter, group filter, and favorites filter in one click (sort order is preserved)
|
||||
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
|
||||
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
@@ -247,16 +253,17 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG
|
||||
- **Loading images** — drag & drop onto the canvas, Upload button, or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★); tool options bar updates per active tool
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers and automatically adds an empty Layer 1 (v0.3.70)
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
- **Inpaint** (v0.3.71) — composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like `VAEEncodeForInpaint`); also supports workflows built around the **Mask Editor One** custom node (`comfyui-mask-editor-one`) instead of LoadImage — image/mask are pushed via its node_id-keyed server cache and `layer_data` widget; **Properties pane**: Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), Denoise (applied to KSampler), Run button; **Use dedicated workflow** checkbox — when off, generates against the workflow currently loaded in GenerateUI; when on, pick a saved workflow from the dropdown to load, edit, and generate entirely in the background without disturbing what's shown in GenerateUI; the result image is shown back in the Properties pane when generation completes; requires a Mask layer painted first (Mask tool)
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load
|
||||
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
|
||||
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
|
||||
@@ -267,14 +274,21 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
|
||||
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio uses `/v1/chat/completions`
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio and Lemonade use `/v1/chat/completions`
|
||||
- **Chat pane — image generation via Tool Calling** (v0.3.73) — if the selected model supports tool calling and the user asks for an image, a `generate_image` tool is invoked and the request runs through the same generation pipeline as the GenerateUI Generate button, with the result shown inline in the chat; an "Allow image generation" checkbox in the input row toggles this on/off (default on); the workflow used is the one currently loaded in GenerateUI, or a dedicated saved workflow configured in the Settings pane; models verified to work well for tool calling and vision: **Gemma 4:e4b**, **Qwen 3.5:9b**, **Ministral 3:3b** (Ollama)
|
||||
- **TOOLS pane (VLM)** — drop an image into the 110px drop zone, select a task (Describe Image / Create Prompt / Create Tags), and click Run to analyze with a vision model; result shown in the output area with a Copy button
|
||||
- **TOOLS pane — shared Chat attachment** (v0.3.74) — an image dropped in the TOOLS drop zone is also usable as the Chat pane's attachment: it's sent to the LLM as vision input, and used as the base image when `generate_image` runs image-to-image; a ✕ button clears it (from either the TOOLS preview or the Chat attachment indicator); it stays attached across turns until explicitly cleared
|
||||
- **TOOLS pane (Wildcards)** (v0.3.40) — select "Create wildcards" from the task dropdown; enter a category name and count; click Run to generate plain-text wildcard entries one per line (no markdown, no numbering); result can be copied directly into wildcard `.txt` files
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Chat pane — image-to-image (I2I)** (v0.3.74) — when an image is attached and `generate_image` is invoked, the attachment is uploaded and swapped into the target workflow's LoadImage node instead of running text-to-image; the target workflow is the one currently loaded in GenerateUI, or a dedicated I2I workflow configured in the Settings pane
|
||||
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio)
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
- **Lemonade's other endpoints (not integrated)** — Lemonade Server also exposes OpenAI-compatible image generation (`/v1/images/generations`), text-to-speech (`/v1/audio/speech`), and transcription (`/v1/audio/transcriptions`) endpoints; these are not wired up — image generation would duplicate the existing ComfyUI-workflow-based `generate_image` tool call, and audio/TTS has no corresponding UI yet, so both are future considerations rather than a natural extension of the current backend switch
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
|
||||
|
||||
@@ -286,7 +300,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
|
||||
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
|
||||
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **model sub-tab** — Checkpoint, VAE, Diffusion Model, and Text Encoder; drag items to canvas to place the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`); double-click also places at canvas center
|
||||
- **lora sub-tab** — detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format `inputs.loras.__value__` supported); shows `strength_model / strength_clip` values; drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (appears for 1+ LoRAs) drags all LoRAs into a single `Lora Loader (LoraManager)` node with LoRA syntax pre-filled
|
||||
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
|
||||
@@ -377,6 +391,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
|
||||
@@ -394,6 +409,71 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.78
|
||||
|
||||
- **Eagle integration — SVG support** — auto-save now handles SVG output alongside raster images: the standard `SaveSVGNode` (via `images`-key detection with correct `image/svg+xml` MIME re-tagging) and `comfyui-tosvg`'s `Save SVG String` node (via its `saved_svg`/`path` UI keys, including a fix for ComfyUI's execution engine splitting single-string UI values into per-character arrays); SVGs are sent to Eagle through `addFromPath` (resolved to a real on-disk path, restricted to ComfyUI's managed directories) instead of `addFromURL`, since `/view` forces `application/octet-stream` for SVG as an XSS guard; temp-only preview images (e.g. `SVG String Preview`) are excluded from both auto-save and the GenerateUI result display, which now shows the actual saved SVG file instead of a stale pre-vectorized preview
|
||||
- **GenerateUI / Metadata tabs — subgraph workflow support (Mage-Flow, and any similar template)** — workflows that merely contain ComfyUI subgraphs (`definitions.subgraphs`) are no longer blocked as unsupported "App format"; only true simplified-UI App-mode workflows (Linear Mode / `.app.json`) still are. `convertUiToApi()` now correctly injects the outer subgraph node's actual widget values (prompt text, seed, model names, etc. — what the user actually sees in the collapsed subgraph form) into the internal template nodes, which previously kept stale placeholder values from when the subgraph was authored; added support for `TextEncodeMageFlowEdit` (prompt analysis) and ComfyUI's new DynamicCombo widget type (e.g. `SaveImageAdvanced`'s format selector)
|
||||
- **Workflow tab — structured AI summary** — the "Summarize" button now extracts model names, LoRAs, prompts, and sampler settings via `analyzeWorkflow()` instead of dumping the raw workflow JSON's first 4000 characters, which used to get crowded out by long `MarkdownNote` text or UI position data on complex/subgraph workflows
|
||||
- **Workflow tab — accurate node counts and format badge** — the side panel's "P:/I:/→" counts and the API/App format badge now recognize `TextEncodeMageFlowEdit`/`SaveImageAdvanced` and other newer node types, and use the same "App means Linear Mode, not just subgraphs" rule as GenerateUI
|
||||
- **Workflow/Gallery tabs — clearer "Send to Canvas" toast** — the toast previously always said "drag the title to canvas" even when the workflow was sent directly via `window.opener` (no drag needed); it now only shows the drag instruction for the actual drag-required fallback path
|
||||
|
||||
### v0.3.77
|
||||
|
||||
- **Gallery Tab — SVG file support** — `.svg` added to `IMAGE_EXTENSIONS` (`gallery_service.py`), covering folder scanning, listing, move/delete, and serving; `serve_thumbnail()` returns the original file for SVG (same as the existing GIF case) since Pillow can't open vector images and the browser scales them natively; no frontend changes needed since `gallery-tab.js` renders every image through plain `<img>` tags
|
||||
- **AI TOOL Chat — SVG generation without a ComfyUI workflow** — assistant replies are now scanned for SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`); when found, it's rendered inline via `<img src="data:image/svg+xml;base64,...">` (script execution is disabled by the browser for SVG loaded through `<img>`) with a "Save to Gallery" button; `/wfm/gallery/image/save` (`gallery_routes.py`) was generalized to detect the target format from the data URL's MIME type instead of assuming PNG, so both Image Edit Tab's PNG saves and Chat's SVG saves go through the same endpoint
|
||||
|
||||
### v0.3.76
|
||||
|
||||
- **Comic Creator — I2I execution bridge** — added `window._wfmReceiveI2IRunRequest` (`gallery-tab.js`) and `runI2IExternal()`/`_runI2IWithImage()` (`image-edit-tab.js`), a mask-free counterpart to the existing Inpaint bridge (`_wfmReceiveInpaintRequest`/`runInpaintExternal`); lets Comic Creator's Image tab send an image with Positive/Negative prompts and Denoise, run generation in place, and get the result image URL back without opening the Workflow Studio UI
|
||||
|
||||
### v0.3.75
|
||||
|
||||
- **Lemonade Server support — third AI backend** — added [Lemonade Server](https://lemonade-server.ai/) alongside Ollama and LM Studio as a selectable backend for Translation, Chat, TOOLS (VLM/Wildcards), and the Prompt tab's AI Assistant, in both the SPA and the Library side panel; Lemonade's OpenAI-compatible API (`/v1/models`, `/v1/chat/completions`) reuses the same request/response handling already used for LM Studio, so no new wire-format code was needed
|
||||
- **Shared default-URL lookup** — `static/js/util.js` gains `getAiBackendDefaultUrl(backend)` (`AI_BACKEND_DEFAULT_URLS`: Ollama `http://localhost:11434`, LM Studio `http://localhost:1234`, Lemonade `http://localhost:13305`), replacing the previous two-way ternary in `ai-tab.js`, `prompt-tab.js`, and `settings-tab.js`; the ComfyUI side panel (`node_sets_menu.js`) keeps its own local copy since it can't import from the SPA's `static/js` modules
|
||||
- **Backend switch now always resets the URL field** — switching the backend radio (Ollama / LM Studio / Lemonade) in any of the three UIs now unconditionally fills the URL field with that backend's default, instead of only when the field was empty; the side panel previously had no such handler at all
|
||||
- **Bug fix — Bypass-mode nodes broke generation with a dangling-reference validation error** — `convertUiToApi()` used to drop Bypass-mode (`mode: 4`) nodes without reconnecting the links that ran through them, so any node downstream of a bypassed node's output referenced a node ID that no longer existed in the API graph, and ComfyUI rejected the prompt (`Exception when validating inner node: 'N'`). Bypassed nodes' wires are now traced through to their upstream source (matching same-type input/output slots, recursively for chained bypasses) before being dropped, exactly like ComfyUI's own Bypass behavior. Mute-mode nodes (`mode: 2`) are now also excluded from the API graph (previously they were sent as if fully active); unlike Bypass they are not rerouted, matching ComfyUI's own behavior where muting a node with required downstream dependents is expected to error.
|
||||
- **GenerateUI — Bypass/Mute indicator above Raw JSON** — when a loaded workflow has Bypass or Mute nodes, a note listing them (title + node ID) now appears above the Raw JSON search bar in the Input/Model/Settings tabs, since the API-format JSON shown there has no `mode` field and can no longer reveal this on its own
|
||||
|
||||
### v0.3.74
|
||||
|
||||
- **AI TOOL Chat — image attachment shared with TOOLS drop zone** — an image dropped in the TOOLS pane's existing drop zone is now also usable as the Chat pane's attachment (no separate drop zone); it is sent to the LLM as vision input (Ollama `images:[]`, LM Studio `image_url` content block) and shown as an indicator (thumbnail + label + ✕) above the Chat input; it persists across turns until explicitly cleared from either pane
|
||||
- **AI TOOL Chat — image-to-image (I2I) generation** — when an image is attached and the `generate_image` tool is invoked, the attachment is uploaded and swapped into the target workflow's `LoadImage` node instead of running text-to-image; the `generate_image` tool description was updated so the LLM knows attaching an image switches it to I2I
|
||||
- **AI TOOL Settings — Chat I2I Generation dedicated workflow** — same "Use dedicated workflow" pattern as the existing T2I setting, but independent and specific to I2I generation
|
||||
- **`comfyui-editor.js` — `applyImageToSlot()` now supports `opts.workflow`/`opts.analysis`** — mirrors the existing pattern in `applyImageAndMaskToSlot()`; lets callers upload+swap an image into a cloned workflow without touching GenerateUI's displayed state (used by the new Chat I2I bridge)
|
||||
- **Chat bubble image sizing** — the attached-image echo shown in the user's own message is now a small thumbnail (max 120px) instead of filling the bubble; generated result images are now capped at 20% width instead of 100%
|
||||
- **Recommended models** — Gemma 4:e4b, Qwen 3.5:9b, and Ministral 3:3b (Ollama) verified to work well for both tool calling and vision in the Chat pane
|
||||
|
||||
### v0.3.73
|
||||
|
||||
- **AI TOOL Chat — image generation via Tool Calling** — if the selected Ollama/LM Studio model supports tool calling, asking Chat for an image (e.g. "generate a picture of a cat") invokes a `generate_image` tool and runs the request through the same generation pipeline as the GenerateUI Generate button (`window._wfmGenerateTab.generate()`); the result is shown inline in the chat as an image bubble; the conversation is not sent back to the LLM after generation
|
||||
- **AI TOOL Chat — "Allow image generation" toggle** — checkbox next to the Send/Clear buttons (default on, persisted to `wfm_ai_settings`); when off, the `generate_image` tool is not offered to the LLM at all
|
||||
- **AI TOOL Settings — Chat Image Generation dedicated workflow** — "Use dedicated workflow" checkbox + saved-workflow picker; when off (default), Chat-triggered generation uses the workflow currently loaded in GenerateUI; when on, it loads the selected saved workflow fresh each time via `/api/wfm/workflows/raw`, without touching what's shown in GenerateUI (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings — Default Checkpoint** — optional checkbox to force every checkpoint loader node (`CheckpointLoaderSimple` / `CheckpointLoader` / `ImageMetadataPromptLoader`) to a chosen model whenever a workflow is loaded into GenerateUI; a safeguard for environments with many saved workflows referencing models that were later moved/renamed
|
||||
- **Bug fix — silent checkpoint substitution now surfaced** — `convertUiToApi()`'s COMBO-value fallback (originally added to tolerate drifting Impact Pack wildcard options) was silently replacing *any* unrecognized `ckpt_name` with the first entry in the installed-checkpoints list, making the Model tab show an unrelated model with no indication anything was wrong; workflow loading now detects this specific case and shows a warning toast naming the missing checkpoint instead of the usual "workflow loaded" toast
|
||||
- **GenerateUI — return value for programmatic generation** — `_coreGenerate`/`handleGenerate` now return `{images, seed}` on success (previously void); existing callers are unaffected, and this enables features like the new Chat image bridge to retrieve the generated image without scraping the DOM
|
||||
|
||||
### v0.3.72
|
||||
|
||||
- **Comic Creator integration — Inpaint via external entry point** — the Image tab in Comic Creator (`comfyui-comic-creator`) can now trigger Inpaint the same way it already triggers I2I, via `window._wfmReceiveInpaintRequest(imageBlob, maskBlob, params, workflowData?, workflowFilename?)` called across the same-origin iframe boundary; common Inpaint logic was factored out into `_runInpaintWithImages()` and shared by the UI-driven flow and this new external entry point
|
||||
- **Bug fix — cross-realm Blob detection** — images/masks generated in a different window (Comic Creator) failed to load (`[object Blob]` was interpreted as a URL string, causing a 404) because `instanceof Blob` checks fail across window realms; switched to a `typeof src !== "string"` check
|
||||
- Hidden mask layers are now excluded from Inpaint in both the UI-driven and external-call paths
|
||||
|
||||
### v0.3.71
|
||||
|
||||
- **Image Edit — Inpaint tool (🩹)** — new tool composites the canvas (all non-mask layers) plus the active mask layer, uploads to an inpaint-capable LoadImage node (or a Mask Editor One node) in the GenerateUI workflow, applies Positive/Negative prompts, Grow Mask By, and Denoise, then triggers generation — all without leaving the Image Edit tab; the result is shown in the Properties pane
|
||||
- **Image Edit — Inpaint: dedicated workflow** — "Use dedicated workflow" checkbox lets you pick a saved workflow to run the inpaint against instead of the one currently loaded in GenerateUI, without disturbing what's shown there
|
||||
- **Image Edit — Inpaint: Mask Editor One support** — workflows built around the `comfyui-mask-editor-one` custom node (no LoadImage) are also supported; image/mask are pushed via its node_id-keyed server cache and `layer_data` widget
|
||||
- **GenerateUI — Input/Image tab mask slots** — LoadImage nodes whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) now show an additional Mask drop zone; image + mask are composited into a single RGBA upload matching ComfyUI's native alpha→MASK extraction
|
||||
- New example workflows: `ws_inpaint_basic.json`, `ws_inpaint_mask_editor_one.json`
|
||||
|
||||
### v0.3.70
|
||||
|
||||
- **Workflow AI summary — Ollama 404 fix** — the summarize button in the workflow detail modal now uses the Ollama URL/model saved in Settings → AI Assistant Settings (previously it silently fell back to a hardcoded default model tag that didn't match locally installed models, causing a 404)
|
||||
- **Image Edit — New button auto-adds Layer 1** — creating a blank canvas via the New button now automatically adds an empty "Layer 1", matching the existing image-load behavior
|
||||
- **Image Edit — mask overlay shown only for the active mask layer** — previously every visible mask layer's overlay was rendered regardless of selection; now only the currently active mask layer shows its overlay, other mask layers stay hidden until selected
|
||||
- **GenerateUI — Revert button for Positive/Negative Prompt** — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value
|
||||
- **GenerateUI — Ctrl+↑/↓ prompt emphasis weight editing** — A1111 / native ComfyUI CLIP Text Encode style: select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05; parentheses are removed automatically when the weight returns to 1.0
|
||||
|
||||
### v0.3.69
|
||||
|
||||
- **Gallery — Send CC button** — new toolbar button, shown only when the Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay via its existing `insertImageFromUrl(url)` helper (no changes required on the Comic Creator side)
|
||||
|
||||
+103
-2
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
@@ -17,6 +18,84 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/eagle/test", handle_test)
|
||||
|
||||
|
||||
def _resolve_local_path(filename, subfolder, type_name):
|
||||
"""filename/subfolder/type を ComfyUI 出力ディレクトリ上の実パスに解決する。
|
||||
ComfyUI と Eagle が同一マシン上で動作している場合のみ有効なパスが返る
|
||||
(別マシンの場合は None を返し、呼び出し側は addFromURL にフォールバックする)。"""
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
base_dir = folder_paths.get_directory_by_type(type_name or "output")
|
||||
if not base_dir:
|
||||
return None
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
|
||||
filename = os.path.basename(filename or "")
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
full_dir = os.path.abspath(os.path.join(base_dir, subfolder or ""))
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(full_dir), os.path.normcase(base_dir))) != os.path.normcase(base_dir):
|
||||
# ディレクトリトラバーサル対策: base_dir 配下以外は拒否
|
||||
return None
|
||||
|
||||
full_path = os.path.join(full_dir, filename)
|
||||
if not os.path.isfile(full_path):
|
||||
return None
|
||||
return full_path
|
||||
|
||||
|
||||
def _resolve_absolute_svg_path(abs_path):
|
||||
"""comfyui-tosvg の Save SVG String ノードなどが返す絶対パスを検証する。
|
||||
ComfyUI管理下(output/input/temp)ディレクトリ配下の実在する .svg ファイルのみ許可する
|
||||
(任意の絶対パスをそのまま信頼すると、外部からこのAPIを叩かれた際に
|
||||
ローカルの任意ファイルを送信させられてしまうため)。"""
|
||||
if not abs_path or not abs_path.lower().endswith(".svg"):
|
||||
return None
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
abs_path = os.path.abspath(abs_path)
|
||||
if not os.path.isfile(abs_path):
|
||||
return None
|
||||
|
||||
for type_name in ("output", "temp", "input"):
|
||||
base_dir = folder_paths.get_directory_by_type(type_name)
|
||||
if not base_dir:
|
||||
continue
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
try:
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(abs_path), os.path.normcase(base_dir))) == os.path.normcase(base_dir):
|
||||
return abs_path
|
||||
except ValueError:
|
||||
# Windowsで異なるドライブレターの場合 commonpath が例外を投げる
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _eagle_add_from_path(eagle_url, file_path, name, tags):
|
||||
"""Proxy request to Eagle's addFromPath API (runs in thread).
|
||||
SVG は ComfyUI の /view がセキュリティ上 application/octet-stream +
|
||||
attachment で返すため、addFromURL 経由だと正しい種別で保存されないことがある。
|
||||
ComfyUI と Eagle が同一マシン上にある場合はローカルパスを直接渡す方が確実。"""
|
||||
eagle_url = eagle_url.rstrip("/")
|
||||
payload = json.dumps({"path": file_path, "name": name, "tags": tags}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{eagle_url}/api/item/addFromPath",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _eagle_add(eagle_url, image_url, name, tags):
|
||||
"""Proxy request to Eagle API (runs in thread)."""
|
||||
eagle_url = eagle_url.rstrip("/")
|
||||
@@ -71,11 +150,33 @@ async def handle_add(request: web.Request) -> web.Response:
|
||||
image_url = body.get("url", "")
|
||||
name = body.get("name", "image.png")
|
||||
tags = body.get("tags", [])
|
||||
filename = body.get("filename", "")
|
||||
subfolder = body.get("subfolder", "")
|
||||
file_type = body.get("type", "output")
|
||||
local_path_hint = body.get("localPath", "")
|
||||
|
||||
if not image_url:
|
||||
if not image_url and not local_path_hint:
|
||||
return web.json_response({"status": "error", "message": "No URL provided"}, status=400)
|
||||
|
||||
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
|
||||
# SVG は /view が application/octet-stream + attachment で返すため、
|
||||
# ローカルファイルが解決できる場合は addFromPath で直接渡す
|
||||
local_path = None
|
||||
if local_path_hint:
|
||||
# comfyui-tosvg の Save SVG String ノードなど、絶対パスが既知の場合
|
||||
local_path = _resolve_absolute_svg_path(local_path_hint)
|
||||
elif (filename or name or "").lower().endswith(".svg"):
|
||||
local_path = _resolve_local_path(filename or name, subfolder, file_type)
|
||||
|
||||
if not local_path and not image_url:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": f"Could not resolve local SVG path: {local_path_hint}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if local_path:
|
||||
result = await asyncio.to_thread(_eagle_add_from_path, eagle_url, local_path, name, tags)
|
||||
else:
|
||||
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
|
||||
return web.json_response(result)
|
||||
except urllib.error.URLError as e:
|
||||
logger.error("Eagle URL error: %s", e)
|
||||
|
||||
@@ -260,8 +260,18 @@ 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 からの PNG をギャラリーのルートフォルダに保存する。"""
|
||||
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
|
||||
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "").strip()
|
||||
@@ -273,15 +283,21 @@ 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")
|
||||
|
||||
# ファイル名サニタイズ(OSで使えない文字を除去)
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(".png"):
|
||||
safe += ".png"
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
|
||||
# base64 data URL をデコード
|
||||
raw = image_data
|
||||
if "," in raw:
|
||||
raw = raw.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(raw)
|
||||
|
||||
save_path = _service._allowed_root / safe
|
||||
|
||||
@@ -82,7 +82,8 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
cfg = _get_ollama_config()
|
||||
model = body.get("model", cfg["model"])
|
||||
model = body.get("model") or cfg["model"]
|
||||
base_url = (body.get("url") or cfg["url"]).rstrip("/")
|
||||
messages = body.get("messages", [])
|
||||
|
||||
def _chat():
|
||||
@@ -92,7 +93,7 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
"stream": False,
|
||||
}).encode("utf-8")
|
||||
|
||||
url = f"{cfg['url']}/api/chat"
|
||||
url = f"{base_url}/api/chat"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
@@ -684,6 +684,11 @@ class GalleryService:
|
||||
if p.suffix.lower() == ".gif":
|
||||
return p
|
||||
|
||||
# SVG はベクター画像でPillowが開けないため、元ファイルをそのまま返す
|
||||
# (<img>タグはブラウザ側で任意サイズにスケーリングして表示する)
|
||||
if p.suffix.lower() == ".svg":
|
||||
return p
|
||||
|
||||
try:
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
except OSError:
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
# プロンプトを直接持つノードタイプ(CLIPTextEncode以外の複合ノード含む)
|
||||
_PROMPT_NODE_TYPES = {
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner",
|
||||
"CLIPTextEncodeEditPlus", "TextEncodeQwenImageEditPlus", "TextEncodeMageFlowEdit",
|
||||
"SDXLPromptStyler", "SDXLPromptStylerAdvanced", "ImageMetadataPromptLoader",
|
||||
"WFS_PromptText", "ImpactWildcardEncode", "ImpactWildcardProcessor",
|
||||
}
|
||||
|
||||
# 画像を最終出力として保存するノードタイプ
|
||||
_OUTPUT_IMAGE_NODE_TYPES = {"SaveImage", "SaveImageAdvanced"}
|
||||
|
||||
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
|
||||
_CLIP_TYPE_TO_MODEL = {
|
||||
"flux": "Flux",
|
||||
@@ -181,25 +192,27 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
if "hidream" in title:
|
||||
model_types.add("HiDream")
|
||||
|
||||
if ntype == "CLIPTextEncode" or "prompt" in title:
|
||||
if ntype in _PROMPT_NODE_TYPES or "prompt" in title:
|
||||
inputs["prompts"] += 1
|
||||
if ntype in ("LoadImage", "LoadImageMask"):
|
||||
inputs["images"] += 1
|
||||
if ntype == "LoadVideo" or "load video" in title:
|
||||
inputs["videos"] += 1
|
||||
if ntype == "SaveImage":
|
||||
if ntype in _OUTPUT_IMAGE_NODE_TYPES:
|
||||
outputs["images"] += 1
|
||||
if ntype in ("SaveVideo", "VH_VideoCombine"):
|
||||
outputs["videos"] += 1
|
||||
|
||||
# Detect workflow format
|
||||
if raw_nodes is not None:
|
||||
# App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
|
||||
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
|
||||
# conversion), so it's treated as "ui" here too — must match detectFormat() in
|
||||
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
|
||||
# with what GenerateUI actually does with the file.
|
||||
extra = workflow_data.get("extra", {})
|
||||
if (
|
||||
"definitions" in workflow_data
|
||||
or extra.get("linearMode") is True
|
||||
):
|
||||
if extra.get("linearMode") is True:
|
||||
wf_format = "app"
|
||||
elif filename and filename.lower().endswith(".app.json"):
|
||||
wf_format = "app"
|
||||
|
||||
+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.69"
|
||||
version = "0.3.78"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -511,6 +511,18 @@ body {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.wfm-select-missing {
|
||||
border-color: var(--wfm-warning);
|
||||
color: var(--wfm-warning);
|
||||
}
|
||||
|
||||
.wfm-model-missing-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-warning);
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-input:focus,
|
||||
.wfm-textarea:focus,
|
||||
.wfm-select:focus {
|
||||
@@ -1044,6 +1056,17 @@ body {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Bypass/Mute node indicator (shown above the Raw JSON search bar when present) */
|
||||
.wfm-gen-rawjson-bypass-note {
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 165, 2, 0.12);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-warning);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* RAW JSON search bar */
|
||||
.wfm-rawjson-search {
|
||||
display: flex;
|
||||
@@ -2938,6 +2961,41 @@ body {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-filename {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-preview-wrap .wfm-i2i-preview-img {
|
||||
max-height: 70px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.wfm-i2i-mask-drop-zone {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Side Panel - Thumbnail Tab
|
||||
============================================ */
|
||||
@@ -5207,6 +5265,9 @@ body {
|
||||
.wfm-ai-pane-settings { flex: 2; }
|
||||
|
||||
.wfm-ai-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
@@ -5218,6 +5279,19 @@ body {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-ai-pane-divider {
|
||||
width: 1px;
|
||||
background: var(--wfm-border);
|
||||
@@ -5435,6 +5509,29 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wfm-ai-vlm-clear {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-vlm-clear:hover {
|
||||
background: var(--wfm-danger);
|
||||
}
|
||||
|
||||
.wfm-ai-vlm-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5511,6 +5608,79 @@ body {
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-img {
|
||||
max-width: 20%;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attach-thumb {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-svg-wrapper {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-svg-img {
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
background: var(--wfm-surface);
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment-clear {
|
||||
margin-left: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment-clear:hover {
|
||||
background: var(--wfm-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-input-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+314
-31
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* AI Tab - Translation, VLM, Settings
|
||||
* Supports Ollama and LM Studio as backends
|
||||
* Supports Ollama, LM Studio, and Lemonade as backends
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_ai_settings";
|
||||
|
||||
@@ -196,7 +199,7 @@ function initTranslateTab() {
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -285,12 +288,11 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
// Update URL placeholder when backend changes
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
if (!urlInput) return;
|
||||
const defaultUrl = r.value === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
if (!saved.backendUrl) urlInput.value = defaultUrl;
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,6 +343,47 @@ function initSettingsTab() {
|
||||
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
|
||||
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
|
||||
|
||||
// Chat image generation — dedicated workflow selection (T2I)
|
||||
const chatGenCheckbox = document.getElementById("wfm-ai-chatgen-dedicated-checkbox");
|
||||
const chatGenRow = document.getElementById("wfm-ai-chatgen-dedicated-row");
|
||||
const chatGenSelect = document.getElementById("wfm-ai-chatgen-dedicated-select");
|
||||
|
||||
if (chatGenCheckbox) chatGenCheckbox.checked = !!saved.chatGenDedicatedEnabled;
|
||||
if (chatGenRow) chatGenRow.style.display = saved.chatGenDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenCheckbox?.addEventListener("change", () => {
|
||||
if (chatGenRow) chatGenRow.style.display = chatGenCheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
// Chat I2I generation — dedicated workflow selection
|
||||
const chatGenI2ICheckbox = document.getElementById("wfm-ai-chatgen-i2i-dedicated-checkbox");
|
||||
const chatGenI2IRow = document.getElementById("wfm-ai-chatgen-i2i-dedicated-row");
|
||||
const chatGenI2ISelect = document.getElementById("wfm-ai-chatgen-i2i-dedicated-select");
|
||||
|
||||
if (chatGenI2ICheckbox) chatGenI2ICheckbox.checked = !!saved.chatGenI2IDedicatedEnabled;
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = saved.chatGenI2IDedicatedEnabled ? "" : "none";
|
||||
|
||||
chatGenI2ICheckbox?.addEventListener("change", () => {
|
||||
if (chatGenI2IRow) chatGenI2IRow.style.display = chatGenI2ICheckbox.checked ? "" : "none";
|
||||
});
|
||||
|
||||
if (chatGenSelect) {
|
||||
fetch(`${comfyUI.baseUrl}/api/wfm/workflows`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
const filenames = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
const optionsHtml = `<option value="">-- select workflow --</option>` +
|
||||
filenames.map((f) => `<option value="${f}">${f}</option>`).join("");
|
||||
chatGenSelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenDedicatedFilename) chatGenSelect.value = saved.chatGenDedicatedFilename;
|
||||
if (chatGenI2ISelect) {
|
||||
chatGenI2ISelect.innerHTML = optionsHtml;
|
||||
if (saved.chatGenI2IDedicatedFilename) chatGenI2ISelect.value = saved.chatGenI2IDedicatedFilename;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Save settings
|
||||
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
|
||||
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
|
||||
@@ -348,13 +391,21 @@ function initSettingsTab() {
|
||||
const model = document.getElementById("wfm-ai-model-select")?.value || "";
|
||||
const freeSrcLang = freeSrcInput?.value?.trim() || "";
|
||||
const freeDstLang = freeDstInput?.value?.trim() || "";
|
||||
const chatGenDedicatedEnabled = !!chatGenCheckbox?.checked;
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
});
|
||||
|
||||
@@ -368,30 +419,190 @@ function initSettingsTab() {
|
||||
// Chat tab
|
||||
// ============================================
|
||||
|
||||
async function callChat(url, backend, model, messages) {
|
||||
const IMAGE_GEN_TOOLS = [{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_image",
|
||||
description: "Generate an image using the currently loaded ComfyUI workflow. If an image is currently attached to the conversation, this performs image-to-image generation using that image as the base; otherwise it performs text-to-image. Call this when the user asks to create, draw, paint, edit, or transform an image.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prompt: { type: "string", description: "Positive prompt describing the desired image." },
|
||||
negative_prompt: { type: "string", description: "What to avoid in the image. Optional." },
|
||||
},
|
||||
required: ["prompt"],
|
||||
},
|
||||
},
|
||||
}];
|
||||
|
||||
// chatHistory の内部共通形式 {role, content, images?:[{base64,mimeType}]} を
|
||||
// バックエンドごとのワイヤーフォーマットへ変換する(callVLM() と同じ規約)
|
||||
function _formatMessagesForBackend(messages, backend) {
|
||||
return messages.map((m) => {
|
||||
if (!m.images?.length) return { role: m.role, content: m.content };
|
||||
if (backend === "ollama") {
|
||||
return { role: m.role, content: m.content, images: m.images.map((i) => i.base64) };
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content: [
|
||||
{ type: "text", text: m.content },
|
||||
...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mimeType};base64,${i.base64}` } })),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content) {
|
||||
function _parseToolArgs(toolCall) {
|
||||
const raw = toolCall.function?.arguments ?? toolCall.arguments;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw); } catch { return {}; }
|
||||
}
|
||||
return raw || {};
|
||||
}
|
||||
|
||||
async function _loadDedicatedChatGenWorkflow(filename) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(filename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let workflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
if (format === "ui") {
|
||||
workflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
return { workflow, analysis: comfyWorkflow.analyzeWorkflow(workflow) };
|
||||
}
|
||||
|
||||
async function generateImageFromChat(prompt, negativePrompt) {
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error(t("aiChatGenerateNotReady"));
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const isI2I = !!_toolsImage;
|
||||
let workflow, analysis;
|
||||
|
||||
if (isI2I && settings.chatGenI2IDedicatedEnabled && settings.chatGenI2IDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenI2IDedicatedFilename));
|
||||
} else if (!isI2I && settings.chatGenDedicatedEnabled && settings.chatGenDedicatedFilename) {
|
||||
({ workflow, analysis } = await _loadDedicatedChatGenWorkflow(settings.chatGenDedicatedFilename));
|
||||
} else {
|
||||
if (!comfyUI.currentWorkflow || !comfyUI.currentAnalysis) throw new Error(t("aiChatNoWorkflowLoaded"));
|
||||
workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
|
||||
analysis = comfyUI.currentAnalysis;
|
||||
}
|
||||
|
||||
if (isI2I) {
|
||||
if (!(analysis.load_image_nodes?.length > 0)) throw new Error(t("aiChatNoLoadImageNode"));
|
||||
await comfyEditor.applyImageToSlot(_toolsImage.file, 0, { workflow, analysis });
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negativePrompt) comfyEditor.setPromptText("negative", negativePrompt, { workflow, analysis });
|
||||
|
||||
const result = await window._wfmGenerateTab.generate(workflow);
|
||||
const images = result?.images || [];
|
||||
if (images.length === 0) throw new Error(t("aiChatNoImageProduced"));
|
||||
|
||||
const img = images[0];
|
||||
return `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
}
|
||||
|
||||
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
|
||||
function _extractSvgCode(text) {
|
||||
if (!text) return null;
|
||||
const fenced = text.match(/```(?:svg|xml)?\s*([\s\S]*?<svg[\s\S]*?<\/svg>)[\s\S]*?```/i);
|
||||
if (fenced) return fenced[1].trim();
|
||||
const bare = text.match(/<svg[\s\S]*?<\/svg>/i);
|
||||
if (bare) return bare[0].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// SVGコードをプレビュー表示し、Galleryへ保存するボタンを追加する。
|
||||
// <img src="data:..."> でのレンダリングはブラウザがスクリプト実行を無効化するため安全。
|
||||
function _appendSvgPreview(container, svgCode) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "wfm-ai-chat-svg-wrapper";
|
||||
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgCode)))}`;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = dataUrl;
|
||||
img.className = "wfm-ai-chat-svg-img";
|
||||
img.alt = "Generated SVG";
|
||||
wrapper.appendChild(img);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "wfm-btn wfm-btn-sm";
|
||||
saveBtn.textContent = t("aiChatSaveSvgToGallery");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: `chat_svg_${Date.now()}.svg`, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
showToast(t("aiChatSvgSaved"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("aiChatSvgSaveFailed") + e.message, "error");
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
wrapper.appendChild(saveBtn);
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
|
||||
div.textContent = content;
|
||||
if (content) div.textContent = content;
|
||||
if (opts.imageUrl) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.imageUrl;
|
||||
img.className = "wfm-ai-chat-img";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (opts.attachThumb) {
|
||||
const img = document.createElement("img");
|
||||
img.src = opts.attachThumb;
|
||||
img.className = "wfm-ai-chat-attach-thumb";
|
||||
div.appendChild(img);
|
||||
}
|
||||
if (role === "assistant" && content) {
|
||||
const svgCode = _extractSvgCode(content);
|
||||
if (svgCode) _appendSvgPreview(div, svgCode);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
@@ -403,9 +614,22 @@ function initChatTab() {
|
||||
const sendBtn = document.getElementById("wfm-ai-chat-send-btn");
|
||||
const clearBtn = document.getElementById("wfm-ai-chat-clear-btn");
|
||||
const statusEl = document.getElementById("wfm-ai-chat-status");
|
||||
const imgGenToggle = document.getElementById("wfm-ai-chat-imggen-toggle");
|
||||
|
||||
if (!messagesEl || !inputEl || !sendBtn) return;
|
||||
|
||||
if (imgGenToggle) {
|
||||
const savedToggle = loadAiSettings();
|
||||
imgGenToggle.checked = savedToggle.chatImageGenEnabled !== false;
|
||||
imgGenToggle.addEventListener("change", () => {
|
||||
saveAiSettings({ chatImageGenEnabled: imgGenToggle.checked });
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("wfm-ai-chat-attachment-clear")?.addEventListener("click", () => {
|
||||
_clearToolsImage();
|
||||
});
|
||||
|
||||
let chatHistory = [];
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -414,7 +638,7 @@ function initChatTab() {
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -427,17 +651,39 @@ function initChatTab() {
|
||||
|
||||
inputEl.value = "";
|
||||
inputEl.style.height = "";
|
||||
chatHistory.push({ role: "user", content: text });
|
||||
appendChatBubble(messagesEl, "user", text);
|
||||
const userEntry = { role: "user", content: text };
|
||||
if (_toolsImage) userEntry.images = [{ base64: _toolsImage.base64, mimeType: _toolsImage.mimeType }];
|
||||
chatHistory.push(userEntry);
|
||||
appendChatBubble(messagesEl, "user", text, _toolsImage ? { attachThumb: `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}` } : {});
|
||||
|
||||
sendBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusChatting");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
|
||||
|
||||
try {
|
||||
const reply = await callChat(url, backend, model, chatHistory);
|
||||
chatHistory.push({ role: "assistant", content: reply });
|
||||
appendChatBubble(messagesEl, "assistant", reply);
|
||||
const tools = imgGenToggle?.checked !== false ? IMAGE_GEN_TOOLS : undefined;
|
||||
const reply = await callChat(url, backend, model, chatHistory, tools);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
|
||||
if (toolCall) {
|
||||
chatHistory.push({ role: "assistant", content: reply.content || "" });
|
||||
if (reply.content) appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
|
||||
statusEl.textContent = t("aiStatusGeneratingImage");
|
||||
const args = _parseToolArgs(toolCall);
|
||||
try {
|
||||
const imageUrl = await generateImageFromChat(args.prompt, args.negative_prompt);
|
||||
appendChatBubble(messagesEl, "assistant", "", { imageUrl });
|
||||
} catch (genErr) {
|
||||
appendChatBubble(messagesEl, "assistant", `${t("aiToastImageGenFailed")}${genErr.message}`);
|
||||
showToast(t("aiToastImageGenFailed") + genErr.message, "error");
|
||||
}
|
||||
} else {
|
||||
chatHistory.push({ role: "assistant", content: reply.content });
|
||||
appendChatBubble(messagesEl, "assistant", reply.content);
|
||||
}
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wfm-ai-trans-status";
|
||||
} catch (err) {
|
||||
@@ -469,14 +715,49 @@ function initChatTab() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOLS ↔ Chat shared image attachment
|
||||
// ============================================
|
||||
|
||||
let _toolsImage = null; // { file: File, base64: string, mimeType: string }
|
||||
|
||||
function _syncAttachmentUI() {
|
||||
const vlmPreview = document.getElementById("wfm-ai-vlm-preview");
|
||||
const vlmLabel = document.getElementById("wfm-ai-vlm-label");
|
||||
const vlmClear = document.getElementById("wfm-ai-vlm-clear");
|
||||
const chatAttach = document.getElementById("wfm-ai-chat-attachment");
|
||||
const chatThumb = document.getElementById("wfm-ai-chat-attachment-thumb");
|
||||
const chatLabel = document.getElementById("wfm-ai-chat-attachment-label");
|
||||
|
||||
if (_toolsImage) {
|
||||
const dataUrl = `data:${_toolsImage.mimeType};base64,${_toolsImage.base64}`;
|
||||
if (vlmPreview) { vlmPreview.src = dataUrl; vlmPreview.style.display = "block"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "none";
|
||||
if (vlmClear) vlmClear.style.display = "flex";
|
||||
if (chatAttach) chatAttach.style.display = "flex";
|
||||
if (chatThumb) chatThumb.src = dataUrl;
|
||||
if (chatLabel) chatLabel.textContent = t("aiChatAttachmentLabel");
|
||||
} else {
|
||||
if (vlmPreview) { vlmPreview.src = ""; vlmPreview.style.display = "none"; }
|
||||
if (vlmLabel) vlmLabel.style.display = "";
|
||||
if (vlmClear) vlmClear.style.display = "none";
|
||||
if (chatAttach) chatAttach.style.display = "none";
|
||||
if (chatThumb) chatThumb.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _clearToolsImage() {
|
||||
_toolsImage = null;
|
||||
_syncAttachmentUI();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VLM tab
|
||||
// ============================================
|
||||
|
||||
function initVlmTab() {
|
||||
const dropEl = document.getElementById("wfm-ai-vlm-drop");
|
||||
const previewEl = document.getElementById("wfm-ai-vlm-preview");
|
||||
const labelEl = document.getElementById("wfm-ai-vlm-label");
|
||||
const clearEl = document.getElementById("wfm-ai-vlm-clear");
|
||||
const fileInput = document.getElementById("wfm-ai-vlm-file");
|
||||
const taskSel = document.getElementById("wfm-ai-vlm-task");
|
||||
const runBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
@@ -489,8 +770,6 @@ function initVlmTab() {
|
||||
|
||||
if (!dropEl) return;
|
||||
|
||||
let vlmImage = null; // { base64, mimeType }
|
||||
|
||||
function updateTaskUI() {
|
||||
const isWildcard = taskSel?.value === "wildcard";
|
||||
dropEl.style.display = isWildcard ? "none" : "";
|
||||
@@ -501,10 +780,9 @@ function initVlmTab() {
|
||||
|
||||
const loadImage = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
vlmImage = await fileToBase64(file);
|
||||
previewEl.src = `data:${vlmImage.mimeType};base64,${vlmImage.base64}`;
|
||||
previewEl.style.display = "block";
|
||||
labelEl.style.display = "none";
|
||||
const { base64, mimeType } = await fileToBase64(file);
|
||||
_toolsImage = { file, base64, mimeType };
|
||||
_syncAttachmentUI();
|
||||
};
|
||||
|
||||
dropEl.addEventListener("click", () => fileInput.click());
|
||||
@@ -516,12 +794,17 @@ function initVlmTab() {
|
||||
dropEl.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
|
||||
});
|
||||
clearEl?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_clearToolsImage();
|
||||
if (fileInput) fileInput.value = "";
|
||||
});
|
||||
|
||||
runBtn.addEventListener("click", async () => {
|
||||
const task = taskSel?.value || "describe";
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
|
||||
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
|
||||
@@ -557,7 +840,7 @@ function initVlmTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
if (!_toolsImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
@@ -565,7 +848,7 @@ function initVlmTab() {
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
|
||||
+13
-2
@@ -310,6 +310,16 @@ function applyI18nToHtml() {
|
||||
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
|
||||
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
|
||||
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
|
||||
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
|
||||
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
|
||||
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
|
||||
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
|
||||
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
|
||||
if (aiChatGenDedicatedLabel) aiChatGenDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
const aiChatGenI2ITitle = document.getElementById("wfm-ai-settings-chatgen-i2i-title");
|
||||
if (aiChatGenI2ITitle) aiChatGenI2ITitle.textContent = t("aiSettingsChatI2IGenTitle");
|
||||
const aiChatGenI2IDedicatedLabel = document.getElementById("wfm-ai-chatgen-i2i-dedicated-label");
|
||||
if (aiChatGenI2IDedicatedLabel) aiChatGenI2IDedicatedLabel.textContent = t("aiSettingsChatGenDedicated");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -423,6 +433,7 @@ function applyI18nToHtml() {
|
||||
"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-7": "helpAi7",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
@@ -445,7 +456,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d",
|
||||
"wfm-help-imageedit-7d": "helpImageEdit7d", "wfm-help-imageedit-7e": "helpImageEdit7e",
|
||||
"wfm-help-imageedit-8b": "helpImageEdit8b",
|
||||
"wfm-help-imageedit-blur-title": "helpImageEditBlurTitle",
|
||||
"wfm-help-imageedit-blur-1": "helpImageEditBlur1", "wfm-help-imageedit-blur-2": "helpImageEditBlur2",
|
||||
@@ -482,7 +493,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
|
||||
"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-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
|
||||
@@ -240,7 +240,13 @@ export const comfyUI = {
|
||||
});
|
||||
const res = await fetch(`${this.baseUrl}/view?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch image: HTTP ${res.status}`);
|
||||
return await res.blob();
|
||||
const blob = await res.blob();
|
||||
// /view はSVGをセキュリティ上application/octet-streamで返すため、
|
||||
// <img>で表示できるよう拡張子から正しいMIMEタイプへ付け替える
|
||||
if (imageData.filename?.toLowerCase().endsWith(".svg") && blob.type !== "image/svg+xml") {
|
||||
return blob.slice(0, blob.size, "image/svg+xml");
|
||||
}
|
||||
return blob;
|
||||
},
|
||||
|
||||
async uploadImage(file, filename, overwrite = true) {
|
||||
@@ -300,6 +306,9 @@ export const comfyUI = {
|
||||
|
||||
// Extract images from SaveImage outputs only
|
||||
const images = [];
|
||||
// comfyui-tosvg の "Save SVG String" ノードは images ではなく
|
||||
// saved_svg(ファイル名) / path(絶対パス) という独自キーで返す
|
||||
const svgOutputs = [];
|
||||
const outputs = history.outputs || {};
|
||||
for (const nodeOutput of Object.values(outputs)) {
|
||||
if (nodeOutput.images) {
|
||||
@@ -307,10 +316,23 @@ export const comfyUI = {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
if (nodeOutput.saved_svg) {
|
||||
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
|
||||
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
|
||||
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
|
||||
const joinIfCharArray = (v) =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
|
||||
? v.join("")
|
||||
: v;
|
||||
svgOutputs.push({
|
||||
filename: joinIfCharArray(nodeOutput.saved_svg),
|
||||
path: joinIfCharArray(nodeOutput.path) || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(images, seed);
|
||||
return { images, seed };
|
||||
return { images, seed, svgOutputs };
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
throw err;
|
||||
|
||||
+466
-11
@@ -4,6 +4,8 @@
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
|
||||
// ── Latent Image preset state ─────────────────────────────
|
||||
const _LATENT_PRESET_KEY = "wfm_latent_presets";
|
||||
@@ -153,6 +155,142 @@ function _syncRawJson() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inpaint: 画像+マスクのRGBA合成 ──────────────────────
|
||||
// mask は白=インペイント対象領域/黒=維持領域のグレースケール画像を想定(Image Editの
|
||||
// マスクレイヤーも、手動でドロップする白黒マスクファイルも同じ規約)。
|
||||
// ComfyUIネイティブのLoadImageはアルファチャンネルから mask = 1 - alpha を抽出するため、
|
||||
// 合成後の alpha は 255 - maskGray とする。
|
||||
async function _loadImageElement(src) {
|
||||
// `instanceof Blob` は別レルム(他ウィンドウ/iframe)で生成されたBlob/Fileでは
|
||||
// falseになる(コンストラクタの参照が異なるため)。Comic CreaterからiframeへBlobを
|
||||
// 直接渡すInpaint連携で発生するため、実体で判定する(文字列URLかどうかだけを見る)。
|
||||
const isBlobLike = typeof src !== "string";
|
||||
const url = isBlobLike ? URL.createObjectURL(src) : src;
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
});
|
||||
return img;
|
||||
} finally {
|
||||
if (isBlobLike) URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function _compositeImageWithMask(imageInput, maskInput) {
|
||||
const img = await _loadImageElement(imageInput);
|
||||
const mask = await _loadImageElement(maskInput);
|
||||
|
||||
const w = img.naturalWidth, h = img.naturalHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const imgData = ctx.getImageData(0, 0, w, h);
|
||||
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = w; maskCanvas.height = h;
|
||||
const mctx = maskCanvas.getContext("2d");
|
||||
mctx.drawImage(mask, 0, 0, w, h);
|
||||
const maskData = mctx.getImageData(0, 0, w, h).data;
|
||||
|
||||
const data = imgData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i + 3] = 255 - maskData[i];
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
}
|
||||
|
||||
// ── Inpaint: Mask Editor One (comfyui-mask-editor-one) ノード向けヘルパー ──────
|
||||
// この custom node は LoadImage を介さず、画像は /mask_editor/store_image でサーバー側
|
||||
// キャッシュ(bg_image_b64, node_idキー)へ、マスクは layer_data(JSON文字列)ウィジェットの
|
||||
// 各レイヤーの「アルファチャンネル」から合成される(LoadImageの 1-alpha とは逆で、
|
||||
// alpha値がそのままmask値になる規約)。
|
||||
async function _imageInputToDataURL(imageInput) {
|
||||
const img = await _loadImageElement(imageInput);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
async function _maskInputToAlphaDataURL(maskInput) {
|
||||
const img = await _loadImageElement(maskInput);
|
||||
const w = img.naturalWidth, h = img.naturalHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const imgData = ctx.getImageData(0, 0, w, h);
|
||||
|
||||
const data = imgData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const gray = data[i];
|
||||
data[i] = data[i + 1] = data[i + 2] = 255;
|
||||
data[i + 3] = gray;
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// ── プロンプト強調weight編集 (Ctrl+↑/↓) ──────────────────────
|
||||
// A1111 / ComfyUIネイティブのCLIP Text Encodeと同様の操作感:
|
||||
// 選択(無ければカーソル位置の括弧ブロックまたは単語)を (text:weight) 形式に変換し、
|
||||
// weightを0.05刻みで増減する。weightが1.0に戻ったら括弧を除去する。
|
||||
const _PROMPT_WEIGHT_DELIMITERS = new Set([",", "\n", "\r", "\t", " ", "(", ")", ":"]);
|
||||
|
||||
function _selectSurroundingParenBlock(text, pos) {
|
||||
const openIdx = text.lastIndexOf("(", pos - 1);
|
||||
if (openIdx === -1) return null;
|
||||
const closeBefore = text.lastIndexOf(")", pos - 1);
|
||||
if (closeBefore > openIdx) return null; // カーソルは既に閉じたブロックの外
|
||||
const closeIdx = text.indexOf(")", pos);
|
||||
if (closeIdx === -1) return null;
|
||||
return { start: openIdx, end: closeIdx + 1 };
|
||||
}
|
||||
|
||||
function _selectSurroundingWord(text, pos) {
|
||||
let start = pos, end = pos;
|
||||
while (start > 0 && !_PROMPT_WEIGHT_DELIMITERS.has(text[start - 1])) start--;
|
||||
while (end < text.length && !_PROMPT_WEIGHT_DELIMITERS.has(text[end])) end++;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function _attachPromptWeightControl(textarea) {
|
||||
textarea.addEventListener("keydown", (e) => {
|
||||
if (!e.ctrlKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.key === "ArrowUp" ? 0.05 : -0.05;
|
||||
const text = textarea.value;
|
||||
let start = textarea.selectionStart;
|
||||
let end = textarea.selectionEnd;
|
||||
|
||||
if (start === end) {
|
||||
const range = _selectSurroundingParenBlock(text, start) || _selectSurroundingWord(text, start);
|
||||
start = range.start;
|
||||
end = range.end;
|
||||
}
|
||||
if (start === end) return;
|
||||
|
||||
const selected = text.substring(start, end);
|
||||
const m = selected.match(/^\((.*):(-?[\d.]+)\)$/s);
|
||||
const inner = m ? m[1] : selected;
|
||||
let weight = (m ? parseFloat(m[2]) : 1.0) + delta;
|
||||
weight = Math.round(weight * 100) / 100;
|
||||
|
||||
const replacement = weight === 1.0 ? inner : `(${inner}:${weight.toFixed(2)})`;
|
||||
textarea.value = text.substring(0, start) + replacement + text.substring(end);
|
||||
textarea.setSelectionRange(start, start + replacement.length);
|
||||
});
|
||||
}
|
||||
|
||||
export const comfyEditor = {
|
||||
models: {
|
||||
checkpoints: [],
|
||||
@@ -228,10 +366,11 @@ export const comfyEditor = {
|
||||
<div class="wfm-form-group">
|
||||
<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">
|
||||
<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("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
<textarea class="wfm-textarea" id="wfm-prompt-pos-text" rows="6">${positiveNodes[0]?.text || ""}</textarea>
|
||||
@@ -239,10 +378,11 @@ export const comfyEditor = {
|
||||
<div class="wfm-form-group">
|
||||
<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">
|
||||
<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("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-apply">Apply</button>
|
||||
</div>
|
||||
<textarea class="wfm-textarea" id="wfm-prompt-neg-text" rows="6">${negativeNodes[0]?.text || ""}</textarea>
|
||||
@@ -284,6 +424,27 @@ export const comfyEditor = {
|
||||
}
|
||||
});
|
||||
|
||||
// Revert: テキストエリアを直前にApplyされたノードの現在値へ戻す
|
||||
document.getElementById("wfm-prompt-pos-revert")?.addEventListener("click", () => {
|
||||
const nodeId = document.getElementById("wfm-prompt-pos-target")?.value;
|
||||
const wfNode = comfyUI.currentWorkflow?.[nodeId];
|
||||
const ta = document.getElementById("wfm-prompt-pos-text");
|
||||
if (!nodeId || !wfNode || !ta) return;
|
||||
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
|
||||
const textKey = promptNode?.textKey || "text";
|
||||
ta.value = wfNode.inputs[textKey] ?? "";
|
||||
});
|
||||
|
||||
document.getElementById("wfm-prompt-neg-revert")?.addEventListener("click", () => {
|
||||
const nodeId = document.getElementById("wfm-prompt-neg-target")?.value;
|
||||
const wfNode = comfyUI.currentWorkflow?.[nodeId];
|
||||
const ta = document.getElementById("wfm-prompt-neg-text");
|
||||
if (!nodeId || !wfNode || !ta) return;
|
||||
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
|
||||
const textKey = promptNode?.textKey || "text";
|
||||
ta.value = wfNode.inputs[textKey] ?? "";
|
||||
});
|
||||
|
||||
// Track last focused prompt textarea for Paste button
|
||||
["wfm-prompt-pos-text", "wfm-prompt-neg-text"].forEach((taId) => {
|
||||
const ta = document.getElementById(taId);
|
||||
@@ -293,6 +454,7 @@ export const comfyEditor = {
|
||||
_lastPromptFocus = { id: taId, selStart: ta.selectionStart, selEnd: ta.selectionEnd };
|
||||
});
|
||||
});
|
||||
_attachPromptWeightControl(ta);
|
||||
});
|
||||
|
||||
// Embedding filter
|
||||
@@ -361,6 +523,7 @@ export const comfyEditor = {
|
||||
.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("");
|
||||
@@ -379,9 +542,11 @@ export const comfyEditor = {
|
||||
<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" id="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>
|
||||
@@ -1037,14 +1202,18 @@ export const comfyEditor = {
|
||||
if (!el) return;
|
||||
|
||||
const loadNodes = analysis.load_image_nodes || [];
|
||||
if (loadNodes.length === 0) {
|
||||
el.innerHTML = `<p class="wfm-placeholder">No LoadImage nodes found in workflow</p>`;
|
||||
const meoNodes = analysis.mask_editor_one_nodes || [];
|
||||
if (loadNodes.length === 0 && meoNodes.length === 0) {
|
||||
el.innerHTML = `<p class="wfm-placeholder">No LoadImage / Mask Editor One nodes found in workflow</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render up to 4 image input slots
|
||||
const slots = loadNodes.slice(0, 4);
|
||||
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
|
||||
const meoSlots = meoNodes.slice(0, 2);
|
||||
el.innerHTML = `
|
||||
${slots.length === 0 ? "" : `
|
||||
<div class="wfm-i2i-grid">
|
||||
${slots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
|
||||
@@ -1061,6 +1230,23 @@ export const comfyEditor = {
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-i2i-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
${node.mask_used ? `
|
||||
<div class="wfm-i2i-mask-section">
|
||||
<div class="wfm-i2i-mask-header">
|
||||
<span>Mask</span>
|
||||
<span class="wfm-i2i-mask-filename" id="wfm-i2i-mask-filename-${i}"></span>
|
||||
</div>
|
||||
<div class="wfm-i2i-preview-wrap wfm-i2i-mask-preview-wrap" id="wfm-i2i-mask-preview-wrap-${i}" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-i2i-mask-preview-${i}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone wfm-i2i-mask-drop-zone" id="wfm-i2i-mask-drop-${i}">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
Drop mask (white=inpaint) or click to select
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-i2i-mask-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
|
||||
@@ -1068,6 +1254,46 @@ export const comfyEditor = {
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`}
|
||||
${meoSlots.length === 0 ? "" : `
|
||||
<div class="wfm-i2i-grid" style="margin-top:12px;">
|
||||
${meoSlots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-meo-slot="${i}" data-node-id="${node.id}">
|
||||
<div class="wfm-i2i-slot-header">
|
||||
<span style="font-weight:600;font-size:12px;">ID:${node.id} ${node.title} (Mask Editor One)</span>
|
||||
</div>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-meo-preview-wrap-${i}" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-meo-preview-${i}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-meo-drop-${i}">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
Drop image or click to select
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-meo-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-i2i-mask-section">
|
||||
<div class="wfm-i2i-mask-header">
|
||||
<span>Mask</span>
|
||||
<span class="wfm-i2i-mask-filename" id="wfm-meo-mask-filename-${i}"></span>
|
||||
</div>
|
||||
<div class="wfm-i2i-preview-wrap wfm-i2i-mask-preview-wrap" id="wfm-meo-mask-preview-wrap-${i}" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-meo-mask-preview-${i}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone wfm-i2i-mask-drop-zone" id="wfm-meo-mask-drop-${i}">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
Drop mask (white=inpaint) or click to select
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-meo-mask-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-meo-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<span class="wfm-i2i-status" id="wfm-meo-status-${i}"></span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
|
||||
// Initialize each slot
|
||||
@@ -1079,7 +1305,13 @@ export const comfyEditor = {
|
||||
const filenameEl = document.getElementById(`wfm-i2i-filename-${i}`);
|
||||
const statusEl = document.getElementById(`wfm-i2i-status-${i}`);
|
||||
const dropZone = document.getElementById(`wfm-i2i-drop-${i}`);
|
||||
const maskFileInput = document.getElementById(`wfm-i2i-mask-file-${i}`);
|
||||
const maskDropZone = document.getElementById(`wfm-i2i-mask-drop-${i}`);
|
||||
const maskPreviewWrap = document.getElementById(`wfm-i2i-mask-preview-wrap-${i}`);
|
||||
const maskPreviewImg = document.getElementById(`wfm-i2i-mask-preview-${i}`);
|
||||
const maskFilenameEl = document.getElementById(`wfm-i2i-mask-filename-${i}`);
|
||||
let pendingFile = null;
|
||||
let pendingMaskFile = null;
|
||||
|
||||
const applyFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
@@ -1092,10 +1324,24 @@ export const comfyEditor = {
|
||||
statusEl.textContent = "";
|
||||
};
|
||||
|
||||
const applyMaskFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
pendingMaskFile = file;
|
||||
const url = URL.createObjectURL(file);
|
||||
maskPreviewImg.src = url;
|
||||
maskPreviewWrap.style.display = "";
|
||||
maskFilenameEl.textContent = file.name;
|
||||
applyBtn.disabled = false;
|
||||
statusEl.textContent = "";
|
||||
};
|
||||
|
||||
// File input change
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
});
|
||||
maskFileInput?.addEventListener("change", () => {
|
||||
if (maskFileInput.files.length > 0) applyMaskFile(maskFileInput.files[0]);
|
||||
});
|
||||
|
||||
// Drag & drop
|
||||
if (dropZone) {
|
||||
@@ -1112,14 +1358,39 @@ export const comfyEditor = {
|
||||
if (e.dataTransfer.files.length > 0) applyFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
if (maskDropZone) {
|
||||
maskDropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
maskDropZone.classList.add("drag-over");
|
||||
});
|
||||
maskDropZone.addEventListener("dragleave", () => {
|
||||
maskDropZone.classList.remove("drag-over");
|
||||
});
|
||||
maskDropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
maskDropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyMaskFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply button: upload to ComfyUI and set on node
|
||||
// (マスクが指定されている場合は画像+マスクをRGBA合成して1枚のPNGとしてアップロードする)
|
||||
applyBtn?.addEventListener("click", async () => {
|
||||
if (!pendingFile) return;
|
||||
if (!pendingFile && !pendingMaskFile) return;
|
||||
applyBtn.disabled = true;
|
||||
statusEl.textContent = "Uploading...";
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(pendingFile, pendingFile.name);
|
||||
let result;
|
||||
if (pendingMaskFile) {
|
||||
const baseImage = pendingFile || previewImg.src;
|
||||
if (!baseImage) throw new Error("No base image to apply mask to");
|
||||
const composedBlob = await _compositeImageWithMask(baseImage, pendingMaskFile);
|
||||
const fileName = (pendingFile?.name || node.image || "inpaint.png").replace(/\.[^.]+$/, "") + "_masked.png";
|
||||
const composedFile = new File([composedBlob], fileName, { type: "image/png" });
|
||||
result = await comfyUI.uploadImage(composedFile, fileName);
|
||||
} else {
|
||||
result = await comfyUI.uploadImage(pendingFile, pendingFile.name);
|
||||
}
|
||||
if (result.name) {
|
||||
// Update workflow node
|
||||
if (comfyUI.currentWorkflow?.[node.id]) {
|
||||
@@ -1139,20 +1410,97 @@ export const comfyEditor = {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
|
||||
meoSlots.forEach((node, i) => {
|
||||
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
|
||||
const maskFileInput = document.getElementById(`wfm-meo-mask-file-${i}`);
|
||||
const dropZone = document.getElementById(`wfm-meo-drop-${i}`);
|
||||
const maskDropZone = document.getElementById(`wfm-meo-mask-drop-${i}`);
|
||||
const previewWrap = document.getElementById(`wfm-meo-preview-wrap-${i}`);
|
||||
const previewImg = document.getElementById(`wfm-meo-preview-${i}`);
|
||||
const maskPreviewWrap = document.getElementById(`wfm-meo-mask-preview-wrap-${i}`);
|
||||
const maskPreviewImg = document.getElementById(`wfm-meo-mask-preview-${i}`);
|
||||
const maskFilenameEl = document.getElementById(`wfm-meo-mask-filename-${i}`);
|
||||
const applyBtn = el.querySelector(`.wfm-meo-apply[data-slot="${i}"]`);
|
||||
const statusEl = document.getElementById(`wfm-meo-status-${i}`);
|
||||
let pendingFile = null;
|
||||
let pendingMaskFile = null;
|
||||
|
||||
const updateApplyEnabled = () => { applyBtn.disabled = !(pendingFile && pendingMaskFile); };
|
||||
|
||||
const applyFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
pendingFile = file;
|
||||
previewImg.src = URL.createObjectURL(file);
|
||||
previewWrap.style.display = "";
|
||||
statusEl.textContent = "";
|
||||
updateApplyEnabled();
|
||||
};
|
||||
const applyMaskFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
pendingMaskFile = file;
|
||||
maskPreviewImg.src = URL.createObjectURL(file);
|
||||
maskPreviewWrap.style.display = "";
|
||||
maskFilenameEl.textContent = file.name;
|
||||
statusEl.textContent = "";
|
||||
updateApplyEnabled();
|
||||
};
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
});
|
||||
maskFileInput?.addEventListener("change", () => {
|
||||
if (maskFileInput.files.length > 0) applyMaskFile(maskFileInput.files[0]);
|
||||
});
|
||||
|
||||
[[dropZone, applyFile], [maskDropZone, applyMaskFile]].forEach(([zone, handler]) => {
|
||||
if (!zone) return;
|
||||
zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drag-over"); });
|
||||
zone.addEventListener("dragleave", () => zone.classList.remove("drag-over"));
|
||||
zone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) handler(e.dataTransfer.files[0]);
|
||||
});
|
||||
});
|
||||
|
||||
applyBtn?.addEventListener("click", async () => {
|
||||
if (!pendingFile || !pendingMaskFile) return;
|
||||
applyBtn.disabled = true;
|
||||
statusEl.textContent = "Uploading...";
|
||||
try {
|
||||
await this.applyImageAndMaskToMaskEditorOneNode(pendingFile, pendingMaskFile, node.id);
|
||||
statusEl.textContent = "✓ Applied";
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `✗ ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
applyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async applyImageToSlot(file, slotIndex = 0) {
|
||||
const loadNodes = comfyUI.currentAnalysis?.load_image_nodes || [];
|
||||
// opts.workflow/opts.analysis を渡すと comfyUI.currentWorkflow/currentAnalysis の代わりに
|
||||
// そちらへ書き込み、DOM更新は行わずアップロード後のファイル名だけを返す
|
||||
// (GenerateUIタブの表示状態には触れない。Chatペインの画像添付I2I連携から利用)。
|
||||
async applyImageToSlot(file, slotIndex = 0, opts = {}) {
|
||||
const workflow = opts.workflow || comfyUI.currentWorkflow;
|
||||
const analysis = opts.analysis || comfyUI.currentAnalysis;
|
||||
const loadNodes = analysis?.load_image_nodes || [];
|
||||
const node = loadNodes[slotIndex];
|
||||
if (!node) throw new Error("No LoadImage node at slot " + slotIndex);
|
||||
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
|
||||
if (comfyUI.currentWorkflow?.[node.id]) {
|
||||
comfyUI.currentWorkflow[node.id].inputs.image = result.name;
|
||||
if (workflow?.[node.id]) {
|
||||
workflow[node.id].inputs.image = result.name;
|
||||
}
|
||||
|
||||
if (opts.workflow) return result.name;
|
||||
|
||||
const previewWrap = document.getElementById(`wfm-i2i-preview-wrap-${slotIndex}`);
|
||||
const previewImg = document.getElementById(`wfm-i2i-preview-${slotIndex}`);
|
||||
const filenameEl = document.getElementById(`wfm-i2i-filename-${slotIndex}`);
|
||||
@@ -1171,6 +1519,113 @@ export const comfyEditor = {
|
||||
return result.name;
|
||||
},
|
||||
|
||||
// imageInput/maskInput: File/Blob いずれも可。マスクは白=インペイント対象領域のグレースケール想定。
|
||||
// 画像とマスクをRGBA合成し、1枚のPNGとして対象LoadImageノードへアップロードする
|
||||
// (ComfyUIネイティブのアルファ→MASK抽出に準拠。Image Edit「Inpaint」タブのRunボタンから利用)。
|
||||
// opts.workflow/opts.analysis を渡すと、comfyUI.currentWorkflow/currentAnalysis の代わりに
|
||||
// そのワークフローへ書き込む(GenerateUIタブの表示・DOM更新は行わない。専用ワークフロー実行用)。
|
||||
async applyImageAndMaskToSlot(imageInput, maskInput, slotIndex = 0, opts = {}) {
|
||||
const workflow = opts.workflow || comfyUI.currentWorkflow;
|
||||
const analysis = opts.analysis || comfyUI.currentAnalysis;
|
||||
const loadNodes = analysis?.load_image_nodes || [];
|
||||
const node = loadNodes[slotIndex];
|
||||
if (!node) throw new Error("No LoadImage node at slot " + slotIndex);
|
||||
|
||||
const composedBlob = await _compositeImageWithMask(imageInput, maskInput);
|
||||
const fileName = `inpaint_${Math.random().toString(36).slice(2, 10)}.png`;
|
||||
const composedFile = new File([composedBlob], fileName, { type: "image/png" });
|
||||
|
||||
const result = await comfyUI.uploadImage(composedFile, fileName);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
|
||||
if (workflow?.[node.id]) {
|
||||
workflow[node.id].inputs.image = result.name;
|
||||
}
|
||||
|
||||
if (opts.workflow) return result.name;
|
||||
|
||||
const previewWrap = document.getElementById(`wfm-i2i-preview-wrap-${slotIndex}`);
|
||||
const previewImg = document.getElementById(`wfm-i2i-preview-${slotIndex}`);
|
||||
const filenameEl = document.getElementById(`wfm-i2i-filename-${slotIndex}`);
|
||||
const statusEl = document.getElementById(`wfm-i2i-status-${slotIndex}`);
|
||||
const applyBtn = document.querySelector(`.wfm-i2i-apply[data-slot="${slotIndex}"]`);
|
||||
|
||||
if (previewImg) {
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
}
|
||||
if (filenameEl) filenameEl.textContent = result.name;
|
||||
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
|
||||
_syncRawJson();
|
||||
return result.name;
|
||||
},
|
||||
|
||||
// imageInput/maskInput: File/Blob いずれも可。マスクは白=インペイント対象領域のグレースケール想定。
|
||||
// Mask Editor One (comfyui-mask-editor-one) ノード向け。画像はサーバー側キャッシュへ
|
||||
// /mask_editor/store_image でPOSHし(node_idキー、bg_image_b64)、マスクは layer_data
|
||||
// ウィジェットへ「アルファチャンネル=mask値」のレイヤーとして書き込む。
|
||||
// opts.workflow を渡すと comfyUI.currentWorkflow の代わりにそのワークフローへ書き込む。
|
||||
async applyImageAndMaskToMaskEditorOneNode(imageInput, maskInput, nodeId, opts = {}) {
|
||||
const workflow = opts.workflow || comfyUI.currentWorkflow;
|
||||
const bgDataUrl = await _imageInputToDataURL(imageInput);
|
||||
const maskDataUrl = await _maskInputToAlphaDataURL(maskInput);
|
||||
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/mask_editor/store_image`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: String(nodeId), bg_image_b64: bgDataUrl }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to store image for Mask Editor One (node ${nodeId})`);
|
||||
|
||||
if (workflow?.[nodeId]) {
|
||||
workflow[nodeId].inputs.layer_data = JSON.stringify({
|
||||
layers: [{ visible: true, opacity: 1, operation: "add", imageData: maskDataUrl }],
|
||||
});
|
||||
}
|
||||
|
||||
if (!opts.workflow) _syncRawJson();
|
||||
},
|
||||
|
||||
// Inpaint Run から呼ばれるポジ/ネガプロンプト設定。
|
||||
// opts.workflow/opts.analysis を渡すと、GenerateUIタブの選択中ターゲット(DOM)に依存せず、
|
||||
// そのワークフロー内の最初の該当role prompt_nodeへ直接反映する(専用ワークフロー実行用)。
|
||||
setPromptText(role, text, opts = {}) {
|
||||
if (opts.workflow && opts.analysis) {
|
||||
const node = (opts.analysis.prompt_nodes || []).find((n) => n.role === role);
|
||||
if (node && opts.workflow[node.id]) {
|
||||
opts.workflow[node.id].inputs[node.textKey || "text"] = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const textareaId = role === "positive" ? "wfm-prompt-pos-text" : "wfm-prompt-neg-text";
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (textarea) textarea.value = text;
|
||||
this.syncToWorkflow();
|
||||
_syncRawJson();
|
||||
},
|
||||
|
||||
// Inpaint Run から呼ばれる grow_mask_by / denoise 設定。
|
||||
// ワークフロー内の最初の VAEEncodeForInpaint ノード / denoise を持つ最初の sampler ノードへ反映する
|
||||
// (複数のインペイントパイプラインが同一ワークフローに存在するケースは現状非対応)。
|
||||
// workflow/analysis を渡すと comfyUI.currentWorkflow/currentAnalysis の代わりにそちらへ書き込む。
|
||||
setInpaintParams({ growMaskBy, denoise, workflow, analysis } = {}) {
|
||||
const wf = workflow || comfyUI.currentWorkflow;
|
||||
const an = analysis || comfyUI.currentAnalysis;
|
||||
const encodeNode = an?.inpaint_encode_nodes?.[0];
|
||||
if (encodeNode && growMaskBy != null && wf?.[encodeNode.id]) {
|
||||
wf[encodeNode.id].inputs.grow_mask_by = growMaskBy;
|
||||
}
|
||||
|
||||
const samplerNode = (an?.sampler_nodes || []).find((n) => n.denoise !== undefined);
|
||||
if (samplerNode && denoise != null && wf?.[samplerNode.id]) {
|
||||
wf[samplerNode.id].inputs.denoise = denoise;
|
||||
}
|
||||
|
||||
if (!workflow) _syncRawJson();
|
||||
},
|
||||
|
||||
syncToWorkflow() {
|
||||
// Sync prompt texts before generation
|
||||
const posSelect = document.getElementById("wfm-prompt-pos-target");
|
||||
|
||||
+281
-15
@@ -5,6 +5,50 @@
|
||||
// Cache for /object_info (loaded once, shared)
|
||||
let _objectInfoCache = null;
|
||||
|
||||
// convertUiToApi() の COMBO フォールバック(未一致時に選択肢の先頭へ無警告で差し替え)が
|
||||
// ckpt_name に対して発生した場合の記録。呼び出し元が読み込み直後に確認して警告表示できるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
let _lastCheckpointSubstitutions = [];
|
||||
|
||||
// convertUiToApi() で除外された Bypass(mode:4) / Mute(mode:2) ノードの記録。
|
||||
// API形式には mode 情報が残らないため、呼び出し元がRaw JSON表示時に
|
||||
// 「このワークフローにはバイパス/ミュート中のノードがあった」と伝えられるよう、
|
||||
// 直近の convertUiToApi() 呼び出し分だけを保持する。
|
||||
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.
|
||||
* 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
|
||||
|
||||
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;
|
||||
} else {
|
||||
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
|
||||
}
|
||||
if (inIdx === -1) return null;
|
||||
|
||||
const upstream = linkMap[idStr][inIdx];
|
||||
if (!upstream) return null;
|
||||
return _resolveBypassSource(nodeById, linkMap, upstream[0], upstream[1], visited);
|
||||
}
|
||||
|
||||
async function _loadObjectInfo() {
|
||||
if (_objectInfoCache) return _objectInfoCache;
|
||||
try {
|
||||
@@ -37,7 +81,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
@@ -51,7 +95,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +106,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
|
||||
/**
|
||||
* Get ordered widget input types for a node class (parallel to _getWidgetInputNames).
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", or "COMBO".
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", or "COMFY_DYNAMICCOMBO_V3".
|
||||
*/
|
||||
function _getWidgetInputTypes(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
@@ -77,7 +121,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +140,19 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
|
||||
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
|
||||
*/
|
||||
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
|
||||
const info = objectInfo[classType];
|
||||
const spec = info?.input?.required?.[widgetName] || info?.input?.optional?.[widgetName];
|
||||
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 || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a widget value is a frontend-only extra (like "control_after_generate").
|
||||
* These values appear in widgets_values but are not in object_info inputs.
|
||||
@@ -123,10 +180,17 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
*
|
||||
* Strategy: expand internal nodes into the parent, redirect parent links through
|
||||
* the boundary ports, and convert internal links from object to array format.
|
||||
* Widget values in internal nodes are preserved as-is (they are synced at save time
|
||||
* by ComfyUI frontend). convertUiToApi handles widget mapping via object_info.
|
||||
*
|
||||
* Widget values: the outer subgraph node's own widgets_values hold the values actually
|
||||
* set via the subgraph's collapsed form (what the user sees/edits). The internal nodes'
|
||||
* widgets_values, stored in the subgraph template (definitions.subgraphs[].nodes), are
|
||||
* only stale defaults from when the template was authored — ComfyUI does NOT keep them
|
||||
* synced in saved workflow JSON. Un-linked outer widget inputs are therefore copied onto
|
||||
* the matching internal node's widgets_values (via objectInfo to get the correct index,
|
||||
* since the internal node's real class type is known). Linked outer inputs are left to
|
||||
* the "Redirect parent links" step above, which takes priority.
|
||||
*/
|
||||
function _flattenSubgraphs(workflow) {
|
||||
function _flattenSubgraphs(workflow, objectInfo) {
|
||||
const defs = workflow.definitions?.subgraphs;
|
||||
if (!defs || defs.length === 0) return workflow;
|
||||
|
||||
@@ -192,6 +256,12 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Redirect parent links ===
|
||||
// Track which {internal node, input slot} pairs end up resolved by a real outer
|
||||
// link after redirection — needed below to tell apart "boundary-only" links
|
||||
// (prompt/model-name ports that are actually widget values, see injection step)
|
||||
// from ports that truly carry a link end-to-end (e.g. width/height wired to a
|
||||
// ResolutionSelector node).
|
||||
const redirectedTargets = new Set();
|
||||
for (const link of wf.links) {
|
||||
if (link[3] === node.id) {
|
||||
const dstSlot = link[4];
|
||||
@@ -204,6 +274,7 @@ function _flattenSubgraphs(workflow) {
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
redirectedTargets.add(`${target.targetNodeId}:${target.targetSlot}`);
|
||||
}
|
||||
}
|
||||
if (link[1] === node.id) {
|
||||
@@ -217,12 +288,82 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === 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);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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];
|
||||
|
||||
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
|
||||
if (portIdx === -1) return;
|
||||
const target = inputPortTargets[portIdx];
|
||||
if (!target) return;
|
||||
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) return;
|
||||
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) return;
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (iWidgetIdx === -1) return;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
});
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
for (const iLink of (sgDef.links || [])) {
|
||||
if (iLink.origin_id === -10 || iLink.target_id === -20) continue;
|
||||
@@ -253,8 +394,11 @@ export const comfyWorkflow = {
|
||||
detectFormat(workflow, filename) {
|
||||
if (!workflow || typeof workflow !== "object") return "unknown";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
|
||||
// App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
if (workflow.definitions || workflow.extra?.linearMode === true) return "app";
|
||||
// App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
// A workflow that merely *contains* subgraphs (definitions.subgraphs) is still
|
||||
// a normal node graph — convertUiToApi() flattens subgraphs before conversion,
|
||||
// so it's treated as "ui" rather than blocked as unsupported "app" format.
|
||||
if (workflow.extra?.linearMode === true) return "app";
|
||||
if (filename && /\.app\.json$/i.test(filename)) return "app";
|
||||
return "ui";
|
||||
}
|
||||
@@ -267,14 +411,30 @@ export const comfyWorkflow = {
|
||||
/**
|
||||
* Convert UI-format workflow to API-format using /object_info for accurate widget mapping.
|
||||
*/
|
||||
getLastCheckpointSubstitutions() {
|
||||
return _lastCheckpointSubstitutions;
|
||||
},
|
||||
|
||||
getLastBypassedNodes() {
|
||||
return _lastBypassedNodes;
|
||||
},
|
||||
|
||||
getLastMutedNodes() {
|
||||
return _lastMutedNodes;
|
||||
},
|
||||
|
||||
async convertUiToApi(workflow) {
|
||||
_lastCheckpointSubstitutions = [];
|
||||
_lastBypassedNodes = [];
|
||||
_lastMutedNodes = [];
|
||||
if (!workflow.nodes || !workflow.links) return {};
|
||||
|
||||
// Flatten subgraphs before conversion
|
||||
const flatWorkflow = _flattenSubgraphs(workflow);
|
||||
|
||||
const objectInfo = await _loadObjectInfo();
|
||||
|
||||
// Flatten subgraphs before conversion (needs objectInfo to correctly map outer
|
||||
// subgraph-node widget values onto internal nodes' widgets_values indices)
|
||||
const flatWorkflow = _flattenSubgraphs(workflow, objectInfo);
|
||||
|
||||
// Build link map: dstNode -> { dstSlot -> [srcNodeId, srcSlot] }
|
||||
const linkMap = {};
|
||||
for (const link of flatWorkflow.links) {
|
||||
@@ -282,10 +442,23 @@ export const comfyWorkflow = {
|
||||
if (!linkMap[dstNode]) linkMap[dstNode] = {};
|
||||
linkMap[dstNode][dstSlot] = [String(srcNode), srcSlot];
|
||||
}
|
||||
const nodeById = {};
|
||||
for (const n of flatWorkflow.nodes) nodeById[String(n.id)] = n;
|
||||
|
||||
const api = {};
|
||||
for (const node of flatWorkflow.nodes) {
|
||||
if (node.mode === 4) continue; // muted/bypassed
|
||||
// Mute (mode 2): excluded, no pass-through — matches ComfyUI itself, where
|
||||
// muting a node with required downstream dependents is expected to error.
|
||||
// Bypass (mode 4): excluded here too, but its wires are rerouted below via
|
||||
// _resolveBypassSource() so downstream nodes reconnect to its upstream source.
|
||||
if (node.mode === 2) {
|
||||
_lastMutedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
if (node.mode === 4) {
|
||||
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
|
||||
continue;
|
||||
}
|
||||
// Skip note/display-only nodes that are unknown to ComfyUI backend
|
||||
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
|
||||
const nodeId = String(node.id);
|
||||
@@ -301,7 +474,9 @@ export const comfyWorkflow = {
|
||||
const linkedSlotNames = new Set();
|
||||
inputDefs.forEach((inp, idx) => {
|
||||
if (nodeLinks[idx]) {
|
||||
inputs[inp.name] = nodeLinks[idx];
|
||||
const resolved = _resolveBypassSource(nodeById, linkMap, nodeLinks[idx][0], nodeLinks[idx][1], new Set());
|
||||
if (!resolved) return; // Bypass chain with no upstream source — leave unlinked
|
||||
inputs[inp.name] = resolved;
|
||||
linkedInputNames.add(inp.name);
|
||||
linkedSlotNames.add(inp.name);
|
||||
}
|
||||
@@ -365,6 +540,22 @@ export const comfyWorkflow = {
|
||||
wIdx++;
|
||||
}
|
||||
}
|
||||
// DynamicCombo (e.g. SaveImageAdvanced's "format"): val is the selected
|
||||
// option's key (e.g. "png"). Per comfy_api/latest/_io.py
|
||||
// DynamicCombo._expand_schema_for_dynamic, the backend looks up flat
|
||||
// dot-prefixed keys ("format", "format.bit_depth", ...) in the raw
|
||||
// inputs dict and assembles them into a single dict itself at execution
|
||||
// time — it does NOT accept a pre-built dict under the "format" key.
|
||||
if (expectedType === "COMFY_DYNAMICCOMBO_V3") {
|
||||
inputs[name] = val;
|
||||
const subNames = _getDynamicComboSubNames(objectInfo, node.type, name, val);
|
||||
for (const subName of subNames) {
|
||||
if (wIdx >= widgets.length) break;
|
||||
inputs[`${name}.${subName}`] = widgets[wIdx];
|
||||
wIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// For COMBO inputs, validate the value against the available choices.
|
||||
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
|
||||
// differ between the saved workflow and the current ComfyUI instance
|
||||
@@ -379,6 +570,14 @@ export const comfyWorkflow = {
|
||||
if (spec) {
|
||||
const choices = Array.isArray(spec[0]) ? spec[0] : null;
|
||||
if (choices && choices.length > 0 && !choices.includes(val)) {
|
||||
if (name === "ckpt_name") {
|
||||
_lastCheckpointSubstitutions.push({
|
||||
nodeId: String(node.id),
|
||||
title: node.title || node.type,
|
||||
original: val,
|
||||
replaced: choices[0],
|
||||
});
|
||||
}
|
||||
val = choices[0];
|
||||
}
|
||||
}
|
||||
@@ -487,6 +686,8 @@ export const comfyWorkflow = {
|
||||
vae_nodes: [],
|
||||
save_nodes: [],
|
||||
load_image_nodes: [],
|
||||
inpaint_encode_nodes: [],
|
||||
mask_editor_one_nodes: [],
|
||||
text_encoder_nodes: [],
|
||||
diffusion_model_nodes: [],
|
||||
controlnet_nodes: [],
|
||||
@@ -686,6 +887,23 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
|
||||
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
|
||||
if (inputs.text_positive !== undefined) {
|
||||
@@ -747,6 +965,20 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
|
||||
// (it builds the latent internally instead of a separate EmptyLatentImage node).
|
||||
// width/height are commonly wired to a ResolutionSelector-style helper node
|
||||
// (array link ref) rather than a direct number — leave those as undefined so
|
||||
// the settings-tab UI falls back to its default instead of rendering a link ref.
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
// --- LoRA nodes ---
|
||||
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
|
||||
result.lora_nodes.push({
|
||||
@@ -809,6 +1041,22 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// --- VAE Encode (for Inpainting) nodes ---
|
||||
if (ct === "VAEEncodeForInpaint") {
|
||||
result.inpaint_encode_nodes.push({
|
||||
id, type: ct, title,
|
||||
grow_mask_by: inputs.grow_mask_by,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mask Editor One nodes (comfyui-mask-editor-one) ---
|
||||
// 画像とマスクをノード自身が内包する(LoadImageを介さない)インペイント画像ソース。
|
||||
// image出力はサーバー側キャッシュ(bg_image_b64、node_idキー)から、mask出力は
|
||||
// layer_data(JSON文字列)ウィジェットの各レイヤーのアルファチャンネルから合成される。
|
||||
if (ct === "MaskEditorOne") {
|
||||
result.mask_editor_one_nodes.push({ id, type: ct, title });
|
||||
}
|
||||
|
||||
// --- Text encoder (CLIP) nodes ---
|
||||
if (ct === "DualCLIPLoader" || ct === "CLIPLoader" || ct === "ClipLoaderGGUF" || ct === "DualClipLoaderGGUF") {
|
||||
result.text_encoder_nodes.push({
|
||||
@@ -850,6 +1098,24 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Post-pass: flag LoadImage nodes whose MASK output (slot 1) is wired to something
|
||||
// downstream (directly, or via a pass-through node like MaskEditorNode). These are the
|
||||
// "inpaint-capable" slots — image + mask both come from the same node's two outputs, so
|
||||
// painting a mask for them only requires re-uploading that one node's image with the mask
|
||||
// baked into the alpha channel (matches ComfyUI's native LoadImage alpha→MASK extraction). ---
|
||||
const loadImageIds = new Set(result.load_image_nodes.filter(n => n.type === "LoadImage").map(n => n.id));
|
||||
if (loadImageIds.size > 0) {
|
||||
for (const node of Object.values(workflow)) {
|
||||
if (!node?.inputs) continue;
|
||||
for (const v of Object.values(node.inputs)) {
|
||||
if (Array.isArray(v) && v.length === 2 && Number(v[1]) === 1 && loadImageIds.has(String(v[0]))) {
|
||||
const target = result.load_image_nodes.find(n => n.id === String(v[0]));
|
||||
if (target) target.mask_used = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.all_nodes.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
return result;
|
||||
},
|
||||
|
||||
+113
-2
@@ -43,6 +43,89 @@ const API = {
|
||||
|
||||
export const FEEDER_GROUP = "__Feeder__";
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
|
||||
// Comic Creater側の「I2Iへ送る」ボタンから
|
||||
// iframe.contentWindow._wfmReceiveImageForI2I(blob, name, workflowData?, workflowFilename?) として呼ばれる。
|
||||
// 画像スロットへのセット自体は wfm-gallery-send-genui-image-btn のクリックハンドラ(Galleryの選択画像を送る版)と同一。
|
||||
// workflowData が渡された場合(Comic Creater側の設定タブでデフォルトワークフローが有効な時)は、
|
||||
// 画像をセットする前にそのワークフローを読み込む。
|
||||
window._wfmReceiveImageForI2I = async (blob, name, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (workflowData) {
|
||||
try {
|
||||
// iframeロード直後などモデルリスト未取得のままワークフローを読み込むと、
|
||||
// Model/生成UIタブのCheckpoint等ドロップダウンが空のままレンダリングされてしまう。
|
||||
// 接続確認・モデルリスト取得を保証してからワークフローを読み込む
|
||||
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("[I2I] failed to load default workflow:", e);
|
||||
}
|
||||
}
|
||||
const file = new File([blob], name || "cc-image.png", { type: blob.type || "image/png" });
|
||||
document.querySelector('[data-tab="generate"]')?.click();
|
||||
document.querySelector('.wfm-gen-subtab-btn[data-subtab="input"]')?.click();
|
||||
document.querySelector('.wfm-input-inner-tab[data-input-tab="image"]')?.click();
|
||||
await comfyEditor.applyImageToSlot(file, 0);
|
||||
showToast(t("gallerySentGenUI"), "success");
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに合成画像+マスクを受け取り、Image Edit タブの
|
||||
// Inpaint機能をUIを開かずに直接実行して結果URLを返す(Inpaint連携)。
|
||||
// Comic Creater側の Image タブ「Inpaint」から
|
||||
// iframe.contentWindow._wfmReceiveInpaintRequest(imageBlob, maskBlob, params, workflowData?, workflowFilename?) として呼ばれる。
|
||||
// workflowData が渡された場合(Comic Creater側の設定タブでInpaint用デフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveInpaintRequest = async (imageBlob, maskBlob, params, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (workflowData) {
|
||||
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");
|
||||
}
|
||||
return await window._wfmImageEditTab.runInpaintExternal(imageBlob, maskBlob, params);
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Image Edit タブの
|
||||
// Select I2I機能をUIを開かずに直接実行して結果URLを返す(I2I連携、マスク不要)。
|
||||
// Comic Creater側の Image タブ「Select→I2I」から
|
||||
// iframe.contentWindow._wfmReceiveI2IRunRequest(imageBlob, params, workflowData?, workflowFilename?) として呼ばれる。
|
||||
// workflowData が渡された場合(Comic Creater側のI2I設定でデフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveI2IRunRequest = async (imageBlob, params, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (workflowData) {
|
||||
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");
|
||||
}
|
||||
return await window._wfmImageEditTab.runI2IExternal(imageBlob, params);
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ── ページング ────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
@@ -1523,6 +1606,34 @@ function bindEvents() {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
|
||||
// I2I送信元がImageタブだった場合はそちらへ画像を読み込ませる(_ccI2ITargetModeはComic Creater側が
|
||||
// 「I2Iへ送る」ボタン押下時にセットするフラグ。undefined/'layout'なら従来通りコマ/オーバーレイへ挿入)
|
||||
let targetMode = null;
|
||||
try {
|
||||
targetMode = window.parent?._ccI2ITargetMode || null;
|
||||
} catch (e) {
|
||||
targetMode = null;
|
||||
}
|
||||
if (targetMode === "image") {
|
||||
let imageTab = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent._ccImageTab?.loadFromUrl === "function") {
|
||||
imageTab = window.parent._ccImageTab;
|
||||
}
|
||||
} catch (e) {
|
||||
imageTab = null;
|
||||
}
|
||||
if (!imageTab) {
|
||||
showToast(t("galleryCCNotAvailable"), "info");
|
||||
return;
|
||||
}
|
||||
imageTab.loadFromUrl(url, (state.selectedImage.filename || "cc-image").replace(/\.[^.]+$/, ""));
|
||||
showToast(t("gallerySentCC"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
let insertFn = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent.insertImageFromUrl === "function") {
|
||||
@@ -1535,7 +1646,7 @@ function bindEvents() {
|
||||
showToast(t("galleryCCNotAvailable"), "info");
|
||||
return;
|
||||
}
|
||||
insertFn(API.serveImage(state.selectedImage.path));
|
||||
insertFn(url);
|
||||
showToast(t("gallerySentCC"), "success");
|
||||
});
|
||||
}
|
||||
@@ -1625,7 +1736,7 @@ function bindEvents() {
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
window.opener.wfmReceiveWorkflow(state.embeddedWorkflow);
|
||||
await navigator.clipboard.writeText(JSON.stringify(state.embeddedWorkflow, null, 2)).catch(() => {});
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
showToast(t("workflowSentToCanvasDirect"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
|
||||
+141
-21
@@ -9,7 +9,7 @@ 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 } from "./util.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
@@ -23,7 +23,7 @@ function getEagleSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
async function saveToEagle(imageUrl, name, tags = []) {
|
||||
async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
@@ -35,6 +35,12 @@ async function saveToEagle(imageUrl, name, tags = []) {
|
||||
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();
|
||||
@@ -218,21 +224,52 @@ async function saveCurrentWorkflow() {
|
||||
// Workflow Loading
|
||||
// ============================================
|
||||
|
||||
// ワークフロー読み込み時、設定タブで有効化されていれば checkpoint loader 系ノード全件の
|
||||
// ckpt_name をデフォルトモデルへ強制上書きする(保存済みワークフローのモデルが
|
||||
// 環境と合わず生成エラー/不鮮明な画像になるのを防ぐ回避策)。
|
||||
// analyzeWorkflow() より前に apiWorkflow 自体を書き換える必要がある — analysis後に
|
||||
// 書き換えると Model タブの表示(analysis.checkpoint_nodes 由来)が古い値のまま残るため。
|
||||
async function _applyDefaultCheckpointIfEnabled(apiWorkflow) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings");
|
||||
if (!res.ok) return;
|
||||
const settings = await res.json();
|
||||
if (!settings.default_checkpoint_enabled || !settings.default_checkpoint_name) return;
|
||||
|
||||
let applied = false;
|
||||
for (const node of Object.values(apiWorkflow)) {
|
||||
const ct = node?.class_type || "";
|
||||
if (node?.inputs && (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader" || ct === "ImageMetadataPromptLoader")) {
|
||||
node.inputs.ckpt_name = settings.default_checkpoint_name;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
if (applied) showToast(t("defaultCheckpointApplied", settings.default_checkpoint_name), "info");
|
||||
} catch { /* non-critical, ignore */ }
|
||||
}
|
||||
|
||||
export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
let apiWorkflow = workflow;
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
let ckptWarning = null;
|
||||
|
||||
if (format === "app") {
|
||||
showToast(t("appFormatNotSupported"), "error");
|
||||
return false;
|
||||
} else if (format === "ui") {
|
||||
apiWorkflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
const ckptSubs = comfyWorkflow.getLastCheckpointSubstitutions();
|
||||
if (ckptSubs.length > 0) {
|
||||
const names = [...new Set(ckptSubs.map((s) => s.original))].join(", ");
|
||||
ckptWarning = t("checkpointSubstitutedWarning", names);
|
||||
}
|
||||
} else if (format === "unknown") {
|
||||
showToast(t("unknownWorkflowFormat"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
comfyUI.currentWorkflow = apiWorkflow;
|
||||
await _applyDefaultCheckpointIfEnabled(apiWorkflow);
|
||||
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
|
||||
|
||||
// Render editor tabs
|
||||
@@ -247,6 +284,30 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
syncJsonHighlight(rawHighlight, jsonStr);
|
||||
}
|
||||
|
||||
// Update Bypass/Mute node indicator (shared Raw JSON widget header, visible on
|
||||
// Input/Model/Settings tabs). API-format JSON has no mode field, so this info is
|
||||
// only available right after a UI→API conversion — captured via comfyWorkflow's
|
||||
// getLastBypassedNodes()/getLastMutedNodes() before it's discarded.
|
||||
const bypassNote = document.getElementById("wfm-gen-rawjson-bypass-note");
|
||||
if (bypassNote) {
|
||||
const bypassed = format === "ui" ? comfyWorkflow.getLastBypassedNodes() : [];
|
||||
const muted = format === "ui" ? comfyWorkflow.getLastMutedNodes() : [];
|
||||
const lines = [];
|
||||
if (bypassed.length > 0) {
|
||||
lines.push(t("rawJsonBypassNote", bypassed.map((n) => `${n.title} (${n.nodeId})`).join(", ")));
|
||||
}
|
||||
if (muted.length > 0) {
|
||||
lines.push(t("rawJsonMutedNote", muted.map((n) => `${n.title} (${n.nodeId})`).join(", ")));
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
bypassNote.innerHTML = lines.map((l) => escapeHtml(l)).join("<br>");
|
||||
bypassNote.style.display = "";
|
||||
} else {
|
||||
bypassNote.textContent = "";
|
||||
bypassNote.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Update workflow name display
|
||||
const nameEl = document.getElementById("wfm-gen-wf-name");
|
||||
if (nameEl) {
|
||||
@@ -260,7 +321,11 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
|
||||
refreshFeederNodeList();
|
||||
|
||||
showToast(t("workflowLoadedName", filename || ""), "success");
|
||||
if (ckptWarning) {
|
||||
showToast(ckptWarning, "error");
|
||||
} else {
|
||||
showToast(t("workflowLoadedName", filename || ""), "success");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1397,7 +1462,7 @@ async function _expandWildcardsInWorkflow(workflow) {
|
||||
// Generation (core — throws on error)
|
||||
// ============================================
|
||||
|
||||
async function _coreGenerate(silent = false) {
|
||||
async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
const progressBar = document.getElementById("wfm-gen-progress-bar");
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
@@ -1409,9 +1474,10 @@ async function _coreGenerate(silent = false) {
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = "Starting...";
|
||||
|
||||
const workflowExpanded = await _expandWildcardsInWorkflow({ ...comfyUI.currentWorkflow });
|
||||
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
|
||||
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
|
||||
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
const { images, seed, svgOutputs } = await comfyUI.generate(
|
||||
workflowForGenerate,
|
||||
{
|
||||
seedMode,
|
||||
@@ -1429,18 +1495,49 @@ async function _coreGenerate(silent = false) {
|
||||
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
// type=temp はプレビュー用の一時画像 (PreviewImage, SVG String Preview など) のため
|
||||
// 生成結果表示からは除外し、実際に保存された成果物のみを表示する
|
||||
const outputImages = images.filter((img) => img.type !== "temp");
|
||||
|
||||
if (svgOutputs?.length > 0) {
|
||||
// comfyui-tosvg の Save SVG String は images ではなく saved_svg/path で返すため、
|
||||
// images配列には(あれば)プレビュー用の一時PNGしか入らず実際の保存物と食い違う。
|
||||
// 実際に保存されたSVGファイルをそのままプレビューに表示する。
|
||||
// /view はSVGをセキュリティ上application/octet-streamで返すため使えず、
|
||||
// 代わりにGalleryタブの配信エンドポイント(絶対パス指定・正しいContent-Type)を使う。
|
||||
const svgUrl = (svg) => `/wfm/gallery/image/serve?path=${encodeURIComponent(svg.path)}`;
|
||||
if (resultImg && svgOutputs[0].path) {
|
||||
resultImg.src = svgUrl(svgOutputs[0]);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (resultThumbs && svgOutputs.length > 1) {
|
||||
resultThumbs.innerHTML = "";
|
||||
svgOutputs.forEach((svg, i) => {
|
||||
if (!svg.path) return;
|
||||
const thumb = document.createElement("img");
|
||||
thumb.src = svgUrl(svg);
|
||||
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
|
||||
thumb.addEventListener("click", () => {
|
||||
resultImg.src = thumb.src;
|
||||
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
|
||||
thumb.classList.add("active");
|
||||
});
|
||||
resultThumbs.appendChild(thumb);
|
||||
});
|
||||
}
|
||||
} else if (outputImages.length > 0) {
|
||||
const blob = await comfyUI.getImageBlob(outputImages[0]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (resultImg) {
|
||||
resultImg.src = url;
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (resultThumbs && images.length > 1) {
|
||||
if (resultThumbs && outputImages.length > 1) {
|
||||
resultThumbs.innerHTML = "";
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
|
||||
for (let i = 0; i < outputImages.length; i++) {
|
||||
const b = i === 0 ? blob : await comfyUI.getImageBlob(outputImages[i]);
|
||||
const u = i === 0 ? url : URL.createObjectURL(b);
|
||||
const thumb = document.createElement("img");
|
||||
thumb.src = u;
|
||||
@@ -1455,18 +1552,28 @@ async function _coreGenerate(silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
if (getEagleSettings().autoSave && images.length > 0) {
|
||||
for (const img of images) {
|
||||
if (getEagleSettings().autoSave && outputImages.length > 0) {
|
||||
for (const img of outputImages) {
|
||||
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
saveToEagle(viewUrl, img.filename);
|
||||
saveToEagle(viewUrl, img.filename, [], img);
|
||||
}
|
||||
}
|
||||
|
||||
if (getEagleSettings().autoSave && svgOutputs?.length > 0) {
|
||||
// comfyui-tosvg の "Save SVG String" は絶対パスを直接返すため、/view を介さず
|
||||
// サーバー側でそのパスを検証した上で addFromPath させる
|
||||
for (const svg of svgOutputs) {
|
||||
saveToEagle("", svg.filename, [], { filename: svg.filename, localPath: svg.path });
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
|
||||
saveGeneratedImagesMeta(images, { ...baseWorkflow }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!silent) showToast(t("generationComplete"), "success");
|
||||
|
||||
return { images, seed };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -1729,14 +1836,20 @@ async function _runBatchGenerate() {
|
||||
// Generate entry point
|
||||
// ============================================
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!comfyUI.currentWorkflow) {
|
||||
// workflowOverride: 明示的に渡された場合、comfyUI.currentWorkflow の代わりにこのワークフローで
|
||||
// 生成する(GenerateUIの表示状態には触れない。Image Edit「Inpaint」の専用ワークフロー実行から利用)。
|
||||
// ボタンのclickハンドラから呼ばれる場合は第一引数がMouseEventになるため、その場合は無視する。
|
||||
async function handleGenerate(workflowOverride = null) {
|
||||
if (workflowOverride instanceof Event) workflowOverride = null;
|
||||
|
||||
if (!workflowOverride && !comfyUI.currentWorkflow) {
|
||||
showToast(t("noWorkflowLoaded"), "error");
|
||||
return;
|
||||
}
|
||||
if (comfyUI.generating) return;
|
||||
|
||||
comfyEditor.syncToWorkflow();
|
||||
// オーバーライド実行時は GenerateUI 側の表示中プロンプトを別ワークフローへ誤って書き込まないよう同期をスキップする
|
||||
if (!workflowOverride) comfyEditor.syncToWorkflow();
|
||||
|
||||
const genBtn = document.getElementById("wfm-gen-generate-btn");
|
||||
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
|
||||
@@ -1747,18 +1860,20 @@ async function handleGenerate() {
|
||||
_ckptBatch.paused = false;
|
||||
_ckptBatch._resumeResolve = null;
|
||||
|
||||
const batchEnabled = _activeBatchType !== null;
|
||||
// オーバーライド実行時はバッチモードの影響を受けない単発生成に固定する
|
||||
const batchEnabled = !workflowOverride && _activeBatchType !== null;
|
||||
|
||||
try {
|
||||
if (batchEnabled) {
|
||||
await _runBatchGenerate();
|
||||
} else {
|
||||
try {
|
||||
await _coreGenerate(false);
|
||||
return await _coreGenerate(false, workflowOverride);
|
||||
} catch (err) {
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
showToast(t("generationError", err.message), "error");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -1819,7 +1934,12 @@ export async function initGenerateTab() {
|
||||
});
|
||||
|
||||
// Generate button
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", handleGenerate);
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
|
||||
handleGenerate().catch(() => {});
|
||||
});
|
||||
|
||||
// Cross-tab entry point (Image Edit「Inpaint」タブの Run ボタンなど外部から生成を呼び出す用)
|
||||
window._wfmGenerateTab = { generate: handleGenerate };
|
||||
|
||||
// Alt+Apply from Input/Model/Settings tabs → Apply & Generate
|
||||
document.addEventListener("wfm:apply-and-generate", () => handleGenerate());
|
||||
|
||||
+135
-45
@@ -92,6 +92,7 @@ const LANGUAGES = {
|
||||
sendToCanvas: "Send to Canvas",
|
||||
copyAndSendCanvas: "Copy & Send Canvas",
|
||||
workflowSentToCanvas: "Workflow ready — drag 'Workflow Studio Library' title to canvas",
|
||||
workflowSentToCanvasDirect: "Workflow sent to canvas",
|
||||
comfyUILoadTimeout: "ComfyUI load timeout",
|
||||
popupBlocked: "Popup blocked. Please allow popups for this site.",
|
||||
setAsDefault: "Set as Default",
|
||||
@@ -116,6 +117,7 @@ const LANGUAGES = {
|
||||
deleteConfirm: (name) => `Delete "${name}"?`,
|
||||
summaryGenerated: "Summary generated",
|
||||
summarizeError: "Summarize error",
|
||||
summarizeOllamaOnly: "Summarize only supports the Ollama backend. Switch backend in Settings.",
|
||||
copied: "Copied",
|
||||
|
||||
// -- GenerateUI --
|
||||
@@ -206,6 +208,10 @@ const LANGUAGES = {
|
||||
unknownWorkflowFormat: "Unknown workflow format",
|
||||
generationComplete: "Generation complete",
|
||||
generationError: (msg) => `Generation error: ${msg}`,
|
||||
defaultCheckpointApplied: (name) => `Default checkpoint applied: ${name}`,
|
||||
checkpointSubstitutedWarning: (names) => `Checkpoint not found in this workflow, silently replaced with another model: ${names}. Please check the Model tab.`,
|
||||
rawJsonBypassNote: (list) => `Bypassed (excluded, wires reconnected): ${list}`,
|
||||
rawJsonMutedNote: (list) => `Muted (excluded): ${list}`,
|
||||
batchNoneSelected: (what) => `No ${what} selected`,
|
||||
batchItemFailed: (i, total, msg) => `[${i}/${total}] Failed: ${msg}`,
|
||||
batchStopped: (completed, failed) => `Batch stopped — ${completed} completed, ${failed} failed`,
|
||||
@@ -356,6 +362,7 @@ const LANGUAGES = {
|
||||
modelsGenUIUnsupported: "This model type cannot be set in GenerateUI",
|
||||
modelsGenUINoWorkflow: "No workflow loaded in GenerateUI",
|
||||
modelsGenUINoNode: (type) => `No ${type} node found in current workflow`,
|
||||
revertPromptTitle: "Revert to last applied prompt",
|
||||
modelsCopyName: "Click to copy model name",
|
||||
modelsCopiedName: "Model name copied",
|
||||
modelsFilePath: "File Path",
|
||||
@@ -600,6 +607,20 @@ const LANGUAGES = {
|
||||
aiStatusChatting: "Thinking...",
|
||||
aiToastChatFailed: "Chat failed: ",
|
||||
aiToastWcNoName: "Please enter a wildcard name",
|
||||
aiStatusGeneratingImage: "Generating image...",
|
||||
aiChatNoWorkflowLoaded: "No workflow loaded in GenerateUI",
|
||||
aiChatGenerateNotReady: "GenerateUI is not ready yet",
|
||||
aiChatNoImageProduced: "Generation completed but no image was produced",
|
||||
aiToastImageGenFailed: "Image generation failed: ",
|
||||
aiChatSaveSvgToGallery: "Save to Gallery",
|
||||
aiChatSvgSaved: "SVG saved to gallery",
|
||||
aiChatSvgSaveFailed: "Failed to save SVG: ",
|
||||
aiChatImgGenToggle: "Allow image generation",
|
||||
aiSettingsChatGenTitle: "Chat Image Generation",
|
||||
aiSettingsChatGenDedicated: "Use dedicated workflow",
|
||||
aiSettingsChatI2IGenTitle: "Chat I2I Generation",
|
||||
aiChatAttachmentLabel: "Image attached",
|
||||
aiChatNoLoadImageNode: "No LoadImage node found in the target workflow for image-to-image",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "Help",
|
||||
@@ -616,15 +637,15 @@ const LANGUAGES = {
|
||||
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, send to canvas, load in GenerateUI, set as default, delete",
|
||||
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
|
||||
helpGen1: "Connect/disconnect ComfyUI and check connection status",
|
||||
helpGen2: "Load workflows from Workflow tab (App format .app.json not supported); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
|
||||
helpGen2: "Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
|
||||
helpGen3: "5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing",
|
||||
helpGen4: "Input tab: inner tabs — Prompt and Image (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused)",
|
||||
helpGen4: "Input tab: inner tabs — Prompt and Image (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a ↺ button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust its emphasis weight in (text:weight) syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter",
|
||||
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)",
|
||||
helpGen8: "Seed control: random, fixed, increment, decrement",
|
||||
helpGen9: "Generate, progress display, stop, and view results",
|
||||
helpGen10: "Eagle integration: auto-save generated images to Eagle",
|
||||
helpGen10: "Eagle integration: auto-save generated images to Eagle (SVG output supported too — both the standard SaveSVGNode and comfyui-tosvg's Save SVG String node)",
|
||||
helpGen11: "Batch tab — enable one batch type at a time by checking the checkbox in the Batch Queue column header; Generate then runs through all queued items sequentially with Pause/Resume and Stop controls",
|
||||
helpGen12: "Batch tab — Left pane has 3 tabs: Checkpoints (folder tree with Filter/All/None; (root) header checkbox selects all), Sampler (KSampler sampler_name list with (root) checkbox), Scheduler (KSampler scheduler list with (root) checkbox); All/None toolbar buttons also available",
|
||||
helpGen13: "Batch tab — Center pane: group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint/Lora groups come from the Models tab, Prompt groups from the Prompt tab, Workflow groups from the Workflow tab — check a group header to select all its members, or expand ▶ to select individually",
|
||||
@@ -656,7 +677,7 @@ const LANGUAGES = {
|
||||
helpFeederGal5: "__Feeder__ group: reserved group pre-created automatically; cannot be renamed or deleted; add images to it from the Gallery tab using the F overlay button on each thumbnail",
|
||||
helpFeederGal6: "WFS_GalleryFeeder canvas controls: the node also has Run / Stop / After Gen widgets added directly on the ComfyUI canvas — these work the same as the WFM UI controls and allow running the gallery feeder loop without opening WFM",
|
||||
helpSearchPlaceholder: "Search help...",
|
||||
helpPrompt1: "AI assistant (Ollama / LM Studio): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
|
||||
helpPrompt1: "AI assistant (Ollama / LM Studio / Lemonade): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
|
||||
helpPrompt2: "Apply AI responses to GenerateUI prompts",
|
||||
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
|
||||
helpPrompt4: "Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)",
|
||||
@@ -669,7 +690,7 @@ const LANGUAGES = {
|
||||
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
|
||||
helpSettings3: "Workflow data folder path",
|
||||
helpSettings4: "ComfyUI connection URL and test",
|
||||
helpSettings5: "AI Assistant (Prompt Tab): backend (Ollama / LM Studio), API URL, default model, and connection test",
|
||||
helpSettings5: "AI Assistant (Prompt Tab): backend (Ollama / LM Studio / Lemonade), API URL, default model, and connection test",
|
||||
helpSettings6: "Default workflow setting",
|
||||
helpSettings7: "Eagle integration URL and auto-save toggle",
|
||||
helpSettings8: "Data Management: export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import to restore. After importing, restart ComfyUI.",
|
||||
@@ -744,19 +765,20 @@ const LANGUAGES = {
|
||||
helpSidepanel13: "I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF, QuadrupleCLIPLoader, and Lora Loader (LoraManager) node types",
|
||||
helpSidepanel14: "Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center",
|
||||
helpSidepanel15: "Information tab — LoRA sub-tab: detects LoRAs from LoraLoader, LoraLoaderModelOnly, and Lora Loader (LoraManager) nodes (API format supported); drag individual LoRA to place LoraLoader; \"Multiple LORA\" section places all LoRAs in a single Lora Loader (LoraManager) node with LoRA syntax pre-filled; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy",
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
|
||||
helpSidepanel17: "Send to Canvas: clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab) sends the workflow directly to the ComfyUI canvas — both UI and API formats are supported; if Workflow Studio was not opened from the ComfyUI toolbar (window.opener unavailable), UI-format workflows fall back to title drag (title turns blue with a green ●)",
|
||||
helpAiTitle: "AI Tab (A)",
|
||||
helpAi1: "4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously",
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line",
|
||||
helpAi5: "Settings pane: choose backend (Ollama / LM Studio), set API URL, test connection, select model, configure Free language names for translation",
|
||||
helpAi6: "Settings are saved to localStorage and shared with the Library panel's A tab",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image (\"generate a cat picture\", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the \"Allow image generation\" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button",
|
||||
helpAi5: "Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names for translation",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage",
|
||||
helpAi7: "Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a \"Save to Gallery\" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support",
|
||||
helpImageEditTitle: "Image Edit Tab",
|
||||
helpImageEdit1: "Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite",
|
||||
helpImageEdit2: "Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas",
|
||||
helpImageEdit3: "New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers",
|
||||
helpImageEdit3: "New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1",
|
||||
helpImageEditToolsTitle: "Tools",
|
||||
helpImageEdit4: "Select (V): click to select a layer; drag to move; drag corner handles to resize; drag the circle above the top edge to rotate; double-click a text layer to re-edit its content; Tool Options bar shows Flip H / Flip V / Rotate field",
|
||||
helpImageEdit5: "Draw (B): freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode; Properties pane includes a MASK EDITOR ONE (COLOR) section for ABR image brushes — always visible, header text is green when brushes are available, gray when none are imported yet; Select is disabled until at least one brush exists",
|
||||
@@ -792,12 +814,13 @@ const LANGUAGES = {
|
||||
helpImageEdit7b: "Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details",
|
||||
helpImageEdit7c: "BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details",
|
||||
helpImageEdit7d: "Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click \"M\" in the Layers header to add a mask layer; see the Mask Tool card below for details",
|
||||
helpImageEdit7e: "Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; \"Use dedicated workflow\" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI",
|
||||
helpImageEdit8b: "\"M\" button in the Layers header: adds a new mask layer above the current layer and automatically switches to the Mask tool",
|
||||
helpImageEdit12: "Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer, ⬚ = mask layer",
|
||||
helpImageEditMaskTitle: "Mask Tool (🎭)",
|
||||
helpImageEditMask1: "Add a mask layer: click the \"M\" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically",
|
||||
helpImageEditMask2: "Clicking a mask layer in the layer list automatically switches to the Mask tool",
|
||||
helpImageEditMask3: "Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge",
|
||||
helpImageEditMask3: "Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected",
|
||||
helpImageEditMask4: "Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)",
|
||||
helpImageEditMask5: "✂ button on a mask layer row: toggles the layer as a clipping mask — when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied when saving PNG / Gallery / ComfyUI",
|
||||
helpImageEditMask6: "SAM3 (Mask Editor One required): text-prompt segmentation powered by the SAM3 model; enter a description of the object to segment (e.g. \"cat\", \"person\"), set Max (maximum mask candidates: 3/6/9/12), then click Segment",
|
||||
@@ -838,12 +861,13 @@ const LANGUAGES = {
|
||||
helpShortcuts4: "Double-click: Open detail modal",
|
||||
helpTroubleTitle: "Troubleshooting",
|
||||
helpTrouble1: "Cannot connect to ComfyUI: Check if ComfyUI can generate images, verify URL in Settings, try refreshing or restarting",
|
||||
helpTrouble2: "Cannot connect to AI assistant: Use the connection test button in Settings tab; for Ollama check it is running and models are downloaded; for LM Studio check the server is running and a model is loaded",
|
||||
helpTrouble2: "Cannot connect to AI assistant: Use the connection test button in Settings tab; for Ollama check it is running and models are downloaded; for LM Studio or Lemonade check the server is running and a model is loaded",
|
||||
helpTrouble3: "Workflows not showing: Click refresh button, check if loadable in ComfyUI, try restarting browser/ComfyUI",
|
||||
helpTrouble4: "Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting",
|
||||
helpTrouble5: "LoRA Stack fails with 'No such file or directory' error: The LoRA may not be recognized by comfyui-lora-manager (known issue). Try a different LoRA or check if the file appears in Lora Manager's own UI",
|
||||
helpTrouble6: "Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.",
|
||||
helpTrouble7: "Tagger tab shows no models: verify the model folder exists at ComfyUI/models/tagger/<model-name>/ and contains a .onnx or .h5 file; if onnxruntime is not installed, run: python_embeded\\python.exe -m pip install onnxruntime",
|
||||
helpTrouble8: "Model tab shows a checkpoint you didn't expect (e.g. always the same model regardless of the loaded workflow): the checkpoint saved in the workflow file is not installed, so it was silently replaced with another available model during loading — an error toast reports the original (missing) model name when this happens. Select the correct checkpoint manually in the Model tab, or enable \"Default Checkpoint\" in Settings to force a known-good model on every load.",
|
||||
helpSupportTitle: "Support",
|
||||
helpSupportDesc: "If you find this plugin useful, please consider supporting its development!",
|
||||
helpGithubDesc: "Report issues, request features, and contribute",
|
||||
@@ -867,6 +891,10 @@ const LANGUAGES = {
|
||||
comfyuiConnection: "ComfyUI Connection",
|
||||
comfyuiUrl: "ComfyUI URL",
|
||||
comfyuiUrlHint: "When running as ComfyUI plugin, leave empty to use same origin. Only set if ComfyUI is on a different server.",
|
||||
defaultCheckpointSettings: "Default Checkpoint",
|
||||
defaultCheckpointEnable: "Force default checkpoint on workflow load",
|
||||
defaultCheckpointModel: "Default checkpoint model",
|
||||
defaultCheckpointHint: "When enabled, the Checkpoint of every checkpoint loader node is overridden with this model whenever a workflow is loaded into GenerateUI — useful to avoid errors or garbled images caused by a mismatched model saved in the workflow file.",
|
||||
connectedCheck: "Connected \u2713",
|
||||
failedConnect: "Failed to connect \u2717",
|
||||
ollamaSettings: "AI Assistant Settings (Prompt Tab)",
|
||||
@@ -875,6 +903,8 @@ const LANGUAGES = {
|
||||
selectModel: "Select model...",
|
||||
noModelsFound: "No models found",
|
||||
failedLoadModels: "Failed to load models",
|
||||
modelNotFound: "not found",
|
||||
modelNotFoundHint: "The model saved in this workflow was not found among the installed models. Please select the correct one from the list.",
|
||||
saveOllama: "Save AI Assistant Settings",
|
||||
testConnection: "Test Connection",
|
||||
ollamaConnected: "Connected \u2713",
|
||||
@@ -984,7 +1014,7 @@ const LANGUAGES = {
|
||||
helpMetadata2: "Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow",
|
||||
helpMetadata3: "Right column: LoRA list (with strength values) and Prompt list (click to view full text below)",
|
||||
helpMetadata4: "Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative",
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus.",
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus.",
|
||||
},
|
||||
|
||||
ja: {
|
||||
@@ -1073,6 +1103,7 @@ const LANGUAGES = {
|
||||
sendToCanvas: "キャンバスへ送る",
|
||||
copyAndSendCanvas: "コピー&キャンバスへ送る",
|
||||
workflowSentToCanvas: "ワークフロー準備完了 — タイトルをキャンバスへドラッグ",
|
||||
workflowSentToCanvasDirect: "ワークフローをキャンバスへ送信しました",
|
||||
comfyUILoadTimeout: "ComfyUIの読み込みがタイムアウトしました",
|
||||
popupBlocked: "ポップアップがブロックされました。このサイトのポップアップを許可してください。",
|
||||
setAsDefault: "デフォルトに設定",
|
||||
@@ -1097,6 +1128,7 @@ const LANGUAGES = {
|
||||
deleteConfirm: (name) => `"${name}" を削除しますか?`,
|
||||
summaryGenerated: "要約を生成しました",
|
||||
summarizeError: "要約エラー",
|
||||
summarizeOllamaOnly: "要約機能はOllamaバックエンドのみ対応しています。設定でバックエンドを切り替えてください。",
|
||||
copied: "コピーしました",
|
||||
|
||||
// -- GenerateUI --
|
||||
@@ -1187,6 +1219,10 @@ const LANGUAGES = {
|
||||
unknownWorkflowFormat: "不明なワークフロー形式です",
|
||||
generationComplete: "生成が完了しました",
|
||||
generationError: (msg) => `生成エラー: ${msg}`,
|
||||
defaultCheckpointApplied: (name) => `デフォルトCheckpointを適用しました: ${name}`,
|
||||
checkpointSubstitutedWarning: (names) => `このワークフローのCheckpoint「${names}」が見つからず、無警告で別のモデルに差し替えられました。Modelタブを確認してください。`,
|
||||
rawJsonBypassNote: (list) => `バイパス中(除外・配線は再接続済み): ${list}`,
|
||||
rawJsonMutedNote: (list) => `ミュート中(除外): ${list}`,
|
||||
batchNoneSelected: (what) => `${what}が選択されていません`,
|
||||
batchItemFailed: (i, total, msg) => `[${i}/${total}] 失敗: ${msg}`,
|
||||
batchStopped: (completed, failed) => `バッチ停止 — 完了${completed}件、失敗${failed}件`,
|
||||
@@ -1337,6 +1373,7 @@ const LANGUAGES = {
|
||||
modelsGenUIUnsupported: "このモデルタイプはGenerateUIに設定できません",
|
||||
modelsGenUINoWorkflow: "GenerateUIにワークフローが読み込まれていません",
|
||||
modelsGenUINoNode: (type) => `現在のワークフローに${type}ノードが見つかりません`,
|
||||
revertPromptTitle: "直前にApplyしたプロンプトに戻す",
|
||||
modelsCopyName: "クリックでモデル名をコピー",
|
||||
modelsCopiedName: "モデル名をコピーしました",
|
||||
modelsFilePath: "ファイルパス",
|
||||
@@ -1581,6 +1618,20 @@ const LANGUAGES = {
|
||||
aiStatusChatting: "考え中...",
|
||||
aiToastChatFailed: "チャット失敗: ",
|
||||
aiToastWcNoName: "ワイルドカード名を入力してください",
|
||||
aiStatusGeneratingImage: "画像を生成中...",
|
||||
aiChatNoWorkflowLoaded: "GenerateUIにワークフローが読み込まれていません",
|
||||
aiChatGenerateNotReady: "GenerateUIの準備ができていません",
|
||||
aiChatNoImageProduced: "生成は完了しましたが画像が出力されませんでした",
|
||||
aiToastImageGenFailed: "画像生成に失敗しました: ",
|
||||
aiChatSaveSvgToGallery: "Galleryに保存",
|
||||
aiChatSvgSaved: "SVGをGalleryに保存しました",
|
||||
aiChatSvgSaveFailed: "SVGの保存に失敗しました: ",
|
||||
aiChatImgGenToggle: "画像生成を許可する",
|
||||
aiSettingsChatGenTitle: "Chat画像生成",
|
||||
aiSettingsChatGenDedicated: "専用ワークフローを使用",
|
||||
aiSettingsChatI2IGenTitle: "Chat I2I生成",
|
||||
aiChatAttachmentLabel: "画像を添付中",
|
||||
aiChatNoLoadImageNode: "対象ワークフローにI2I用のLoadImageノードが見つかりません",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "ヘルプ",
|
||||
@@ -1597,15 +1648,15 @@ const LANGUAGES = {
|
||||
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、バッジ付与、タグ、メモ、AI要約、キャンバスへ送る、生成UIに読み込み、デフォルト設定、削除",
|
||||
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
|
||||
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
|
||||
helpGen2: "Workflowタブからワークフローを読み込み(App形式 .app.json は非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
|
||||
helpGen2: "Workflowタブからワークフローを読み込み(サブグラフを含むワークフローにも対応 — Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等のテンプレート含む;ComfyUIの簡略化App形式 [.app.json / Linear Mode] のみ非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
|
||||
helpGen3: "5タブ構成 — Input(プロンプト+画像)、Model、Settings、Feeder、Batch — Input/Model/SettingsタブにはRaw JSON列を常時表示",
|
||||
helpGen4: "Inputタブ:Prompt・Imageの内部タブ切り替え;PromptタブにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加)",
|
||||
helpGen4: "Inputタブ:Prompt・Imageの内部タブ切り替え;PromptタブにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加);各Applyボタンの左隣の↺ボタンはテキストエリアをノードの現在の適用値に戻す;テキストを選択(または単語・括弧内にカーソルを置くだけでも可)してCtrl+↑/↓を押すと(text:weight)形式で強調weightを±0.05単位で調整(weightが1.0に戻ると括弧を自動解除)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA(Single/Stackタブ)/ControlNet/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)/Hypernetwork(Strength入力付き)のセレクタ(フィルタ検索付き)",
|
||||
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ)",
|
||||
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
|
||||
helpGen9: "画像生成、進捗表示、生成停止、結果表示",
|
||||
helpGen10: "Eagle連携:生成画像をEagleへ自動保存",
|
||||
helpGen10: "Eagle連携:生成画像をEagleへ自動保存(SVG出力にも対応 — 標準SaveSVGNodeおよびcomfyui-tosvgのSave SVG Stringノードの両方)",
|
||||
helpGen11: "Batchタブ — バッチキューのカラムヘッダーのチェックボックスで1種類だけ有効化;Generateで全キュー項目を順次処理;一時停止/再開・停止ボタンで制御可能",
|
||||
helpGen12: "Batchタブ — 左ペインは3タブ構成:Checkpoints(フォルダツリー、Filter/All/None、(root)ヘッダーで全選択)、Sampler(KSamplerのsampler_nameリスト、(root)チェックボックスで全選択)、Scheduler(KSamplerのschedulerリスト、(root)チェックボックスで全選択);各タブにAll/Noneボタンも完備",
|
||||
helpGen13: "Batchタブ — 中央ペイン:グループ選択(Checkpoint | Lora | Prompt | Workflowの内部タブ);Checkpoint/LoraグループはModelsタブ、PromptグループはPromptタブ、WorkflowグループはWorkflowタブの定義を反映 — グループヘッダーにチェックで全メンバーを選択、▶で展開して個別選択も可能",
|
||||
@@ -1637,7 +1688,7 @@ const LANGUAGES = {
|
||||
helpFeederGal5: "__Feeder__ グループ:自動で作成される予約グループ;名前変更・削除不可;ギャラリータブの各サムネイル上の F ボタンでこのグループに画像を追加",
|
||||
helpFeederGal6: "WFS_GalleryFeeder キャンバスコントロール:このノードには ComfyUI キャンバス上に直接 Run / Stop / After Gen ウィジェットが追加されており、WFM を開かずにギャラリーフィーダーループを実行できます",
|
||||
helpSearchPlaceholder: "ヘルプを検索...",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio / Lemonade):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
|
||||
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
|
||||
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
|
||||
helpPrompt4: "プリセット管理:作成・編集、ポジティブ/ネガティブプロンプトのクリップボードコピー(PP/NP)",
|
||||
@@ -1650,7 +1701,7 @@ const LANGUAGES = {
|
||||
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
|
||||
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
|
||||
helpSettings4: "ComfyUI接続URL設定と接続テスト",
|
||||
helpSettings5: "AI Assistant(プロンプトタブ):バックエンド(Ollama / LM Studio)・API URL・デフォルトモデル・接続テスト",
|
||||
helpSettings5: "AI Assistant(プロンプトタブ):バックエンド(Ollama / LM Studio / Lemonade)・API URL・デフォルトモデル・接続テスト",
|
||||
helpSettings6: "デフォルトワークフローの設定・解除",
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
|
||||
@@ -1729,15 +1780,16 @@ const LANGUAGES = {
|
||||
helpSidepanel17: "Send to Canvas:WorkflowタブのSend to CanvasまたはGalleryタブのCopy & Send Canvasをクリックするとワークフローをキャンバスへ直接送信(UI形式・API形式どちらも対応);ComfyUIのツールバーから開いていない場合はUI形式のみタイトルドラッグのフォールバック(タイトルが青くなり緑の●が表示される — ドラッグで読み込み)",
|
||||
helpAiTitle: "AI タブ(A)",
|
||||
helpAi1: "4ペインレイアウト — Translation | Chat | TOOLS | Settings — 全ペインが常時表示される",
|
||||
helpAi2: "翻訳ペイン:Ollama または LM Studio を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成",
|
||||
helpAi5: "設定ペイン:バックエンド(Ollama / LM Studio)・API URL・接続テスト・モデル選択・Free 言語名を設定",
|
||||
helpAi6: "設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、または Lemonade を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット;モデルがtool callingに対応している場合、「猫の画像を作って」のように頼むとGenerateUIに読み込み中のワークフローで生成を実行し、結果をチャット内に表示する;ペインヘッダーの「画像生成を許可する」チェックボックスでこの機能のON/OFFを切り替えられる;画像が添付されている場合(TOOLSペインにドロップした画像)はLLMにもビジョン入力として送信され、generate_image呼び出し時はT2Iの代わりにI2I(image-to-image)の元画像として使われる;tool callingとビジョンの動作を確認済みのモデル: Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される",
|
||||
helpAi5: "設定ペイン:バックエンド(Ollama / LM Studio / Lemonade)・API URL・接続テスト・モデル選択・Free 言語名を設定",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade)の選択、APIのURL設定、接続テスト、モデル選択、Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi7: "Chat ペイン — SVG:LLMはComfyUIワークフローを一切使わず、SVGグラフィックをテキストとして直接生成することもできる;「SVGで〜を描いて」のように頼み、応答にSVGコードが含まれていれば自動でインラインプレビュー表示され、「Galleryに保存」ボタンでGalleryタブの出力フォルダに.svgファイルとして保存できる;tool calling(function calling)を使用しないため、tool calling非対応のモデルでも動作する",
|
||||
helpImageEditTitle: "Image Edit タブ",
|
||||
helpImageEdit1: "レイヤーベースの画像エディタ — 複数のレイヤー(画像・テキスト・描画・マスク)を重ね合わせて画像を合成・エクスポート",
|
||||
helpImageEdit2: "画像の読み込み:キャンバスへのドラッグ&ドロップ、Uploadボタン、またはGalleryタブのImage Editボタンから送信;最初の画像はLayer 1として自動ロック;以降の画像はキャンバスサイズに合わせてスケールされ新規レイヤーとして追加",
|
||||
helpImageEdit3: "Newボタン:空白キャンバスを作成 — W×Hサイズを入力(例: 512x768);既存レイヤーは全て削除",
|
||||
helpImageEdit3: "Newボタン:空白キャンバスを作成 — W×Hサイズを入力(例: 512x768);既存レイヤーは全て削除し、空のLayer 1を自動追加",
|
||||
helpImageEditToolsTitle: "ツール",
|
||||
helpImageEdit4: "Select (V):クリックでレイヤーを選択、ドラッグで移動、コーナーハンドルでリサイズ、上辺の円ハンドルで回転;テキストレイヤーをダブルクリックすると再編集可能;ツールオプションバーにFlip H / Flip V / 回転角度入力を表示",
|
||||
helpImageEdit5: "Draw (B):アクティブな描画レイヤーにフリーハンドで描画;ツールオプション:カラー、ブラシサイズ、ブレンドモード;プロパティペインにABR画像ブラシ用のMASK EDITOR ONE (COLOR)セクションを常時表示 — ブラシが利用可能な場合は見出しが緑、未インポートの場合はグレー;ブラシが1つも無い間はSelectボタンが無効",
|
||||
@@ -1773,11 +1825,12 @@ const LANGUAGES = {
|
||||
helpImageEdit7b: "Blur (≈):アクティブレイヤーにガウスぼかしまたはモザイクを適用 — 全体またはドラッグで矩形範囲指定;詳細は下の「Blur Tool」カードを参照",
|
||||
helpImageEdit7c: "BG Remove (⬚):Lightweight(@imgly、ブラウザ内処理)またはBiRefNet(Mask Editor One + ComfyUI/models/background_removal/にbirefnet.safetensors必須)でアクティブレイヤーの背景を除去;詳細は下の「BG Remove Tool」カードを参照",
|
||||
helpImageEdit7d: "Mask (🎭):6つのサブツールでマスクレイヤーにマスクを描画 — Paint(フリーハンド)・Color(色類似度フラッドセレクト)・Alpha(画像の透過チャンネル抽出)・Text(テキストスタンプ)・Vector(Catmull-Romスプライン多角形)・Shape(矩形/楕円);Mask Editor OneインストールでSAM3テキストプロンプトセグメンテーションも利用可能;レイヤーヘッダーの「M」でマスクレイヤーを追加;詳細は下の「Mask Tool」カードを参照",
|
||||
helpImageEdit7e: "Inpaint (🩹):表示中のレイヤーとマスクレイヤーを1枚のRGBA画像に合成(マスクをアルファチャンネルに焼き込み)し、GenerateUIで読み込み中のワークフロー内でインペイント対応の最初のLoadImageノード(MASK出力がVAEEncodeForInpaint等に接続されているノード)へ適用;プロパティパネルでポジ/ネガプロンプト、Grow Mask By(VAE Encode (for Inpainting)ノードへ反映)、Denoise(KSamplerへ反映)を入力してRunを押すと、Image Editタブに留まったままアップロード・生成を実行;完了後は結果画像がプロパティパネルに表示される;事前にマスクレイヤーの描画とインペイント対応ワークフローの読み込みが必要;LoadImageの代わりにMask Editor Oneカスタムノード(comfyui-mask-editor-one)を使ったワークフローにも対応(node_idキーのサーバーキャッシュとlayer_dataウィジェット経由で画像/マスクを反映);「Use dedicated workflow」チェックボックス:OFF時はGenerateUIで読み込み中のワークフローを使用(従来通り)、ON時はドロップダウンから保存済みワークフローを選択すると、GenerateUIの表示状態には触れずバックグラウンドで読み込み・編集・生成を実行",
|
||||
helpImageEdit8b: "レイヤーヘッダーの「M」ボタン:アクティブレイヤーの上に新しいマスクレイヤーを追加し、自動的にMaskツールに切り替える",
|
||||
helpImageEditMaskTitle: "Mask ツール (🎭)",
|
||||
helpImageEditMask1: "マスクレイヤーの追加:レイヤーパネルヘッダーの「M」ボタンをクリック;新しいマスクレイヤーはアクティブレイヤーの上に挿入され、Maskツールが自動的に有効化される",
|
||||
helpImageEditMask2: "レイヤーリストでマスクレイヤーをクリックすると自動的にMaskツールに切り替わる",
|
||||
helpImageEditMask3: "ツールオプションバー — サブツール:Paint / Color / Alpha / Text / Vector / Shape(全てMask Editor One不要)/ SAM3(Mask Editor One必須・未インストール時は無効);Invertチェックボックスでオーバーレイプレビューを反転;Overlayカラーピッカーでプレビューカラーを変更(デフォルト赤);Blurスライダー(0〜50 px)でオーバーレイ輪郭をぼかす",
|
||||
helpImageEditMask3: "ツールオプションバー — サブツール:Paint / Color / Alpha / Text / Vector / Shape(全てMask Editor One不要)/ SAM3(Mask Editor One必須・未インストール時は無効);Invertチェックボックスでオーバーレイプレビューを反転;Overlayカラーピッカーでプレビューカラーを変更(デフォルト赤);Blurスライダー(0〜50 px)でオーバーレイ輪郭をぼかす;オーバーレイは選択中(アクティブ)のマスクレイヤーのみ表示され、非アクティブなマスクは選択するまで非表示",
|
||||
helpImageEditMask4: "プロパティペイン — Paintモード:Mode(Add / Erase)・Size(1〜200 px)・Hardness(0〜100%);MASK EDITOR ONEセクション(常時表示 — ABRブラシが利用可能な場合は見出しが緑、未インポートの場合はグレー):Selectボタンでブラシピッカーを開く(ブラシが1つも無い間は無効)(選択したブラシがデフォルト円形に代わる);✕でCircle(デフォルト)に戻す;画像ブラシ選択中はHardnessが無効になり追加コントロールが表示:Spacing(スタンプ間隔 5〜100%)・Angle(回転角 0〜359°)・Sz Jitter(スタンプごとのサイズランダム変動 0〜100%)・Rot. Jitter(スタンプごとの角度ランダム変動 on/off)",
|
||||
helpImageEditMask5: "マスクレイヤー行の ✂ ボタン:クリッピングマスクとして機能させるon/off切り替え — 有効(青)のとき、直下のレイヤーをマスクの描画範囲でクリッピングして表示;クリッピングはPNG保存 / Gallery / ComfyUI送信にも反映される",
|
||||
helpImageEditMask6: "SAM3(Mask Editor One必須):SAM3モデルによるテキストプロンプトセグメンテーション;対象オブジェクトの説明(例:\"cat\"・\"person\")を入力し、Max(マスク候補の最大数:3/6/9/12)を設定してSegmentをクリック",
|
||||
@@ -1818,12 +1871,13 @@ const LANGUAGES = {
|
||||
helpShortcuts4: "ダブルクリック:詳細モーダルを開く",
|
||||
helpTroubleTitle: "トラブルシューティング",
|
||||
helpTrouble1: "ComfyUIに接続できない:ComfyUIで画像生成できるか確認、設定タブでURL確認、ブラウザ更新・再起動",
|
||||
helpTrouble2: "AIアシスタントに接続できない:設定タブの接続テストボタンで確認;Ollamaの場合は起動とモデルのダウンロードを確認;LM Studioの場合はサーバー起動とモデルのロードを確認",
|
||||
helpTrouble2: "AIアシスタントに接続できない:設定タブの接続テストボタンで確認;Ollamaの場合は起動とモデルのダウンロードを確認;LM StudioまたはLemonadeの場合はサーバー起動とモデルのロードを確認",
|
||||
helpTrouble3: "ワークフローが表示されない:更新ボタン押下、ComfyUIで読み込めるか確認、ブラウザ・ComfyUI再起動",
|
||||
helpTrouble4: "画像生成が失敗する:指定モデルの存在確認、ComfyUI接続確認、再起動",
|
||||
helpTrouble5: "LoRAスタック実行時に「No such file or directory」エラー:comfyui-lora-managerが該当LoRAを認識していない可能性があります(既知の問題)。別のLoRAを試すか、Lora Manager自体のUIで該当ファイルが表示されるか確認してください",
|
||||
helpTrouble6: "モデルの移動・削除後にモデル読み込みエラーが発生する:ComfyUIがファイルパスをキャッシュしており、古い場所を参照することがあります。ComfyUIメニューのHard Reset(Queue → Hard Reset)またはComfyUIを再起動してキャッシュをクリアしてください。",
|
||||
helpTrouble7: "Taggerタブにモデルが表示されない:ComfyUI/models/tagger/<モデル名>/ フォルダの存在と .onnx または .h5 ファイルの有無を確認;onnxruntimeが未インストールの場合は python_embeded\\python.exe -m pip install onnxruntime を実行",
|
||||
helpTrouble8: "ModelタブのCheckpointが想定と違うモデルになっている(どのワークフローを読み込んでも同じモデルになる等):ワークフローファイルに保存されたCheckpointが実際にはインストールされておらず、読み込み時に無警告で別のインストール済みモデルに差し替えられています。この場合、元の(見つからなかった)モデル名を含むエラートーストが表示されます。Modelタブで正しいCheckpointを手動選択するか、設定タブの「デフォルトCheckpoint」を有効にして、読み込み時に常に動作確認済みのモデルを強制適用してください。",
|
||||
helpSupportTitle: "サポート",
|
||||
helpSupportDesc: "このプラグインが役に立ったら、開発を支援していただけると嬉しいです!",
|
||||
helpGithubDesc: "バグ報告、機能リクエスト、コントリビュート",
|
||||
@@ -1847,6 +1901,10 @@ const LANGUAGES = {
|
||||
comfyuiConnection: "ComfyUI 接続",
|
||||
comfyuiUrl: "ComfyUI URL",
|
||||
comfyuiUrlHint: "ComfyUIプラグインとして動作する場合は空欄(同一オリジン推奨)。別サーバーの場合のみ設定してください。",
|
||||
defaultCheckpointSettings: "デフォルトCheckpoint",
|
||||
defaultCheckpointEnable: "ワークフロー読み込み時にデフォルトCheckpointを強制適用する",
|
||||
defaultCheckpointModel: "デフォルトCheckpointモデル",
|
||||
defaultCheckpointHint: "有効にすると、GenerateUIにワークフローを読み込むたびに、すべてのCheckpointローダーノードのモデルがこの値で上書きされます。ワークフローファイルに保存されたモデルが環境と合わず、生成エラーや不鮮明な画像が発生するのを防ぐための設定です。",
|
||||
connectedCheck: "接続成功 \u2713",
|
||||
failedConnect: "接続失敗 \u2717",
|
||||
ollamaSettings: "AI Assistant設定(プロンプトタブ)",
|
||||
@@ -1855,6 +1913,8 @@ const LANGUAGES = {
|
||||
selectModel: "モデルを選択...",
|
||||
noModelsFound: "モデルが見つかりません",
|
||||
failedLoadModels: "モデル一覧の取得に失敗",
|
||||
modelNotFound: "見つかりません",
|
||||
modelNotFoundHint: "このワークフローに保存されているモデルが、インストール済みモデルの中に見つかりませんでした。正しいモデルを一覧から選択してください。",
|
||||
saveOllama: "AI Assistant設定を保存",
|
||||
testConnection: "接続テスト",
|
||||
ollamaConnected: "接続成功 \u2713",
|
||||
@@ -1964,7 +2024,7 @@ const LANGUAGES = {
|
||||
helpMetadata2: "中列: ワークフローから抽出した Checkpoint・VAE・Diffusion Model・Text Encoder の一覧",
|
||||
helpMetadata3: "右列: LoRA 一覧(強度値付き)+プロンプト一覧(クリックで下部に全文表示)",
|
||||
helpMetadata4: "プロンプトアクション — Copy: クリップボードにコピー; GenUI:P/N: GenerateUI のポジティブ/ネガティブプロンプトにセット; Prompt:P/N: Prompt タブ Presets のポジティブ/ネガティブにセット",
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -2053,6 +2113,7 @@ const LANGUAGES = {
|
||||
sendToCanvas: "发送到画布",
|
||||
copyAndSendCanvas: "复制并发送到画布",
|
||||
workflowSentToCanvas: "工作流已就绪 — 将标题拖拽到画布",
|
||||
workflowSentToCanvasDirect: "工作流已发送到画布",
|
||||
comfyUILoadTimeout: "ComfyUI加载超时",
|
||||
popupBlocked: "弹窗被阻止。请允许此站点的弹窗。",
|
||||
setAsDefault: "设为默认",
|
||||
@@ -2077,6 +2138,7 @@ const LANGUAGES = {
|
||||
deleteConfirm: (name) => `确定删除 "${name}" 吗?`,
|
||||
summaryGenerated: "摘要已生成",
|
||||
summarizeError: "摘要错误",
|
||||
summarizeOllamaOnly: "摘要功能仅支持 Ollama 后端,请在设置中切换后端。",
|
||||
copied: "已复制",
|
||||
|
||||
// -- GenerateUI --
|
||||
@@ -2167,6 +2229,10 @@ const LANGUAGES = {
|
||||
unknownWorkflowFormat: "未知的工作流格式",
|
||||
generationComplete: "生成完成",
|
||||
generationError: (msg) => `生成错误: ${msg}`,
|
||||
defaultCheckpointApplied: (name) => `已应用默认 Checkpoint:${name}`,
|
||||
checkpointSubstitutedWarning: (names) => `此工作流中的 Checkpoint「${names}」未找到,已被静默替换为其他模型。请检查 Model 标签页。`,
|
||||
rawJsonBypassNote: (list) => `已绕过(排除,已重新连接布线): ${list}`,
|
||||
rawJsonMutedNote: (list) => `已静音(排除): ${list}`,
|
||||
batchNoneSelected: (what) => `未选择${what}`,
|
||||
batchItemFailed: (i, total, msg) => `[${i}/${total}] 失败: ${msg}`,
|
||||
batchStopped: (completed, failed) => `批处理已停止 — 完成${completed}个,失败${failed}个`,
|
||||
@@ -2317,6 +2383,7 @@ const LANGUAGES = {
|
||||
modelsGenUIUnsupported: "此模型类型无法在GenerateUI中设置",
|
||||
modelsGenUINoWorkflow: "GenerateUI中未加载工作流",
|
||||
modelsGenUINoNode: (type) => `当前工作流中未找到${type}节点`,
|
||||
revertPromptTitle: "恢复到上次应用的提示词",
|
||||
modelsCopyName: "点击复制模型名称",
|
||||
modelsCopiedName: "模型名称已复制",
|
||||
modelsFilePath: "文件路径",
|
||||
@@ -2561,6 +2628,20 @@ const LANGUAGES = {
|
||||
aiStatusChatting: "思考中...",
|
||||
aiToastChatFailed: "聊天失败:",
|
||||
aiToastWcNoName: "请输入通配符名称",
|
||||
aiStatusGeneratingImage: "正在生成图像...",
|
||||
aiChatNoWorkflowLoaded: "GenerateUI 中未加载工作流",
|
||||
aiChatGenerateNotReady: "GenerateUI 尚未就绪",
|
||||
aiChatNoImageProduced: "生成完成但未产生图像",
|
||||
aiToastImageGenFailed: "图像生成失败:",
|
||||
aiChatSaveSvgToGallery: "保存到图库",
|
||||
aiChatSvgSaved: "SVG已保存到图库",
|
||||
aiChatSvgSaveFailed: "SVG保存失败:",
|
||||
aiChatImgGenToggle: "允许生成图像",
|
||||
aiSettingsChatGenTitle: "Chat 图像生成",
|
||||
aiSettingsChatGenDedicated: "使用专用工作流",
|
||||
aiSettingsChatI2IGenTitle: "Chat I2I 生成",
|
||||
aiChatAttachmentLabel: "已附加图像",
|
||||
aiChatNoLoadImageNode: "目标工作流中未找到用于图生图的 LoadImage 节点",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "帮助",
|
||||
@@ -2577,15 +2658,15 @@ const LANGUAGES = {
|
||||
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、徽章分配、标签、备注、AI摘要、发送到画布、加载到生成UI、设为默认、删除",
|
||||
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
|
||||
helpGen1: "连接/断开ComfyUI,检查连接状态",
|
||||
helpGen2: "从工作流标签页加载工作流(不支持App格式.app.json)。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
|
||||
helpGen2: "从工作流标签页加载工作流(支持含子图的工作流,例如 Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等模板;仅不支持ComfyUI的简化App格式 [.app.json / Linear Mode])。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
|
||||
helpGen3: "5标签布局 — Input(提示词+图像)、Model、Settings、Feeder、Batch — Input/Model/Settings各含实时预览和直接编辑的Raw JSON列",
|
||||
helpGen4: "Input标签:含Prompt和Image子标签(拖放上传);Prompt标签显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive)",
|
||||
helpGen4: "Input标签:含Prompt和Image子标签(拖放上传);Prompt标签显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive);每个Apply按钮左侧的↺按钮可将文本框恢复为节点当前已应用的值;选中文字(或将光标置于单词/括号内也可)后按Ctrl+↑/↓,即可以(text:weight)语法以±0.05为单位调整强调权重(权重恢复为1.0时自动去除括号)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/LoRA(Single/Stack标签)/ControlNet/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)/Hypernetwork(带Strength输入)选择器(含筛选)",
|
||||
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致)",
|
||||
helpGen8: "种子管理:随机/固定/递增/递减",
|
||||
helpGen9: "生成图片、进度显示、停止生成、查看结果",
|
||||
helpGen10: "Eagle集成:自动保存生成图片到Eagle",
|
||||
helpGen10: "Eagle集成:自动保存生成图片到Eagle(也支持SVG输出 — 标准SaveSVGNode节点和comfyui-tosvg的Save SVG String节点均可)",
|
||||
helpGen11: "Batch标签 — 勾选批次队列列标题复选框激活对应批次类型(每次仅一种);Generate按顺序处理所有队列项目;支持暂停/继续/停止控制",
|
||||
helpGen12: "Batch标签 — 左窗格含3个标签:Checkpoints(文件树,Filter/All/None,(root)标题勾选全选)、Sampler(KSampler采样器列表,(root)复选框全选)、Scheduler(KSampler调度器列表,(root)复选框全选);每个标签均有All/None按钮",
|
||||
helpGen13: "Batch标签 — 中央窗格:基于组的选择(含Checkpoint | Lora | Prompt | Workflow内部标签);Checkpoint/Lora组来自Models标签,Prompt组来自Prompt标签,Workflow组来自Workflow标签 — 勾选组标题选中所有成员,▶展开后也可单独选择",
|
||||
@@ -2617,7 +2698,7 @@ const LANGUAGES = {
|
||||
helpFeederGal5: "__Feeder__ 分组:自动预先创建的保留分组;不可重命名或删除;在图库标签页中点击每个缩略图上的 F 按钮可将图片添加到此分组",
|
||||
helpFeederGal6: "WFS_GalleryFeeder 画布控件:该节点在 ComfyUI 画布上直接添加了 Run / Stop / After Gen 组件——与 WFM 界面中的控件功能相同,无需打开 WFM 即可运行图库供给循环",
|
||||
helpSearchPlaceholder: "搜索帮助...",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio / Lemonade):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
|
||||
helpPrompt2: "将AI回复应用到生成UI的提示词",
|
||||
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
|
||||
helpPrompt4: "预设管理:创建、编辑、正面/负面提示词剪贴板复制(PP/NP)",
|
||||
@@ -2630,7 +2711,7 @@ const LANGUAGES = {
|
||||
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
|
||||
helpSettings3: "工作流数据文件夹路径更改/重置",
|
||||
helpSettings4: "ComfyUI连接URL设置和测试",
|
||||
helpSettings5: "AI助手(提示词标签页):后端(Ollama / LM Studio)、API URL、默认模型、连接测试",
|
||||
helpSettings5: "AI助手(提示词标签页):后端(Ollama / LM Studio / Lemonade)、API URL、默认模型、连接测试",
|
||||
helpSettings6: "默认工作流设置/取消",
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
|
||||
@@ -2709,15 +2790,16 @@ const LANGUAGES = {
|
||||
helpSidepanel17: "Send to Canvas:在Workflow标签点击'Send to Canvas'或Gallery标签点击'Copy & Send Canvas',工作流直接发送到ComfyUI画布(支持UI和API所有格式);若Workflow Studio不是从ComfyUI工具栏打开,UI格式工作流将回退到标题拖拽(标题变蓝并显示绿色●——拖拽到画布加载)",
|
||||
helpAiTitle: "AI 标签页(A)",
|
||||
helpAi1: "4 面板布局 — Translation | Chat | TOOLS | Settings — 所有面板始终可见",
|
||||
helpAi2: "翻译面板:使用 Ollama 或 LM Studio 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条)",
|
||||
helpAi5: "设置面板:选择后端(Ollama / LM Studio)、设置 API URL、测试连接、选择模型、配置 Free 语言名称",
|
||||
helpAi6: "设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio 或 Lemonade 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史;如果模型支持 tool calling,当你要求生成图像(例如\"生成一张猫的图片\")时,会使用 GenerateUI 中当前加载的工作流执行生成,并在对话中显示结果;面板标题栏的\"允许生成图像\"复选框可开关此功能;如果附加了图像(在 TOOLS 面板中拖放的图像),该图像会作为视觉输入发送给 LLM,调用 generate_image 时会以该图像为基础执行图生图(I2I),而不是文生图;已验证可正常支持 tool calling 与视觉功能的模型:Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条);拖放的图像也会作为 Chat 面板的附件共享(在该处显示并用于图生图生成),直到点击 ✕ 按钮清除",
|
||||
helpAi5: "设置面板:选择后端(Ollama / LM Studio / Lemonade)、设置 API URL、测试连接、选择模型、配置 Free 语言名称",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade)、设置 API URL、测试连接、选择模型、配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi7: "Chat 面板 — SVG:LLM 还可以直接以文本形式生成 SVG 图形,完全不涉及 ComfyUI 工作流;只需要求它用 SVG 绘制内容,如果回复中包含 SVG 代码,会自动渲染为内嵌预览,并提供\"保存到图库\"按钮,将其保存为 .svg 文件到 Gallery 标签页的输出文件夹;该功能不使用 tool calling(函数调用),因此不支持 tool calling 的模型也可以使用",
|
||||
helpImageEditTitle: "图像编辑选项卡",
|
||||
helpImageEdit1: "基于图层的图像编辑器 — 通过多个图层(图像、文字、绘制、遮罩类型)合成图像并导出",
|
||||
helpImageEdit2: "加载图像:拖放到画布、点击Upload按钮、或通过Gallery标签工具栏的Image Edit按钮发送;第一张图像成为图层1(自动锁定);后续图像缩放至画布大小作为新图层添加",
|
||||
helpImageEdit3: "New按钮:创建空白画布 — 输入宽高(如512x768);清除所有现有图层",
|
||||
helpImageEdit3: "New按钮:创建空白画布 — 输入宽高(如512x768);清除所有现有图层并自动添加空白Layer 1",
|
||||
helpImageEditToolsTitle: "工具",
|
||||
helpImageEdit4: "Select (V):点击选择图层;拖动移动;拖动角点手柄调整大小;拖动顶边圆形手柄旋转;双击文字图层可重新编辑;工具选项栏显示翻转H / 翻转V / 旋转角度",
|
||||
helpImageEdit5: "Draw (B):在当前绘制图层上自由绘画;工具选项:颜色、笔刷大小、混合模式;属性面板包含用于ABR图像笔刷的MASK EDITOR ONE (COLOR)区域 — 始终显示,笔刷可用时标题为绿色,未导入笔刷时为灰色;没有任何笔刷时Select按钮为禁用状态",
|
||||
@@ -2753,11 +2835,12 @@ const LANGUAGES = {
|
||||
helpImageEdit7b: "Blur (≈):对活动图层应用高斯模糊或马赛克 — 整层或拖动矩形区域;详细内容参见下方「Blur Tool」卡片",
|
||||
helpImageEdit7c: "BG Remove (⬚):使用Lightweight(@imgly,在浏览器中运行)或BiRefNet(需要Mask Editor One + ComfyUI/models/background_removal/中的birefnet.safetensors)去除活动图层背景;详细内容参见下方「BG Remove Tool」卡片",
|
||||
helpImageEdit7d: "Mask (🎭):使用6种子工具在遮罩图层上绘制遮罩 — Paint(徒手绘制)、Color(按颜色相似度泛选)、Alpha(从图像透明度提取)、Text(文字图章)、Vector(Catmull-Rom样条多边形)、Shape(矩形/椭圆);安装Mask Editor One后还可使用SAM3文本提示分割;点击图层标题栏的「M」添加遮罩图层;详细内容参见下方「Mask Tool」卡片",
|
||||
helpImageEdit7e: "Inpaint (🩹):将可见图层与遮罩图层合成为一张RGBA图像(遮罩烘焙进alpha通道),并应用到GenerateUI当前加载的工作流中第一个支持内绘的LoadImage节点(其MASK输出连接到VAEEncodeForInpaint等节点);在属性面板中输入正/负向提示词、Grow Mask By(应用到VAE Encode (for Inpainting)节点)、Denoise(应用到KSampler)后点击Run,即可在不离开Image Edit标签的情况下完成上传与生成;完成后结果图像会显示在属性面板中;需先绘制遮罩图层并在GenerateUI中加载支持内绘的工作流;同样支持基于Mask Editor One自定义节点(comfyui-mask-editor-one)而非LoadImage构建的工作流(通过其按node_id缓存的服务端图像与layer_data组件写入图像/遮罩);「Use dedicated workflow」复选框:关闭时使用GenerateUI当前加载的工作流(与之前相同);开启时可从下拉列表选择已保存的工作流,将在后台加载、编辑并生成,不影响GenerateUI中显示的工作流",
|
||||
helpImageEdit8b: "图层标题栏的「M」按钮:在当前活动图层上方添加新的遮罩图层,并自动切换至Mask工具",
|
||||
helpImageEditMaskTitle: "Mask 工具 (🎭)",
|
||||
helpImageEditMask1: "添加遮罩图层:点击图层面板标题栏的「M」按钮;新遮罩图层插入到活动图层上方,Mask工具自动激活",
|
||||
helpImageEditMask2: "在图层列表中点击遮罩图层会自动切换至Mask工具",
|
||||
helpImageEditMask3: "顶部工具栏 — 子工具:Paint / Color / Alpha / Text / Vector / Shape(均无需Mask Editor One)/ SAM3(需要Mask Editor One,未安装时不可用);Invert复选框反转叠加预览;Overlay拾色器更改预览颜色(默认红色);Blur滑块(0–50 px)柔化叠加边缘",
|
||||
helpImageEditMask3: "顶部工具栏 — 子工具:Paint / Color / Alpha / Text / Vector / Shape(均无需Mask Editor One)/ SAM3(需要Mask Editor One,未安装时不可用);Invert复选框反转叠加预览;Overlay拾色器更改预览颜色(默认红色);Blur滑块(0–50 px)柔化叠加边缘;叠加显示仅在当前激活的蒙版图层上显示,未激活的蒙版图层在被选中前保持隐藏",
|
||||
helpImageEditMask4: "属性面板 — Paint模式:Mode(Add / Erase)、Size(1–200 px)、Hardness(0–100%);MASK EDITOR ONE区域(始终显示 — 有可用的ABR笔刷时标题为绿色,尚未导入笔刷时为灰色):Select按钮打开ABR笔刷库(没有任何笔刷时禁用) — 选择笔刷将替换默认圆形笔刷;✕恢复Circle(默认);选择图像笔刷后Hardness禁用,并显示额外控件:Spacing(印章间距 5–100%)、Angle(旋转角度 0–359°)、Sz Jitter(每个印章的随机大小变化 0–100%)、Rot. Jitter(每个印章的随机旋转 on/off)",
|
||||
helpImageEditMask5: "遮罩图层行的 ✂ 按钮:切换是否作为剪贴蒙版使用 — 启用(蓝色)时,遮罩将裁剪其正下方图层,仅显示已绘制区域;剪贴效果在保存PNG / Gallery / 发送至ComfyUI时均会生效",
|
||||
helpImageEditMask6: "SAM3(需要Mask Editor One):由SAM3模型驱动的文本提示分割;输入目标对象描述(如\"cat\"、\"person\"),设置Max(最大蒙版候选数:3/6/9/12),然后点击Segment",
|
||||
@@ -2798,12 +2881,13 @@ const LANGUAGES = {
|
||||
helpShortcuts4: "双击:打开详情弹窗",
|
||||
helpTroubleTitle: "故障排除",
|
||||
helpTrouble1: "无法连接ComfyUI:确认ComfyUI能否生成图片、在设置中验证URL、尝试刷新或重启",
|
||||
helpTrouble2: "无法连接AI助手:使用设置标签页的连接测试按钮;Ollama需确认已启动且模型已下载;LM Studio需确认服务器已启动且模型已加载",
|
||||
helpTrouble2: "无法连接AI助手:使用设置标签页的连接测试按钮;Ollama需确认已启动且模型已下载;LM Studio或Lemonade需确认服务器已启动且模型已加载",
|
||||
helpTrouble3: "工作流不显示:点击刷新按钮、确认能在ComfyUI中加载、尝试重启浏览器/ComfyUI",
|
||||
helpTrouble4: "图片生成失败:确认指定模型存在、验证ComfyUI连接、尝试重启",
|
||||
helpTrouble5: "LoRA Stack出现「No such file or directory」错误:该LoRA可能未被comfyui-lora-manager识别(已知问题)。请尝试其他LoRA,或检查该文件是否显示在Lora Manager自身的界面中",
|
||||
helpTrouble6: "移动或删除模型后出现模型加载错误:ComfyUI会缓存文件路径,可能仍引用旧位置。请通过ComfyUI菜单执行Hard Reset(Queue → Hard Reset)或重启ComfyUI以清除缓存。",
|
||||
helpTrouble7: "Tagger标签不显示模型:确认ComfyUI/models/tagger/<模型名>/文件夹存在且包含.onnx或.h5文件;如未安装onnxruntime,请运行:python_embeded\\python.exe -m pip install onnxruntime",
|
||||
helpTrouble8: "Model 标签页显示的 Checkpoint 与预期不符(例如无论加载哪个工作流都显示相同模型):工作流文件中保存的 Checkpoint 实际未安装,加载时已被静默替换为其他已安装模型,此时会弹出包含原始(未找到)模型名称的错误提示。请在 Model 标签页手动选择正确的 Checkpoint,或在设置标签页启用\"默认 Checkpoint\",在每次加载时强制应用一个已验证可用的模型。",
|
||||
helpSupportTitle: "支持",
|
||||
helpSupportDesc: "如果您觉得这个插件有用,请考虑支持它的开发!",
|
||||
helpGithubDesc: "报告问题、请求功能和贡献代码",
|
||||
@@ -2827,6 +2911,10 @@ const LANGUAGES = {
|
||||
comfyuiConnection: "ComfyUI 连接",
|
||||
comfyuiUrl: "ComfyUI URL",
|
||||
comfyuiUrlHint: "作为ComfyUI插件运行时留空(推荐同源)。仅在不同服务器时设置。",
|
||||
defaultCheckpointSettings: "默认 Checkpoint",
|
||||
defaultCheckpointEnable: "加载工作流时强制应用默认 Checkpoint",
|
||||
defaultCheckpointModel: "默认 Checkpoint 模型",
|
||||
defaultCheckpointHint: "启用后,每次将工作流加载到 GenerateUI 时,所有 Checkpoint 加载节点的模型都会被覆盖为此值,用于避免工作流文件中保存的模型与当前环境不匹配导致生成错误或图像模糊。",
|
||||
connectedCheck: "已连接 \u2713",
|
||||
failedConnect: "连接失败 \u2717",
|
||||
ollamaSettings: "AI助手设置(提示词标签页)",
|
||||
@@ -2835,6 +2923,8 @@ const LANGUAGES = {
|
||||
selectModel: "选择模型...",
|
||||
noModelsFound: "未找到模型",
|
||||
failedLoadModels: "获取模型列表失败",
|
||||
modelNotFound: "未找到",
|
||||
modelNotFoundHint: "此工作流中保存的模型在已安装的模型列表中未找到,请从列表中选择正确的模型。",
|
||||
saveOllama: "保存AI助手设置",
|
||||
testConnection: "测试连接",
|
||||
ollamaConnected: "已连接 \u2713",
|
||||
@@ -2944,7 +3034,7 @@ const LANGUAGES = {
|
||||
helpMetadata2: "中列:从工作流中提取的 Checkpoint、VAE、扩散模型、文本编码器列表",
|
||||
helpMetadata3: "右列:LoRA 列表(含强度值)和提示词列表(点击在下方显示全文)",
|
||||
helpMetadata4: "提示词操作 — Copy:复制到剪贴板;GenUI:P/N:设置 GenerateUI 正/负向提示;Prompt:P/N:设置 Prompt 标签页预设正/负向提示",
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image 子图工作流)、SD WebUI、SD Forge、Fooocus。",
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow 子图工作流)、SD WebUI、SD Forge、Fooocus。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+301
-2
@@ -12,6 +12,9 @@ import { ShapeTool } from "./image-edit/ShapeTool.js";
|
||||
import { MaskTool } from "./image-edit/MaskTool.js";
|
||||
import { MaskColorTool, MaskAlphaTool, MaskTextTool, MaskVectorTool, MaskShapeTool, MASK_TEXT_FONTS } from "./image-edit/MaskEditorOneTools.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
const TOOL_DEFS = [
|
||||
{ id: "select", icon: "▲", label: "Select", ready: true },
|
||||
@@ -22,6 +25,7 @@ const TOOL_DEFS = [
|
||||
{ id: "blur", icon: "≈", label: "Blur", ready: true },
|
||||
{ id: "filter", icon: "★", label: "Filter", ready: true },
|
||||
{ id: "bgremove", icon: "⬚", label: "BG Remove", ready: true },
|
||||
{ id: "inpaint", icon: "🩹", label: "Inpaint", ready: true },
|
||||
];
|
||||
|
||||
const UNDO_LIMIT = 20;
|
||||
@@ -107,6 +111,19 @@ class ImageEditTab {
|
||||
// ABR brush (Mask Editor One) — optional feature
|
||||
this._abrAvailable = false;
|
||||
this._abrBrushTree = [];
|
||||
// Inpaint ツール
|
||||
this._inpaintPositive = "";
|
||||
this._inpaintNegative = "";
|
||||
this._inpaintGrowMaskBy = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this._inpaintDenoise = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this._inpaintRunning = false;
|
||||
this._inpaintResultUrl = null;
|
||||
// Select I2I(Comic Creator連携、マスク不要のI2I外部実行)
|
||||
this._i2iExternalRunning = false;
|
||||
// Inpaint 専用ワークフロー(OFF時は GenerateUI で読み込み中のワークフローを使用)
|
||||
this._inpaintUseDedicated = false;
|
||||
this._inpaintDedicatedFilename = null;
|
||||
this._inpaintWorkflowList = [];
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────
|
||||
@@ -124,6 +141,20 @@ class ImageEditTab {
|
||||
this._checkBiRefNetAvailability();
|
||||
this._checkSam3Availability();
|
||||
this._checkAbrAvailability();
|
||||
// Inpaint「専用ワークフロー」選択肢のため、保存済みワークフロー一覧を取得
|
||||
this._fetchInpaintWorkflowList();
|
||||
}
|
||||
|
||||
async _fetchInpaintWorkflowList() {
|
||||
try {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows`);
|
||||
if (!resp.ok) return;
|
||||
const list = await resp.json();
|
||||
this._inpaintWorkflowList = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
if (this._activeTool === "inpaint") this._renderInpaintProps();
|
||||
} catch {
|
||||
this._inpaintWorkflowList = [];
|
||||
}
|
||||
}
|
||||
|
||||
async _checkBiRefNetAvailability() {
|
||||
@@ -243,8 +274,8 @@ class ImageEditTab {
|
||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
}
|
||||
// Draw/Mask以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw") {
|
||||
// Draw/Mask/Inpaint以外に切り替えたらプロパティペインを非表示
|
||||
if (toolId !== "mask" && toolId !== "draw" && toolId !== "inpaint") {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
if (pane) pane.style.display = "none";
|
||||
}
|
||||
@@ -689,6 +720,10 @@ class ImageEditTab {
|
||||
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this._gmicOpenGui());
|
||||
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this._gmicApplyResult());
|
||||
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this._gmicAbort());
|
||||
|
||||
} else if (toolId === "inpaint") {
|
||||
el.innerHTML = "";
|
||||
this._renderInpaintProps();
|
||||
} else {
|
||||
const def = TOOL_DEFS.find(d => d.id === toolId);
|
||||
el.innerHTML = `<span style="font-size:12px;color:var(--wfm-text-secondary);">${def?.label ?? toolId}: coming soon</span>`;
|
||||
@@ -1230,7 +1265,267 @@ class ImageEditTab {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// ── Inpaint ツール ─────────────────────────────
|
||||
// マスクレイヤー(透過背景+白ペイント)を黒背景に合成し、白=インペイント対象/黒=維持の
|
||||
// 不透明なグレースケールPNGとして書き出す(LayerのgetThumbnailDataURLと同じ規約)。
|
||||
_exportMaskCanvas(maskLayer) {
|
||||
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(maskLayer.canvas, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
_renderInpaintProps() {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
const body = document.getElementById("ie-props-body");
|
||||
const title = document.getElementById("ie-props-title");
|
||||
if (!pane || !body) return;
|
||||
pane.style.display = "flex";
|
||||
if (title) title.textContent = "Inpaint";
|
||||
|
||||
// 未初期化ならワークフローの現在値から初期化(VAEEncodeForInpaint.grow_mask_by / KSampler.denoise)
|
||||
if (this._inpaintGrowMaskBy == null) {
|
||||
this._inpaintGrowMaskBy = comfyUI.currentAnalysis?.inpaint_encode_nodes?.[0]?.grow_mask_by ?? 6;
|
||||
}
|
||||
if (this._inpaintDenoise == null) {
|
||||
const samplerNode = (comfyUI.currentAnalysis?.sampler_nodes || []).find((n) => n.denoise !== undefined);
|
||||
this._inpaintDenoise = samplerNode?.denoise ?? 1.0;
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-inpaint-use-dedicated" ${this._inpaintUseDedicated ? "checked" : ""}>
|
||||
Use dedicated workflow
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-props-row" id="ie-inpaint-dedicated-row" style="${this._inpaintUseDedicated ? "" : "display:none;"}">
|
||||
<select id="ie-inpaint-dedicated-select" style="flex:1;">
|
||||
<option value="">-- select workflow --</option>
|
||||
${this._inpaintWorkflowList.map((f) => `<option value="${f}" ${f === this._inpaintDedicatedFilename ? "selected" : ""}>${f}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this._inpaintPositive}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this._inpaintNegative}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Grow Mask By</label>
|
||||
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this._inpaintGrowMaskBy}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Denoise</label>
|
||||
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this._inpaintDenoise}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this._inpaintRunning ? "disabled" : ""}>
|
||||
${this._inpaintRunning ? "Running..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-inpaint-status" style="font-size:11px;color:var(--wfm-text-secondary);"></span>
|
||||
${this._inpaintResultUrl ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Result</label>
|
||||
<img src="${this._inpaintResultUrl}" style="max-width:100%;border-radius:var(--wfm-radius-sm);border:1px solid var(--wfm-border);">
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
|
||||
document.getElementById("ie-inpaint-use-dedicated")?.addEventListener("change", e => {
|
||||
this._inpaintUseDedicated = e.target.checked;
|
||||
this._renderInpaintProps();
|
||||
});
|
||||
document.getElementById("ie-inpaint-dedicated-select")?.addEventListener("change", e => {
|
||||
this._inpaintDedicatedFilename = e.target.value || null;
|
||||
});
|
||||
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this._inpaintPositive = e.target.value; });
|
||||
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this._inpaintNegative = e.target.value; });
|
||||
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
|
||||
this._inpaintGrowMaskBy = parseInt(e.target.value) || 0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
|
||||
this._inpaintDenoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
|
||||
if (Number.isNaN(this._inpaintDenoise)) this._inpaintDenoise = 1.0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this._runInpaint());
|
||||
}
|
||||
|
||||
async _runInpaint() {
|
||||
if (this._inpaintRunning) return;
|
||||
if (!this._layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
|
||||
const maskLayer = (this._layerMgr.activeLayer?.type === "mask" && this._layerMgr.activeLayer.visible)
|
||||
? this._layerMgr.activeLayer
|
||||
: this._layerMgr.layers.find(l => l.type === "mask" && l.visible);
|
||||
if (!maskLayer) { showToast("Create or show a mask layer first (Mask tool)", "info"); return; }
|
||||
|
||||
if (this._inpaintUseDedicated) {
|
||||
if (!this._inpaintDedicatedFilename) { showToast("Select a dedicated workflow first", "info"); return; }
|
||||
} else if (!comfyUI.currentAnalysis) {
|
||||
showToast("Load a workflow in GenerateUI first", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const runBtn = document.getElementById("ie-inpaint-run-btn");
|
||||
const statusEl = document.getElementById("ie-inpaint-status");
|
||||
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
this._inpaintRunning = true;
|
||||
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
|
||||
setStatus(this._inpaintUseDedicated ? "Loading dedicated workflow..." : "Compositing...");
|
||||
|
||||
try {
|
||||
// 専用ワークフロー使用時は GenerateUI の comfyUI.currentWorkflow/currentAnalysis には
|
||||
// 一切触れず、ここでロード・解析したローカルなワークフロー/解析結果だけを使う。
|
||||
let overrideOpts = {};
|
||||
|
||||
if (this._inpaintUseDedicated) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(this._inpaintDedicatedFilename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let dedicatedWorkflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(dedicatedWorkflow, this._inpaintDedicatedFilename);
|
||||
if (format === "ui") {
|
||||
dedicatedWorkflow = await comfyWorkflow.convertUiToApi(dedicatedWorkflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
overrideOpts = { workflow: dedicatedWorkflow, analysis: comfyWorkflow.analyzeWorkflow(dedicatedWorkflow) };
|
||||
}
|
||||
|
||||
setStatus("Compositing...");
|
||||
const bgCanvas = this._buildBgCanvas();
|
||||
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
|
||||
const maskCanvas = this._exportMaskCanvas(maskLayer);
|
||||
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
|
||||
|
||||
const resultUrl = await this._runInpaintWithImages(imageBlob, maskBlob, {
|
||||
positive: this._inpaintPositive,
|
||||
negative: this._inpaintNegative,
|
||||
growMaskBy: this._inpaintGrowMaskBy,
|
||||
denoise: this._inpaintDenoise,
|
||||
overrideOpts,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
if (resultUrl) this._inpaintResultUrl = resultUrl;
|
||||
|
||||
setStatus("Done");
|
||||
showToast("Inpaint generation complete", "success");
|
||||
} catch (err) {
|
||||
setStatus("Error");
|
||||
showToast(`Inpaint failed: ${err.message}`, "error");
|
||||
} finally {
|
||||
this._inpaintRunning = false;
|
||||
this._renderInpaintProps();
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像+マスクBlobを対象ノード(LoadImage/MaskEditorOne)へ反映し、
|
||||
// プロンプト/grow_mask_by/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// UI駆動の _runInpaint() と外部(Comic Creator等)向けの runInpaintExternal() の両方から使う。
|
||||
async _runInpaintWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
// LoadImage(の MASK 出力が使われている)スロットを優先し、無ければ Mask Editor One
|
||||
// ノード(LoadImage を介さず画像/マスクを内包する custom node)にフォールバックする
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
const slotIndex = (analysisForLookup.load_image_nodes || []).findIndex(n => n.mask_used);
|
||||
const meoNode = (analysisForLookup.mask_editor_one_nodes || [])[0];
|
||||
if (slotIndex === -1 && !meoNode) {
|
||||
throw new Error("No inpaint-capable LoadImage or Mask Editor One node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
if (slotIndex !== -1) {
|
||||
await comfyEditor.applyImageAndMaskToSlot(imageBlob, maskBlob, slotIndex, overrideOpts);
|
||||
} else {
|
||||
await comfyEditor.applyImageAndMaskToMaskEditorOneNode(imageBlob, maskBlob, meoNode.id, overrideOpts);
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ growMaskBy, denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント。
|
||||
// 専用ワークフロー選択(_inpaintUseDedicated)は扱わず、常にGenerate UIに現在
|
||||
// ロード中のワークフロー(comfyUI.currentWorkflow/currentAnalysis)を対象にする。
|
||||
async runInpaintExternal(imageBlob, maskBlob, { positive = "", negative = "", growMaskBy = 6, denoise = 1.0 } = {}) {
|
||||
if (this._inpaintRunning) throw new Error("Inpaint is already running");
|
||||
this._inpaintRunning = true;
|
||||
try {
|
||||
const resultUrl = await this._runInpaintWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this._inpaintRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像Blob(マスクなし)を対象LoadImageノード(slot 0)へ反映し、
|
||||
// プロンプト/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// _runInpaintWithImages のマスクなし版(Comic CreatorのSelect I2I連携専用)。
|
||||
async _runI2IWithImage(imageBlob, { positive, negative, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
if (!(analysisForLookup.load_image_nodes?.length > 0)) {
|
||||
throw new Error("No LoadImage node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
const file = new File([imageBlob], `i2i_${Math.random().toString(36).slice(2, 10)}.png`, { type: "image/png" });
|
||||
await comfyEditor.applyImageToSlot(file, 0, overrideOpts);
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント(I2I版)。
|
||||
// runInpaintExternal と対になる、マスク不要のシンプルなI2I実行。
|
||||
async runI2IExternal(imageBlob, { positive = "", negative = "", denoise = 1.0 } = {}) {
|
||||
if (this._i2iExternalRunning) throw new Error("I2I is already running");
|
||||
this._i2iExternalRunning = true;
|
||||
try {
|
||||
const resultUrl = await this._runI2IWithImage(imageBlob, { positive, negative, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this._i2iExternalRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
_renderMaskLayerOverlay(ctx, maskLayer) {
|
||||
// アクティブなマスクレイヤーのみオーバーレイ表示(非アクティブなマスクは隠す)
|
||||
if (this._layerMgr?.activeLayer !== maskLayer) return;
|
||||
|
||||
const overlayColor = this._maskOverlayColor;
|
||||
const blurPx = this._maskBlur;
|
||||
const inverted = this._maskInverted;
|
||||
@@ -1588,9 +1883,12 @@ class ImageEditTab {
|
||||
this._canvasH = h;
|
||||
this._baseName = "new-canvas";
|
||||
this._initCanvases();
|
||||
const layer1 = this._layerMgr.addLayer("draw", "Layer 1");
|
||||
this._layerMgr.setActive(layer1.id);
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._setActiveTool("select");
|
||||
this._selectTool?.setLayer(layer1);
|
||||
this._refreshLayerList();
|
||||
this._updateCompositeView();
|
||||
this._fitToView();
|
||||
@@ -2066,6 +2364,7 @@ class ImageEditTab {
|
||||
this._updateCompositeView();
|
||||
} else if (this._activeTool === "select" && layer) {
|
||||
this._selectTool?.setLayer(layer);
|
||||
this._updateCompositeView();
|
||||
} else {
|
||||
this._updateCompositeView();
|
||||
}
|
||||
|
||||
+46
-12
@@ -6,6 +6,7 @@
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
// ── File size limit ───────────────────────────────────────────
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
@@ -244,11 +245,22 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
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");
|
||||
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.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
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);
|
||||
if (!text) continue;
|
||||
const name = inp.name ?? "";
|
||||
if (name === "positive" || name.startsWith("positive")) pos.add(text);
|
||||
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler) return null;
|
||||
@@ -346,10 +358,20 @@ function extractPromptsAPI(wf) {
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || 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.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
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]));
|
||||
if (!text) continue;
|
||||
if (key === "positive" || key.startsWith("positive")) pos.add(text);
|
||||
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
|
||||
@@ -451,10 +473,22 @@ async function extractAllMetadata(file) {
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
|
||||
function fromWorkflow(wf, source) {
|
||||
async function fromWorkflow(originalWf, source) {
|
||||
let wf = originalWf;
|
||||
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
|
||||
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
|
||||
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
|
||||
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
|
||||
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
|
||||
try {
|
||||
const apiWf = await comfyWorkflow.convertUiToApi(wf);
|
||||
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
|
||||
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
|
||||
}
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)
|
||||
const mdm = extractMarkdownNoteModels(wf);
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
|
||||
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
|
||||
const mdm = extractMarkdownNoteModels(originalWf);
|
||||
if (mdm) {
|
||||
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
|
||||
if (!base.vaes.length) base.vaes = mdm.vaes;
|
||||
@@ -467,20 +501,20 @@ async function extractAllMetadata(file) {
|
||||
|
||||
if (isJSON) {
|
||||
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
|
||||
return wf ? fromWorkflow(wf, "comfyui") : null;
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
if (isWebP) {
|
||||
const exif = await readWebPEXIFChunk(file);
|
||||
if (!exif) return null;
|
||||
const wf = extractWorkflowFromEXIF(exif);
|
||||
return wf ? fromWorkflow(wf, "comfyui") : null;
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
// PNG
|
||||
const chunks = await readAllPNGTextChunks(file);
|
||||
if (!chunks) return null;
|
||||
|
||||
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
|
||||
|
||||
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
|
||||
const f = parseFooocusMetadata(chunks.parameters);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -390,7 +390,7 @@ let _promptAiBackendForModels = null;
|
||||
function getPromptAiConfig() {
|
||||
const s = loadPromptAiSettings();
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return { backend, url };
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ async function chatWithAi(model, messages) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
} else {
|
||||
// LM Studio: convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
// LM Studio / Lemonade (OpenAI-compatible): convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
const openAiMessages = messages.map(msg => {
|
||||
if (msg.images?.length) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage } from "./util.js";
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
@@ -637,6 +637,30 @@ export async function initSettingsTab() {
|
||||
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Default Checkpoint -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("defaultCheckpointSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-settings-default-ckpt-enabled" ${serverSettings.default_checkpoint_enabled ? "checked" : ""}>
|
||||
<span id="wfm-settings-default-ckpt-enable-label">${t("defaultCheckpointEnable")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label id="wfm-settings-default-ckpt-model-label">${t("defaultCheckpointModel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="wfm-select" id="wfm-settings-default-ckpt-select" style="flex:1;">
|
||||
<option value="">${t("selectModel")}</option>
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-default-ckpt-refresh">${t("refresh")}</button>
|
||||
</div>
|
||||
<small id="wfm-settings-default-ckpt-hint" style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("defaultCheckpointHint")}
|
||||
</small>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-default-ckpt-save">${t("save")}</button>
|
||||
</details>
|
||||
|
||||
<!-- AI Assistant Settings (Prompt Tab) -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
@@ -649,6 +673,9 @@ export async function initSettingsTab() {
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-prompt-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
@@ -1101,14 +1128,14 @@ export async function initSettingsTab() {
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = promptAiBackendSaved === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → update URL placeholder
|
||||
// Backend change → switch URL to the new backend's default
|
||||
document.querySelectorAll('input[name="wfm-prompt-ai-backend"]').forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
if (promptAiUrlEl) promptAiUrlEl.placeholder = backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1117,7 +1144,7 @@ export async function initSettingsTab() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
let models = [];
|
||||
if (backend === "ollama") {
|
||||
@@ -1142,7 +1169,7 @@ export async function initSettingsTab() {
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
let count = 0;
|
||||
@@ -1210,6 +1237,35 @@ export async function initSettingsTab() {
|
||||
statusEl.style.color = ok ? "var(--wfm-success)" : "var(--wfm-danger)";
|
||||
});
|
||||
|
||||
// Default Checkpoint
|
||||
const defaultCkptSelect = document.getElementById("wfm-settings-default-ckpt-select");
|
||||
|
||||
async function loadDefaultCkptOptions() {
|
||||
if (!defaultCkptSelect) return;
|
||||
try {
|
||||
const list = await comfyUI.fetchCheckpoints();
|
||||
const saved = serverSettings.default_checkpoint_name || "";
|
||||
defaultCkptSelect.innerHTML = `<option value="">${t("selectModel")}</option>` +
|
||||
(list || []).map((m) => `<option value="${m}" ${m === saved ? "selected" : ""}>${m}</option>`).join("");
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadDefaultCkptOptions();
|
||||
|
||||
document.getElementById("wfm-settings-default-ckpt-refresh")?.addEventListener("click", loadDefaultCkptOptions);
|
||||
|
||||
document.getElementById("wfm-settings-default-ckpt-save")?.addEventListener("click", async () => {
|
||||
const enabled = !!document.getElementById("wfm-settings-default-ckpt-enabled")?.checked;
|
||||
const ckptName = defaultCkptSelect?.value || "";
|
||||
try {
|
||||
await saveServerSettings({ default_checkpoint_enabled: enabled, default_checkpoint_name: ckptName });
|
||||
serverSettings.default_checkpoint_enabled = enabled;
|
||||
serverSettings.default_checkpoint_name = ckptName;
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(`${t("saveError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Test Eagle URL (via server proxy to avoid CORS)
|
||||
document.getElementById("wfm-settings-test-eagle")?.addEventListener("click", async () => {
|
||||
const urlInput = document.getElementById("wfm-settings-eagle-url");
|
||||
|
||||
@@ -27,6 +27,17 @@ export function getSettings() {
|
||||
return readJsonStorage("wfm_settings");
|
||||
}
|
||||
|
||||
// AIバックエンド(Ollama/LM Studio/Lemonade)ごとのデフォルトURL
|
||||
export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
};
|
||||
|
||||
export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
|
||||
@@ -51,7 +51,7 @@ function sendToCanvas(workflowData) {
|
||||
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
window.opener.wfmReceiveWorkflow(workflowData);
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
showToast(t("workflowSentToCanvasDirect"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
@@ -270,6 +270,85 @@ async function getRawWorkflow(filename) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Summary — structured extraction (not raw JSON dump)
|
||||
// ============================================
|
||||
|
||||
// analyzeWorkflow() の結果をコンパクトなテキストに整形する。
|
||||
// 生JSONをそのままLLMに渡すと、MarkdownNoteの長文やUI座標情報などのノイズで
|
||||
// 文字数制限に達し、肝心のノード構成情報(サブグラフ内含む)が切り捨てられやすい。
|
||||
function _buildStructuredSummaryText(analysis) {
|
||||
const lines = [];
|
||||
|
||||
if (analysis.checkpoint_nodes.length) {
|
||||
lines.push(`Checkpoint: ${analysis.checkpoint_nodes.map((n) => n.ckpt_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.diffusion_model_nodes.length) {
|
||||
lines.push(`Diffusion Model (UNET): ${analysis.diffusion_model_nodes.map((n) => n.unet_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.text_encoder_nodes.length) {
|
||||
lines.push(`Text Encoder (CLIP): ${analysis.text_encoder_nodes.map((n) => [n.clip_name1, n.clip_name2].filter(Boolean).join("+")).join(", ")}`);
|
||||
}
|
||||
if (analysis.vae_nodes.length) {
|
||||
lines.push(`VAE: ${analysis.vae_nodes.map((n) => n.vae_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.lora_nodes.length) {
|
||||
lines.push(`LoRA: ${analysis.lora_nodes.map((n) => {
|
||||
if (n.is_lora_manager) return "(LoraManager)";
|
||||
const strengths = n.strength_clip !== undefined
|
||||
? `model:${n.strength_model}, clip:${n.strength_clip}`
|
||||
: `model:${n.strength_model}`;
|
||||
return `${n.lora_name} (${strengths})`;
|
||||
}).join(", ")}`);
|
||||
}
|
||||
if (analysis.controlnet_nodes.length) {
|
||||
lines.push(`ControlNet: ${analysis.controlnet_nodes.map((n) => n.control_net_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.hypernetwork_nodes.length) {
|
||||
lines.push(`Hypernetwork: ${analysis.hypernetwork_nodes.map((n) => n.hypernetwork_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
|
||||
const positives = analysis.prompt_nodes.filter((n) => n.role === "positive").map((n) => n.text).filter(Boolean);
|
||||
const negatives = analysis.prompt_nodes.filter((n) => n.role === "negative").map((n) => n.text).filter(Boolean);
|
||||
if (positives.length) lines.push(`Positive Prompt: ${positives.join(" / ")}`);
|
||||
if (negatives.length) lines.push(`Negative Prompt: ${negatives.join(" / ")}`);
|
||||
|
||||
if (analysis.sampler_nodes.length) {
|
||||
const s = analysis.sampler_nodes[0];
|
||||
lines.push(`Sampler: steps=${s.steps}, cfg=${s.cfg}, sampler=${s.sampler_name}, scheduler=${s.scheduler}, denoise=${s.denoise}`);
|
||||
}
|
||||
if (analysis.latent_nodes.length) {
|
||||
const l = analysis.latent_nodes[0];
|
||||
lines.push(`Latent size: ${l.width ?? "?"}x${l.height ?? "?"}, batch=${l.batch_size ?? "?"}`);
|
||||
}
|
||||
|
||||
if (analysis.all_nodes.length) {
|
||||
const nodeTypes = [...new Set(analysis.all_nodes.map((n) => n.type))];
|
||||
lines.push(`Node types used (${analysis.all_nodes.length} nodes total): ${nodeTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ワークフロー(UI/API/サブグラフ入りいずれの形式でも)から要約用テキストを組み立てる。
|
||||
// 変換・解析に失敗した場合は、従来通り生JSON先頭部分にフォールバックする。
|
||||
async function _buildSummarySourceText(wfJson, filename) {
|
||||
try {
|
||||
const fmt = comfyWorkflow.detectFormat(wfJson, filename);
|
||||
let apiWf = wfJson;
|
||||
if (fmt === "ui") {
|
||||
apiWf = await comfyWorkflow.convertUiToApi(wfJson);
|
||||
} else if (fmt !== "api") {
|
||||
return JSON.stringify(wfJson).substring(0, 4000);
|
||||
}
|
||||
const analysis = comfyWorkflow.analyzeWorkflow(apiWf);
|
||||
const text = _buildStructuredSummaryText(analysis);
|
||||
return text || JSON.stringify(wfJson).substring(0, 4000);
|
||||
} catch {
|
||||
return JSON.stringify(wfJson).substring(0, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rendering Helpers
|
||||
// ============================================
|
||||
@@ -1128,11 +1207,20 @@ function openDetailModal(wf) {
|
||||
btn.textContent = t("summarizing");
|
||||
try {
|
||||
const wfJson = await getRawWorkflow(wf.filename);
|
||||
const promptText = getSummaryPrompt() + JSON.stringify(wfJson).substring(0, 4000);
|
||||
const sourceText = await _buildSummarySourceText(wfJson, wf.filename);
|
||||
const promptText = getSummaryPrompt() + sourceText;
|
||||
const aiCfg = readJsonStorage("wfm_prompt_ai_settings", {});
|
||||
if (aiCfg.backend && aiCfg.backend !== "ollama") {
|
||||
throw new Error(t("summarizeOllamaOnly"));
|
||||
}
|
||||
const chatRes = await fetch("/api/wfm/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: promptText }] }),
|
||||
body: JSON.stringify({
|
||||
url: aiCfg.backendUrl || undefined,
|
||||
model: aiCfg.model || undefined,
|
||||
messages: [{ role: "user", content: promptText }],
|
||||
}),
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
if (chatData.error) throw new Error(chatData.error);
|
||||
|
||||
+58
-12
@@ -751,6 +751,7 @@
|
||||
<span class="wfm-gen-rawjson-label">Raw JSON</span>
|
||||
<button id="wfm-gen-apply-raw-btn" class="wfm-btn wfm-btn-sm">Apply</button>
|
||||
</div>
|
||||
<div id="wfm-gen-rawjson-bypass-note" class="wfm-gen-rawjson-bypass-note" style="display:none;"></div>
|
||||
<div class="wfm-rawjson-search">
|
||||
<input type="text" id="wfm-gen-raw-search" class="wfm-rawjson-search-input" placeholder="Search...">
|
||||
<span id="wfm-gen-raw-search-count" class="wfm-rawjson-search-count"></span>
|
||||
@@ -1086,15 +1087,15 @@
|
||||
<h3>GenerateUI Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gen-1">Connect/disconnect ComfyUI and check connection status</li>
|
||||
<li id="wfm-help-gen-2">Load workflows from Workflow tab (App format .app.json not supported); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; <b>Save</b> button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default; <b>Style</b> checkbox and dropdown (next to Reset Workflow): enable to apply a Fooocus-style JSON style to the positive and negative prompts at generation time — style files are loaded from <code>user/default/Workflow-Studio/style/*.json</code>; the style's <code>{prompt}</code> placeholder is replaced with the original prompt, or the style text is appended if no placeholder exists</li>
|
||||
<li id="wfm-help-gen-2">Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; <b>Save</b> button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default; <b>Style</b> checkbox and dropdown (next to Reset Workflow): enable to apply a Fooocus-style JSON style to the positive and negative prompts at generation time — style files are loaded from <code>user/default/Workflow-Studio/style/*.json</code>; the style's <code>{prompt}</code> placeholder is replaced with the original prompt, or the style text is appended if no placeholder exists</li>
|
||||
<li id="wfm-help-gen-3">5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing</li>
|
||||
<li id="wfm-help-gen-4">Input tab: inner tabs — <b>Prompt</b> and <b>Image</b> (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an <b>Embeddings</b> selector at the bottom — select a model, set Weight (default 1.0), then click <b>Paste</b> to insert <code>(embedding:Name:weight)</code> at the cursor position of the last focused textarea (defaults to Positive if neither is focused)</li>
|
||||
<li id="wfm-help-gen-4">Input tab: inner tabs — <b>Prompt</b> and <b>Image</b> (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an <b>Embeddings</b> selector at the bottom — select a model, set Weight (default 1.0), then click <b>Paste</b> to insert <code>(embedding:Name:weight)</code> at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a <b>↺</b> button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press <b>Ctrl+↑/↓</b> to adjust its emphasis weight in <code>(text:weight)</code> syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)</li>
|
||||
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement — seed input and mode selector stacked vertically for readability</li>
|
||||
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
|
||||
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
|
||||
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle (SVG output supported too — both the standard SaveSVGNode and comfyui-tosvg's Save SVG String node)</li>
|
||||
<li id="wfm-help-gen-11">Batch tab — enable one batch type at a time by checking the checkbox in the Batch Queue column header; Generate then runs through all queued items sequentially with Pause/Resume and Stop controls</li>
|
||||
<li id="wfm-help-gen-12">Batch tab — Left pane has 4 tabs: <b>Checkpoints</b> (folder tree with Filter/All/None; (root) header checkbox selects all), <b>Sampler</b> (KSampler sampler_name list with (root) checkbox), <b>Scheduler</b> (KSampler scheduler list with (root) checkbox), <b>Style</b> (flat list of all styles loaded from <code>Workflow-Studio/style/</code> with All/None buttons); All/None toolbar buttons available for each tab</li>
|
||||
<li id="wfm-help-gen-13">Batch tab — Center pane: group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint tab shows groups defined in the Models tab — check a group header to select all its members, or expand ▶ to check individual models; group membership always reflects the current Models tab state</li>
|
||||
@@ -1166,7 +1167,7 @@
|
||||
<li id="wfm-help-metadata-2">Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow</li>
|
||||
<li id="wfm-help-metadata-3">Right column: LoRA list (with strength values) and Prompt list (click to view full text below)</li>
|
||||
<li id="wfm-help-metadata-4">Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative</li>
|
||||
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus. Model extraction covers UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, and QuadrupleCLIPLoader (e.g. HiDream 4-CLIP).</li>
|
||||
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus. Model extraction covers UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, and QuadrupleCLIPLoader (e.g. HiDream 4-CLIP).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1271,7 +1272,7 @@
|
||||
<li id="wfm-help-sidepanel-13">I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF and QuadrupleCLIPLoader node types</li>
|
||||
<li id="wfm-help-sidepanel-14">Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center</li>
|
||||
<li id="wfm-help-sidepanel-15">Information tab — LoRA sub-tab: drag individual LoRA to place LoraLoader; "Multiple LORA" section at bottom places all LoRAs in a single Lora Loader (LoraManager) node; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy</li>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
<li id="wfm-help-sidepanel-17">Title drag (Send to Canvas): after clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab), the 'Workflow Studio Library' title turns blue with a green ● — drag it onto the canvas to load the workflow; title resets automatically after loading</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1282,11 +1283,12 @@
|
||||
<h3 id="wfm-help-ai-title">AI TOOL Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-ai-1">4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously</li>
|
||||
<li id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</li>
|
||||
<li id="wfm-help-ai-3">Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history</li>
|
||||
<li id="wfm-help-ai-4">TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), then click Run to analyze with a vision model; result shown with Copy button</li>
|
||||
<li id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</li>
|
||||
<li id="wfm-help-ai-3">Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image ("generate a cat picture", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the "Allow image generation" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)</li>
|
||||
<li id="wfm-help-ai-4">TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), then click Run to analyze with a vision model; result shown with Copy button; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button</li>
|
||||
<li id="wfm-help-ai-5">TOOLS pane — Wildcards: select "Create wildcards" from the dropdown, enter a category name and count, click Run to generate plain-text wildcard entries one per line</li>
|
||||
<li id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio), set API URL, test connection, select model, configure Free language names; settings are shared with the Library panel's A tab via localStorage</li>
|
||||
<li id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</li>
|
||||
<li id="wfm-help-ai-7">Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a "Save to Gallery" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1314,7 +1316,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-1">Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite</li>
|
||||
<li id="wfm-help-imageedit-2">Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li id="wfm-help-imageedit-3">New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers</li>
|
||||
<li id="wfm-help-imageedit-3">New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1327,6 +1329,7 @@
|
||||
<li id="wfm-help-imageedit-7b">Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7c">BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7d">Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click "M" in the Layers header to add a mask layer; see the Mask Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7e">Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; "Use dedicated workflow" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1367,7 +1370,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-mask-1">Add a mask layer: click the "M" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically</li>
|
||||
<li id="wfm-help-imageedit-mask-2">Clicking a mask layer in the layer list automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-mask-3">Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge</li>
|
||||
<li id="wfm-help-imageedit-mask-3">Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected</li>
|
||||
<li id="wfm-help-imageedit-mask-4">Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)</li>
|
||||
<li id="wfm-help-imageedit-mask-5">✂ button on a mask layer row: toggles the layer as a clipping mask — when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied when saving PNG / Gallery / ComfyUI</li>
|
||||
<li id="wfm-help-imageedit-mask-6">SAM3 (Mask Editor One required): text-prompt segmentation powered by the SAM3 model; enter a description of the object to segment (e.g. "cat", "person"), set Max (maximum mask candidates: 3/6/9/12), then click Segment</li>
|
||||
@@ -1467,6 +1470,7 @@
|
||||
<li id="wfm-help-trouble-5">LoRA Stack fails with "No such file or directory" error: The LoRA may not be recognized by comfyui-lora-manager (known issue). Try a different LoRA or check if the file appears in Lora Manager's own UI</li>
|
||||
<li id="wfm-help-trouble-6">Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.</li>
|
||||
<li id="wfm-help-trouble-7"></li>
|
||||
<li id="wfm-help-trouble-8"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1670,6 +1674,7 @@
|
||||
<button class="ie-tool-btn" data-tool="blur" title="Blur">≈</button>
|
||||
<button class="ie-tool-btn" data-tool="filter" title="Filter">★</button>
|
||||
<button class="ie-tool-btn" data-tool="bgremove" title="BG Remove">⬚</button>
|
||||
<button class="ie-tool-btn" data-tool="inpaint" title="Inpaint">🩹</button>
|
||||
</div>
|
||||
|
||||
<!-- Center -->
|
||||
@@ -2000,11 +2005,20 @@
|
||||
<div class="wfm-ai-chat-container">
|
||||
<div class="wfm-ai-chat-messages" id="wfm-ai-chat-messages"></div>
|
||||
<div class="wfm-ai-chat-input-area">
|
||||
<div id="wfm-ai-chat-attachment" class="wfm-ai-chat-attachment" style="display:none;">
|
||||
<img id="wfm-ai-chat-attachment-thumb" class="wfm-ai-chat-attachment-thumb">
|
||||
<span id="wfm-ai-chat-attachment-label">Image attached</span>
|
||||
<button id="wfm-ai-chat-attachment-clear" class="wfm-ai-chat-attachment-clear" title="Close">✕</button>
|
||||
</div>
|
||||
<textarea id="wfm-ai-chat-input" class="wfm-ai-chat-input" placeholder="Type a message... (Enter to send, Shift+Enter for newline)"></textarea>
|
||||
<div class="wfm-ai-chat-actions">
|
||||
<button id="wfm-ai-chat-send-btn" class="wfm-btn wfm-btn-primary">Send</button>
|
||||
<button id="wfm-ai-chat-clear-btn" class="wfm-btn">Clear</button>
|
||||
<span id="wfm-ai-chat-status" class="wfm-ai-trans-status"></span>
|
||||
<label class="wfm-ai-chat-toggle-label" for="wfm-ai-chat-imggen-toggle">
|
||||
<input type="checkbox" id="wfm-ai-chat-imggen-toggle" checked>
|
||||
<span id="wfm-ai-chat-imggen-label-text">Allow image generation</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2018,6 +2032,7 @@
|
||||
<img id="wfm-ai-vlm-preview" style="display:none;max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;">
|
||||
<span id="wfm-ai-vlm-label" class="wfm-ai-vlm-label">Drop or click PNG / JPG / WebP</span>
|
||||
<input type="file" id="wfm-ai-vlm-file" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||
<button id="wfm-ai-vlm-clear" class="wfm-ai-vlm-clear" style="display:none;" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="wfm-ai-wc-inputs" id="wfm-ai-wc-inputs" style="display:none">
|
||||
<div class="wfm-ai-wc-row">
|
||||
@@ -2057,6 +2072,9 @@
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
@@ -2090,6 +2108,34 @@
|
||||
<input type="text" id="wfm-ai-free-dst-lang" class="wfm-input" placeholder="e.g. German">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-chatgen-title">Chat Image Generation</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-ai-chatgen-dedicated-checkbox">
|
||||
<span id="wfm-ai-chatgen-dedicated-label">Use dedicated workflow</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row" id="wfm-ai-chatgen-dedicated-row" style="display:none;">
|
||||
<select id="wfm-ai-chatgen-dedicated-select" class="wfm-select">
|
||||
<option value="">-- select workflow --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-chatgen-i2i-title">Chat I2I Generation</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-ai-chatgen-i2i-dedicated-checkbox">
|
||||
<span id="wfm-ai-chatgen-i2i-dedicated-label">Use dedicated workflow</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row" id="wfm-ai-chatgen-i2i-dedicated-row" style="display:none;">
|
||||
<select id="wfm-ai-chatgen-i2i-dedicated-select" class="wfm-select">
|
||||
<option value="">-- select workflow --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<button id="wfm-ai-settings-save-btn" class="wfm-btn wfm-btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -3003,6 +3003,14 @@ const renderInfoTab = (container) => {
|
||||
|
||||
const AI_SETTINGS_KEY = "wfm_ai_settings";
|
||||
const AI_LANG_NAMES = { ja: "Japanese", en: "English", zh: "Chinese" };
|
||||
const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
};
|
||||
function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
function isValidAiUrl(url) {
|
||||
try {
|
||||
@@ -3184,6 +3192,7 @@ const renderAiTab = (container) => {
|
||||
<div class="wfm-nlp-ai-radio-row">
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="ollama"> Ollama</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lmstudio"> LM Studio</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lemonade"> Lemonade</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
@@ -3244,6 +3253,13 @@ const setupAiHandlers = (container) => {
|
||||
if (urlInput && cfg.backendUrl) urlInput.value = cfg.backendUrl;
|
||||
else if (urlInput) urlInput.value = "http://localhost:11434";
|
||||
|
||||
// Backend change → switch URL to the new backend's default
|
||||
backendRadios.forEach(r => {
|
||||
r.addEventListener("change", () => {
|
||||
if (urlInput) urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
const freeSrcInput = container.querySelector("#wfm-nlp-ai-free-src");
|
||||
const freeDstInput = container.querySelector("#wfm-nlp-ai-free-dst");
|
||||
if (freeSrcInput && cfg.freeSrcLang) freeSrcInput.value = cfg.freeSrcLang;
|
||||
@@ -3273,7 +3289,7 @@ const setupAiHandlers = (container) => {
|
||||
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
@@ -3403,7 +3419,7 @@ const setupAiHandlers = (container) => {
|
||||
if (!text) return;
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
@@ -3497,7 +3513,7 @@ const setupAiHandlers = (container) => {
|
||||
const task = vlmTaskSel?.value || "describe";
|
||||
const c = loadAiCfg();
|
||||
const backend = c.backend || "ollama";
|
||||
const url = c.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = c.backendUrl || getAiBackendDefaultUrl(backend);
|
||||
const model = c.model;
|
||||
if (!isValidAiUrl(url)) { showToast("URLは http:// または https:// で始まる必要があります", "error"); return; }
|
||||
if (!model) { showToast("Please select a model in Settings", "error"); return; }
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
{
|
||||
"id": "6d392e45-b20b-4dee-a422-0b4a18e645cd",
|
||||
"revision": 0,
|
||||
"last_node_id": 35,
|
||||
"last_link_id": 102,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1351.4570000000006,
|
||||
197.9979999999999
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 42
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 84
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
22
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-35,
|
||||
161
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
80
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
81,
|
||||
82
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
84,
|
||||
90
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"anime\\merge\\hayochamixIDXL_v11+_blueberryPlus_v10-amanatsuIllustrious_v11_x0.1_trainDifference.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
330,
|
||||
369
|
||||
],
|
||||
"size": [
|
||||
412.2799987792969,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 82
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"w\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-20.402179424849777,
|
||||
348.01722821269084
|
||||
],
|
||||
"size": [
|
||||
282.798828125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "image",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "choose file to upload",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
101
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "MASK",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
102
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.8"
|
||||
},
|
||||
"image": "clipspace/clipspace-painted-masked-1784801792090.png [input]"
|
||||
},
|
||||
"widgets_values": [
|
||||
"clipspace/clipspace-painted-masked-1784801792090.png [input]",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
997,
|
||||
174
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 80
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 72
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "steps",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "sampler_name",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "scheduler",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "denoise",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
42
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
1008854893628621,
|
||||
"randomize",
|
||||
30,
|
||||
4,
|
||||
"ddim",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "VAEEncodeForInpaint",
|
||||
"pos": [
|
||||
759,
|
||||
169
|
||||
],
|
||||
"size": [
|
||||
226.8000030517578,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 101
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 90
|
||||
},
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 102
|
||||
},
|
||||
{
|
||||
"localized_name": "grow_mask_by",
|
||||
"name": "grow_mask_by",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "grow_mask_by"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
72
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncodeForInpaint",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1362.0710000000008,
|
||||
369.32099999999986
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
326.97,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
422.8500061035156,
|
||||
164.30999755859375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 81
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.18",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"version": "7.8",
|
||||
"input_ue_unconnectable": {}
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"smile"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
42,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
72,
|
||||
26,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
80,
|
||||
29,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
81,
|
||||
29,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
82,
|
||||
29,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
84,
|
||||
29,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
90,
|
||||
29,
|
||||
2,
|
||||
26,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
101,
|
||||
32,
|
||||
0,
|
||||
26,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
102,
|
||||
32,
|
||||
1,
|
||||
26,
|
||||
2,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9229599817706418,
|
||||
"offset": [
|
||||
365.6203139679832,
|
||||
118.10373545614928
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": []
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Reference in New Issue
Block a user