Compare commits

...
4 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 5 e1ffadbdca release: v0.3.78 — Eagle SVG support, subgraph workflow support (Mage-Flow), structured AI summary
- Eagle integration: SVG auto-save for both standard SaveSVGNode and comfyui-tosvg's Save SVG String node
- GenerateUI/Metadata tabs: subgraph-containing workflows (Mage-Flow etc.) no longer blocked as unsupported App format; outer subgraph widget values now correctly injected into internal nodes; DynamicCombo widget type support
- Workflow tab: AI summary now uses structured analysis instead of raw JSON truncation; accurate P/I/-> node counts and format badge for newer node types
- Workflow/Gallery tabs: Send to Canvas toast now distinguishes direct-load from drag-required fallback

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 14:29:24 +09:00
ketle-manandClaude Sonnet 5 65ef71b4ad release: v0.3.77 — Gallery SVG support, Chat SVG generation without a workflow
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:19:19 +09:00
ketle-manandClaude Sonnet 5 49019dfb05 DEVLOGに含まれるユーザー固有のパスを汎用表現に置き換え
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:41:28 +09:00
ketle-manandClaude Sonnet 5 78d9bba904 release: v0.3.76 — Comic Creator I2I execution bridge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:29:37 +09:00
19 changed files with 811 additions and 73 deletions
+102 -2
View File
@@ -2,6 +2,106 @@
---
## 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バックエンド)
@@ -968,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` のフォールバック値も同様に空文字に変更。
---
@@ -1036,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`
+20 -1
View File
@@ -21,7 +21,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
- Built-in AI tools (translation and more)
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.3.75-green)
![Version](https://img.shields.io/badge/version-0.3.78-green)
## Screenshots
@@ -197,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)
@@ -280,6 +281,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
- **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
- **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
@@ -407,6 +409,23 @@ 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
+103 -2
View File
@@ -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)
+23 -7
View File
@@ -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
+6 -1
View File
@@ -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:
+20 -7
View File
@@ -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
View File
@@ -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.75"
version = "0.3.78"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
+17
View File
@@ -5624,6 +5624,23 @@ body {
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;
+53
View File
@@ -534,6 +534,55 @@ async function generateImageFromChat(prompt, negativePrompt) {
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}`;
@@ -550,6 +599,10 @@ function appendChatBubble(messagesEl, role, content, opts = {}) {
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;
+1
View File
@@ -433,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",
+24 -2
View File
@@ -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;
+161 -13
View File
@@ -81,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
@@ -95,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);
}
}
@@ -106,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];
@@ -121,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);
}
}
@@ -132,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);
}
}
@@ -140,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.
@@ -167,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;
@@ -236,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];
@@ -248,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) {
@@ -261,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;
@@ -297,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";
}
@@ -329,11 +429,12 @@ export const comfyWorkflow = {
_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) {
@@ -439,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
@@ -770,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) {
@@ -831,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({
+24 -1
View File
@@ -103,6 +103,29 @@ window._wfmReceiveInpaintRequest = async (imageBlob, maskBlob, params, workflowD
}
};
// 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;
@@ -1713,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形式のみ)
+55 -10
View File
@@ -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();
@@ -1471,7 +1477,7 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
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,
@@ -1489,18 +1495,49 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
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;
@@ -1515,10 +1552,18 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
}
}
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 });
}
}
+24 -9
View File
@@ -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",
@@ -611,6 +612,9 @@ const LANGUAGES = {
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",
@@ -633,7 +637,7 @@ const LANGUAGES = {
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, send to canvas, load in GenerateUI, set as default, delete",
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
helpGen1: "Connect/disconnect ComfyUI and check connection status",
helpGen2: "Load workflows from Workflow tab (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); 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",
@@ -641,7 +645,7 @@ const LANGUAGES = {
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",
@@ -770,6 +774,7 @@ const LANGUAGES = {
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",
@@ -1009,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: {
@@ -1098,6 +1103,7 @@ const LANGUAGES = {
sendToCanvas: "キャンバスへ送る",
copyAndSendCanvas: "コピー&キャンバスへ送る",
workflowSentToCanvas: "ワークフロー準備完了 — タイトルをキャンバスへドラッグ",
workflowSentToCanvasDirect: "ワークフローをキャンバスへ送信しました",
comfyUILoadTimeout: "ComfyUIの読み込みがタイムアウトしました",
popupBlocked: "ポップアップがブロックされました。このサイトのポップアップを許可してください。",
setAsDefault: "デフォルトに設定",
@@ -1617,6 +1623,9 @@ const LANGUAGES = {
aiChatGenerateNotReady: "GenerateUIの準備ができていません",
aiChatNoImageProduced: "生成は完了しましたが画像が出力されませんでした",
aiToastImageGenFailed: "画像生成に失敗しました: ",
aiChatSaveSvgToGallery: "Galleryに保存",
aiChatSvgSaved: "SVGをGalleryに保存しました",
aiChatSvgSaveFailed: "SVGの保存に失敗しました: ",
aiChatImgGenToggle: "画像生成を許可する",
aiSettingsChatGenTitle: "Chat画像生成",
aiSettingsChatGenDedicated: "専用ワークフローを使用",
@@ -1639,7 +1648,7 @@ 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に追加);各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入力付き)のセレクタ(フィルタ検索付き)",
@@ -1647,7 +1656,7 @@ const LANGUAGES = {
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タブの定義を反映 — グループヘッダーにチェックで全メンバーを選択、▶で展開して個別選択も可能",
@@ -1776,6 +1785,7 @@ const LANGUAGES = {
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として自動ロック;以降の画像はキャンバスサイズに合わせてスケールされ新規レイヤーとして追加",
@@ -2014,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: {
@@ -2103,6 +2113,7 @@ const LANGUAGES = {
sendToCanvas: "发送到画布",
copyAndSendCanvas: "复制并发送到画布",
workflowSentToCanvas: "工作流已就绪 — 将标题拖拽到画布",
workflowSentToCanvasDirect: "工作流已发送到画布",
comfyUILoadTimeout: "ComfyUI加载超时",
popupBlocked: "弹窗被阻止。请允许此站点的弹窗。",
setAsDefault: "设为默认",
@@ -2622,6 +2633,9 @@ const LANGUAGES = {
aiChatGenerateNotReady: "GenerateUI 尚未就绪",
aiChatNoImageProduced: "生成完成但未产生图像",
aiToastImageGenFailed: "图像生成失败:",
aiChatSaveSvgToGallery: "保存到图库",
aiChatSvgSaved: "SVG已保存到图库",
aiChatSvgSaveFailed: "SVG保存失败:",
aiChatImgGenToggle: "允许生成图像",
aiSettingsChatGenTitle: "Chat 图像生成",
aiSettingsChatGenDedicated: "使用专用工作流",
@@ -2644,7 +2658,7 @@ 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);每个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输入)选择器(含筛选)",
@@ -2652,7 +2666,7 @@ const LANGUAGES = {
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标签 — 勾选组标题选中所有成员,▶展开后也可单独选择",
@@ -2781,6 +2795,7 @@ const LANGUAGES = {
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(自动锁定);后续图像缩放至画布大小作为新图层添加",
@@ -3019,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。",
},
};
+45
View File
@@ -118,6 +118,8 @@ class ImageEditTab {
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;
@@ -1477,6 +1479,49 @@ class ImageEditTab {
}
}
// 共通処理: 画像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;
+46 -12
View File
@@ -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);
+82 -2
View File
@@ -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,7 +1207,8 @@ 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"));
+4 -3
View File
@@ -1087,7 +1087,7 @@
<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); 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>
@@ -1095,7 +1095,7 @@
<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>
@@ -1167,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>
@@ -1288,6 +1288,7 @@
<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 / 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>