Compare commits

...
49 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 5 e9fbb6dd39 release: v0.4.2 — Lab「Model」列統合(Checkpoint/LoRA Single/VAE)+ フィールド単位継承 + C/L/V表示切替
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:24:30 +09:00
ketle-man f858c5138a release: v0.4.1 — Mask Editor One連携ボタンを選択中ノード方式に再設計
Send to Workflowと同じ「選択中ノード優先・フォールバック」方式に
変更し、WFS生成UIタブへの事前読み込みを不要にした。
comfyUI.currentAnalysis依存を撤去し、ボタンは常時有効化。
2026-08-20 13:42:00 +09:00
ketle-man 7d9c498234 fix: Save to Inputボタンの背景色をデフォルトに戻す
改名時にwfm-btn-primaryクラスが残っており、他のアクションバー
ボタン(Save PNG/Save to Gallery/Send to Workflow)と見た目が
不揃いになっていた。
2026-08-20 12:17:49 +09:00
ketle-man d43b35dde3 chore: graphifyナレッジグラフをv0.4.0まで更新 + graphify CLAUDE.md統合
- graphify-out/: モジュール分割・Mask Editor One連携・Send to Workflow等、
  v0.4.0までの変更10ファイルを再抽出しグラフ再構築
- QWEN_CHAT_INTEGRATION_PLAN.md(未追跡の非公開プラン文書)由来の
  ノード/エッジ/ハイパーエッジを除去し、.graphifyignoreで今後も除外
- graphify claude install: CLAUDE.mdにgraphifyクエリ優先参照ルールを追記、
  .claude/settings.jsonにPreToolUseフック(graphify hook-guard)を登録
2026-08-20 12:14:48 +09:00
ketle-man b79f4e8fd0 release: v0.4.0 — モジュール分割リファクタ + Mask Editor One連携 + Send to Workflow
DEVLOGにv0.3.99以降の作業をまとめて記録:
- image-edit-tab.js / models-tab.js の低凝集度モジュール分割(前日実施分)
- Mask Editor One連携ボタン(DEVLOGフェーズ2計画の実装)
- Send to Workflowボタン追加 + Send to ComfyUI→Save to Input改名

pyproject.toml/READMEバージョンバッジをv0.4.0に更新。
push・GitHub Releaseはユーザー指示待ち。
2026-08-20 11:34:59 +09:00
ketle-man 87c53ef977 Rename Send to ComfyUI → Save to Input、Send to Workflowのヘルプ追加
- Image Edit タブの「Send to ComfyUI」ボタンを「Save to Input」に改名
  (README・ヘルプ英/日/中を追従)
- 前回追加した「Send to Workflow」ボタンのヘルプ項目が未記載だったため追加
2026-08-20 11:26:34 +09:00
ketle-man 8b75d2a8d0 Add Image Edit タブ「Send to Workflow」ボタン
Send to ComfyUI(アップロードのみ)とは別に、アップロード後の
ファイル名を ComfyUI 側で選択中のノードの image ウィジェットへ
直接書き込む機能を追加。chat_TE カスタムノードの
「Send to workflow」と同等(window.opener 経由のクロスウィンドウ
ブリッジ、Mask Editor One 連携と同じパターン)。
2026-08-20 11:15:38 +09:00
ketle-man 7aa2535964 Add Mask Editor One連携ボタン(DEVLOGフェーズ2) + ヘルプ更新
Image Edit タブの Mask ツールに「Edit in Mask Editor One →」ボタンを追加。
ワークフロー中の既存 MaskEditorOne ノードへキャンバス合成画像を送信し、
window.opener 経由で ComfyUI 側の実際のモーダルエディタを開いて編集、
Apply 後のマスクを新規マスクレイヤーとして WFS に取り込む。
2026-08-20 10:31:07 +09:00
ketle-manandClaude Sonnet 5 7769de6fbb chore: update graphify knowledge graph after models-tab.js refactor
Incremental update covering the 8 changed/new JS files from the
models-tab.js cohesion refactor (models-tab.js + 7 new models/*.js
modules). Code-only change, so semantic extraction was skipped.
2369->2378 nodes, 5401->5453 edges, 148->151 communities.

Unlike the image-edit-tab.js refactor, this one did NOT improve
cohesion: the 85 models-tab-related nodes remain a single community
(density 0.096), essentially unchanged from before the split (76 nodes,
same single community). Root cause: Phase 5 (grid-view/selection-bulk/
detail-panel) deliberately uses a circular import design rather than
one-directional callback injection, since rendering a card wires up
selection/side-panel/detail-modal handlers that in turn re-render the
grid. Files got smaller and easier to read, but the dependency graph
stayed tightly coupled - documented as a real finding, not a failure
to fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:21:29 +09:00
ketle-manandClaude Sonnet 5 7eafe9a684 refactor: models-tab.jsからグリッド描画/選択操作/詳細パネルをmodels/へ切り出し
低凝集度モジュール分割計画(models-tab.js側)Phase 5完了。
models/grid-view.js: グリッド描画(Thumbnail/Table)とBatch/Stack/
Favoriteトグル。
models/selection-bulk.js: 複数選択モードとバルクグループ/バッジ/
お気に入り/移動/削除操作。
models/detail-panel.js: 詳細モーダルとサイドパネルのInfo/Group/CivitAI
タブ。

この3モジュールはmodels-tab.js本体を含めた4者間で循環importする設計
にした(カード描画が選択/サイドパネル/詳細モーダルのクリックハンドラを
配線し、それらが変更後にグリッドを再描画するため、単純な一方向の依存
関係にならない)。全export/importがfunction宣言(hoisting対象)で、
相互呼び出しはすべてイベントハンドラ内(モジュール評価時ではない)に
限られるため、ES Modulesとして安全に解決される。実機でグリッド描画・
サイドパネル・複数選択・バルクバー・お気に入り/バッチトグル・詳細
モーダル開閉を確認し、循環import由来のエラーがないことを確認した。

models-tab.js: 2074→592行(約71%削減、Phase 3-5合計で2522→592行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:14:31 +09:00
ketle-manandClaude Sonnet 5 c9089c2892 refactor: models-tab.jsからフィルタ/ソートと共有ヘルパーをmodels/へ切り出し
低凝集度モジュール分割計画(models-tab.js側)Phase 4完了。
models/filters.js: フィルタリング(ステータス/お気に入り/バッチ/タグ/
バッジ/ディレクトリ/グループ/検索)・ソート・タグ/グループ/
ディレクトリフィルタドロップダウンの描画一式。
models/helpers.js: parseModelPath/getExtension/getStem/previewUrl/
loadPreviewImageという、以降のフェーズ(grid-view/detail-panel等)
でも共有される軽量パスヘルパーを、重複を避けるため先出しで切り出した。
(2522→2074行)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:01:45 +09:00
ketle-manandClaude Sonnet 5 9b3532704a refactor: models-tab.jsからバッジ機能をmodels/badges.jsへ切り出し
低凝集度モジュール分割計画(models-tab.js側)Phase 4の一部。バッジ
パレット管理・バッジHTML描画・バッジ編集モーダル・バッジフィルターバー
一式を切り出した。openBadgeEditModalはworkflow-tab.jsが直接importする
公開APIのため本体で再exportし、外部呼び出し側は無変更。renderModelGrid
(まだ本体に残るグリッド描画)への依存は循環importを避けるため
setGridChangeCallback()によるコールバック注入で解消した(2522→2300行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:55:08 +09:00
ketle-manandClaude Sonnet 5 f6f4b2c95c refactor: models-tab.jsから共有stateとルックアップ定数をmodels/state.jsへ切り出し
低凝集度モジュール分割計画(models-tab.js側)のPhase 3。共有state・
RESERVED_GROUPS/BATCH_MODEL_TYPES/STACK_MODEL_TYPES・FETCH_MAP/
TYPE_LABELS/GENUI_TYPE_MAPを切り出した。stateはconstのまま参照を
再代入せずプロパティ書き換えのみで運用し、ESM read-only制約を回避
している(2522→2446行)。以降のフェーズ(badges/filters/
selection-bulk/detail-panel/grid-view)はこのモジュールをimportして
共有する基盤。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:50:45 +09:00
ketle-manandClaude Sonnet 5 ac6a4f2dd6 chore: update graphify knowledge graph after image-edit-tab.js refactor
Incremental update covering the 6 new image-edit/*.js modules from the
cohesion refactor plus the 6 previously-hung docs (README.md, CLAUDE.md,
DEVLOG.md split into 4 parts, QWEN_CHAT_INTEGRATION_PLAN.md,
requirements.txt, publish.yml). 2094->2369 nodes, 5083->5401 edges,
112->148 communities.

Verifies the refactor's intent: each newly extracted module (GmicIntegration,
BlurTool, BgRemove, Sam3Segmentation, InpaintI2IActions, FileExport) now
forms its own high-cohesion community (density 0.18-0.48) versus the
original monolith's 0.07-0.08, while the intentionally-retained high-coupling
core (Mask Editor One dispatch, image loading) stays in one lower-cohesion
community as designed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 18:13:18 +09:00
ketle-manandClaude Sonnet 5 e6c503ef75 refactor: image-edit-tab.jsからファイル出力をFileExport.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。レイヤー合成(maskApply対応の
書き出し用コンポジット)・PNG保存・Gallery保存・ComfyUIアップロードの
3出力先を独立クラスに切り出した。レイヤー初期化・画像ロード(_loadFile/
_loadFromDataUrl/_newCanvas)はキャンバス/Undoスタックと深く結合した
高結合コアのため本体に残置(2668→2577行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:25:36 +09:00
ketle-manandClaude Sonnet 5 e21f5596d8 refactor: image-edit-tab.jsからInpaint/I2I実行をInpaintI2IActions.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。状態管理・ツールパネル描画・
Inpaint実行(ワークフロー合成/専用ワークフロー切替)・マスクなしI2I実行
一式を切り出した。gallery-tab.js(Comic Creator連携)から呼ばれる公開API
runInpaintExternal/runI2IExternalは本体に薄い委譲メソッドとして残し、
外部呼び出し側のインターフェースは変更していない(2932→2668行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:17:07 +09:00
ketle-manandClaude Sonnet 5 61bf2c83da refactor: image-edit-tab.jsからSAM3セグメンテーションをSam3Segmentation.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。状態管理・可用性チェック・
セグメント実行/マスク適用ロジック・結果サムネイルパネルを独立クラスに
切り出した。マスクツールバー全体(Paint/Color/Alpha/Text/Vector/Shape/
SAM3共通の統合UI)は高結合コアとして本体に残し、SAM3固有部分のみ
インスタンス参照に置き換えている(3130→2932行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:11:06 +09:00
ketle-manandClaude Sonnet 5 d000219b86 refactor: image-edit-tab.jsから背景除去をBgRemove.jsへ切り出し
低凝集度モジュール分割計画のPhase 2続き。imgly(軽量ブラウザ内モデル)と
Mask Editor One連携のBiRefNetの2バックエンドを持つ背景除去一式(可用性
チェック・bgremoveツールパネルのrenderブロック・マスク合成処理)を独立
クラスとして切り出した(3304→3130行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:06:30 +09:00
ketle-manandClaude Sonnet 5 52ab637bd0 refactor: image-edit-tab.jsからBlur/MosaicツールをBlurTool.jsへ切り出し
低凝集度モジュール分割計画のPhase 2着手分。DrawTool等の既存ツールと同じ
onMouseDown/Move/Up/Leaveインターフェースを持つ独立クラスとして、全体/矩形
Blur・Mosaic機能一式を切り出した。ツール切替ディスパッチャーは委譲のみ残す
(3504→3304行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:01:01 +09:00
ketle-manandClaude Sonnet 5 5e5709594b refactor: image-edit-tab.jsからG'MIC統合をGmicIntegration.jsへ切り出し
低凝集度モジュール分割計画のPhase 1。G'MIC統合一式(状態管理・GUI起動/中断/
ジョブ待機/結果反映・filterツールのパネル描画)を独立クラスとして切り出し、
本体はコールバック注入するインスタンスを持つのみにした(3665→3504行)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:54:53 +09:00
ketle-manandClaude Sonnet 5 b20519c092 chore: track graphify knowledge graph, ignore cache/cost/local-path files
- Add graphify-out/{graph.json,graph.html,GRAPH_REPORT.md,manifest.json,
  .graphify_labels.json} so the knowledge graph is available to other
  clones and future sessions without rebuilding.
- .gitignore: exclude graphify-out/cost.json (churns every run),
  graphify-out/cache/ (local extraction cache, no cross-machine value),
  and graphify-out/.graphify_python|.graphify_root (machine-local paths).
- Add .claudeignore for graph.json/graphify-out/ so Claude Code's
  automatic context loading doesn't invalidate the prompt cache on
  every graphify rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:18:01 +09:00
ketle-manandClaude Sonnet 5 9d952c4b6e Add Unsloth backend to AI TOOL tab / sidebar A tab (v0.3.99)
Unsloth's OpenAI-compatible API always requires an API key, unlike the
existing Ollama/LM Studio/Lemonade backends, so it's relayed through a
new server-side proxy (py/routes/unsloth_routes.py) that attaches the
key from a local .env file (UNSLOTH_API_KEY, loaded via python-dotenv
in prestartup_script.py) instead of ever sending it to the browser.

Also fixes a client-controlled baseUrl SSRF that could have exfiltrated
the API key, and folds Unsloth's separate reasoning_content field back
into <think> tags so low Max tokens no longer produces silently empty
replies from reasoning models.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 17:51:13 +09:00
ketle-manandClaude Sonnet 5 8248ff8ac7 release: v0.3.98 — AI TOOL翻訳の信頼性向上 + GenerateUI Modelタブ再構成 + Thinking mode/Max tokens設定
- AI TOOL翻訳: system role対応chat APIへ切替、出力クリーニング、未翻訳検知を追加(SPA/サイドパネル両対応)
- GenerateUI Modelタブ: ワークフローに含まれる項目のハイライト色設定を追加、ControlNet/Hypernetworkを折りたたみ化、Text Encoderの表示順をControlNetより上に変更、LoRA列見出しもハイライト対応
- AI TOOL Settings: Thinking mode ON/OFF・Max tokens設定を追加(Ollama think/num_predict、LM Studio/Lemonade max_tokens、OFF時は<think>タグをクライアント側で除去)
- ヘルプタブ・README・DEVLOGを更新

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 12:27:06 +09:00
ketle-manandClaude Sonnet 5 7548c49910 release: v0.3.97 — KREA-2ワークフロー対応 + Labタブ拡張 + Input Imageプレースホルダ機能
- KREA-2ワークフロー対応で発覚した2件の既存バグ修正
  - PreviewAny/StringConcatenate中継ノードの役割伝播バグ修正
    (GenerateUI/Metadata/IタブでPositive Promptが空になる問題)
  - DynamicComboウィジェットのスロット数カウント修正
    (Prompt outputs failed validationエラーの原因)
- Labタブ: プロンプトモーダルにClear/Get from Previousボタン追加
- Labタブ: Setting 1(キーフレーム#1)の各列にGenerateUIの現在値をライブ反映
- Labタブ: 各キーフレームにBypassチェックボックス追加(Lab内部のみのスキップ機構)
- GenerateUI Input Imageサブパネル: Clear/Placeholderボタン追加
  (Color/Imageモード、設定は記憶、低頻度機能のため折りたたみ表示)
- ヘルプタブ更新(3点セット: index.html/app.js/i18n.js)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:39:42 +09:00
ketle-manandClaude Sonnet 5 e9f2c6171a release: v0.3.96 — カタログ作成バッチ無言停止バグ修正 + Style Catalog/Promptタブ機能拡張
- trackProgress()にタイムアウト・execution_interrupted処理・WebSocket切断時のPromise解放を追加し、
  バッチ処理が特定件数で無言のまま停止するバグを修正
- Style Catalogタブに「Load in GenerateUI」「Open in Metadata Tab」ボタンとダブルクリック拡大表示を追加
- Promptタブ右ペインにStyle管理サブタブ(Wildcardと切替式)を新設。作成・編集・削除、
  ファイル名バッジ表示、既存ファイルへの追加(+ Add to this file)に対応
- カタログ作成モーダルに「Outputフォルダにコピーを残さない」オプションを追加

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 13:47:03 +09:00
ketle-manandClaude Sonnet 5 90a7d824fb Merge feature/style-catalog-imageprompt: v0.3.95 ImagePrompt改名+Style_Catalogギャラリー
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 01:26:46 +09:00
ketle-manandClaude Sonnet 5 05e061d656 release: v0.3.95 — Style/Prompt→ImagePrompt改名 + Style_Catalogギャラリー + カタログ作成機能
Galleryタブが Output / ImagePrompt / Style_Catalog の3サブタブ構成に。既存の
Style/PromptサブタブをImagePromptへ全面改名(フォルダ ws_style_prompt→ws_image_prompt
含む)した上で、GenerateUIの登録済みStyleをプレビュー画像から視覚的に選べる
Style_Catalogサブタブを新設。

- Style_Catalogは ws_style_catalog フォルダ配下の画像を閲覧し、埋め込み
  Positive/Negativeプロンプトを表示・コピー、「Select as Style」で該当スタイルへ
  GenerateUIのStyleドロップダウンを切り替え可能
- GenerateUIに「カタログ作成」ボタンを追加。Batchタブ Styleサブタブで選択済みの
  スタイル(複数可)を対象に、現在のワークフローで順次生成しスタイル名で
  ws_style_catalog配下へ保存(既存のバッチ実行機構を再利用)。隣に「カタログ」
  ボタンでStyle_Catalogサブタブへワンクリック遷移
- Output Galleryの詳細パネルにプロンプト表示が出ていなかった不具合も併せて修正

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 01:26:41 +09:00
ketle-manandClaude Sonnet 5 5afdec395c Merge feature/style-prompt-gallery: v0.3.94 Style/Promptギャラリー追加
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:16:35 +09:00
ketle-manandClaude Sonnet 5 3ab4f3e36b release: v0.3.94 — Galleryタブをサブタブ化しStyle/Promptギャラリーを新設
Output(既存)/ Style-Prompt(新規)の2サブタブ構成に変更。Style/Promptギャラリーは
output/ws_style_prompt フォルダを対象に、フォルダツリー・サムネイル一覧・プロンプト
ビルダー(選択→追加→チップ→最終プロンプト→コピー)を提供する視覚的プロンプト作成UI。

- プロンプトは画像と同名の.txtサイドカーで保存(gallery_metadata.jsonには非依存)
- .txtサイドカーが無い画像は ponyxlWildcardsVault形式(*.yaml + thumbnails/)を
  その場で解決するフォールバックに対応(インポート不要でパックを直接閲覧可能)
- tools/import_style_prompt_seed.py でwildcardデータから画像+プロンプトの対を
  ポータブルな.txtサイドカー形式へ変換投入可能(画像データ自体はリポジトリに含めない)
- 親フォルダ選択時のサブフォルダ画像再帰表示、右ペイン2カラムレイアウトにも対応

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:16:22 +09:00
ketle-manandClaude Sonnet 5 4a23564c66 release: v0.3.93 — LabタブVAEキーフレーム不発バグ修正・Style適用UI刷新・Plan JSONスクロール同期修正・キーフレーム個別削除・Batch数超過警告・ワイルドカード対応・Save index imageデフォルトON
- VAE: 独立VAELoaderノードが無いワークフローでキーフレームが無警告で無視されるバグを修正(動的ノード注入+リンク張替え)
- Style: 上部Style有効化チェックボックスに依存しないApplyボタン+ON/OFFチェックボックス+別表示名に刷新
- Plan JSON: テキストエリアとハイライトオーバーレイのスクロール同期漏れを修正
- キーフレーム: 最後以外もモーダルから個別削除可能に
- Batch数: 既存キーフレームの回数目を下回った場合に警告トーストを追加
- ワイルドカード: Promptキーフレームでも__name__記法によるSPA側展開に対応
- Save index image to Output on Run のデフォルトをONに変更(+ Plan Clear時のチェックボックス表示ズレを修正)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 19:28:31 +09:00
ketle-manandClaude Sonnet 5 d8ffcc182e release: v0.3.92 — CLIPTextEncodeEditPlus実行結果不一致の根本修正(convertUiToApiのforceInput未考慮バグ)+LoRA Stack機能改善
- convertUiToApiの_getWidgetInputNames/_getWidgetInputTypesがforceInput:trueを考慮しておらず、未接続の任意入力(例: CLIPTextEncodeEditPlusのtext2)が誤ってwidgets_valuesの余分な値を吸収し送信データに紛れ込んでいたバグを修正。ComfyUI直接実行との生成結果不一致(text_edit欠落)の根本原因だった。
- Metadata/Infoタブ(metadata-tab.js, node_sets_menu.js)でCLIPTextEncodeEditPlusのtext1+text_edit+mode結合ロジック(RAW/EDIT/front/back)を追加し、ノード本体のPython実装と同じルールで正しくプロンプトを抽出するよう修正。
- GenerateUI ModelタブにLoRA Stack機能を3点改善: Use Stack Groupチェックボックス(ワークフロー実データ/Modelsタブ登録グループの切替)、複数LoRAの正しい表示、タブ切替でのON/OFF状態リセットバグ修正。
- ヘルプ(GenerateUIタブページ)にModel Tab — LoRA Detailsカードを追加(EN/JA/ZH)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:54:47 +09:00
ketle-man 3d5bc47e20 release: v0.3.91 — Labインデックス画像にPlanメタデータをPNG埋め込み、READMEのChangelogセクションを廃止
- Lab: インデックス画像PNG(Plan Save/Save As、Run時Output保存の両方)にPlan全体をiTXtチャンク(wfm_lab_plan)として埋め込み
- Lab: Planドロップエリアへの.pngドロップ時、埋め込みメタデータを直接読み込むように変更(対応する.jsonが無いOutputフォルダ側の画像でも読込可能に)。古い画像は従来通りサーバーへフォールバック
- README: 肥大化した Changelog セクションを削除。以後のバージョン履歴はGitHub Releaseのリリースノートで管理
- CLAUDE.md: リリース手順からREADME Changelog更新の手順を削除
2026-08-10 11:14:11 +09:00
ketle-man 35899bf53e release: v0.3.90 — Labタブにワークフロー呼び出し・T2I対応・Run時インデックス画像Output保存+Eagle連携・プラン接頭辞を追加、Galleryルート表示を簡略化
- Lab: Plan JSONに使用ワークフローファイル名を記録し、Open Workflowボタンで呼び戻し可能に
- Lab: T2I workflow (no source image) チェックボックスでソース画像なしのT2Iワークフローにも対応
- Lab: Save index image to Output on Run チェックボックスで、Run完了ごとにインデックス画像をComfyUIのOutputフォルダへ保存し、Eagle自動保存にも連携
- Lab: Plan Save/Save Asで保存されるファイル名にws_labplan_接頭辞を自動付与
- Gallery: フォルダツリーのルート表示を「[root] <フォルダ名>」から「[root]」に簡略化
- README: FeaturesセクションをタブごとにHTML detailsで折りたたみ化
2026-08-10 10:35:20 +09:00
ketle-manandClaude Sonnet 5 f356826e9f release: v0.3.89 — Lab機能への実動作確認フィードバック3件を反映(Get from Image・スタイル追記・Modelタブフィルター✕)
Get from ImageをLabタブに現在読み込まれている画像から取得する方式に変更。
スタイル適用を{prompt}置換ではなく常時カンマ区切り追記(既存プロンプトを消さない)に変更。
GenerateUI ModelタブのCheckpoint/VAE/Diffusion Model/ControlNet/Hypernetwork/Lora各フィルターに
オーバーレイ✕クリアボタンを追加。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:43:33 +09:00
ketle-manandClaude Sonnet 5 003f3408fc release: v0.3.88 — Labタブ Promptセルにスタイル適用・画像/GenerateUIからのプロンプト取得、Checkpoint/VAEフィルター、Plan JSONサブタブを追加
Promptセル編集モーダルにGet from GenerateUI/Get from Image/Styleボタンを追加(Styleは
GenerateUIツールバー上部のチェックボックス・ドロップダウンをそのまま参照)。
Checkpoint/VAEセルにModelタブ同様のフィルター入力+オーバーレイ✕クリアボタンを追加。
Labに3つ目のサブタブ「Plan JSON」を新設し、現在のプランをJSONとして確認・編集・
Settingへ適用できるようにした。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:16:19 +09:00
ketle-manandClaude Sonnet 5 54198f196a release: v0.3.87 — GenerateUIタブに実験的なLabサブタブ(I2Iバッチ生成・キーフレーム方式)を追加
Checkpoint/VAE/Prompt/KSamplerを列ごとに独立したキーフレームで管理し、任意の回数目から
値を変更できるI2Iバッチ生成機能。ワークフローを直接書き換えず、実行時にクローンして適用。
Plan Save/Save As/Load(json/pngドロップ対応)、画像連鎖生成、Eagle自動保存連携、
インデックス画像(最大9枚コンタクトシート)に対応。詳細はDEVLOG.md参照。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 12:15:07 +09:00
ketle-manandClaude Sonnet 5 3ff76aacc6 release: v0.3.86 — GalleryMetadataStoreの競合状態を修正
画像を一括生成(I2I)等でメタデータ保存リクエストが短時間に連発すると、バックグラウンドのフォルダインデックススレッドと競合し「dictionary changed size during iteration」が発生していた不具合を修正。GalleryMetadataStoreの共有辞書アクセスをthreading.RLockで保護した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 15:50:46 +09:00
ketle-manandClaude Sonnet 5 ec8be7ee6f release: v0.3.85 — Generate UI連携ブリッジ(T2I)にデフォルトワークフロー・Negative対応を追加
_wfmReceiveGenerateRequestをI2I/Inpaint連携と同じパターンに拡張し、
negative/workflowData/workflowFilename引数を追加。Comic Creator側の
「画像を一括生成」(T2I)からデフォルトワークフローを指定できるようになった。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:53:00 +09:00
ketle-manandClaude Sonnet 5 117fcad22a fix: release v0.3.84 — ernie_t2i.json実行時のPrompt outputs failed validationエラーを修正
サブグラフ境界のwidget値(width/height等)が複数の内部ノードへfan-outする場合
(EmptyFlux2LatentImage.widthへの実ウィジェット注入 + PreviewAny.sourceという
ウィジェット表現を持たないワイルドカード必須入力への中継)、_flattenSubgraphs
のinputPortTargetsが最後の1ターゲットで上書きされ、片方が未接続のまま
Required input is missing: sourceで検証エラーになっていた。全fan-outターゲット
を追跡し、非ウィジェット入力にはリテラル値を直接注入するよう修正。
実サーバーへの/promptバリデーションおよび実ブラウザでのGenerate実行で確認。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 21:45:00 +09:00
ketle-manandClaude Sonnet 5 feed327c23 release: v0.3.83 — Flux.2 Klein / Ernie Imageのプロンプト表示不具合を修正
CLIPTextEncode.textがPrimitiveStringMultilineへのリンク参照になっているパターン
(Metadata/IタブのresolveLinkedTextがvalueキー未対応)と、ComfySwitchNode経由で
生プロンプト/LLM強化版のどちらかに切り替わるErnie Imageのプロンプト強化トグル
(LLM出力は静的解析不能なため両分岐を辿りリテラルへ解決できる方を採用)に対応。
GenerateUIタブ・Metadataタブ・サイドパネルIタブの3箇所を修正し、実ブラウザで
3ワークフローすべての表示を確認。ヘルプにErnie Imageの表記を追加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 19:31:37 +09:00
ketle-manandClaude Sonnet 5 82ddbb340c release: v0.3.82 — Metadata/Iタブのプロンプト抽出修正・widgets_values/Rerouteバグ修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:17:13 +09:00
ketle-manandClaude Sonnet 5 31f02962f5 ヘルプ・対応フォーマット表記にLongCat/Boogu/HiDream E1/FireRedを追加
GenerateUIタブ(helpGen2)・Metadataタブ(helpMetadata5)のヘルプ、および
Metadataタブ内の対応フォーマット補足(metaFormatTodo)を3言語(EN/JA/ZH)で更新。
併せてmetaFormatTodoにMage-Flowが未記載だった既存の抜けも修正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:10:01 +09:00
ketle-manandClaude Sonnet 5 413f0d5068 MetadataタブとサイドパネルIタブのプロンプト抽出をImage Edit系ワークフローに対応
Flux.2 Klein/LongCat/Boogu/HiDream E1/FireRedをAI TOOL Chatで実際に生成し
Metadataタブで確認したところ、qwen_image_edit2509/2511・longcat・boogu・
Fireredの画像でプロンプトが表示されない問題が見つかった(同じ画像を
GenerateUIタブへ読み込むと正しく表示されるため、GenerateUIタブ側の解析
[comfyui-workflow.js]とは別に実装されているMetadataタブ独自のプロンプト
抽出ロジックが未対応と判明)。

原因は主に2点:
- PNGは`prompt`チャンク(既にAPI形式)が`workflow`チャンクより優先して読まれる
  ため、extractPromptsAPI()のtextMap構築が常に使われるが、そこは"text"/
  "text_g"キーのみを見ており、TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit
  が使う"prompt"キーを見ていなかった。
- SamplerCustomAdvancedはpositive/negativeを直接持たずGuiderノード
  (CFGGuider/DualCFGGuider/BasicGuider)経由のため、サンプラーのinputsを
  直接見るだけのrole判定では見つけられなかった(Flux.2 Klein/HiDream E1)。

extractPromptsAPI/extractPromptsFromNodeSet双方に、guider経由のinputsを
辿る処理、TextEncodeBooguEditの1ノード2ロール(prompt/negative_prompt)対応、
InstructPixToPixConditioning経由の追加ホップ解決を追加。node_sets_menu.js
(ComfyUIサイドパネルのIタブ、web/comfyui/はESモジュールをimportできない
制約からcomfyui-workflow.js/metadata-tab.js相当のロジックを`_wfm`接頭辞で
ローカル複製している)にも同じ修正を適用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:02:47 +09:00
ketle-manandClaude Sonnet 5 bec84ac30b release: v0.3.81 — Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Image Edit対応
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 17:40:33 +09:00
ketle-manandClaude Sonnet 5 61e16ddce0 Rerouteノードを透過解決し、hidream_comicの400エラーを修正
HiDream E1ワークフロー(hidream_comic.json)はUNETLoaderの出力をBasicScheduler/
DualCFGGuiderへ分岐させるのにRerouteノードを使っていた。RerouteはUI専用の
パススルーノードでComfyUIバックエンドにノードクラスが存在しないため、
そのままAPIペイロードに含めると「missing_node_type」の400エラーになっていた
(実機でGenerateして初めて発覚)。

_resolveBypassSource()をReroute対応に拡張し、Bypass(mode:4)ノードと同様に
配線を実際の入力元まで直結して解決するようにした。Rerouteの入力型は多くの場合
汎用型"*"のまま保存されており、Bypass用の型一致検索では見つからないため、
Reroute(常に1入力1出力)は型チェックを省略して唯一の入力を辿るようにしている。
convertUiToApi本体のノードループでもReroute自体をAPIペイロードから除外する。

実機でGenerate実行し、エラーなく生成できることを確認済み。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:39:48 +09:00
ketle-manandClaude Sonnet 5 174a67a569 LongCat/Boogu/HiDream E1/FireRedのImage Editワークフローに対応
GenerateUIタブでの検出範囲を拡張:
- TextEncodeQwenImageEdit(Plus無し版)とFluxGuidance/FluxKontextMultiReferenceLatentMethod
  のCOND_PASSTHROUGH対応(LongCat)
- SamplerCustomをSamplerCustomAdvancedと分離し、自身が持つpositive/negative/cfg/noise_seed
  を正しく読み取るよう修正、TextEncodeBooguEdit対応(Boogu)
- DualCFGGuiderをguiderとして認識(cfg_condsを代表cfg値に)、InstructPixToPixConditioning
  のrole伝播中継に対応(HiDream E1)
- KSamplerのseed/steps/cfg/denoiseがリンク(Primitive経由)で非数値の場合にundefined化

あわせて、サブグラフ展開処理(_flattenSubgraphs)のwidgets_valuesノーマライズを実機検証で
発見した既存バグごと全面的に書き直した。従来は「widgets_values長とwidget数が完全一致する
場合のみ」ノーマライズが働く設計で、seed直後のcontrol_after_generate拡張値('fixed'等)が
挟まるノード(KSampler等)では条件が成立せず、リンクされたwidget(steps/cfg等)のエントリが
除去されないまま後続処理に渡り、cfg/denoiseなどの値が一つずつズレて上書きされていた
(Flux.2 KleinのEmptyFlux2LatentImage、Firered_comicのKSamplerで実際に発生)。
_simulateWidgetValues/_stripLegacyLinkedWidgetValues/_findInjectedWidgetIndexとして、
legacy-full形式かどうかの自動判定・control_after_generate込みの正確な位置計算・注入を
共通ロジックに統一した。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:52:04 +09:00
ketle-manandClaude Sonnet 5 d903f33a31 Flux.2 Klein(Advanced Sampling)ワークフローのGenerateUI対応
CFGGuider+RandomNoise+SamplerCustomAdvanced+KSamplerSelect+Flux2Schedulerという
KSamplerを使わない構成を、SamplerCustomAdvanced起点でたどってSettingsパネルの
Seed/Steps/CFG/Sampler欄に集約表示・編集できるようにした。EmptyFlux2LatentImageも
Latentパネルに対応させ、GetImageSizeへリンクされたwidth/heightは空欄+説明表示にして
誤上書きを防止。CFGGuider/BasicGuiderをpositive/negative判定の起点にも追加。

あわせて実機検証で見つかった既存バグを修正:
- サブグラフ内部完結リンク(GetImageSize等)の扱いが誤り、widgets_values変換で
  batch_sizeが1024になったりCLIPTextEncodeのtextが消えたりしていた不具合
- 同ロールのプロンプトノードが複数ある場合にドロップダウン選択とテキスト内容が
  食い違う不具合
- ミュート/バイパスしたサブグラフの内部ノードにmodeが伝播せず、無効なパイプライン
  のノードがModelタブ等のデフォルト表示に紛れ込む不具合

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 15:55:55 +09:00
ketle-manandClaude Sonnet 5 afed788537 release: v0.3.80 — Comic Creator Semi-Automatic Manga bridges (LLM prompt draft + batch generate)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:14:55 +09:00
ketle-manandClaude Sonnet 5 ee020cb0c0 Comic Creator「半自動マンガ作成」向けブリッジを追加(LLMプロンプト下書き・バッチ画像生成)
gallery-tab.jsに_wfmReceiveLLMPromptRequest/_wfmReceiveGenerateRequestを追加。既存のI2I/Inpaint連携と同じ配置パターンで、Comic Creator側からiframe越しに呼び出される。ai-tab.jsのcallLLM/loadAiSettings/isValidBackendUrlをexportし再利用。

Comic Creator側からKaptureで実機E2E確認済み(Ollama接続でのLLM呼び出し、t2iワークフローでの画像生成とも成功)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:22:18 +09:00
62 changed files with 106518 additions and 4468 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard read"
}
]
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
# graphify: keep graph.json/graphify-out/ out of Claude Code's automatic
# context so re-running `graphify extract` doesn't churn the prompt cache
# on every rebuild. These are still tracked by git (see .gitignore).
graph.json
graphify-out/
+11
View File
@@ -0,0 +1,11 @@
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
#
# Unsloth Desktop requires an API key even for local access
# (Settings -> API in the Unsloth app, shown only once when issued).
#
# 1. Copy this file to ".env" (same directory as this example file)
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
# line becomes a comment and the key will not be loaded)
# 3. Restart ComfyUI
UNSLOTH_API_KEY=
+9
View File
@@ -5,3 +5,12 @@ __pycache__/
.venv
*.egg-info/
data/
# graphify: keep the knowledge graph itself under version control,
# but exclude the run-by-run cost/token log, the local extraction
# cache (both churn every rebuild and have no value to other clones),
# and the machine-local interpreter/scan-root pointers.
graphify-out/cost.json
graphify-out/cache/
graphify-out/.graphify_python
graphify-out/.graphify_root
+3
View File
@@ -0,0 +1,3 @@
# Private/unpublished planning notes — never committed to git, must not leak
# into the published graphify-out/ knowledge graph either.
QWEN_CHAT_INTEGRATION_PLAN.md
+13 -4
View File
@@ -194,9 +194,18 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
2. `README.md` のバージョンバッジを更新(`![Version](https://img.shields.io/badge/version-X.Y.Z-green)`)
3. `README.md` の Changelog に新バージョンのエントリを追加
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
5. 上記すべてを同一コミットにまとめてプッシュ
6. `gh release create vX.Y.Z` でリリースを作成
3. `DEVLOG.md` の先頭に変更詳細エントリを追加
4. 上記すべてを同一コミットにまとめてプッシュ
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+1174 -152
View File
File diff suppressed because it is too large Load Diff
+152 -678
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+745
View File
@@ -0,0 +1,745 @@
# Graph Report - ComfyUI-Workflow-Studio (2026-08-20)
## Corpus Check
- 140 files · ~0 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 2627 nodes · 5787 edges · 171 communities (119 shown, 52 thin omitted)
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.66)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- Models Tab Frontend
- Models API Routes
- Tagger API Routes
- Image Edit Mask Tools (Code)
- Prompt Tab Frontend
- Gallery API Routes
- Gallery Tab Frontend & Toasts
- Image Edit Layer & Draw Tools
- Tagger/Prompt i18n Helpers
- GenerateUI Feeder Tab
- Nodes API Routes
- Settings API Routes
- App Shell & i18n Init
- Nodes Tab Frontend
- GenerateUI Lab Tab
- Metadata Tab Frontend
- Node Sets Menu & Sidebar
- AI Tool Tab Frontend
- Generate Tab Frontend & Modals
- Workflow Tab Frontend & Storage
- Wildcard Service & Routes
- CivitAI Service & Data Import/Export
- GenerateUI Tab & AI Chat Features
- ComfyUI Client & Editor
- DEVLOG v0.1.x Early Features
- AI Chat/VLM Sidebar Helpers
- AI TOOL Tab Backends
- Node Sets Menu UI Components
- Node Sets Draggable Items
- Workflow API Routes
- Gallery Metadata Store
- Workflow Service Metadata/Import
- CLAUDE.md Node Dev Rules
- Lab Tab & PNG Metadata Utilities
- Lab Plan Service & Routes
- Skill Service & Routes
- Image Edit Select Tool
- Image Edit Draw Tool
- Image Edit Layer Manager Core
- CivitAI Service
- Image Edit Mask Tool
- Gallery Service Core
- Eagle Routes Backend
- Style Catalog Tab
- Image Prompt Tab
- SDXL 3D Pose Editor DWPose Workflow
- Gallery Service Core
- Image Edit Tab & Mask Editor One
- App Shell & Nodes Tab Ecosystem
- Node Sets Menu Model Fetch
- Image Edit Background Removal
- ComfyUI Workflow Utilities
- Generate Tab Frontend & Modals
- Batch Group UI & Security Fixes
- DEVLOG Early History & Manager Registration
- App Shell & ComfyUI Ecosystem Refs
- GenerateUI Batch & Workflow Editor Core
- Image Edit Blur Tool (BlurTool.js)
- Image Edit Shape Tool
- Tagger Tab & Gallery Bulk Actions
- Image Edit Layer Core & Bug Fixes
- G'MIC Routes
- Ollama Routes
- Gallery Metadata/Service Bridge
- Image Edit Text Tool
- Mask Editor One BiRefNet/SAM3 Integration
- Eagle Routes Backend
- Prompts API Routes/Service
- Workflow Analyzer
- Gallery Tab Core & Feeder Group
- AI TOOL Backend & Model Tab Updates
- Tagger Tab Implementation & Security Fixes
- Eagle Routes Backend
- Prompts API Routes/Service
- Metadata & Nodes Tab Core
- AI Tool Tab Frontend
- Node Sets Menu & Sidebar
- Gallery Feeder Extension (Frontend)
- Gallery Speed & Bulk Features
- Style Catalog & Batch Loop Core
- Metadata Tab Node Support History
- Image Edit Background Removal (BgRemove.js)
- Gallery Feeder Node (Backend)
- App Shell & i18n Init
- Generate Tab Frontend & Modals
- Image Edit Inpaint/I2I Actions
- Image Edit SAM3 Segmentation
- Node Sets Metadata Extraction
- Node Sets Prompt Extraction
- Inpaint Basic Workflow
- Gallery Feeder & Batch Features
- Gallery Service Background Indexing
- Settings/CivitAI Config Resolution
- Gallery Service Vault Helpers
- GenerateUI Lab Tab
- Style Prompt Seed Import Tool
- FireRed 3D Pose Image Edit Workflow
- Chat Image Generation & Inpaint Bridge
- Subgraph Workflow Support & Fixes
- KREA-2 & Lab Tab Extensions
- LoRA Pane Evolution & Help Tab
- Image Edit File Export (FileExport.js)
- Image Edit G'MIC Integration
- App Shell & i18n Init
- DWPose Image Feeder Workflow
- AI Tab & RAW JSON Search History
- Prompt Extraction Bug Fixes
- Comic Creator Bridge & LoRA Batch Fixes
- Settings/CivitAI Config Resolution
- Node Sets Menu Model Fetch
- G'MIC Integration & Security Fixes
- Comic Creator Integration Bridges
- GenerateUI Model Tab GGUF/Embedding Support
- Bypass Node Handling & Advanced Sampling
- Prompt Text Node
- Gallery Metadata/Service Bridge
- Gallery Service Vault Helpers
- PNG Metadata Extractor
- AI Skills & Help Redesign
- Prompt Extraction Bug Fixes
- AI TOOL Chat & Wildcard Features
- Config & Service Modules
- Group Feature Path Bug Fixes
- SVG Generation & Display Support
- Send to Canvas Feature
- Module Split Refactor (v0.4.0)
- CivitAI Detail Panel History
- GenerateUI Layout & JSON Highlight
- Workflow Tab Bug Fixes
- CivitAI Batch Fetch Optimization
- DEVLOG JSON Highlighting & Subgraph
- Mask Tool 5 Sub-tools
- Prestartup Script
- Workflow Service Dir Update
- Project Banner Asset
- Project Icon Asset
- SDXL 2D Pose Editor I2I Workflow
- Tagger Tab Screenshot
- Models Tab Screenshot
- Topbar Icon Strip Screenshot
- GenerateUI LoRA Stack Screenshot
- Favicon Asset
- FireRed 2D Pose Image Edit Workflow
- Flux.2 Klein 3D Pose Image Edit Workflow
- SDXL DWPose ControlNet Workflow
- SDXL From Metadata Workflow
- SDXL I2I FaceDetailer Workflow
- SDXL Image Feeder I2I Workflow
- SDXL T2I FaceDetailer Workflow
- Default View Setting
- Latent Resolution Presets
- Send to Canvas Toast Fix
- ZIT/Lumina2 Prompt Fix
- GenerateUI Batch Tab Screenshot
- Multiple Select Menu Screenshot
- Image Edit Tab Screenshot
- Workflow Tab Screenshot
- Prompt Input Assistance Screenshot
- GenerateUI Feeder Screenshot
- Library Panel LoRA Info Screenshot
- Library Panel Prompts Screenshot
- Settings Tab Screenshot
- Project Thumbnail Asset
- Project Manifest (pyproject.toml)
- Main Stylesheet
- SD1.5 T2I Basic Workflow
- SDXL T2I Basic Workflow Screenshot
## God Nodes (most connected - your core abstractions)
1. `t()` - 174 edges
2. `showToast()` - 138 edges
3. `escapeHtml()` - 57 edges
4. `ImageEditTab` - 53 edges
5. `GalleryService` - 45 edges
6. `initPromptTab()` - 34 edges
7. `setup_routes()` - 32 edges
8. `initSettingsTab()` - 31 edges
9. `add()` - 31 edges
10. `renderModelGrid()` - 28 edges
## Surprising Connections (you probably didn't know these)
- `Sidepanel Information (I) Tab` --semantically_similar_to--> `Metadata Tab` [INFERRED] [semantically similar]
templates/index.html → README.md
- `Batch Type: Checkpoint` --shares_data_with--> `Models Tab` [EXTRACTED]
templates/index.html → README.md
- `Workflow Tab` --shares_data_with--> `Badge System (shared)` [EXTRACTED]
README.md → templates/index.html
- `Settings Tab` --conceptually_related_to--> `Settings: Connections (ComfyUI/Ollama/Eagle)` [EXTRACTED]
README.md → templates/index.html
- `Settings Tab` --conceptually_related_to--> `Settings: Workflow Defaults` [EXTRACTED]
README.md → templates/index.html
## Import Cycles
- 3-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
- 3-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/GmicIntegration.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/FileExport.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/InpaintI2IActions.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BgRemove.js -> static/js/app.js`
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.js -> static/js/app.js`
- 4-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
- 4-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
- 4-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
- 5-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
- 5-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
## Hyperedges (group relationships)
- **Gallery Feeder System** — devlog_gallery_feeder_node, devlog_gallery_feeder_canvas_controls, devlog_feeder_group_protection, devlog_feeder_tab_gallery_mode [EXTRACTED 0.90]
- **LoRA Pane Iterative Evolution (v0.3.29-v0.3.33)** — devlog_v0_3_29_lora_section_integration, devlog_v0_3_30_lora_trigger_bugfix, devlog_v0_3_31_lora_single_stack_tabs, devlog_v0_3_32_loramanager_detection, devlog_v0_3_33_lora_pane_bugfix [EXTRACTED 0.90]
- **Shared Badge System Across Workflow and Models Tabs** — templates_index_badge_system, readme_workflow_tab, readme_models_tab [EXTRACTED 1.00]
- **ComfyUI Comic Creator Bidirectional Bridge Functions** — devlog_comic_creator_integration, devlog_v0_3_69_send_cc_button, devlog_i2i_bridge_receive_image, devlog_v0_3_76_i2i_run_bridge, devlog_v0_3_72_comic_creator_inpaint_entrypoint, devlog_v0_3_80_comic_creator_bridge [EXTRACTED 1.00]
- **Mask Editor One Integration Features (BiRefNet/SAM3/ABR/Icon Fix/Connect Button)** — devlog_mask_editor_one_custom_node, devlog_birefnet_bg_remove_integration, devlog_sam3_segmentation_integration, devlog_mask_tool_abr_brush_support, devlog_bg_image_b64_field_rename_fix, devlog_v0_4_0_mask_editor_one_connect_button [EXTRACTED 1.00]
- **Feeder Group Image Queueing Flow** — readme_feeder_group, readme_gallery_tab, templates_index_feeder_gallery_mode, templates_index_wfs_galleryfeeder_node [EXTRACTED 1.00]
- **node_sets_menu.js Sidepanel Evolution Across Versions** — devlog_v0_1_7_nodes_tab, devlog_v0_1_8_wf_node_library, devlog_v0_1_9_sidepanel_improvements, devlog_v0_2_0_prompt_presets, devlog_v0_2_3_badge_unification_genui_model, devlog_v0_2_5_category_package_theme [EXTRACTED 1.00]
- **AI TOOL Tab multi-backend support** — readme_ai_tool_tab, readme_ollama, readme_lm_studio, readme_lemonade_server, readme_unsloth [EXTRACTED 1.00]
- **Feeder generation-loop ecosystem** — readme_feeder_subtab, readme_image_loop_mode, readme_gallery_mode, readme_wfs_galleryfeeder_node, readme_feeder_group [EXTRACTED 1.00]
- **GenerateUI 6-tab layout** — readme_generateui, readme_feeder_subtab, readme_batch_tab, readme_lab_subtab [EXTRACTED 1.00]
- **py/routes API layer (per-tab route modules)** — readme_workflow_routes_py, readme_models_routes_py, readme_nodes_routes_py, readme_settings_routes_py, readme_gallery_routes_py [EXTRACTED 1.00]
- **GenerateUI Style System (shared named-Style registry)** — readme_style_manager, readme_generateui, readme_batch_tab, readme_style_catalog_gallery_subtab, readme_create_catalog [EXTRACTED 1.00]
- **Release & Registry Publish Flow** — claude_release_procedure, github_workflows_publish_trigger, readme_version_badge, github_workflows_publish_action [EXTRACTED 1.00]
- **Shared Style File System Across Tabs** — templates_index_style_system, templates_index_style_feature, templates_index_style_manager_prompt, templates_index_batch_style_type, templates_index_create_catalog_feature, templates_index_gallery_stylecatalog_panel [EXTRACTED 1.00]
- **Path Traversal Protection Pattern (_safe_path)** — devlog_v0_1_6_path_traversal_fix, devlog_v0_3_3_wildcard_panel, devlog_v0_2_8_data_dir_export_import [INFERRED 0.70]
- **Shared Badge Palette System (Workflow/Models tabs)** — devlog_v0_2_3_badge_unification_genui_model, devlog_v0_3_0_model_group_display, devlog_v0_2_9_model_enable_disable [INFERRED 0.80]
- **Batch Reserved Group System** — devlog_reserved_groups_mechanism, devlog_models_batch_group_ui, devlog_prompt_workflow_batch_group_ui, devlog_v0_3_24 [INFERRED 0.80]
- **CivitAI Integration Pipeline** — devlog_v0_3_17, devlog_v0_3_19, devlog_v0_3_20, devlog_v0_3_21 [INFERRED 0.80]
- **ComfyUI Custom Node Robustness Patterns** — claude_is_changed_caching, claude_validate_inputs_pitfall, claude_hidden_inputs, claude_lazy_inputs, claude_safe_node_loading [INFERRED 0.85]
- **Recurring Dev/Runtime Deployment Sync Failures Across Versions** — devlog_dev_deploy_sync_reminder, devlog_v0_3_69_send_cc_button, devlog_v0_3_70_ollama_404_prompt_weight [INFERRED 0.85]
- **LoraManager Integration** — devlog_v0_3_17, devlog_v0_3_25, devlog_v0_3_26, devlog_lora_manager_format [INFERRED 0.85]
- **Image Edit Tab Tool Suite** — devlog_v0_3_53_image_edit_tab, devlog_v0_3_54_shape_tool, devlog_v0_3_57_blur_tool, devlog_v0_3_57_bgremove_tool, devlog_v0_3_59_mask_tool, devlog_gmic_qt_filter_tool [INFERRED 0.90]
- **Mask Editor One Inpainting Pipeline** — comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_load_checkpoint_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_mask_editor_one_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_positive_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_negative_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_vae_encode_for_inpainting_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_save_image_node [INFERRED]
## Communities (171 total, 52 thin omitted)
### Community 0 - "Models Tab Frontend"
Cohesion: 0.10
Nodes (77): badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange(), openBadgeEditModal(), renderBadgeFilter() (+69 more)
### Community 1 - "Models API Routes"
Cohesion: 0.06
Nodes (55): handle_change_preview(), handle_civitai_batch(), handle_civitai_cache(), handle_civitai_fetch(), handle_delete_models(), handle_get_disabled(), handle_get_filepath(), handle_get_groups() (+47 more)
### Community 2 - "Tagger API Routes"
Cohesion: 0.07
Nodes (29): Connection, Image, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list() (+21 more)
### Community 3 - "Image Edit Mask Tools (Code)"
Cohesion: 0.05
Nodes (6): MASK_TEXT_FONTS, MaskAlphaTool, MaskColorTool, MaskShapeTool, MaskTextTool, MaskVectorTool
### Community 4 - "Prompt Tab Frontend"
Cohesion: 0.08
Nodes (59): refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile(), chatWithAi() (+51 more)
### Community 5 - "Gallery API Routes"
Cohesion: 0.10
Nodes (54): add_to_group(), bulk_favorite(), bulk_group(), clear_group_images(), create_folder_route(), create_group(), delete_folder_route(), delete_group() (+46 more)
### Community 6 - "Gallery Tab Frontend & Toasts"
Cohesion: 0.10
Nodes (53): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+45 more)
### Community 8 - "Tagger/Prompt i18n Helpers"
Cohesion: 0.11
Nodes (50): _appendSvgPreview(), closeModal(), showToast(), _applyDefaultCheckpointIfEnabled(), handleGenerate(), loadWorkflowIntoEditor(), openCatalogCreateModal(), saveCurrentWorkflow() (+42 more)
### Community 9 - "GenerateUI Feeder Tab"
Cohesion: 0.10
Nodes (50): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+42 more)
### Community 10 - "Nodes API Routes"
Cohesion: 0.09
Nodes (28): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+20 more)
### Community 11 - "Settings API Routes"
Cohesion: 0.09
Nodes (44): _apply_import_bundle(), _build_export_bundle(), _find_style(), _get_comfyui_output_dir(), handle_create_style(), handle_delete_style(), handle_export(), handle_get() (+36 more)
### Community 12 - "App Shell & i18n Init"
Cohesion: 0.09
Nodes (43): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+35 more)
### Community 13 - "Nodes Tab Frontend"
Cohesion: 0.12
Nodes (42): buildPromptItem(), _applyNodeSelectionToDOM(), bulkNodeAddToGroup(), bulkNodeCreateAndAddToGroup(), bulkNodeRemoveFromGroup(), bulkNodeSetFavorite(), categoryBadgeHtml(), createNodeCard() (+34 more)
### Community 14 - "GenerateUI Lab Tab"
Cohesion: 0.12
Nodes (41): openModal(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildPlanData(), _buildWorkflowForIteration(), _cellHtml(), _cellLabel() (+33 more)
### Community 15 - "Metadata Tab Frontend"
Cohesion: 0.11
Nodes (39): buildLoRAItem(), buildModelItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints(), extractDiffusionModels() (+31 more)
### Community 16 - "Node Sets Menu & Sidebar"
Cohesion: 0.09
Nodes (38): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+30 more)
### Community 17 - "AI Tool Tab Frontend"
Cohesion: 0.12
Nodes (38): appendChatBubble(), _appendSkillSaveButton(), buildTranslationMessages(), cleanTranslationOutput(), _extractSkillBlock(), _extractSvgCode(), fetchModels(), generateImageFromChat() (+30 more)
### Community 18 - "Generate Tab Frontend & Modals"
Cohesion: 0.09
Nodes (37): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _ckptBatch, _ckptState (+29 more)
### Community 19 - "Workflow Tab Frontend & Storage"
Cohesion: 0.13
Nodes (34): badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows(), filterWorkflows() (+26 more)
### Community 20 - "Wildcard Service & Routes"
Cohesion: 0.12
Nodes (21): handle_create_link(), handle_delete(), handle_get_content(), handle_link_status(), handle_list(), handle_remove_link(), handle_save(), Application (+13 more)
### Community 21 - "CivitAI Service & Data Import/Export"
Cohesion: 0.06
Nodes (36): CivitAI, CivitAI Integration, py/services/civitai_service.py, Data Management (export/import), G'MIC-Qt Integration, Highlight color (v0.3.98), static/js/i18n.js, static/js/json-highlight.js (+28 more)
### Community 22 - "GenerateUI Tab & AI Chat Features"
Cohesion: 0.07
Nodes (36): ComfyUI-Impact-Pack (external), Eagle Integration, GenerateUI Tab, Lab Subtab, Wildcard Integration (Settings), Alt+Click Apply & Generate, Batch Type: Checkpoint, Batch Type: Prompt (+28 more)
### Community 23 - "ComfyUI Client & Editor"
Cohesion: 0.09
Nodes (26): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+18 more)
### Community 24 - "DEVLOG v0.1.x Early Features"
Cohesion: 0.08
Nodes (30): Feeder Tab (GenerateUI), Gallery タブ追加 & 全タブ Group UI 統一, node_sets_menu.js (ComfyUI Sidepanel Library), プロジェクトリネーム & アイコン適用(Workflow-Manager→Workflow-Studio), プロンプトタブ 中国語↔英語翻訳追加, テーマカスタマイズ機能・設定タブ改善, トップバーボタンアイコン修正・Appモードバッジ調査, v0.1.0 リリース(GitHub公開・ComfyUI Manager登録) (+22 more)
### Community 25 - "AI Chat/VLM Sidebar Helpers"
Cohesion: 0.11
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
### Community 26 - "AI TOOL Tab Backends"
Cohesion: 0.09
Nodes (28): static/js/ai-tab.js, AI TOOL Tab, Lemonade Server, LM Studio, Ollama, py/routes/ollama_routes.py, Prompt Tab, static/js/prompt-tab.js (+20 more)
### Community 27 - "Node Sets Menu UI Components"
Cohesion: 0.14
Nodes (27): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), NODE_SETS_TOOLTIP, attachTopMenuButton(), beforeRegisterNodeDef(), captureCanvasSnapshot(), compareVersions() (+19 more)
### Community 28 - "Node Sets Draggable Items"
Cohesion: 0.16
Nodes (28): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), esc(), extractPackageName(), getNodeCategory(), getNodePackage(), getWfBadges() (+20 more)
### Community 29 - "Workflow API Routes"
Cohesion: 0.15
Nodes (26): handle_analyze(), handle_change_thumbnail(), handle_delete(), handle_import(), handle_list(), handle_metadata(), handle_raw(), handle_reanalyze_all() (+18 more)
### Community 30 - "Gallery Metadata Store"
Cohesion: 0.13
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
### Community 31 - "Workflow Service Metadata/Import"
Cohesion: 0.13
Nodes (13): Get raw workflow JSON content., Save metadata (tags, memo, summary, etc.) for a workflow., Manages workflow files and metadata., Import workflow files. Returns list of results., Rename workflow and its associated thumbnail., Delete workflow JSON and associated thumbnail., Re-analyze a workflow and save results to metadata., Re-analyze all workflows and update metadata. (+5 more)
### Community 32 - "CLAUDE.md Node Dev Rules"
Cohesion: 0.10
Nodes (25): CLAUDE.md Development Guidelines, Bounded Module-level Cache Pattern, ComfyUI Restart Required After Python Change, Pre-Commit Checklist, Loose Dependency Pinning Policy, Source Repo vs custom_nodes Deploy Sync, folder_paths-based File Path Handling, Hidden Inputs (UNIQUE_ID/PROMPT/EXTRA_PNGINFO) (+17 more)
### Community 33 - "Lab Tab & PNG Metadata Utilities"
Cohesion: 0.09
Nodes (25): wfm:apply-and-generate Custom Event (Circular Import Avoidance), embedPngTextChunk() iTXt PNG Chunk Embedding (util.js), extractAllMetadata() Exported Reusable Prompt Extraction, Gallery Root Folder Label Simplification ("[root]"), Lab Index Image Save-to-Output + Eagle Auto-Save, Lab Keyframe Data Model (atIteration/value/revertToBase), Lab Plan JSON "Open Workflow" Button (Dynamic Import), Lab Tab Plan JSON Sub-tab (Refresh/Apply to Setting) (+17 more)
### Community 34 - "Lab Plan Service & Routes"
Cohesion: 0.15
Nodes (14): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+6 more)
### Community 35 - "Skill Service & Routes"
Cohesion: 0.14
Nodes (16): handle_delete(), handle_get_content(), handle_list(), handle_save(), Application, Request, Response, AI skill (.md system prompt) file management API routes. (+8 more)
### Community 39 - "CivitAI Service"
Cohesion: 0.15
Nodes (11): CivitaiService, Build request headers, optionally including Bearer token., Calculate SHA256 hash of a file., Fetch model version info from CivitAI by SHA256 hash (GET). 429/5xx…, POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。 レスポンスの…, Extract relevant fields from CivitAI API response., Batch fetch CivitAI info for multiple model files. Phase 1: SHA256…, Clear cache for a specific hash or all. (+3 more)
### Community 41 - "Gallery Service Core"
Cohesion: 0.15
Nodes (8): Path, パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow], 複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。, 選択フォルダ内に新しいサブフォルダを作成する, 画像ファイルを別フォルダへ移動する(複数対応), 画像のPathオブジェクトを返す(ルートで使用), 縮小サムネイルのPathを返す。 ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。…
### Community 42 - "Eagle Routes Backend"
Cohesion: 0.14
Nodes (20): _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test(), Application, Request, Response (+12 more)
### Community 43 - "Style Catalog Tab"
Cohesion: 0.19
Nodes (20): selectStyleByName(), _wireLabValueFilter(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce() (+12 more)
### Community 44 - "Image Prompt Tab"
Cohesion: 0.21
Nodes (20): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+12 more)
### Community 45 - "SDXL 3D Pose Editor DWPose Workflow"
Cohesion: 0.19
Nodes (20): CLIP Text Encode Negative Prompt Node, CLIP Text Encode Positive Prompt Node, Inpaint with Mask Editor One (Workflow Screenshot), Load Checkpoint Node, Mask Editor One Node, Save Image Node, VAE Encode (for Inpainting) Node, 3D Pose Editor Node (comfyui-vrm-pose-editor) (+12 more)
### Community 46 - "Gallery Service Core"
Cohesion: 0.10
Nodes (7): GalleryService, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。 フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。, outputフォルダのフォルダツリーを返す, ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を 返す。無ければ作成する。, Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を 返す。無ければ作成する。
### Community 47 - "Image Edit Tab & Mask Editor One"
Cohesion: 0.13
Nodes (20): Image Edit Tab, Mask Editor One Integration, Mask slot for inpainting (v0.3.71), G'MIC-Qt Standalone, Help Tab, ABR Brush Library (Mask Editor One), Image Edit BG Remove Tool, Image Edit Blur Tool (Whole/Rect Blur & Mosaic) (+12 more)
### Community 48 - "App Shell & Nodes Tab Ecosystem"
Cohesion: 0.13
Nodes (19): ComfyUI-Workflow-Studio README, Canvas Snapshot Feature, ComfyUI-Custom-Scripts (external), Node Sets, web/comfyui/node_sets_menu.js, py/routes/nodes_routes.py, py/services/nodes_service.py, Nodes Tab (+11 more)
### Community 49 - "Node Sets Menu Model Fetch"
Cohesion: 0.22
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
### Community 50 - "Image Edit Background Removal"
Cohesion: 0.18
Nodes (7): comfyUI, comfyEditor, comfyWorkflow, MaskEditorOneBridge, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
### Community 51 - "ComfyUI Workflow Utilities"
Cohesion: 0.20
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
### Community 52 - "Generate Tab Frontend & Modals"
Cohesion: 0.20
Nodes (18): _buildSimpleGroupList(), _getGroupSelCount(), _getGroupSelCountFrom(), initBatchTab(), _loadBatchCheckpointGroups(), _loadBatchLoraGroups(), _loadPromptGroupsForBatch(), _loadWorkflowGroupsForBatch() (+10 more)
### Community 53 - "Batch Group UI & Security Fixes"
Cohesion: 0.12
Nodes (17): GenerateUI Batch 4-Type Group Selection (unreleased), Batch Queue Sampler/Scheduler Columns, LoraManager Data Format (__value__ / text), LORA SYNTAX innerHTML Display Bug, Models Batch Group Registration UI (unreleased), Prompt/Workflow Batch Group Registration UI (unreleased), RESERVED_GROUPS Mechanism, SSL CERT_NONE MitM Vulnerability (+9 more)
### Community 54 - "DEVLOG Early History & Manager Registration"
Cohesion: 0.12
Nodes (17): comfyui-image-feeder (external node), __Feeder__ reserved group, Feeder Subtab, static/js/feeder-tab.js, web/comfyui/gallery_feeder_extension.js, py/nodes/gallery_feeder_node.py, Gallery mode (Feeder), Help & Support Tab (+9 more)
### Community 55 - "App Shell & ComfyUI Ecosystem Refs"
Cohesion: 0.12
Nodes (16): static/js/app.js, ComfyUI, static/js/comfyui-client.js, ComfyUI-Lora-Manager (external), ComfyUI Manager, ComfyUI-Workflow-Studio, py/config.py, data/ (fallback data dir) (+8 more)
### Community 56 - "GenerateUI Batch & Workflow Editor Core"
Cohesion: 0.14
Nodes (16): Batch tab (v0.3.18), Bypass/Mute node handling (v0.3.75), static/js/comfyui-editor.js, Create Catalog / Catalog buttons (v0.3.95), Eagle, py/routes/eagle_routes.py, Emphasis weight editing (v0.3.70), Fooocus (external, by lllyasviel) (+8 more)
### Community 59 - "Tagger Tab & Gallery Bulk Actions"
Cohesion: 0.16
Nodes (15): prestartup_script.py Early Env Setup, Tagger Tab, static/js/tagger-tab.js, Unsloth Backend (AI TOOL Tab), Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading) (+7 more)
### Community 60 - "Image Edit Layer Core & Bug Fixes"
Cohesion: 0.16
Nodes (15): async Event Handler e.currentTarget Null-After-Await Pattern, Draw/Mask Layer Disappearing Bug Fix, G'MIC-Qt Filter Tool Integration, Image Edit Layer Lock Feature, LayerManager.js (Layer Compositing Core), Image Edit Save to Gallery Feature, MaskTool.js, Models Tab Overlay Button Not-Updating Bug Fix (+7 more)
### Community 61 - "G'MIC Routes"
Cohesion: 0.21
Nodes (14): _gmic_run_gui(), handle_open(), handle_result(), handle_status(), Application, Request, Response, G'MIC-Qt integration API routes. (+6 more)
### Community 62 - "Ollama Routes"
Cohesion: 0.23
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
### Community 63 - "Gallery Metadata/Service Bridge"
Cohesion: 0.15
Nodes (11): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。, OSで使えない文字を除去し、拡張子が無ければ付与する, 任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。 Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。 (+3 more)
### Community 65 - "Mask Editor One BiRefNet/SAM3 Integration"
Cohesion: 0.15
Nodes (14): store_image API bg_image_b64 Field Rename Compatibility Fix, BiRefNet BG Remove (Mask Editor One Integration), Mask Editor One (comfyui-mask-editor-one Custom Node), Mask Layer Add/Subtract Composite Mode (toggleOperation), MaskTool.js ABR Brush Support (spacing/angle/jitter), Plan: Image Edit Extension — ABR Brush + Mask Editor One Integration, SAM3 Text-Prompt Segmentation (Mask Editor One Integration), Top Bar Icon Disappearance Fix (MutationObserver, top_menu_extension.js) (+6 more)
### Community 66 - "Eagle Routes Backend"
Cohesion: 0.23
Nodes (11): Request, Response, Serve the main SPA page., Serve files from the current workflows directory (dynamic path)., Serve files (index thumbnails) from the Lab plan directory., Main entry point for Workflow Studio plugin., Register all routes with ComfyUI's server., serve_index_page() (+3 more)
### Community 67 - "Prompts API Routes/Service"
Cohesion: 0.25
Nodes (13): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+5 more)
### Community 68 - "Workflow Analyzer"
Cohesion: 0.20
Nodes (13): analyze_workflow(), _clip_type_from_ui_node(), _collect_all_ui_nodes(), _detect_model_type_from_name(), _model_name_from_api_node(), _model_name_from_ui_node(), Workflow analyzer - detects model types and counts input/output nodes., Analyze workflow JSON and return model types, input/output node counts. (+5 more)
### Community 69 - "Gallery Tab Core & Feeder Group"
Cohesion: 0.15
Nodes (14): ComfyUI-Gallery (external, by PanicTitan), Folder tree root label (v0.3.90), py/services/gallery_metadata.py, py/routes/gallery_routes.py, py/services/gallery_service.py, Gallery Tab, static/js/gallery-tab.js, static/js/image-prompt-tab.js (+6 more)
### Community 70 - "AI TOOL Backend & Model Tab Updates"
Cohesion: 0.15
Nodes (13): AI_BACKEND_DEFAULT_URLS / getAiBackendDefaultUrl() Helper (util.js), GenerateUI Model Tab Active Highlight Color Feature, ponyxlWildcardsVault Format Live (Un-imported) Support, reasoning_content Field Handling Bug Fix (Unsloth), SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist), AI TOOL Thinking Mode / Max Tokens Setting, Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention), Unsloth Server-Side Proxy (unsloth_routes.py) (+5 more)
### Community 71 - "Tagger Tab Implementation & Security Fixes"
Cohesion: 0.15
Nodes (13): Gallery Detail Panel UI Improvement (Tab/Action Row Split), Gallery Tag Save Bug Fix (tags.forEach not a function), Path Traversal Security Fix (Tagger + Gallery), Security Fix: XSS Escaping + Path Validation (2026-06-20), Tagger Batch .txt Output Feature, Tagger Single GenUI:P Button, TaggerService / TaggerDbService (Backend), Tagger Tab New Implementation (+5 more)
### Community 72 - "Eagle Routes Backend"
Cohesion: 0.19
Nodes (12): _get_api_key(), handle_proxy(), _is_allowed_base_url(), Application, Request, Response, Unsloth API proxy routes. Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop…, Return the Unsloth API key from the environment (.env), or None. (+4 more)
### Community 73 - "Prompts API Routes/Service"
Cohesion: 0.28
Nodes (3): PromptsService, Prompt presets management service., Manages prompt presets (CRUD, categories, favorites).
### Community 74 - "Metadata & Nodes Tab Core"
Cohesion: 0.17
Nodes (13): CLIPTextEncodeEditPlus edit+ support (v0.3.92), static/js/comfyui-workflow.js, Metadata Tab, static/js/metadata-tab.js, py/services/png_extractor.py, UI-to-API conversion, py/services/workflow_analyzer.py, Gallery Bulk Actions (Group/Favorite/Compare/Move/Export/Delete) (+5 more)
### Community 75 - "AI Tool Tab Frontend"
Cohesion: 0.26
Nodes (12): _applyGenOptions(), callChat(), callLLM(), callVLM(), _clearToolsImage(), fileToBase64(), _formatMessagesForBackend(), initVlmTab() (+4 more)
### Community 76 - "Node Sets Menu & Sidebar"
Cohesion: 0.22
Nodes (13): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), injectStyles() (+5 more)
### Community 77 - "Gallery Feeder Extension (Frontend)"
Cohesion: 0.36
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
### Community 78 - "Gallery Speed & Bulk Features"
Cohesion: 0.20
Nodes (10): Background Daemon Thread Indexing Pattern (asyncio.to_thread + Cancelable Scan), Select All Button + Gallery Bulk Bar i18n, Bulk API Endpoint Pattern (Single Request vs N Parallel), v0.3.44 Gallery Image Compare Mode, v0.3.44 Gallery Prompt Full-Text Search, v0.3.44 Gallery Speedup (Server Thumbnails + Infinite Scroll), v0.3.45 Gallery Shift+Click Range Selection, v0.3.48 Gallery Image Download / Bulk ZIP Export (+2 more)
### Community 79 - "Style Catalog & Batch Loop Core"
Cohesion: 0.20
Nodes (10): Catalog Creation "Don't keep copy in Output" Option, Style/Prompt to ImagePrompt Full Rename, Prompt Tab Style Management Sub-tab (CRUD), Reuse Existing Batch/Selection UI Before Building New, _runBatchLoop() Generic Batch Loop, Backend save_image_to_folder() Generalization, Style Catalog Gallery Sub-tab, trackProgress() Timeout & execution_interrupted Handling Fix (+2 more)
### Community 80 - "Metadata Tab Node Support History"
Cohesion: 0.20
Nodes (10): CLIPLoader Widget Name Bug (clip_name1 to clip_name), model-and-prompt-from-metadata Custom Node Pack, Top Bar Icon Not Showing Bug, v0.3.10: Information Tab Canvas Drag Support, v0.3.11: Metadata Tab Node Type Expansion + Settings Text Size, v0.3.27: model-and-prompt-from-metadata Node Support, v0.3.6: Metadata Tab Addition, v0.3.7: Metadata Tab Flux2/Qwen/Z-Image Support (+2 more)
### Community 82 - "Gallery Feeder Node (Backend)"
Cohesion: 0.27
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
### Community 83 - "App Shell & i18n Init"
Cohesion: 0.22
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
### Community 84 - "Generate Tab Frontend & Modals"
Cohesion: 0.24
Nodes (6): initGenerateTab(), updateStatus(), highlightJSON(), syncJsonHighlight(), syncScroll(), refreshLabLiveDefaults()
### Community 87 - "Node Sets Metadata Extraction"
Cohesion: 0.36
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
### Community 88 - "Node Sets Prompt Extraction"
Cohesion: 0.33
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
### Community 89 - "Inpaint Basic Workflow"
Cohesion: 0.53
Nodes (9): CLIP Text Encode (Prompt) - negative conditioning, CLIP Text Encode (Prompt) - positive conditioning, KSampler node (ddim sampler, 30 steps, cfg 4.0, denoise 1.00), Load Checkpoint node (hayochamixIDXL_v11+_bl checkpoint), Load Image node (clipspace painted mask input), Save Image node (ComfyUI filename prefix), VAE Decode node, VAE Encode (for Inpainting) node with grow_mask_by (+1 more)
### Community 90 - "Gallery Feeder & Batch Features"
Cohesion: 0.22
Nodes (9): __Feeder__ Reserved Group Protection + F Button + Seed Bug Fix, Feeder Tab Gallery Mode (vs Image Loop Mode), Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync), WFS_GalleryFeeder ComfyUI Canvas Controls Extension, WFS_GalleryFeeder Node (Gallery Feeder Feature), Models Tab Thumbnail Update Bug Fix (Cache/Corrupt-File/Disabled-Model), v0.3.28 Batch Tab Sampler/Scheduler Support, v0.3.56 Style Feature (GenerateUI Toolbar & Batch Tab) (+1 more)
### Community 91 - "Gallery Service Background Indexing"
Cohesion: 0.25
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
### Community 92 - "Settings/CivitAI Config Resolution"
Cohesion: 0.28
Nodes (4): Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over…, Merge updates into existing settings., Manages application settings (data/settings.json)., SettingsService
### Community 93 - "Gallery Service Vault Helpers"
Cohesion: 0.22
Nodes (4): 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…, 画像と同名の.txtサイドカーにプロンプトテキストを保存する
### Community 94 - "GenerateUI Lab Tab"
Cohesion: 0.36
Nodes (9): _coreGenerate(), _expandWildcardsInWorkflow(), _annotatedImageRef(), _maybeSaveIndexImageOnRun(), _runLabBatch(), _setRunUiState(), _waitIfPaused(), getEagleSettings() (+1 more)
### Community 95 - "Style Prompt Seed Import Tool"
Cohesion: 0.36
Nodes (8): clean_tags(), import_category(), main(), parse_yaml_leaves(), Path, Style/Prompt ギャラリー用シードデータ投入スクリプト。 comfyui_prompt_gallery / prompt_builder_proto…, comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。 「key:」行の直後が「-…, 1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)
### Community 96 - "FireRed 3D Pose Image Edit Workflow"
Cohesion: 0.32
Nodes (8): 3D Pose Editor node, FireRed-Image-Edit-1.1_fp8mixed_comfy.safetensors, FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors (LoRA), Image Edit (FireRed Image Edit 1.1) node, qwen_2.5_vl_7b_fp8_scaled.safetensors (CLIP), qwen_image_vae.safetensors (VAE), Save Image node, 3D Pose Editor to Image Edit (Fire Red) Workflow
### Community 97 - "Chat Image Generation & Inpaint Bridge"
Cohesion: 0.29
Nodes (8): comfyEditor.applyImageToSlot() Method, "Use Dedicated Workflow" Toggle Pattern (Inpaint/Chat Image Gen Shared Design), generate_image Tool Call (LLM Tool Calling to ComfyUI Generation), tagger_settings.json Missing from Data Export Bug Fix, v0.3.62 Send GenUI Image Button + tagger_settings.json Export Fix, v0.3.71 Inpaint Feature (GenerateUI Image Tab + Image Edit Inpaint Tool), v0.3.73 AI TOOL Chat Tool Calling Image Generation + Checkpoint Safety, v0.3.74 AI TOOL Chat Image Attachment + I2I Bridge
### Community 98 - "Subgraph Workflow Support & Fixes"
Cohesion: 0.25
Nodes (8): detectFormat() App vs UI Judgment Criteria Fix (linearMode), DynamicCombo (COMFY_DYNAMICCOMBO_V3) Flat Dot-Key Input Fix, _flattenSubgraphs() Outer Widget Value Injection Into Inner Nodes, Subgraph Fan-out Fix: inputPortTargets Array + _wfmLiteralInputs, v0.3.78 GenerateUI/Metadata Tab Subgraph Workflow Support (Mage-Flow), v0.3.84 Ernie T2I "source" Validation Error / Subgraph Fan-out Bug, workflow_analyzer.py P:/I:/→ Count and Format Badge Fix, Workflow Tab Summarize: Structured Data Instead of Raw JSON Truncation
### Community 99 - "KREA-2 & Lab Tab Extensions"
Cohesion: 0.25
Nodes (8): DynamicCombo Multi-Slot Consumption Bug (sampling_mode), GenerateUI Input Image Placeholder Feature (Color/Image mode), KREA-2 Turbo Workflow Support, Lab Tab Bypass Checkbox (Per-Keyframe Row Disable), Lab Tab Clear / Get from Previous Buttons, Lab Tab Keyframe #1 Live Default Reflection, Role Propagation Bug: PreviewAny / StringConcatenate Relay Nodes, v0.3.97 KREA-2 Workflow Support + Lab Tab Extension + Input Image Placeholder
### Community 100 - "LoRA Pane Evolution & Help Tab"
Cohesion: 0.29
Nodes (8): GenerateUI Reset Workflow Button + Input Tab Tabify (2026-06-08), Help Tab Search Feature + Font Enlargement + Gallery-mode i18n, Help Tab Sidebar Layout (2-Column), v0.3.29 LoRA Section Integration (Single+Stack Merge), v0.3.30 LoRA Stack Trigger Words Bug Fix, v0.3.31 LoRA Pane Single/Stack Tab Split, v0.3.32 Lora Loader (LoraManager) LoRA Detection Support, v0.3.33 LoRA Pane / Workflow Parse Bug Fixes
### Community 103 - "App Shell & i18n Init"
Cohesion: 0.43
Nodes (7): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), _joinBytes(), _n2b(), _pngCrc32()
### Community 104 - "DWPose Image Feeder Workflow"
Cohesion: 0.52
Nodes (7): DWPose Image Feeder Workflow, Apply ControlNet Node, DWPose Estimator Node (comfyui-controlnet-aux), Image Feeder Node (comfyui-image-feeder), KSampler Node, Load Checkpoint Node (animagineXLRealistic_v6), Load ControlNet Model Node (OpenPoseXL2)
### Community 105 - "AI Tab & RAW JSON Search History"
Cohesion: 0.29
Nodes (7): AI TOOL Tab English Localization, RAW JSON Panel Search Feature, v0.3.12: README Screenshot Refresh + workflow_analyzer Extension, v0.3.13: AI Tab (A) Addition, v0.3.14: AI TOOL Tab 3-Pane Redesign + RAW JSON Colors, v0.3.15: Sample Workflow Bundling + CivitAI Auto Preview, v0.3.16: CivitAI Preview Fallback Display
### Community 106 - "Prompt Extraction Bug Fixes"
Cohesion: 0.33
Nodes (7): ComfySwitchNode Branch Resolution (Prompt Enhancement Toggle), GenerateUI vs Metadata Tab: Separate Non-Synced Analysis Logic, node_sets_menu.js Local Duplication Pattern (web/comfyui/ import restriction), v0.3.79 Side Panel I-Tab Mage-Flow Subgraph Support, v0.3.82 Metadata Tab / Side Panel I-Tab Prompt Extraction Bug (LongCat/Boogu/HiDream E1/FireRed), v0.3.83 Ernie Image Support + Link-Sourced Prompt Pattern Fix, WFS_PromptText Node GenerateUI Prompt Tab Support + Drop Title Fix
### Community 107 - "Comic Creator Bridge & LoRA Batch Fixes"
Cohesion: 0.29
Nodes (7): Development Repo vs Runtime custom_nodes Folder Sync Reminder, _wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS), LoRA Batch Path Separator Mismatch Fix + resolveLoraName(), LoRA Batch Execution Prompt Auto-Reflection + Restore, Prompt Weight Ctrl+Up/Down Editing (A1111-style), v0.3.69 Gallery to Comic Creator "Send CC" Button, v0.3.70 Ollama 404 Fix + Prompt Revert Button + Weight Ctrl+Arrow Editing
### Community 108 - "Settings/CivitAI Config Resolution"
Cohesion: 0.33
Nodes (5): _get_ssl_context(), _make_ssl_context(), CivitAI API integration service., Return an SSL context with CA verification. 1. certifi CA bundle (available in…, Download an image from URL and save to save_path. Returns True on success.
### Community 109 - "Node Sets Menu Model Fetch"
Cohesion: 0.57
Nodes (7): createModelItem(), fetchModelList(), matchesModelSearch(), renderModelAll(), renderModelByType(), renderModelFavorites(), renderModelGroups()
### Community 110 - "G'MIC Integration & Security Fixes"
Cohesion: 0.40
Nodes (6): Draw/Mask Brush Circular Cursor + Off-canvas Drag Support, gmic_qt.exe Argument Order Fix (-o output input), handle_result Path Traversal Fix (gmic/result endpoint), gmic_routes.py (New Backend for G'MIC-Qt Job Handling), v0.3.60 Image Edit Filter Tool (G'MIC-Qt Integration), v0.3.61 G'MIC Bug Fix + Security + Draw/Mask Brush Operability
### Community 111 - "Comic Creator Integration Bridges"
Cohesion: 0.40
Nodes (6): ComfyUI Comic Creator (External iframe-embedded Custom Node), _runInpaintWithImages() / runInpaintExternal() Shared Inpaint Logic, v0.3.72 Comic Creator External Inpaint Entry Point, v0.3.76 Comic Creator I2I Run Bridge (_wfmReceiveI2IRunRequest), v0.3.80 Comic Creator Semi-auto Manga Bridge (LLM Prompt Draft + Batch Gen), v0.3.85 Generate UI Bridge (T2I) Default Workflow + Negative Prompt
### Community 112 - "GenerateUI Model Tab GGUF/Embedding Support"
Cohesion: 0.40
Nodes (6): GenUI Model LoRA Reset-on-Subtab-Switch Bug Fix, v0.3.51 Embedding GenUI PP/NP Support, v0.3.51 GenerateUI Hypernetwork Support, v0.3.51 Models Tab Side Panel GenUI Model Button, Hypernetwork/Embedding Not-Displayed Bug (V3 object_info Format), v0.3.58 GenerateUI GGUF Diffusion Model / Text Encoder Support
### Community 113 - "Bypass Node Handling & Advanced Sampling"
Cohesion: 0.40
Nodes (6): GenerateUI Raw JSON Bypass/Mute Node Indicator, Reroute Node missing_node_type 400 Error Fix (_resolveBypassSource extension), _resolveBypassSource() Bypass Pass-through Link Resolution, _simulateWidgetValues / _stripLegacyLinkedWidgetValues Common Logic Rewrite, v0.3.75 Bypass (mode:4) Node Workflow Generation Failure Fix, v0.3.81 Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Advanced Sampling Support
### Community 114 - "Prompt Text Node"
Cohesion: 0.33
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
### Community 116 - "Gallery Service Vault Helpers"
Cohesion: 0.33
Nodes (3): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)
### Community 117 - "PNG Metadata Extractor"
Cohesion: 0.40
Nodes (4): extract_png_workflow(), Extract ComfyUI workflow JSON from PNG metadata., Extract ComfyUI workflow JSON from PNG byte data. Returns dict or None., Workflow CRUD and metadata management service.
### Community 118 - "AI Skills & Help Redesign"
Cohesion: 0.40
Nodes (5): AI Skills Mechanism (Selectable System Prompt Library), Help Tab Card-Format Redesign (16 Pages), Skill Creator (Interactive Skill Creation Meta-skill), SVG Icon Generator Skill (Shape Decomposition Prompting), v0.3.79 AI TOOL Chat Skills Mechanism + SVG Generation Quality Improvement
### Community 119 - "Prompt Extraction Bug Fixes"
Cohesion: 0.40
Nodes (5): Checkpoint Silent Substitution Bug (getLastCheckpointSubstitutions), CLIPTextEncodeEditPlus Metadata Extraction Gap, convertUiToApi forceInput Widget Misclassification (Root Cause), LoRA Stack: Use Stack Group Checkbox, v0.3.92 convertUiToApi forceInput Bug + LoRA Stack Improvements
### Community 120 - "AI TOOL Chat & Wildcard Features"
Cohesion: 0.40
Nodes (5): v0.3.41 ImpactWildcardEncode/Processor Prompt Detection Bug Fix, v0.3.40 AI TOOL Tab Chat Pane, v0.3.40 AI TOOL Wildcard Generation Feature, v0.3.46 Prompt Tab AI Assistant LM Studio Support, v0.3.49 A1111-style Wildcard Expansion in GenerateUI
### Community 121 - "Config & Service Modules"
Cohesion: 0.40
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
### Community 122 - "Group Feature Path Bug Fixes"
Cohesion: 0.50
Nodes (4): Windows Path Separator Normalization Pattern, v0.3.45 Group Orphan Entry Auto Cleanup, v0.3.47 Models Tab File-Move Group State Fix, v0.3.55 Group Feature Bug Fixes (All Tabs)
### Community 123 - "SVG Generation & Display Support"
Cohesion: 0.67
Nodes (3): AI TOOL Chat Workflow-less SVG Generation/Display/Gallery Save, v0.3.77 Gallery Tab SVG File Display/Management Support, v0.3.78 Eagle Auto-Save SVG Support
### Community 124 - "Send to Canvas Feature"
Cohesion: 1.00
Nodes (3): API-Format Workflow Loads Empty on Canvas Bug Fix, Send to Canvas Feature (Workflow Tab / Gallery Tab Title DnD), Send to Canvas via window.opener Direct Load
### Community 125 - "Module Split Refactor (v0.4.0)"
Cohesion: 0.67
Nodes (3): Callback Injection Pattern (One-way Dependency), Graphify Community Cohesion Analysis, v0.4.0 image-edit-tab.js / models-tab.js Low-Cohesion Module Split
### Community 126 - "CivitAI Detail Panel History"
Cohesion: 0.67
Nodes (3): CivitAI modelUrl Broken Fallback Bug, v0.3.20: CivitAI Detail Panel Enhancement, v0.3.21: CivitAI Host Setting + Sample Subtab
### Community 127 - "GenerateUI Layout & JSON Highlight"
Cohesion: 0.67
Nodes (3): JSONシンタックスハイライト, Raw JSON同期修正, v0.2.4 GenerateUIタブ レイアウト再設計
### Community 128 - "Workflow Tab Bug Fixes"
Cohesion: 0.67
Nodes (3): v0.3.23: Two Bug Fixes, Workflow Batch 404 Endpoint Bug, Workflow Rename Duplicate Listener Bug
## Ambiguous Edges - Review These
- `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` → `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()` [AMBIGUOUS]
DEVLOG.md · relation: references
- `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` → `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)` [AMBIGUOUS]
DEVLOG.md · relation: semantically_similar_to
- `Models Tab Overlay Button Not-Updating Bug Fix` → `v0.3.54 Image Edit Shape Tool` [AMBIGUOUS]
DEVLOG.md · relation: conceptually_related_to
- `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` → `v0.3.28 Batch Tab Sampler/Scheduler Support` [AMBIGUOUS]
DEVLOG.md · relation: conceptually_related_to
## Knowledge Gaps
- **311 isolated node(s):** `AI_BACKEND_DEFAULT_URLS`, `state`, `LANGUAGE_OPTIONS`, `LANGUAGES`, `SUMMARY_LANGUAGE_OPTIONS` (+306 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **52 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **What is the exact relationship between `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` and `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()`?**
_Edge tagged AMBIGUOUS (relation: references) - confidence is low._
- **What is the exact relationship between `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` and `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)`?**
_Edge tagged AMBIGUOUS (relation: semantically_similar_to) - confidence is low._
- **What is the exact relationship between `Models Tab Overlay Button Not-Updating Bug Fix` and `v0.3.54 Image Edit Shape Tool`?**
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
- **What is the exact relationship between `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` and `v0.3.28 Batch Tab Sampler/Scheduler Support`?**
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
- **Why does `showToast()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `Image Edit Layer & Draw Tools`, `GenerateUI Feeder Tab`, `App Shell & i18n Init`, `Nodes Tab Frontend`, `GenerateUI Lab Tab`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `Workflow Tab Frontend & Storage`, `Image Edit Layer Manager Core`, `Style Catalog Tab`, `Image Prompt Tab`, `Image Edit Background Removal`, `Image Edit Blur Tool (BlurTool.js)`, `AI Tool Tab Frontend`, `Image Edit Background Removal (BgRemove.js)`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Image Edit SAM3 Segmentation`, `GenerateUI Lab Tab`, `Image Edit File Export (FileExport.js)`, `Image Edit G'MIC Integration`?**
_High betweenness centrality (0.042) - this node is a cross-community bridge._
- **Why does `t()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `GenerateUI Feeder Tab`, `AI Tool Tab Frontend`, `App Shell & i18n Init`, `Image Prompt Tab`, `GenerateUI Lab Tab`, `Style Catalog Tab`, `Metadata Tab Frontend`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Nodes Tab Frontend`, `Workflow Tab Frontend & Storage`, `ComfyUI Client & Editor`, `GenerateUI Lab Tab`?**
_High betweenness centrality (0.023) - this node is a cross-community bridge._
- **Why does `GalleryService` connect `Gallery Service Core` to `Gallery API Routes`, `Gallery Service Core`, `Gallery Metadata/Service Bridge`, `Gallery Service Vault Helpers`, `Gallery Service Background Indexing`, `Gallery Service Vault Helpers`, `Gallery Metadata Store`, `Gallery Metadata/Service Bridge`?**
_High betweenness centrality (0.018) - this node is a cross-community bridge._
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+842
View File
@@ -0,0 +1,842 @@
{
"__init__.py": {
"mtime": 1781939762.254596,
"seen": 1787194338.8008406,
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
},
"prestartup_script.py": {
"mtime": 1787036267.332648,
"seen": 1787194338.800846,
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
},
"py/__init__.py": {
"mtime": 1773797715.629029,
"seen": 1787194338.8008478,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/config.py": {
"mtime": 1786245317.3352194,
"seen": 1787194338.800849,
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
},
"py/nodes/__init__.py": {
"mtime": 1774423045.6646452,
"seen": 1787194338.8008502,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/nodes/gallery_feeder_node.py": {
"mtime": 1781943269.5205867,
"seen": 1787194338.8008513,
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
},
"py/nodes/prompt_text.py": {
"mtime": 1774423045.6669831,
"seen": 1787194338.8008525,
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
},
"py/routes/__init__.py": {
"mtime": 1773797716.663036,
"seen": 1787194338.8008535,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/routes/eagle_routes.py": {
"mtime": 1785464911.9023821,
"seen": 1787194338.800855,
"ast_hash": "edd32039935a892317c55b434c9839ab",
"semantic_hash": "edd32039935a892317c55b434c9839ab"
},
"py/routes/gallery_routes.py": {
"mtime": 1786682156.6899943,
"seen": 1787194338.800856,
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
},
"py/routes/gmic_routes.py": {
"mtime": 1782544784.03979,
"seen": 1787194338.8008568,
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
},
"py/routes/lab_routes.py": {
"mtime": 1786322410.571655,
"seen": 1787194338.8008578,
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
},
"py/routes/models_routes.py": {
"mtime": 1782289767.1073005,
"seen": 1787194338.8008592,
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
},
"py/routes/nodes_routes.py": {
"mtime": 1774255599.0841086,
"seen": 1787194338.8008602,
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
},
"py/routes/ollama_routes.py": {
"mtime": 1784126582.1613884,
"seen": 1787194338.800861,
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
},
"py/routes/prompts_routes.py": {
"mtime": 1774423045.6696556,
"seen": 1787194338.8008618,
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
},
"py/routes/settings_routes.py": {
"mtime": 1786681377.025748,
"seen": 1787194338.8008628,
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
},
"py/routes/skill_routes.py": {
"mtime": 1785634791.033795,
"seen": 1787194338.800864,
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
},
"py/routes/tagger_routes.py": {
"mtime": 1781503496.8879485,
"seen": 1787194338.8008647,
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
},
"py/routes/unsloth_routes.py": {
"mtime": 1787036726.267709,
"seen": 1787194338.8008657,
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
},
"py/routes/wildcard_routes.py": {
"mtime": 1777370036.4121041,
"seen": 1787194338.8008666,
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
},
"py/routes/workflow_routes.py": {
"mtime": 1773896239.095392,
"seen": 1787194338.8008673,
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
},
"py/services/__init__.py": {
"mtime": 1773797717.739297,
"seen": 1787194338.800868,
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"py/services/civitai_service.py": {
"mtime": 1780233122.7587051,
"seen": 1787194338.8008692,
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
},
"py/services/gallery_metadata.py": {
"mtime": 1786170828.9941547,
"seen": 1787194338.80087,
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
},
"py/services/gallery_service.py": {
"mtime": 1786638406.9058151,
"seen": 1787194338.800871,
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
},
"py/services/lab_plan_service.py": {
"mtime": 1786322396.0192728,
"seen": 1787194338.8008716,
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
},
"py/services/models_service.py": {
"mtime": 1782393318.5989842,
"seen": 1787194338.8008728,
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
},
"py/services/nodes_service.py": {
"mtime": 1774255599.0862236,
"seen": 1787194338.8008735,
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
},
"py/services/png_extractor.py": {
"mtime": 1773797967.5810304,
"seen": 1787194338.8008747,
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
},
"py/services/prompts_service.py": {
"mtime": 1774423045.6711988,
"seen": 1787194338.8008757,
"ast_hash": "79a98e220d2b556106e8530054547155",
"semantic_hash": "79a98e220d2b556106e8530054547155"
},
"py/services/settings_service.py": {
"mtime": 1773798498.9270506,
"seen": 1787194338.8008769,
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
},
"py/services/skill_service.py": {
"mtime": 1785637306.8654232,
"seen": 1787194338.8008778,
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
},
"py/services/tagger_db_service.py": {
"mtime": 1781503496.8949444,
"seen": 1787194338.8008785,
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
},
"py/services/tagger_service.py": {
"mtime": 1781503496.8969479,
"seen": 1787194338.8008797,
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
},
"py/services/wildcard_service.py": {
"mtime": 1777380000.0054882,
"seen": 1787194338.8008807,
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
},
"py/services/workflow_analyzer.py": {
"mtime": 1785474766.4736228,
"seen": 1787194338.8008814,
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
},
"py/services/workflow_service.py": {
"mtime": 1775439760.4805496,
"seen": 1787194338.8008823,
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
},
"py/wfm.py": {
"mtime": 1787036294.0749958,
"seen": 1787194338.8008835,
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
},
"pyproject.toml": {
"mtime": 1787193198.5117335,
"seen": 1787194338.8008842,
"ast_hash": "de0f37033a118144dc86368704dc7a34",
"semantic_hash": "de0f37033a118144dc86368704dc7a34"
},
"static/js/ai-tab.js": {
"mtime": 1787041206.037751,
"seen": 1787194338.8008852,
"ast_hash": "4103e9c646b3011bf5ce42d77487497c",
"semantic_hash": "4103e9c646b3011bf5ce42d77487497c"
},
"static/js/app.js": {
"mtime": 1787192745.6783228,
"seen": 1787194338.8008862,
"ast_hash": "b73d44820900bb125886ea51cf879475",
"semantic_hash": "b73d44820900bb125886ea51cf879475"
},
"static/js/comfyui-client.js": {
"mtime": 1786676208.2330563,
"seen": 1787194338.8008873,
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
},
"static/js/comfyui-editor.js": {
"mtime": 1786975595.7088158,
"seen": 1787194338.800888,
"ast_hash": "2e378bbfd83ae94e3e068b48bec8676c",
"semantic_hash": "2e378bbfd83ae94e3e068b48bec8676c"
},
"static/js/comfyui-workflow.js": {
"mtime": 1786930771.5730827,
"seen": 1787194338.8008888,
"ast_hash": "c5f980df6dddaa9e5374d7ddc2643098",
"semantic_hash": "c5f980df6dddaa9e5374d7ddc2643098"
},
"static/js/feeder-tab.js": {
"mtime": 1781939989.0796032,
"seen": 1787194338.80089,
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
},
"static/js/gallery-tab.js": {
"mtime": 1786638406.9234293,
"seen": 1787194338.800891,
"ast_hash": "a5469191f6f41638aa43decce8ee252c",
"semantic_hash": "a5469191f6f41638aa43decce8ee252c"
},
"static/js/generate-tab.js": {
"mtime": 1786932592.3725004,
"seen": 1787194338.8008919,
"ast_hash": "58f05ad49f87dfd8cff67290e739c3a8",
"semantic_hash": "58f05ad49f87dfd8cff67290e739c3a8"
},
"static/js/i18n.js": {
"mtime": 1787192763.662967,
"seen": 1787194338.8008926,
"ast_hash": "b626aca2093efe87d899d57bc02ce80d",
"semantic_hash": "b626aca2093efe87d899d57bc02ce80d"
},
"static/js/image-edit-tab.js": {
"mtime": 1787192013.6171837,
"seen": 1787194338.8008935,
"ast_hash": "bda888263a8f3aa325c0da5b688d75cb",
"semantic_hash": "bda888263a8f3aa325c0da5b688d75cb"
},
"static/js/image-edit/DrawTool.js": {
"mtime": 1782717315.6436062,
"seen": 1787194338.8008964,
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
},
"static/js/image-edit/LayerManager.js": {
"mtime": 1782717943.772222,
"seen": 1787194338.8009,
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
},
"static/js/image-edit/MaskEditorOneTools.js": {
"mtime": 1782719665.7021136,
"seen": 1787194338.8009021,
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
},
"static/js/image-edit/MaskTool.js": {
"mtime": 1782699721.4711535,
"seen": 1787194338.8009028,
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
},
"static/js/image-edit/SelectTool.js": {
"mtime": 1782368193.2572634,
"seen": 1787194338.800905,
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
},
"static/js/image-edit/ShapeTool.js": {
"mtime": 1782375200.0253434,
"seen": 1787194338.800906,
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
},
"static/js/image-edit/TextTool.js": {
"mtime": 1782368193.2592669,
"seen": 1787194338.800907,
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
},
"static/js/image-prompt-tab.js": {
"mtime": 1786638406.9362252,
"seen": 1787194338.8009079,
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
},
"static/js/json-highlight.js": {
"mtime": 1773834925.1977189,
"seen": 1787194338.8009088,
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
},
"static/js/lab-tab.js": {
"mtime": 1786933679.1300104,
"seen": 1787194338.80091,
"ast_hash": "f04c1d830856c01cc03945857d39bd9b",
"semantic_hash": "f04c1d830856c01cc03945857d39bd9b"
},
"static/js/metadata-tab.js": {
"mtime": 1786929497.4063816,
"seen": 1787194338.800911,
"ast_hash": "989f56e4b0eff94923fc73c75a6ce756",
"semantic_hash": "989f56e4b0eff94923fc73c75a6ce756"
},
"static/js/models-tab.js": {
"mtime": 1787145102.2314196,
"seen": 1787194338.8009121,
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
},
"static/js/nodes-tab.js": {
"mtime": 1782619197.3828287,
"seen": 1787194338.8009202,
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
},
"static/js/prompt-tab.js": {
"mtime": 1786681377.2302153,
"seen": 1787194338.8009212,
"ast_hash": "9c66fbb4c615e61c3b3ef1220ab20253",
"semantic_hash": "9c66fbb4c615e61c3b3ef1220ab20253"
},
"static/js/settings-tab.js": {
"mtime": 1786975595.7637193,
"seen": 1787194338.8009222,
"ast_hash": "b88c87057ac42e7cbca22cdf1dc522f7",
"semantic_hash": "b88c87057ac42e7cbca22cdf1dc522f7"
},
"static/js/style-catalog-tab.js": {
"mtime": 1786678976.6511285,
"seen": 1787194338.8009229,
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
},
"static/js/tagger-tab.js": {
"mtime": 1781503496.933307,
"seen": 1787194338.8009238,
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
},
"static/js/util.js": {
"mtime": 1787036300.7369778,
"seen": 1787194338.8009248,
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
},
"static/js/workflow-tab.js": {
"mtime": 1785474064.011484,
"seen": 1787194338.8009257,
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
},
"tools/import_style_prompt_seed.py": {
"mtime": 1786616195.405381,
"seen": 1787194338.8009267,
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
},
"web/comfyui/gallery_feeder_extension.js": {
"mtime": 1781943835.232655,
"seen": 1787194338.8009274,
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
},
"web/comfyui/node_sets_menu.js": {
"mtime": 1787192023.3791485,
"seen": 1787194338.8009284,
"ast_hash": "81c0d1363ee69d952ad16a294c6e8a8d",
"semantic_hash": "81c0d1363ee69d952ad16a294c6e8a8d"
},
"web/comfyui/top_menu_extension.js": {
"mtime": 1782696309.9055538,
"seen": 1787194338.8009293,
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
},
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
"mtime": 1779256606.5720088,
"seen": 1787194338.8009303,
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
"mtime": 1779257129.3572881,
"seen": 1787194338.8009312,
"ast_hash": "8260ca467693c21c497f98113abf37f9",
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
"mtime": 1779257864.2496417,
"seen": 1787194338.8009322,
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
},
"workflows/ws_dwpose_image_feeder.json": {
"mtime": 1779255324.1402953,
"seen": 1787194338.8009331,
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
},
"workflows/ws_inpaint_basic.json": {
"mtime": 1784801838.7836998,
"seen": 1787194338.8009338,
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
},
"workflows/ws_inpaint_mask_editor_one.json": {
"mtime": 1784801946.0336668,
"seen": 1787194338.800935,
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
},
"workflows/ws_sd15_t2i_basic.json": {
"mtime": 1779253098.1947012,
"seen": 1787194338.800936,
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
},
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
"mtime": 1779256158.4671156,
"seen": 1787194338.800937,
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
},
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
"mtime": 1779255768.6302803,
"seen": 1787194338.800938,
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
},
"workflows/ws_sdxl_dwpose.json": {
"mtime": 1779255037.8698418,
"seen": 1787194338.800939,
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
},
"workflows/ws_sdxl_from_metadata_t2i.json": {
"mtime": 1779254360.9712114,
"seen": 1787194338.8009398,
"ast_hash": "463fca53dd0a4030555ff639661f2772",
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
},
"workflows/ws_sdxl_i2i_facedetailer.json": {
"mtime": 1779253668.0067616,
"seen": 1787194338.8009408,
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
},
"workflows/ws_sdxl_image_feeder_i2i.json": {
"mtime": 1779254714.0149894,
"seen": 1787194338.8009415,
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
},
"workflows/ws_sdxl_t2i_basic.json": {
"mtime": 1779253180.3940487,
"seen": 1787194338.8009427,
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
},
"workflows/ws_sdxl_t2i_facedetaller.json": {
"mtime": 1779253367.7975926,
"seen": 1787194338.8009434,
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
},
"templates/index.html": {
"mtime": 1787192740.1538603,
"seen": 1787194338.8009465,
"ast_hash": "0b51d18f3fa91d4be929d72a499e669f",
"semantic_hash": "0b51d18f3fa91d4be929d72a499e669f"
},
"assets/banner.svg": {
"mtime": 1776081443.419675,
"seen": 1787114579.030294,
"ast_hash": "059ec317eaba9e353d220d883477927a",
"semantic_hash": "059ec317eaba9e353d220d883477927a"
},
"assets/icon.svg": {
"mtime": 1776081443.4219253,
"seen": 1787114579.030295,
"ast_hash": "9689aa0178236c19d5240250e08a303e",
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
},
"docs/10_GenUI_Batch.png": {
"mtime": 1781057457.561953,
"seen": 1787114579.0302958,
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
},
"docs/11_multiple_select_menu.png": {
"mtime": 1781057528.0140572,
"seen": 1787114579.0302966,
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
},
"docs/12_Tagger.png": {
"mtime": 1782551031.7107866,
"seen": 1787114579.030297,
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
},
"docs/13_ImageEdit.png": {
"mtime": 1782550946.6263509,
"seen": 1787114579.030298,
"ast_hash": "25a313806ba3c2396c9361b42599b063",
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
},
"docs/1_workflowtab.png": {
"mtime": 1779078100.5980048,
"seen": 1787114579.0302987,
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
},
"docs/2_modelstab.png": {
"mtime": 1779077971.8302422,
"seen": 1787114579.0302994,
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
},
"docs/3_PromptInputAssistance.png": {
"mtime": 1779077804.0445502,
"seen": 1787114579.0303004,
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
},
"docs/4_GenUI_feeder.png": {
"mtime": 1779077729.1484556,
"seen": 1787114579.0303009,
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
},
"docs/5_topbar.png": {
"mtime": 1779076905.6012611,
"seen": 1787114579.0303018,
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
},
"docs/6_ws_library.png": {
"mtime": 1781057870.2308166,
"seen": 1787114579.0303028,
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
},
"docs/7_library_Infomation.png": {
"mtime": 1779076410.2629254,
"seen": 1787114579.0303032,
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
},
"docs/8_Customize.png": {
"mtime": 1779077388.8494852,
"seen": 1787114579.030304,
"ast_hash": "c9f979d40753ce41c015b2d594007658",
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
},
"docs/9_GenUI_LoraStack.png": {
"mtime": 1781057821.3736062,
"seen": 1787114579.030305,
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
},
"docs/thumb.png": {
"mtime": 1778457744.7955222,
"seen": 1787114579.0303059,
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
},
"static/favicon.svg": {
"mtime": 1773877378.0368707,
"seen": 1787114579.0303063,
"ast_hash": "0cd547c25c922d632890be287d6084ff",
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
},
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
"mtime": 1779256606.562932,
"seen": 1787114579.0303073,
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
"mtime": 1779257129.3453796,
"seen": 1787114579.0303097,
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
},
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
"mtime": 1779257864.2375357,
"seen": 1787114579.0303104,
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
},
"workflows/ws_dwpose_image_feeder.png": {
"mtime": 1779255324.1356418,
"seen": 1787114579.0303113,
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
},
"workflows/ws_inpaint_basic.png": {
"mtime": 1784801838.7777002,
"seen": 1787114579.0303123,
"ast_hash": "79321edb85aed7c0a8c777accc005486",
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
},
"workflows/ws_inpaint_mask_editor_one.png": {
"mtime": 1784801946.027666,
"seen": 1787114579.030313,
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
},
"workflows/ws_sd15_t2i_basic.png": {
"mtime": 1779253098.1897035,
"seen": 1787114579.0303137,
"ast_hash": "9582bb698439fff1a748315145538777",
"semantic_hash": "9582bb698439fff1a748315145538777"
},
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
"mtime": 1779256158.4621174,
"seen": 1787114579.0303144,
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
},
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
"mtime": 1779255768.6191878,
"seen": 1787114579.0303156,
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
},
"workflows/ws_sdxl_dwpose.png": {
"mtime": 1779255037.8606594,
"seen": 1787114579.0303164,
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
},
"workflows/ws_sdxl_from_metadata_t2i.png": {
"mtime": 1779254360.9671092,
"seen": 1787114579.030317,
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
},
"workflows/ws_sdxl_i2i_facedetailer.png": {
"mtime": 1779253668.0007572,
"seen": 1787114579.0303178,
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
},
"workflows/ws_sdxl_image_feeder_i2i.png": {
"mtime": 1779254714.0099926,
"seen": 1787114579.0303185,
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
},
"workflows/ws_sdxl_t2i_basic.png": {
"mtime": 1779253180.3890488,
"seen": 1787114579.0303195,
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
},
"workflows/ws_sdxl_t2i_facedetaller.png": {
"mtime": 1779253367.7925913,
"seen": 1787114579.0303202,
"ast_hash": "51cae742b39316023cf5332f1d794854",
"semantic_hash": "51cae742b39316023cf5332f1d794854"
},
"static/js/image-edit/BgRemove.js": {
"mtime": 1787126632.8601232,
"seen": 1787194338.8008947,
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
},
"static/js/image-edit/BlurTool.js": {
"mtime": 1787126263.0542421,
"seen": 1787194338.8008955,
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
},
"static/js/image-edit/FileExport.js": {
"mtime": 1787192008.741717,
"seen": 1787194338.8008974,
"ast_hash": "cf645b775e537ad276c9e070c7849349",
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
},
"static/js/image-edit/GmicIntegration.js": {
"mtime": 1787125777.3889306,
"seen": 1787194338.8008983,
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
},
"static/js/image-edit/InpaintI2IActions.js": {
"mtime": 1787127273.4351766,
"seen": 1787194338.800899,
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
},
"static/js/image-edit/Sam3Segmentation.js": {
"mtime": 1787126893.774897,
"seen": 1787194338.800904,
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
},
".github/workflows/publish.yml": {
"mtime": 1778460176.5699222,
"seen": 1787130571.383575,
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
},
"CLAUDE.md": {
"mtime": 1786327833.871171,
"seen": 1787130571.3835762,
"ast_hash": "c36546d2d007859555904d89e55a8ed7",
"semantic_hash": "c36546d2d007859555904d89e55a8ed7"
},
"DEVLOG.md": {
"mtime": 1787193273.171442,
"seen": 1787194338.8009443,
"ast_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4",
"semantic_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4"
},
"README.md": {
"mtime": 1787193228.4769182,
"seen": 1787194338.8009455,
"ast_hash": "858c9d43205808ad6a111defeb66859c",
"semantic_hash": "858c9d43205808ad6a111defeb66859c"
},
"requirements.txt": {
"mtime": 1787036261.1817627,
"seen": 1787130571.3835788,
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
},
"static/js/models/badges.js": {
"mtime": 1787143926.3653927,
"seen": 1787194338.800913,
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
},
"static/js/models/detail-panel.js": {
"mtime": 1787144898.6748104,
"seen": 1787194338.8009143,
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
},
"static/js/models/filters.js": {
"mtime": 1787144201.7602522,
"seen": 1787194338.8009152,
"ast_hash": "afd754f68d01f64c51995929b3841e56",
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
},
"static/js/models/grid-view.js": {
"mtime": 1787144745.5522888,
"seen": 1787194338.8009164,
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
},
"static/js/models/helpers.js": {
"mtime": 1787144193.741004,
"seen": 1787194338.8009179,
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
},
"static/js/models/selection-bulk.js": {
"mtime": 1787144787.0092065,
"seen": 1787194338.8009186,
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
},
"static/js/models/state.js": {
"mtime": 1787143674.705312,
"seen": 1787194338.8009193,
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
},
"static/js/image-edit/MaskEditorOneBridge.js": {
"mtime": 1787187872.7481277,
"seen": 1787194338.8009012,
"ast_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a",
"semantic_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a"
}
}
+24
View File
@@ -0,0 +1,24 @@
"""Runs before ComfyUI imports any custom node — the only place early enough
to populate os.environ for code that reads env vars at import/request time.
Loads a .env file (if present) from this plugin's root directory so the
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
settings.json or exposing it to the frontend.
"""
from pathlib import Path
PLUGIN_DIR = Path(__file__).resolve().parent
ENV_FILE = PLUGIN_DIR / ".env"
if ENV_FILE.exists():
try:
from dotenv import load_dotenv
load_dotenv(ENV_FILE)
except ImportError:
print(
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
)
except Exception as e:
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
+1
View File
@@ -70,6 +70,7 @@ PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
WILDCARD_DIR = DATA_DIR / "wildcard"
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
# Tagger
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
+99 -32
View File
@@ -2,11 +2,9 @@
Gallery Routes - ギャラリータブ用APIエンドポイント
"""
import asyncio
import base64
import json
import mimetypes
import logging
import re
from pathlib import Path
from aiohttp import web
@@ -43,6 +41,16 @@ def _init_allowed_root() -> None:
except Exception as e:
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
# ImagePrompt/Style Catalogギャラリー用に実outputフォルダを常に別途保持する
# (Settingsで_allowed_rootが別フォルダに変更されていても影響しないようにするため)
try:
import folder_paths # type: ignore
output_dir = folder_paths.get_output_directory()
if output_dir and Path(output_dir).is_dir():
_service.set_comfy_output_root(output_dir)
except Exception as e:
logger.warning("GalleryRoutes: failed to get ComfyUI output dir for image-prompt root: %s", e)
_init_allowed_root()
@@ -55,7 +63,12 @@ def setup_routes(app: web.Application):
app.router.add_get("/wfm/gallery/image/serve", serve_image)
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
app.router.add_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
app.router.add_get("/wfm/gallery/style-catalog/root", get_style_catalog_root)
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
app.router.add_post("/wfm/gallery/image/save-to-folder", save_image_to_folder_route)
app.router.add_post("/wfm/gallery/output-image/delete", delete_output_image)
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
app.router.add_get("/wfm/gallery/groups", list_groups)
app.router.add_post("/wfm/gallery/groups", create_group)
@@ -104,6 +117,7 @@ async def list_images(request: web.Request) -> web.Response:
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
tag_filter = request.rel_url.query.get("tag", "")
group_filter = request.rel_url.query.get("group", "")
recursive = request.rel_url.query.get("recursive", "false") == "true"
if not folder:
return web.json_response({"error": "folder parameter required"}, status=400)
@@ -117,6 +131,7 @@ async def list_images(request: web.Request) -> web.Response:
favorite_only=favorite_only,
tag_filter=tag_filter,
group_filter=group_filter,
recursive=recursive,
)
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
if not search:
@@ -248,6 +263,36 @@ async def save_image_meta(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
async def get_image_prompt_root(request: web.Request) -> web.Response:
"""GET /wfm/gallery/image-prompt/root — ImagePromptギャラリーのルートフォルダを返す"""
root = _service.get_image_prompt_root()
if root is None:
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
return web.json_response({"root": root})
async def save_image_prompt(request: web.Request) -> web.Response:
"""POST /wfm/gallery/image/image-prompt — 画像の.txtサイドカーにプロンプトを保存"""
try:
body = await request.json()
img_path = body.get("path", "")
prompt = body.get("prompt", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
ok = _service.save_image_prompt(img_path, prompt)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def get_style_catalog_root(request: web.Request) -> web.Response:
"""GET /wfm/gallery/style-catalog/root — Style Catalogギャラリーのルートフォルダを返す"""
root = _service.get_style_catalog_root()
if root is None:
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
return web.json_response({"root": root})
async def toggle_favorite(request: web.Request) -> web.Response:
try:
body = await request.json()
@@ -260,15 +305,6 @@ async def toggle_favorite(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
_SAVE_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
"image/svg+xml": ".svg",
}
async def save_image_to_gallery(request: web.Request) -> web.Response:
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
@@ -283,33 +319,64 @@ async def save_image_to_gallery(request: web.Request) -> web.Response:
if _service._allowed_root is None:
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
# data URL から MIME タイプを判定し、拡張子を決定する
raw = image_data
mime = "image/png"
if raw.startswith("data:"):
header, _, raw = raw.partition(",")
m = re.match(r"data:([^;]+)", header)
if m:
mime = m.group(1)
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
result = _service.save_image_to_folder(str(_service._allowed_root), filename, image_data)
if not result["ok"]:
return web.json_response({"error": result["error"]}, status=400)
# ファイル名サニタイズ(OSで使えない文字を除去)
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
if not safe.lower().endswith(ext):
safe += ext
image_bytes = base64.b64decode(raw)
save_path = _service._allowed_root / safe
save_path.write_bytes(image_bytes)
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
return web.json_response({"ok": True, "path": str(save_path)})
logger.info("save_image_to_gallery: saved %s", result["path"])
return web.json_response({"ok": True, "path": result["path"]})
except Exception as e:
logger.error("save_image_to_gallery error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def save_image_to_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/image/save-to-folder — 任意フォルダへ画像を保存(同名なら上書き)。
Style Catalogのカタログ作成で、スタイル名をファイル名として使う際に利用する。"""
try:
body = await request.json()
folder = body.get("folder", "").strip()
filename = body.get("filename", "").strip()
image_data = body.get("imageData", "")
if not folder or not filename or not image_data:
return web.json_response({"error": "folder, filename and imageData required"}, status=400)
result = _service.save_image_to_folder(folder, filename, image_data)
if not result["ok"]:
return web.json_response({"error": result["error"]}, status=400)
return web.json_response({"ok": True, "path": result["path"]})
except Exception as e:
logger.error("save_image_to_folder_route error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def delete_output_image(request: web.Request) -> web.Response:
"""POST /wfm/gallery/output-image/delete — ComfyUI標準の画像参照(filename/subfolder/type)から
実ファイルを削除する。Style Catalogのカタログ作成で「Outputフォルダに画像を残さない」
オプションが有効な時、カタログへコピー保存した直後の元画像を消すために使う。
type="output" 以外は拒否する(temp/inputの誤削除を防止)。"""
try:
body = await request.json()
filename = (body.get("filename") or "").strip()
subfolder = body.get("subfolder") or ""
img_type = body.get("type") or ""
if not filename or img_type != "output":
return web.json_response({"error": "filename required and type must be 'output'"}, status=400)
import folder_paths # type: ignore
output_dir = Path(folder_paths.get_output_directory())
target = output_dir / subfolder / filename
result = await asyncio.to_thread(_service.delete_images, [str(target)])
if result["deleted"]:
return web.json_response({"ok": True})
return web.json_response({"error": "; ".join(result["errors"]) or "File not found"}, status=404)
except Exception as e:
logger.error("delete_output_image error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
+85
View File
@@ -0,0 +1,85 @@
"""Lab tab plan file management API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import LAB_PLAN_DIR
from ..services.lab_plan_service import LabPlanService
logger = logging.getLogger(__name__)
_service = LabPlanService(LAB_PLAN_DIR)
def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/lab/plans", handle_list)
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
app.router.add_post("/api/wfm/lab/index-image/save-to-output", handle_save_index_to_output)
async def handle_list(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.list_plans)
return web.json_response(result)
except Exception as e:
logger.error("Error listing lab plans: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_content(request: web.Request) -> web.Response:
filename = request.rel_url.query.get("filename", "")
try:
data = await asyncio.to_thread(_service.get_plan, filename)
return web.json_response({"data": data})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error getting lab plan content: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
data = body.get("data", {})
index_image_base64 = body.get("index_image_base64")
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
return web.json_response({"status": "ok", **result})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error saving lab plan: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete(request: web.Request) -> web.Response:
try:
body = await request.json()
filename = body.get("filename", "")
await asyncio.to_thread(_service.delete_plan, filename)
return web.json_response({"status": "ok"})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error deleting lab plan: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_index_to_output(request: web.Request) -> web.Response:
try:
body = await request.json()
image_base64 = body.get("image_base64", "")
if not image_base64:
return web.json_response({"error": "image_base64 required"}, status=400)
result = await asyncio.to_thread(_service.save_index_image_to_output, image_base64)
return web.json_response({"status": "ok", **result})
except Exception as e:
logger.error("Error saving lab index image to output: %s", e)
return web.json_response({"error": str(e)}, status=500)
+155 -6
View File
@@ -38,6 +38,9 @@ def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/settings/export", handle_export)
app.router.add_post("/api/wfm/settings/import", handle_import)
app.router.add_get("/api/wfm/styles", handle_get_styles)
app.router.add_post("/api/wfm/styles", handle_create_style)
app.router.add_put("/api/wfm/styles/{name}", handle_update_style)
app.router.add_delete("/api/wfm/styles/{name}", handle_delete_style)
async def handle_get(request: web.Request) -> web.Response:
@@ -245,18 +248,28 @@ async def handle_import(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
def _load_styles() -> list:
"""Load all style JSON files from DATA_DIR/style/ directory."""
def _style_files() -> list:
style_dir = DATA_DIR / "style"
styles = []
if not style_dir.is_dir():
return styles
for json_file in sorted(style_dir.glob("*.json")):
return []
return sorted(style_dir.glob("*.json"))
def _load_styles() -> list:
"""Load all style JSON files from DATA_DIR/style/ directory.
各エントリに定義元ファイル名を "file" として付与する(Promptタブの一覧表示・
「このファイルへ追加」機能で使用)。"""
styles = []
for json_file in _style_files():
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
styles.extend(data)
for entry in data:
if isinstance(entry, dict):
entry = dict(entry)
entry["file"] = json_file.name
styles.append(entry)
except Exception as e:
logger.warning("Failed to load style file %s: %s", json_file.name, e)
return styles
@@ -270,3 +283,139 @@ async def handle_get_styles(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error loading styles: %s", e)
return web.json_response({"error": str(e)}, status=500)
# Promptタブ「Style」サブタブでの新規作成分の保存先。既存スタイルの編集は
# 元々そのスタイルが記載されているファイルをそのまま書き換える(他ファイルへは移動しない)。
_CUSTOM_STYLE_FILENAME = "custom.json"
def _find_style(name: str):
"""名前が一致する最初のスタイルを探し、(ファイルパス, そのファイルの全データ, インデックス) を返す。
見つからなければ None。"""
for json_file in _style_files():
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.warning("Failed to load style file %s: %s", json_file.name, e)
continue
if not isinstance(data, list):
continue
for i, entry in enumerate(data):
if isinstance(entry, dict) and entry.get("name") == name:
return json_file, data, i
return None
def _write_style_file(path: Path, data: list) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
async def handle_create_style(request: web.Request) -> web.Response:
"""POST /api/wfm/styles - 新規スタイルを作成する。
body.file を指定すると既存のそのファイルへ追記する(Promptタブの「このファイルへ追加」用、
ファイル名はstyleディレクトリに実在するもののみ許可しパストラバーサルを防ぐ)。
未指定ならデフォルトのcustom.jsonへ追記する。"""
try:
body = await request.json()
name = (body.get("name") or "").strip()
prompt = body.get("prompt", "") or ""
negative_prompt = body.get("negative_prompt", "") or ""
target_file = (body.get("file") or "").strip()
if not name:
return web.json_response({"error": "name is required"}, status=400)
def _create():
if _find_style(name) is not None:
return "duplicate"
style_dir = DATA_DIR / "style"
style_dir.mkdir(parents=True, exist_ok=True)
if target_file:
path = next((f for f in _style_files() if f.name == target_file), None)
if path is None:
return "file_not_found"
else:
path = style_dir / _CUSTOM_STYLE_FILENAME
data = []
if path.is_file():
try:
with open(path, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, list):
data = loaded
except Exception as e:
logger.warning("Failed to load %s, recreating: %s", path.name, e)
data.append({"name": name, "prompt": prompt, "negative_prompt": negative_prompt})
_write_style_file(path, data)
return "ok"
result = await asyncio.to_thread(_create)
if result == "duplicate":
return web.json_response({"error": f'Style "{name}" already exists'}, status=400)
if result == "file_not_found":
return web.json_response({"error": f'File "{target_file}" not found'}, status=400)
return web.json_response({"ok": True})
except Exception as e:
logger.error("Error creating style: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_update_style(request: web.Request) -> web.Response:
"""PUT /api/wfm/styles/{name} - 既存スタイルを更新する(記載元のファイルをそのまま書き換え、リネームも可)。"""
try:
original_name = request.match_info.get("name", "")
body = await request.json()
new_name = (body.get("name") or "").strip()
prompt = body.get("prompt", "") or ""
negative_prompt = body.get("negative_prompt", "") or ""
if not new_name:
return web.json_response({"error": "name is required"}, status=400)
def _update():
found = _find_style(original_name)
if found is None:
return "not_found"
path, data, idx = found
if new_name != original_name and _find_style(new_name) is not None:
return "duplicate"
data[idx] = {"name": new_name, "prompt": prompt, "negative_prompt": negative_prompt}
_write_style_file(path, data)
return "ok"
result = await asyncio.to_thread(_update)
if result == "not_found":
return web.json_response({"error": "Style not found"}, status=404)
if result == "duplicate":
return web.json_response({"error": f'Style "{new_name}" already exists'}, status=400)
return web.json_response({"ok": True})
except Exception as e:
logger.error("Error updating style: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_style(request: web.Request) -> web.Response:
"""DELETE /api/wfm/styles/{name} - スタイルを削除する(記載元のファイルから該当エントリのみ除去)。"""
try:
name = request.match_info.get("name", "")
def _delete():
found = _find_style(name)
if found is None:
return False
path, data, idx = found
del data[idx]
_write_style_file(path, data)
return True
ok = await asyncio.to_thread(_delete)
if not ok:
return web.json_response({"error": "Style not found"}, status=404)
return web.json_response({"ok": True})
except Exception as e:
logger.error("Error deleting style: %s", e)
return web.json_response({"error": str(e)}, status=500)
+99
View File
@@ -0,0 +1,99 @@
"""Unsloth API proxy routes.
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
never sent to the frontend — the frontend calls this proxy with the target
path/method/payload, and the server attaches the Authorization header before
relaying to Unsloth's OpenAI-compatible API.
"""
import json
import logging
import os
import urllib.request
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from aiohttp import web
logger = logging.getLogger(__name__)
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
# baseUrl is client-supplied (so a custom Unsloth port works), but the
# Authorization header carries a real secret — restrict the host to loopback
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
# server (SSRF). Port is unrestricted.
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _get_api_key():
"""Return the Unsloth API key from the environment (.env), or None."""
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
def _is_allowed_base_url(base_url):
"""Only relay (and attach the API key) to a loopback host."""
try:
parsed = urlparse(base_url)
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
except Exception:
return False
def setup_routes(app: web.Application):
"""Register Unsloth API routes."""
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
async def handle_proxy(request: web.Request) -> web.Response:
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
"""
import asyncio
try:
body = await request.json()
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
path = body.get("path") or "/v1/models"
method = (body.get("method") or "GET").upper()
payload = body.get("payload")
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
return web.json_response({"message": "Unsupported proxy target"}, status=400)
if not _is_allowed_base_url(base_url):
return web.json_response({
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
}, status=400)
api_key = _get_api_key()
if not api_key:
return web.json_response({
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
"plugin folder, fill in the key, and restart ComfyUI.",
}, status=401)
def _fetch():
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {"Authorization": f"Bearer {api_key}"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode("utf-8"))
data = await asyncio.to_thread(_fetch)
return web.json_response(data)
except HTTPError as e:
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
except URLError as e:
logger.warning("Unsloth proxy connection error: %s", e)
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
except Exception as e:
logger.error("Unsloth proxy error: %s", e)
return web.json_response({"message": str(e)}, status=500)
+107 -81
View File
@@ -3,6 +3,7 @@ Gallery Metadata Store - ギャラリー画像のメタデータ永続化
"""
import json
import logging
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -24,14 +25,24 @@ class GalleryMetadataStore:
{"name": str}
]
}
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
"dictionary changed size during iteration" が発生するため、_data へアクセスする
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
内部呼び出しするため、通常のLockではなくRLockを使う)。
"""
def __init__(self, file_path: Path):
self.file_path = file_path
self._lock = threading.RLock()
self._data: dict = {"images": {}, "groups": []}
self._load()
def _load(self):
# コンストラクタからのみ呼ばれ、この時点ではまだ他スレッドと共有されていないためロック不要
if self.file_path.exists():
try:
with open(self.file_path, "r", encoding="utf-8") as f:
@@ -44,6 +55,7 @@ class GalleryMetadataStore:
logger.warning("GalleryMetadataStore: load error: %s", e)
def _save_to_disk(self):
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w", encoding="utf-8") as f:
@@ -58,113 +70,126 @@ class GalleryMetadataStore:
def get(self, image_path: str) -> dict:
key = self._normalize_path(image_path)
return dict(self._data["images"].get(key, {}))
with self._lock:
return dict(self._data["images"].get(key, {}))
def save(self, image_path: str, data: dict) -> bool:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
with self._lock:
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
# ── グループ管理 ──────────────────────────────────────────────
def list_groups(self) -> list[dict]:
return list(self._data.get("groups", []))
with self._lock:
return list(self._data.get("groups", []))
def create_group(self, name: str) -> bool:
groups = self._data.get("groups", [])
if any(g["name"] == name for g in groups):
return False
groups.append({"name": name})
self._data["groups"] = groups
return self._save_to_disk()
with self._lock:
groups = self._data.get("groups", [])
if any(g["name"] == name for g in groups):
return False
groups.append({"name": name})
self._data["groups"] = groups
return self._save_to_disk()
def rename_group(self, old_name: str, new_name: str) -> bool:
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
groups = self._data.get("groups", [])
if not any(g["name"] == old_name for g in groups):
return False
if any(g["name"] == new_name for g in groups):
return False # 新名前が既に存在
# グループリスト更新
for g in groups:
if g["name"] == old_name:
g["name"] = new_name
break
# 全画像のgroupsフィールドを更新
for meta in self._data["images"].values():
img_groups = meta.get("groups", [])
if old_name in img_groups:
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
return self._save_to_disk()
with self._lock:
groups = self._data.get("groups", [])
if not any(g["name"] == old_name for g in groups):
return False
if any(g["name"] == new_name for g in groups):
return False # 新名前が既に存在
# グループリスト更新
for g in groups:
if g["name"] == old_name:
g["name"] = new_name
break
# 全画像のgroupsフィールドを更新
for meta in self._data["images"].values():
img_groups = meta.get("groups", [])
if old_name in img_groups:
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
return self._save_to_disk()
def delete_group(self, name: str) -> bool:
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
self._data["groups"] = groups
# 画像のgroupsからも削除
for meta in self._data["images"].values():
if name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != name]
return self._save_to_disk()
with self._lock:
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
self._data["groups"] = groups
# 画像のgroupsからも削除
for meta in self._data["images"].values():
if name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != name]
return self._save_to_disk()
def list_images_in_group(self, group_name: str) -> list[str]:
result = []
for key, meta in self._data["images"].items():
if group_name in meta.get("groups", []):
result.append(key)
return result
with self._lock:
result = []
for key, meta in self._data["images"].items():
if group_name in meta.get("groups", []):
result.append(key)
return result
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
stale_set = {self._normalize_path(p) for p in stale_paths}
count = 0
for key, meta in self._data["images"].items():
if key in stale_set and group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
count += 1
if count:
self._save_to_disk()
return count
with self._lock:
stale_set = {self._normalize_path(p) for p in stale_paths}
count = 0
for key, meta in self._data["images"].items():
if key in stale_set and group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
count += 1
if count:
self._save_to_disk()
return count
def get_group_member_set(self, group_name: str) -> set:
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
with self._lock:
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
def clear_group(self, group_name: str) -> bool:
"""グループ内の全画像を除外する(グループ自体は残す)"""
for meta in self._data["images"].values():
if group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
return self._save_to_disk()
with self._lock:
for meta in self._data["images"].values():
if group_name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != group_name]
return self._save_to_disk()
def ensure_group(self, name: str) -> bool:
"""グループが存在しない場合のみ作成する"""
if any(g["name"] == name for g in self._data.get("groups", [])):
return True
return self.create_group(name)
with self._lock:
if any(g["name"] == name for g in self._data.get("groups", [])):
return True
return self.create_group(name)
def delete(self, image_path: str) -> bool:
"""画像のメタデータエントリを削除する"""
key = self._normalize_path(image_path)
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
with self._lock:
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
def rename_path(self, old_path: str, new_path: str) -> bool:
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
old_key = self._normalize_path(old_path)
new_key = self._normalize_path(new_path)
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
with self._lock:
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
"""folder_path 配下の存在しないファイルのメタデータを削除する。
@@ -173,14 +198,15 @@ class GalleryMetadataStore:
folder_key = self._normalize_path(folder_path)
if not folder_key.endswith("/"):
folder_key += "/"
stale = [
key for key in self._data["images"]
if key.startswith(folder_key) and key not in existing_paths
]
if not stale:
return 0
for key in stale:
del self._data["images"][key]
self._save_to_disk()
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
return len(stale)
with self._lock:
stale = [
key for key in self._data["images"]
if key.startswith(folder_key) and key not in existing_paths
]
if not stale:
return 0
for key in stale:
del self._data["images"][key]
self._save_to_disk()
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
return len(stale)
+264 -11
View File
@@ -1,6 +1,7 @@
"""
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
"""
import base64
import hashlib
import json
import logging
@@ -18,6 +19,37 @@ logger = logging.getLogger(__name__)
# サポートする画像拡張子
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
# data URL の MIME タイプ -> 拡張子(save_image_to_gallery / save_image_to_folder共用)
_SAVE_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
"image/svg+xml": ".svg",
}
def _decode_image_data_url(image_data: str) -> tuple[bytes, str]:
"""data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。
ヘッダが無ければPNG扱い(旧互換)。"""
raw = image_data
mime = "image/png"
if raw.startswith("data:"):
header, _, raw = raw.partition(",")
m = re.match(r"data:([^;]+)", header)
if m:
mime = m.group(1)
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
return base64.b64decode(raw), ext
def _sanitize_save_filename(filename: str, ext: str) -> str:
"""OSで使えない文字を除去し、拡張子が無ければ付与する"""
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
if not safe.lower().endswith(ext):
safe += ext
return safe
# メタデータ保存ファイル (gallery専用)
from .gallery_metadata import GalleryMetadataStore
@@ -30,6 +62,54 @@ _JPEG_READ_MAX = 64 * 1024 * 1024
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
_CACHE_TTL = 60.0
# ImagePromptギャラリー: 画像と同名の.txtサイドカーに保存されたプロンプトテキストの最大読み込みサイズ
_SIDECAR_PROMPT_MAX = 20_000
# ImagePromptギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
IMAGE_PROMPT_FOLDER_NAME = "ws_image_prompt"
# Style Catalogギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
STYLE_CATALOG_FOLDER_NAME = "ws_style_catalog"
# ponyxlWildcardsVault形式(.yaml + thumbnails/)を検出するための、画像の祖先フォルダ探索上限
_VAULT_ROOT_SEARCH_DEPTH = 8
_VAULT_THUMB_DIR_NAMES = ("thumbnails", "thumbnails_option2")
def _clean_vault_tags(text: str) -> str:
"""タグ文字列の前後の空白・カンマを整理する(tools/import_style_prompt_seed.py と同じ処理)"""
text = text.strip()
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
text = re.sub(r"\s*,\s*", ", ", text)
return text
def _parse_vault_yaml_leaves(yaml_path: Path) -> dict[str, str]:
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
戻り値: {leaf_name(小文字): タグ文字列}
"""
leaves: dict[str, str] = {}
try:
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
except OSError:
return leaves
for i, line in enumerate(lines):
trimmed = line.strip()
if not trimmed or trimmed.startswith("#") or not trimmed.endswith(":"):
continue
if i + 1 >= len(lines):
continue
next_trimmed = lines[i + 1].strip()
if not next_trimmed.startswith("-"):
continue
key = trimmed[:-1].strip()
tags = next_trimmed[1:].strip()
if not tags or key.lower() == "skip":
continue
leaves[key.lower()] = _clean_vault_tags(tags)
return leaves
class _FolderCache:
"""フォルダ単位の画像スキャン結果キャッシュ。
@@ -73,15 +153,26 @@ class GalleryService:
self.data_dir = data_dir
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
self._allowed_root: Path | None = None
# ComfyUI実outputフォルダの不変ルート。Settingsでユーザーが_allowed_rootを
# 別フォルダに変更しても、ImagePrompt/Style Catalogギャラリー(常に実output配下)への
# アクセスが塞がれないようにするため_allowed_rootとは別管理にする
self._comfy_output_root: Path | None = None
self._folder_cache = _FolderCache()
self._bg_cancel = threading.Event()
self._bg_cancel.set() # 初期状態: キャンセル済み
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (yamlシグネチャ, {leaf: tags})
self._vault_leaf_cache: dict[str, tuple[tuple, dict]] = {}
def update_output_root(self, root_path: str) -> None:
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
p = Path(root_path).resolve() if root_path else None
self._allowed_root = p
def set_comfy_output_root(self, root_path: str) -> None:
"""ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ)"""
p = Path(root_path).resolve() if root_path else None
self._comfy_output_root = p
# ──────────────────────────────────────────────────────────────
# バックグラウンドインデックス
# ──────────────────────────────────────────────────────────────
@@ -147,14 +238,23 @@ class GalleryService:
logger.debug("BG index error: %s", e)
def _check_path_allowed(self, path: Path) -> bool:
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
if self._allowed_root is None:
return False
try:
path.resolve().relative_to(self._allowed_root)
return True
except ValueError:
return False
"""パスが許可ルート配下かチェック(パストラバーサル防止)。
Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの
_comfy_output_root のいずれか配下であれば許可する。"""
resolved = path.resolve()
if self._allowed_root is not None:
try:
resolved.relative_to(self._allowed_root)
return True
except ValueError:
pass
if self._comfy_output_root is not None:
try:
resolved.relative_to(self._comfy_output_root)
return True
except ValueError:
pass
return False
# ──────────────────────────────────────────────────────────────
# フォルダツリー
@@ -183,11 +283,13 @@ class GalleryService:
except PermissionError:
pass
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
total_count = image_count + sum(c["image_count_total"] for c in children_nodes)
return {
"name": path.name,
"path": rel if rel != "." else "",
"abs_path": str(path).replace("\\", "/"),
"image_count": image_count,
"image_count_total": total_count,
"children": children_nodes,
}
@@ -230,6 +332,33 @@ class GalleryService:
self._folder_cache.set(folder, entries)
return entries
def _scan_folder_recursive(self, folder: Path) -> list[tuple[str, str, int, float]]:
"""os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
ImagePrompt/Style Catalogギャラリーのように深い階層に画像が置かれるケース用。
キャッシュはしない(フォルダ数が少なく、都度スキャンしても軽い想定)。
"""
entries = []
try:
for root, dirs, files in os.walk(folder):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for name in files:
if Path(name).suffix.lower() not in IMAGE_EXTENSIONS:
continue
full = Path(root) / name
try:
st = full.stat()
entries.append((
name,
str(full.resolve()).replace("\\", "/"),
st.st_size,
st.st_mtime,
))
except OSError:
continue
except PermissionError:
pass
return entries
def list_images(
self,
folder_path: str,
@@ -238,8 +367,9 @@ class GalleryService:
favorite_only: bool = False,
tag_filter: str = "",
group_filter: str = "",
recursive: bool = False,
) -> list[dict]:
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
"""指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)"""
folder = Path(folder_path).resolve()
if not folder.is_dir():
return []
@@ -253,7 +383,7 @@ class GalleryService:
group_member_set = self.metadata_store.get_group_member_set(group_filter)
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
raw_entries = self._scan_folder(folder)
raw_entries = self._scan_folder_recursive(folder) if recursive else self._scan_folder(folder)
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
@@ -289,14 +419,16 @@ class GalleryService:
"groups": meta.get("groups", []),
}
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト・.txtサイドカー)
if search:
s = search.lower()
sidecar_prompt = self._read_sidecar_prompt(Path(abs_path)) or ""
if not (
s in name.lower()
or s in item["memo"].lower()
or any(s in t.lower() for t in tags)
or s in meta.get("prompt_cache", "").lower()
or s in sidecar_prompt.lower()
):
continue
@@ -314,6 +446,126 @@ class GalleryService:
return results
# ──────────────────────────────────────────────────────────────
# ImagePromptギャラリー: .txtサイドカーによるプロンプト保存
# ──────────────────────────────────────────────────────────────
def get_image_prompt_root(self) -> str | None:
"""ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を
返す。無ければ作成する。"""
if self._comfy_output_root is None:
return None
root = self._comfy_output_root / IMAGE_PROMPT_FOLDER_NAME
root.mkdir(parents=True, exist_ok=True)
return str(root).replace("\\", "/")
def get_style_catalog_root(self) -> str | None:
"""Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を
返す。無ければ作成する。"""
if self._comfy_output_root is None:
return None
root = self._comfy_output_root / STYLE_CATALOG_FOLDER_NAME
root.mkdir(parents=True, exist_ok=True)
return str(root).replace("\\", "/")
def save_image_to_folder(self, folder_path: str, filename: str, image_data: str) -> dict:
"""任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。
Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。"""
folder = Path(folder_path).resolve()
if not folder.is_dir():
return {"ok": False, "error": "Destination folder not found"}
if not self._check_path_allowed(folder):
return {"ok": False, "error": "Access denied"}
try:
image_bytes, ext = _decode_image_data_url(image_data)
except Exception as e:
return {"ok": False, "error": f"Invalid imageData: {e}"}
safe = _sanitize_save_filename(filename, ext)
if not safe:
return {"ok": False, "error": "Invalid filename"}
save_path = folder / safe
try:
save_path.write_bytes(image_bytes)
except OSError as e:
return {"ok": False, "error": str(e)}
return {"ok": True, "path": str(save_path).replace("\\", "/")}
def _sidecar_path(self, image_path: Path) -> Path:
return image_path.with_suffix(".txt")
def _read_sidecar_prompt(self, image_path: Path) -> str | None:
"""画像のプロンプトテキストを返す。
1. 画像と同名の .txt サイドカーがあればそれを使う
2. 無ければ ponyxlWildcardsVault 形式 (*.yaml + thumbnails/) をフォールバックとして
その場で解決する(インポート未実施のフォルダをそのまま置いた場合に対応するため)
"""
sidecar = self._sidecar_path(image_path)
try:
if sidecar.is_file() and sidecar.stat().st_size <= _SIDECAR_PROMPT_MAX:
text = sidecar.read_text(encoding="utf-8", errors="replace").strip()
if text:
return text
except OSError:
pass
return self._read_vault_prompt(image_path)
def _find_vault_category_root(self, image_path: Path) -> Path | None:
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
(=ponyxlWildcardsVaultのカテゴリルート) を探す"""
cur = image_path.parent
for _ in range(_VAULT_ROOT_SEARCH_DEPTH):
if any((cur / name).is_dir() for name in _VAULT_THUMB_DIR_NAMES):
return cur
if cur.parent == cur:
break
cur = cur.parent
return None
def _load_vault_leaves(self, category_root: Path) -> dict[str, str]:
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
if not yaml_files:
return {}
try:
signature = tuple(sorted((str(f), f.stat().st_mtime) for f in yaml_files))
except OSError:
signature = ()
cache_key = str(category_root)
cached = self._vault_leaf_cache.get(cache_key)
if cached is not None and cached[0] == signature:
return cached[1]
leaves: dict[str, str] = {}
for yf in yaml_files:
leaves.update(_parse_vault_yaml_leaves(yf))
self._vault_leaf_cache[cache_key] = (signature, leaves)
return leaves
def _read_vault_prompt(self, image_path: Path) -> str | None:
"""ponyxlWildcardsVault形式のフォールバック解決。
thumbnails_option2 の "{leaf}.preview3.ext" のような二重拡張子にも対応するため、
ファイル名の最初の "." より前をリーフ名候補として使う。"""
category_root = self._find_vault_category_root(image_path)
if category_root is None:
return None
leaves = self._load_vault_leaves(category_root)
if not leaves:
return None
leaf_candidate = image_path.name.split(".")[0].lower()
return leaves.get(leaf_candidate)
def save_image_prompt(self, image_path: str, text: str) -> bool:
"""画像と同名の.txtサイドカーにプロンプトテキストを保存する"""
p = Path(image_path).resolve()
if not p.is_file():
return False
if not self._check_path_allowed(p):
return False
try:
self._sidecar_path(p).write_text((text or "").strip(), encoding="utf-8")
return True
except OSError as e:
logger.warning("save_image_prompt: failed for %s: %s", p, e)
return False
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
@@ -355,6 +607,7 @@ class GalleryService:
"tags": saved.get("tags", []),
"memo": saved.get("memo", ""),
"groups": saved.get("groups", []),
"image_prompt": self._read_sidecar_prompt(path),
}
def _read_png_metadata(self, path: Path) -> dict:
+138
View File
@@ -0,0 +1,138 @@
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
import base64
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
class LabPlanService:
def __init__(self, plan_dir: Path):
self.plan_dir = plan_dir
self.plan_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# Path safety
# ------------------------------------------------------------------
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
if not filename or not filename.strip():
return None
name = filename.strip()
if not name.endswith(ext):
name = name + ext
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
return None
resolved = (self.plan_dir / name).resolve()
plan_dir_resolved = self.plan_dir.resolve()
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
return None
return resolved
def _now_iso(self) -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
def list_plans(self) -> list[dict]:
if not self.plan_dir.is_dir():
return []
result = []
for path in sorted(self.plan_dir.glob("*.json")):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
continue
stem = path.stem
thumbnail = None
thumb_path = self.plan_dir / f"{stem}.png"
if thumb_path.is_file():
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
result.append({
"filename": path.name,
"name": data.get("name", stem),
"note": data.get("note", ""),
"batch_count": data.get("batch_count", 1),
"updated_at": data.get("updated_at", ""),
"thumbnail": thumbnail,
})
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
return result
def get_plan(self, filename: str) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if not path.is_file():
raise FileNotFoundError(f"Plan not found: {filename}")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
data = dict(data)
data["updated_at"] = self._now_iso()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if index_image_base64:
self.save_index_image(filename, index_image_base64)
return {"filename": path.name, "updated_at": data["updated_at"]}
def save_index_image(self, filename: str, image_base64: str) -> None:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
stem = path.stem
thumb_path = self.plan_dir / f"{stem}.png"
# Accept both raw base64 and data: URL forms
b64 = image_base64
if "," in b64 and b64.strip().lower().startswith("data:"):
b64 = b64.split(",", 1)[1]
image_bytes = base64.b64decode(b64)
with open(thumb_path, "wb") as f:
f.write(image_bytes)
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Lab_index") -> dict:
"""Saves the contact-sheet index image into ComfyUI's own output folder,
alongside the images generated during the run (unlike save_index_image,
which saves it next to the plan file in plan_dir)."""
b64 = image_base64
if "," in b64 and b64.strip().lower().startswith("data:"):
b64 = b64.split(",", 1)[1]
image_bytes = base64.b64decode(b64)
import folder_paths # type: ignore
output_dir = folder_paths.get_output_directory()
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
save_name = f"{filename}_{counter:05}_.png"
save_path = Path(full_output_folder) / save_name
save_path.write_bytes(image_bytes)
return {"filename": save_name, "subfolder": subfolder}
def delete_plan(self, filename: str) -> None:
path = self._safe_path(filename)
if path is None:
raise ValueError(f"Invalid filename: {filename}")
if path.is_file():
path.unlink()
thumb_path = self.plan_dir / f"{path.stem}.png"
if thumb_path.is_file():
thumb_path.unlink()
+23 -2
View File
@@ -4,7 +4,7 @@ import jinja2
from aiohttp import web
from pathlib import Path
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR
logger = logging.getLogger(__name__)
@@ -41,6 +41,22 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
})
async def serve_lab_plan_file(request: web.Request) -> web.Response:
"""Serve files (index thumbnails) from the Lab plan directory."""
filename = request.match_info.get("filename", "")
if not filename or ".." in filename or "/" in filename or "\\" in filename:
return web.Response(status=404)
file_path = LAB_PLAN_DIR / filename
if not file_path.is_file():
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(file_path))
return web.FileResponse(file_path, headers={
"Content-Type": content_type or "application/octet-stream",
})
class WorkflowStudio:
"""Main entry point for Workflow Studio plugin."""
@@ -57,15 +73,19 @@ class WorkflowStudio:
# Dynamic workflow file serving (thumbnails etc.)
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
# Dynamic Lab plan file serving (index thumbnails)
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
# Main page
app.router.add_get("/wfm", serve_index_page)
# API routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
ollama_routes.setup_routes(app)
unsloth_routes.setup_routes(app)
eagle_routes.setup_routes(app)
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
@@ -75,5 +95,6 @@ class WorkflowStudio:
tagger_routes.setup_routes(app)
gmic_routes.setup_routes(app)
skill_routes.setup_routes(app)
lab_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+1 -1
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.79"
version = "0.4.2"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
+3
View File
@@ -19,3 +19,6 @@ piexif>=1.1.3
# Omit if you do not use DeepDanbooru models.
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
# tensorflow>=2.10
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
python-dotenv>=1.0,<2.0
+160 -1
View File
@@ -2,11 +2,45 @@
Gallery Tab Styles
============================================================ */
/* ── サブタブ (Output / Style-Prompt) ──────────────────────── */
.wfm-gallery-subtab-nav {
display: flex;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-gallery-subtab-btn {
padding: 6px 16px;
font-size: 12px;
background: transparent;
border: none;
color: var(--wfm-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s;
}
.wfm-gallery-subtab-btn:hover { color: var(--wfm-text-primary); }
.wfm-gallery-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
.wfm-gallery-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.wfm-gallery-panel.active { display: flex; }
/* ── 3カラムレイアウト ──────────────────────────────────────── */
.wfm-gallery-layout {
display: flex;
height: calc(100vh - var(--wfm-header-height, 96px));
flex: 1;
min-height: 0;
overflow: hidden;
gap: 0;
}
@@ -499,6 +533,20 @@
white-space: nowrap;
}
.wfm-gallery-image-prompt-text {
font-size: 12px;
color: var(--wfm-text-primary);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
padding: 6px 8px;
max-height: 160px;
overflow-y: auto;
}
.wfm-gallery-tags-display {
display: flex;
flex-wrap: wrap;
@@ -833,3 +881,114 @@
gap: 8px;
justify-content: flex-end;
}
/* ============================================================
ImagePrompt / Style_Catalog ギャラリー (右ペイン)
============================================================ */
.wfm-imageprompt-detail-panel {
width: 520px;
min-width: 520px;
max-width: 520px;
flex-direction: row;
overflow: hidden;
}
.wfm-imageprompt-detail-col {
display: flex;
flex-direction: column;
min-width: 0;
}
.wfm-imageprompt-detail-col-left {
width: 280px;
min-width: 280px;
max-width: 280px;
overflow-y: auto;
border-right: 1px solid var(--wfm-border);
padding-bottom: 8px;
}
.wfm-imageprompt-detail-col-right {
flex: 1;
min-width: 0;
padding: 8px 10px;
}
.wfm-imageprompt-section {
padding: 8px 10px;
}
.wfm-imageprompt-prompt-box {
width: 100%;
resize: vertical;
font-size: 12px;
padding: 6px 8px;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
color: var(--wfm-text-primary);
box-sizing: border-box;
}
.wfm-imageprompt-btn-row {
display: flex;
gap: 6px;
margin-top: 6px;
}
.wfm-imageprompt-btn-row .wfm-btn {
flex: 1;
}
.wfm-imageprompt-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
min-height: 24px;
}
.wfm-imageprompt-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
background: rgba(79,142,247,0.15);
color: var(--wfm-accent, #4f8ef7);
border: 1px solid rgba(79,142,247,0.3);
border-radius: 10px;
padding: 2px 4px 2px 8px;
font-size: 11px;
}
.wfm-imageprompt-chip-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.wfm-imageprompt-chip-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 2px;
font-size: 13px;
line-height: 1;
opacity: 0.7;
}
.wfm-imageprompt-chip-remove:hover { opacity: 1; }
.wfm-imageprompt-final-section {
height: 100%;
}
.wfm-imageprompt-final-textarea {
margin-top: 4px;
flex: 1;
min-height: 0;
resize: none;
}
+386
View File
@@ -0,0 +1,386 @@
/* Lab tab — experimental I2I batch generation with per-column keyframes */
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
Lab needs its Setting/Results nav stacked above the panel body. */
#wfm-gen-subtab-lab.active {
flex-direction: column;
}
.wfm-lab-subtab-nav {
display: flex;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-lab-subtab-btn {
padding: 6px 16px;
font-size: 12px;
background: transparent;
border: none;
color: var(--wfm-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s;
}
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
.wfm-lab-panel {
display: none;
flex-direction: column;
flex: 1;
overflow: hidden;
height: calc(100% - 33px);
}
.wfm-lab-panel.active { display: flex; }
.wfm-lab-planjson-toolbar {
display: flex;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-lab-pane-header {
font-size: 11px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 0 6px;
flex-shrink: 0;
}
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
.wfm-lab-setting-layout {
display: flex;
height: 100%;
overflow: hidden;
gap: 0;
}
.wfm-lab-pane {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
border-right: 1px solid var(--wfm-border);
padding: 10px;
}
.wfm-lab-pane:last-child { border-right: none; }
.wfm-lab-left { width: 220px; flex-shrink: 0; }
.wfm-lab-center { flex: 1; padding: 0; }
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
.wfm-lab-side-btns {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.wfm-lab-plan-workflow-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 8px;
background: var(--wfm-bg-secondary);
border: 1px solid var(--wfm-border);
border-radius: 4px;
}
.wfm-lab-plan-workflow-name {
flex: 1;
min-width: 0;
font-size: 11px;
color: var(--wfm-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-lab-field-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.wfm-lab-field-row label {
font-size: 12px;
color: var(--wfm-text-secondary);
white-space: nowrap;
}
.wfm-lab-field-row input {
flex: 1;
min-width: 0;
}
.wfm-lab-note {
width: 100%;
resize: vertical;
font-size: 12px;
}
.wfm-lab-checkbox-label {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: var(--wfm-text-secondary);
cursor: pointer;
}
/* ---------------- Keyframe columns ---------------- */
.wfm-lab-columns {
display: flex;
height: 100%;
overflow: hidden;
}
.wfm-lab-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--wfm-border);
overflow: hidden;
}
.wfm-lab-column:last-child { border-right: none; }
.wfm-lab-col-header {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px;
row-gap: 4px;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
color: var(--wfm-text-secondary);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-lab-col-btns { display: flex; gap: 2px; }
.wfm-lab-col-header-left {
display: flex;
align-items: center;
gap: 6px;
}
/* Model column header: C/L/V toggle that switches which single field its cells
summarize (see _modelViewMode in lab-tab.js) — not part of the Plan data. */
.wfm-lab-model-view-toggle { display: flex; gap: 2px; }
.wfm-lab-model-view-btn {
width: 20px;
height: 20px;
padding: 0;
font-size: 10px;
font-weight: 700;
line-height: 1;
background: var(--wfm-bg-tertiary);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
color: var(--wfm-text-secondary);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.wfm-lab-model-view-btn:hover { color: var(--wfm-text-primary); border-color: var(--wfm-primary); }
.wfm-lab-model-view-btn.active {
color: #fff;
background: var(--wfm-primary);
border-color: var(--wfm-primary);
}
.wfm-lab-col-cells {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.wfm-lab-cell {
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
padding: 6px 8px;
font-size: 11px;
cursor: pointer;
background: var(--wfm-bg-tertiary);
transition: border-color 0.15s;
}
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
.wfm-lab-cell-index {
font-weight: 600;
color: var(--wfm-primary);
margin-right: 4px;
}
.wfm-lab-cell-value {
color: var(--wfm-text-primary);
word-break: break-all;
}
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
color: var(--wfm-text-secondary);
font-style: italic;
}
/* Setting 1, not yet explicitly overridden: showing the live GenerateUI value instead of
its own stored one (see _isLiveDisplay in lab-tab.js). Dashed border distinguishes it
from a saved override (solid border) at a glance. */
.wfm-lab-cell.wfm-lab-cell-live {
border-style: dashed;
}
.wfm-lab-cell.wfm-lab-cell-live .wfm-lab-cell-value {
color: var(--wfm-text-secondary);
}
/* This keyframe row is skipped entirely (see _resolveKeyframe's bypassed handling) —
amber to read as "temporarily disabled", distinct from the neutral gray used for
revert/live. Takes priority over both of those in _cellHtml. */
.wfm-lab-cell.wfm-lab-cell-bypassed {
border-color: var(--wfm-warning);
opacity: 0.6;
}
.wfm-lab-cell.wfm-lab-cell-bypassed .wfm-lab-cell-value {
color: var(--wfm-warning);
}
.wfm-lab-cell-applied {
margin-top: 3px;
padding-top: 3px;
border-top: 1px dashed var(--wfm-border);
font-size: 10px;
color: var(--wfm-text-secondary);
}
.wfm-lab-cell-prompt-line {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
.wfm-lab-modal-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.wfm-lab-modal-row label {
font-size: 12px;
color: var(--wfm-text-secondary);
}
.wfm-lab-modal-inline {
display: flex;
gap: 8px;
}
.wfm-lab-modal-btnrow {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.wfm-lab-modal-hint {
font-size: 11px;
color: var(--wfm-text-secondary);
font-style: italic;
margin: 0 0 10px;
}
.wfm-lab-modal-inline > div {
flex: 1;
min-width: 0;
}
.wfm-lab-modal-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin-top: 4px;
}
/* ---------------- Results layout ---------------- */
.wfm-lab-results-layout {
display: flex;
height: 100%;
overflow: hidden;
}
.wfm-lab-results-left {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--wfm-border);
padding: 10px;
}
.wfm-lab-results-center {
flex: 1;
min-width: 0;
padding: 10px;
overflow-y: auto;
}
.wfm-lab-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.wfm-lab-result-thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--wfm-radius-sm);
border: 1px solid var(--wfm-border);
cursor: pointer;
transition: border-color 0.15s;
}
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
.wfm-lab-result-item { position: relative; }
.wfm-lab-result-index {
position: absolute;
top: 2px;
left: 2px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 10px;
padding: 1px 5px;
border-radius: var(--wfm-radius-sm);
}
+61
View File
@@ -2342,6 +2342,38 @@ body {
flex-shrink: 0;
}
/* Col3 (Wildcard/Style) タブ切り替え */
.wfm-prompt-col3-tabs {
display: flex;
background: var(--wfm-surface);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-prompt-col3-tab {
flex: 1;
padding: 8px 4px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--wfm-text-secondary);
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: all 0.15s;
}
.wfm-prompt-col3-tab:hover { color: var(--wfm-text); }
.wfm-prompt-col3-tab.active {
color: var(--wfm-text);
border-bottom-color: var(--wfm-primary);
}
.wfm-prompt-col3-tab-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Ollama Assistant */
.wfm-ollama-assistant {
display: flex;
@@ -2885,6 +2917,29 @@ body {
Image Input (I2I) Slots
============================================ */
/* Container/header styling comes from .wfm-settings-section / .wfm-settings-summary
(shared details/summary accordion used by the Settings tab) — collapsed by default
since this is a low-frequency feature. */
.wfm-i2i-placeholder-mode {
display: flex;
gap: 16px;
font-size: 12px;
margin-bottom: 8px;
}
.wfm-i2i-placeholder-mode label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.wfm-i2i-placeholder-fields input[type="color"] {
padding: 2px;
height: 30px;
}
.wfm-i2i-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -2948,6 +3003,12 @@ body {
background: rgba(74, 158, 255, 0.08);
}
.wfm-i2i-drop-zone.wfm-disabled {
opacity: 0.4;
pointer-events: none;
cursor: default;
}
.wfm-i2i-drop-label {
font-size: 12px;
color: var(--wfm-text-secondary);
+168 -30
View File
@@ -50,33 +50,102 @@ function fileToBase64(file) {
});
}
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
// ============================================
// Thinking mode / Max tokens (shared by callLLM / callChat / callVLM)
// ============================================
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
// <think>タグ除去でも担保する(stripThinkingTagsを参照)。
function _applyGenOptions(body, backend, settings) {
const maxTokens = parseInt(settings?.maxTokens, 10);
if (backend === "ollama") {
body.think = !!settings?.thinkingMode;
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
} else if (maxTokens > 0) {
body.max_tokens = maxTokens;
}
return body;
}
function stripThinkingTags(text) {
return (text || "")
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
.trim();
}
async function callVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
body: JSON.stringify(_applyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).response || "";
const text = (await res.json()).response || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _applyGenOptions({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}, backend, settings);
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _unslothContent(data.choices?.[0]?.message);
return settings.thinkingMode ? text : stripThinkingTags(text);
} else {
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(_applyGenOptions({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}),
}, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).choices?.[0]?.message?.content || "";
const text = (await res.json()).choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
}
}
// ============================================
// Unsloth proxy (server relays the request, attaching the API key from .env
// so it never reaches the frontend — see py/routes/unsloth_routes.py)
// ============================================
async function unslothProxy(baseUrl, path, method, payload) {
const res = await fetch("/api/wfm/unsloth/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl, path, method, payload }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
return data;
}
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
// `reasoning_content` field. Fold it back into a <think> block so the
// existing Thinking-mode show/strip logic (_applyGenOptions/stripThinkingTags)
// still applies uniformly. Without this, a low max_tokens can make the model
// spend its whole budget reasoning and return an empty `content` with no
// visible explanation.
function _unslothContent(message) {
const reasoning = message?.reasoning_content;
const content = message?.content || "";
return reasoning ? `<think>${reasoning}</think>${content}` : content;
}
// ============================================
// URL validation
// ============================================
@@ -100,6 +169,9 @@ async function fetchModels(url, backend) {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return (data.models || []).map((m) => m.name);
} else if (backend === "unsloth") {
const data = await unslothProxy(url, "/v1/models", "GET");
return (data.data || []).map((m) => m.id);
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/models`);
@@ -114,30 +186,37 @@ async function testConnection(url, backend) {
return models.length;
}
async function callLLM(url, backend, model, prompt) {
async function callLLM(url, backend, model, prompt, settings = {}) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, stream: false }),
body: JSON.stringify(_applyGenOptions({ model, prompt, stream: false }, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.response || "";
const text = data.response || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _applyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _unslothContent(data.choices?.[0]?.message);
return settings.thinkingMode ? text : stripThinkingTags(text);
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(_applyGenOptions({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
}, backend, settings)),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content || "";
const text = data.choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : stripThinkingTags(text);
}
}
@@ -145,11 +224,41 @@ async function callLLM(url, backend, model, prompt) {
// Translation prompt builder
// ============================================
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
// Uses a system role (via callChat) rather than a single raw prompt string —
// local models follow "translate only, no commentary" far more reliably
// when it's a system instruction instead of buried inside the user text.
function buildTranslationMessages(text, srcLang, dstLang, settings) {
const srcName = srcLang === "free" ? (settings.freeSrcLang || "").trim() : LANG_NAMES[srcLang];
const dstName = dstLang === "free" ? (settings.freeDstLang || "English").trim() || "English" : LANG_NAMES[dstLang];
const fromPart = srcName ? ` from ${srcName}` : "";
const system = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
+ `no surrounding quotes, and do not repeat or echo the original text.`;
return [
{ role: "system", content: system },
{ role: "user", content: text },
];
}
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
function cleanTranslationOutput(raw) {
let s = (raw || "").trim();
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
if (s.length >= 2) {
const first = s[0], last = s[s.length - 1];
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
if (pairs[first] === last) s = s.slice(1, -1).trim();
}
return s;
}
// Heuristic: if the cleaned output is (near-)identical to the input, the
// model most likely echoed the source instead of translating it.
function looksUntranslated(input, output) {
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
const a = normalize(input), b = normalize(output);
return a.length > 0 && a === b;
}
// ============================================
@@ -217,11 +326,19 @@ function initTranslateTab() {
outputEl.value = "";
try {
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
const result = await callLLM(url, backend, model, prompt);
outputEl.value = result.trim();
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
const messages = buildTranslationMessages(text, srcLangSel.value, dstLangSel.value, settings);
const { content } = await callChat(url, backend, model, messages, undefined, settings);
const cleaned = cleanTranslationOutput(content);
outputEl.value = cleaned;
if (srcLangSel.value !== dstLangSel.value && looksUntranslated(text, cleaned)) {
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
showToast(t("aiToastMaybeNotTranslated"), "error");
} else {
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
}
} catch (err) {
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
@@ -288,6 +405,11 @@ function initSettingsTab() {
const urlInput = document.getElementById("wfm-ai-backend-url");
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
const thinkingModeCheckbox = document.getElementById("wfm-ai-thinking-mode-checkbox");
if (thinkingModeCheckbox) thinkingModeCheckbox.checked = !!saved.thinkingMode;
const maxTokensInput = document.getElementById("wfm-ai-max-tokens");
if (maxTokensInput && saved.maxTokens) maxTokensInput.value = saved.maxTokens;
// Switch URL to the new backend's default when the user changes backend
backendRadios.forEach((r) => {
r.addEventListener("change", () => {
@@ -395,6 +517,8 @@ function initSettingsTab() {
const chatGenDedicatedFilename = chatGenSelect?.value || "";
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
const thinkingMode = !!thinkingModeCheckbox?.checked;
const maxTokens = Math.max(0, parseInt(maxTokensInput?.value, 10) || 0);
if (url && !isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
@@ -403,6 +527,7 @@ function initSettingsTab() {
saveAiSettings({
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
thinkingMode, maxTokens,
chatGenDedicatedEnabled, chatGenDedicatedFilename,
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
});
@@ -453,10 +578,10 @@ function _formatMessagesForBackend(messages, backend) {
});
}
async function callChat(url, backend, model, messages, tools) {
async function callChat(url, backend, model, messages, tools, settings = {}) {
const formattedMessages = _formatMessagesForBackend(messages, backend);
if (backend === "ollama") {
const body = { model, messages: formattedMessages, stream: false };
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
if (tools) body.tools = tools;
const res = await fetch(`${url}/api/chat`, {
method: "POST",
@@ -465,9 +590,17 @@ async function callChat(url, backend, model, messages, tools) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const message = (await res.json()).message || {};
return { content: message.content || "", toolCalls: message.tool_calls || null };
const content = message.content || "";
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
} else if (backend === "unsloth") {
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
const message = data.choices?.[0]?.message || {};
const content = _unslothContent(message);
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
} else {
const body = { model, messages: formattedMessages, stream: false };
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
@@ -476,7 +609,8 @@ async function callChat(url, backend, model, messages, tools) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const message = (await res.json()).choices?.[0]?.message || {};
return { content: message.content || "", toolCalls: message.tool_calls || null };
const content = message.content || "";
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
}
}
@@ -909,7 +1043,7 @@ function initChatTab() {
const messagesToSend = skillPrompt
? [{ role: "system", content: skillPrompt }, ...chatHistory]
: chatHistory;
const reply = await callChat(url, backend, model, messagesToSend, tools);
const reply = await callChat(url, backend, model, messagesToSend, tools, settings);
const toolCall = reply.toolCalls?.find(
(tc) => (tc.function?.name || tc.name) === "generate_image"
);
@@ -1069,7 +1203,7 @@ function initVlmTab() {
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
try {
const result = await callLLM(url, backend, model, prompt);
const result = await callLLM(url, backend, model, prompt, settings);
resultEl.value = result.trim()
.split("\n")
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
@@ -1095,7 +1229,7 @@ function initVlmTab() {
resultEl.value = "";
try {
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType, settings);
resultEl.value = result.trim();
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
@@ -1125,3 +1259,7 @@ export function initAiTab() {
initVlmTab();
initSettingsTab();
}
// gallery-tab.js の外部(Comic Creator)向けブリッジ(半自動マンガ作成の画像プロンプト
// 下書き機能)が、設定タブと同じAI接続設定・LLM呼び出しロジックを再利用するためのexport。
export { callLLM, loadAiSettings, isValidBackendUrl };
+113 -2
View File
@@ -222,6 +222,68 @@ function applyI18nToHtml() {
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
// Gallery サブタブ (Output / ImagePrompt / Style_Catalog)
const galleryOutputSubtab = document.getElementById("wfm-gallery-subtab-output");
if (galleryOutputSubtab) galleryOutputSubtab.textContent = t("galleryTabOutput");
const galleryImagePromptSubtab = document.getElementById("wfm-gallery-subtab-imageprompt");
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
// ImagePrompt ギャラリーパネル
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
if (imagePromptSearch) imagePromptSearch.placeholder = t("gallerySearchPlaceholder");
const imagePromptRefreshBtn = document.getElementById("wfm-imageprompt-refresh-btn");
if (imagePromptRefreshBtn) imagePromptRefreshBtn.textContent = t("refresh");
const imagePromptTreeTitle = document.getElementById("wfm-imageprompt-tree-title");
if (imagePromptTreeTitle) imagePromptTreeTitle.textContent = t("galleryFolders");
const imagePromptListPlaceholder = document.getElementById("wfm-imageprompt-list-placeholder");
if (imagePromptListPlaceholder) imagePromptListPlaceholder.textContent = t("gallerySelectFolder");
const imagePromptNoSelection = document.getElementById("wfm-imageprompt-no-selection");
if (imagePromptNoSelection) imagePromptNoSelection.textContent = t("galleryNoSelection");
const imagePromptSelectedPromptTitle = document.getElementById("wfm-imageprompt-selected-prompt-title");
if (imagePromptSelectedPromptTitle) imagePromptSelectedPromptTitle.textContent = t("imagePromptSelectedPrompt");
const imagePromptSelectedPrompt = document.getElementById("wfm-imageprompt-selected-prompt");
if (imagePromptSelectedPrompt) imagePromptSelectedPrompt.placeholder = t("imagePromptNoPrompt");
const imagePromptSavePromptBtn = document.getElementById("wfm-imageprompt-save-prompt-btn");
if (imagePromptSavePromptBtn) imagePromptSavePromptBtn.textContent = t("save");
const imagePromptAddBtn = document.getElementById("wfm-imageprompt-add-btn");
if (imagePromptAddBtn) imagePromptAddBtn.textContent = t("imagePromptAddBtn");
const imagePromptChipsTitle = document.getElementById("wfm-imageprompt-chips-title");
if (imagePromptChipsTitle) imagePromptChipsTitle.textContent = t("imagePromptChipsTitle");
const imagePromptClearBtn = document.getElementById("wfm-imageprompt-clear-btn");
if (imagePromptClearBtn) imagePromptClearBtn.textContent = t("imagePromptClearBtn");
const imagePromptFinalPromptTitle = document.getElementById("wfm-imageprompt-final-prompt-title");
if (imagePromptFinalPromptTitle) imagePromptFinalPromptTitle.textContent = t("imagePromptFinalPromptTitle");
const imagePromptCopyBtn = document.getElementById("wfm-imageprompt-copy-btn");
if (imagePromptCopyBtn) imagePromptCopyBtn.textContent = t("copy");
// Style_Catalog ギャラリーパネル
const scSearch = document.getElementById("wfm-stylecatalog-search");
if (scSearch) scSearch.placeholder = t("stylecatalogSearchPlaceholder");
const scRefreshBtn = document.getElementById("wfm-stylecatalog-refresh-btn");
if (scRefreshBtn) scRefreshBtn.textContent = t("refresh");
const scTreeTitle = document.getElementById("wfm-stylecatalog-tree-title");
if (scTreeTitle) scTreeTitle.textContent = t("galleryFolders");
const scListPlaceholder = document.getElementById("wfm-stylecatalog-list-placeholder");
if (scListPlaceholder) scListPlaceholder.textContent = t("gallerySelectFolder");
const scNoSelection = document.getElementById("wfm-stylecatalog-no-selection");
if (scNoSelection) scNoSelection.textContent = t("galleryNoSelection");
const scPositiveTitle = document.getElementById("wfm-stylecatalog-positive-title");
if (scPositiveTitle) scPositiveTitle.textContent = t("stylecatalogPositive");
const scCopyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
if (scCopyPositiveBtn) scCopyPositiveBtn.textContent = t("copy");
const scNegativeTitle = document.getElementById("wfm-stylecatalog-negative-title");
if (scNegativeTitle) scNegativeTitle.textContent = t("stylecatalogNegative");
const scCopyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
if (scCopyNegativeBtn) scCopyNegativeBtn.textContent = t("copy");
const scSelectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
if (scSelectStyleBtn) scSelectStyleBtn.textContent = t("stylecatalogSelectStyleBtn");
const scLoadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
if (scLoadGenuiBtn) scLoadGenuiBtn.textContent = t("stylecatalogLoadGenuiBtn");
const scOpenMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
if (scOpenMetadataBtn) scOpenMetadataBtn.textContent = t("stylecatalogOpenMetadataBtn");
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
@@ -302,6 +364,14 @@ function applyI18nToHtml() {
}
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
@@ -380,6 +450,11 @@ function applyI18nToHtml() {
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
"wfm-help-gen-19": "helpGen19",
"wfm-help-gen-20": "helpGen20", "wfm-help-gen-21": "helpGen21",
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
"wfm-help-gen-28": "helpGen28",
"wfm-help-feeder-title": "helpFeederTitle",
"wfm-help-feeder-desc": "helpFeederDesc",
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
@@ -394,18 +469,43 @@ function applyI18nToHtml() {
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
"wfm-help-lab-title": "helpLabTitle",
"wfm-help-lab-desc": "helpLabDesc",
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
"wfm-help-lab-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
"wfm-help-lab-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
"wfm-help-lab-15": "helpLab15",
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
"wfm-help-lab-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
"wfm-help-lab-plan-11": "helpLabPlan11",
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
"wfm-help-lab-results-3": "helpLabResults3",
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
"wfm-help-prompt-9": "helpPrompt9",
"wfm-help-prompt-card-style-title": "helpPromptCardStyleTitle",
"wfm-help-prompt-10": "helpPrompt10", "wfm-help-prompt-11": "helpPrompt11",
"wfm-help-prompt-12": "helpPrompt12", "wfm-help-prompt-13": "helpPrompt13",
"wfm-help-prompt-14": "helpPrompt14",
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
"wfm-help-settings-13": "helpSettings13",
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
@@ -426,6 +526,12 @@ function applyI18nToHtml() {
"wfm-help-gallery-13": "helpGallery13", "wfm-help-gallery-14": "helpGallery14",
"wfm-help-gallery-15": "helpGallery15", "wfm-help-gallery-16": "helpGallery16",
"wfm-help-gallery-17": "helpGallery17",
"wfm-help-gallery-18": "helpGallery18", "wfm-help-gallery-19": "helpGallery19",
"wfm-help-gallery-20": "helpGallery20", "wfm-help-gallery-21": "helpGallery21",
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
"wfm-help-tagger-title": "helpTaggerTitle",
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
@@ -472,6 +578,7 @@ function applyI18nToHtml() {
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
"wfm-help-imageedit-18": "helpImageEdit18", "wfm-help-imageedit-19": "helpImageEdit19",
"wfm-help-imageedit-19b": "helpImageEdit19b",
"wfm-help-imageedit-20": "helpImageEdit20", "wfm-help-imageedit-21": "helpImageEdit21",
"wfm-help-imageedit-22": "helpImageEdit22", "wfm-help-imageedit-23": "helpImageEdit23",
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
@@ -497,6 +604,7 @@ function applyI18nToHtml() {
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
"wfm-help-imageedit-mask-14": "helpImageEditMask14",
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
@@ -517,6 +625,7 @@ function applyI18nToHtml() {
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
"wfm-help-trouble-5": "helpTrouble5", "wfm-help-trouble-6": "helpTrouble6",
"wfm-help-trouble-7": "helpTrouble7", "wfm-help-trouble-8": "helpTrouble8",
"wfm-help-trouble-9": "helpTrouble9",
"wfm-help-support-title": "helpSupportTitle",
"wfm-help-support-desc": "helpSupportDesc",
"wfm-help-github-desc": "helpGithubDesc",
@@ -534,6 +643,7 @@ function applyI18nToHtml() {
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
"wfm-help-gen-card-lora-title": "helpGenCardLora",
"wfm-help-prompt-title": "helpPromptTitle",
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
@@ -658,7 +768,7 @@ import { initWorkflowTab } from "./workflow-tab.js";
import { initNodesTab } from "./nodes-tab.js";
import { initGenerateTab } from "./generate-tab.js";
import { initPromptTab } from "./prompt-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
import { initModelsTab } from "./models-tab.js";
import { initGalleryTab } from "./gallery-tab.js";
import { initMetadataTab } from "./metadata-tab.js";
@@ -674,6 +784,7 @@ try {
const _s = getSettings();
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
applyJsonColors(_s.jsonColors);
applyModelTabActiveColor(_s.modelTabActiveColor);
} catch {}
function initHelpTab() {
+42 -8
View File
@@ -18,8 +18,15 @@ export const comfyUI = {
connected: false,
generating: false,
currentPromptId: null,
// trackProgress() 実行中の Promise を promptId -> reject関数 で保持。
// WebSocket切断時に、待機中のPromiseを解決させずに放置してバッチループが
// 無言のまま停止する(例: カタログ作成バッチが特定件数で止まる)のを防ぐ。
_pendingTrackers: new Map(),
currentWorkflow: null,
currentAnalysis: null,
// ワークフロー読み込み時点のLora Loader (LoraManager)ノードの loras/text 状態。
// Stackグループチェックボックスのトグル時に、OFF側の「読み込み時の状態へ戻す」用に使う。
loraManagerSnapshots: {},
updateUrl(url) {
if (!url) {
@@ -66,10 +73,18 @@ export const comfyUI = {
this.socket = new WebSocket(url);
this.socket.onopen = () => resolve(true);
this.socket.onerror = () => resolve(false);
this.socket.onclose = () => { this.socket = null; };
this.socket.onclose = () => {
this.socket = null;
this._rejectPendingTrackers(new Error("WebSocket disconnected"));
};
});
},
_rejectPendingTrackers(err) {
for (const rejectFn of this._pendingTrackers.values()) rejectFn(err);
this._pendingTrackers.clear();
},
// Node info fetching
async fetchObjectInfo(nodeClass) {
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
@@ -196,12 +211,29 @@ export const comfyUI = {
return await res.json();
},
trackProgress(promptId, progressCallback) {
// timeoutMs: この時間内に完了/エラー/中断通知が来なければ強制的にreject。
// WebSocketの取りこぼしやexecution_interrupted未処理でPromiseが永久に
// ぶら下がり続け、バッチループが無言で停止するのを防ぐための安全弁。
trackProgress(promptId, progressCallback, timeoutMs = 10 * 60 * 1000) {
return new Promise((resolve, reject) => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
reject(new Error("WebSocket not connected"));
return;
}
let settled = false;
const finish = (isResolve, value) => {
if (settled) return;
settled = true;
this.socket?.removeEventListener("message", handler);
clearTimeout(timer);
this._pendingTrackers.delete(promptId);
if (isResolve) resolve(value); else reject(value);
};
const timer = setTimeout(() => {
finish(false, new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s (prompt_id=${promptId})`));
}, timeoutMs);
const handler = (event) => {
try {
const msg = JSON.parse(event.data);
@@ -211,17 +243,19 @@ export const comfyUI = {
}
if (msg.type === "executing" && msg.data?.prompt_id === promptId) {
if (msg.data.node === null) {
this.socket.removeEventListener("message", handler);
resolve();
finish(true);
}
}
if (msg.type === "execution_error" && msg.data?.prompt_id === promptId) {
this.socket.removeEventListener("message", handler);
reject(new Error(msg.data.exception_message || "Execution error"));
finish(false, new Error(msg.data.exception_message || "Execution error"));
}
if (msg.type === "execution_interrupted" && msg.data?.prompt_id === promptId) {
finish(false, new Error("Execution interrupted"));
}
} catch {}
};
this.socket.addEventListener("message", handler);
this._pendingTrackers.set(promptId, (err) => finish(false, err));
});
},
@@ -276,7 +310,7 @@ export const comfyUI = {
// High-level generate
async generate(workflow, options = {}) {
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError } = options;
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError, timeoutMs } = options;
this.generating = true;
try {
@@ -298,7 +332,7 @@ export const comfyUI = {
this.currentPromptId = result.prompt_id;
// Track progress
await this.trackProgress(result.prompt_id, onProgress);
await this.trackProgress(result.prompt_id, onProgress, timeoutMs);
// Get history/results
const history = await this.getHistory(result.prompt_id);
+449 -85
View File
@@ -5,7 +5,7 @@
import { comfyUI } from "./comfyui-client.js";
import { syncJsonHighlight } from "./json-highlight.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./util.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
// ── Latent Image preset state ─────────────────────────────
const _LATENT_PRESET_KEY = "wfm_latent_presets";
@@ -27,6 +27,114 @@ function _saveLatentPresets(list) {
localStorage.setItem(_LATENT_PRESET_KEY, JSON.stringify(list));
}
// ── I2I placeholder image (Input tab / Image sub-panel) ──────────────────
// One shared default (color-generated or a fixed uploaded image), remembered across
// sessions. Each LoadImage slot's own "Placeholder" button applies this same default
// to just that slot — see renderImageTab.
const _I2I_PLACEHOLDER_KEY = "wfm_i2i_placeholder";
const _I2I_PLACEHOLDER_DEFAULT = { mode: "color", color: "#808080", width: 512, height: 512, imageFilename: "" };
function _loadI2IPlaceholderConfig() {
try {
const raw = localStorage.getItem(_I2I_PLACEHOLDER_KEY);
return raw ? { ..._I2I_PLACEHOLDER_DEFAULT, ...JSON.parse(raw) } : { ..._I2I_PLACEHOLDER_DEFAULT };
} catch { return { ..._I2I_PLACEHOLDER_DEFAULT }; }
}
function _saveI2IPlaceholderConfig(cfg) {
localStorage.setItem(_I2I_PLACEHOLDER_KEY, JSON.stringify(cfg));
}
// Renders a solid-color PNG on an offscreen canvas and returns it as a File, ready to
// upload through comfyUI.uploadImage() the same way a picked/dropped file would be.
function _generateColorImageFile(width, height, color) {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, width || 512);
canvas.height = Math.max(1, height || 512);
const ctx = canvas.getContext("2d");
ctx.fillStyle = color || "#808080";
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) { reject(new Error("Failed to render placeholder image")); return; }
resolve(new File([blob], `placeholder_${canvas.width}x${canvas.height}.png`, { type: "image/png" }));
}, "image/png");
});
}
// Wires the placeholder config panel at the top of the Image sub-panel (mode switch,
// color/size fields, default-image drop zone). Every change is persisted immediately via
// _saveI2IPlaceholderConfig so it's remembered next time the panel is rendered — this
// panel is display-only config, it doesn't touch any workflow node itself; only each
// slot's own Placeholder button (see renderImageTab) does that.
function _wireI2IPlaceholderConfig(scopeEl) {
const colorModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-color");
const imageModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-image");
const colorFields = scopeEl.querySelector("#wfm-i2i-ph-color-fields");
const imageFields = scopeEl.querySelector("#wfm-i2i-ph-image-fields");
const widthInput = scopeEl.querySelector("#wfm-i2i-ph-width");
const heightInput = scopeEl.querySelector("#wfm-i2i-ph-height");
const colorInput = scopeEl.querySelector("#wfm-i2i-ph-color");
const dropZone = scopeEl.querySelector("#wfm-i2i-ph-drop");
const fileInput = scopeEl.querySelector("#wfm-i2i-ph-file");
const previewWrap = scopeEl.querySelector("#wfm-i2i-ph-preview-wrap");
const previewImg = scopeEl.querySelector("#wfm-i2i-ph-preview-img");
const statusEl = scopeEl.querySelector("#wfm-i2i-ph-status");
if (!colorModeRadio) return; // no LoadImage slots rendered — panel doesn't exist
const setMode = (mode) => {
if (colorFields) colorFields.style.display = mode === "color" ? "" : "none";
if (imageFields) imageFields.style.display = mode === "image" ? "" : "none";
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), mode });
};
colorModeRadio.addEventListener("change", () => { if (colorModeRadio.checked) setMode("color"); });
imageModeRadio?.addEventListener("change", () => { if (imageModeRadio.checked) setMode("image"); });
widthInput?.addEventListener("change", () => {
const width = Math.max(1, parseInt(widthInput.value, 10) || 512);
widthInput.value = width;
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), width });
});
heightInput?.addEventListener("change", () => {
const height = Math.max(1, parseInt(heightInput.value, 10) || 512);
heightInput.value = height;
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), height });
});
colorInput?.addEventListener("change", () => {
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), color: colorInput.value });
});
// Default image: uploaded to ComfyUI immediately (its filename is what actually gets
// reused later, not the raw file) so every slot's Placeholder button can apply it
// without re-uploading.
const applyDefaultImageFile = async (file) => {
if (!file || !file.type.startsWith("image/")) return;
if (statusEl) statusEl.textContent = t("i2iPlaceholderUploading");
try {
const result = await comfyUI.uploadImage(file, file.name);
if (!result.name) throw new Error("Upload returned no filename");
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), imageFilename: result.name });
if (previewImg) previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
if (previewWrap) previewWrap.style.display = "";
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
} catch (err) {
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
}
};
fileInput?.addEventListener("change", () => {
if (fileInput.files.length > 0) applyDefaultImageFile(fileInput.files[0]);
});
if (dropZone) {
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length > 0) applyDefaultImageFile(e.dataTransfer.files[0]);
});
}
}
function _buildPresetOptions(customPresets) {
const customs = customPresets.map((p) =>
`<option value="${p.w}x${p.h}">${p.w}x${p.h}</option>`
@@ -344,11 +452,11 @@ export const comfyEditor = {
return found || name;
},
renderAll(analysis, workflow) {
renderAll(analysis, workflow, opts = {}) {
this.renderPromptTab(analysis, "wfm-gen-prompt-fields");
this.renderImageTab(analysis, "wfm-gen-image-fields");
this.renderModelTab(analysis, "wfm-gen-model-fields");
this.renderLoraPane(analysis, "wfm-gen-lora-fields"); // async, fires independently
this.renderLoraPane(analysis, "wfm-gen-lora-fields", opts); // async, fires independently
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
_syncRawJson();
},
@@ -367,7 +475,7 @@ export const comfyEditor = {
<label>Positive Prompt</label>
<div style="display:flex;gap:8px;margin-bottom:6px;">
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${positiveNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
@@ -379,7 +487,7 @@ export const comfyEditor = {
<label>Negative Prompt</label>
<div style="display:flex;gap:8px;margin-bottom:6px;">
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${negativeNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
@@ -508,10 +616,14 @@ export const comfyEditor = {
const el = document.getElementById(containerId);
if (!el) return;
const sections = [
// 使用頻度の高い順: Checkpoint/VAE/Diffusion Model/Text Encoder は常時表示。
// ControlNet/Hypernetwork は意図的に使う場面が限られるため折りたたみにする。
const mainSections = [
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
];
const collapsibleSections = [
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
{
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
@@ -519,48 +631,70 @@ export const comfyEditor = {
},
];
el.innerHTML = sections
.map((s) => {
const models = this.models[s.key] || [];
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
const isMissing = !!currentVal && !models.includes(currentVal);
const targetOpts = s.nodes
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
.join("");
const extrasHtml = (s.extras || []).map((ex) => {
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
data-key="${s.key}" data-input-key="${ex.inputKey}"
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
style="width:80px;">
</div>`;
}).join("");
const renderSectionBody = (s) => {
const models = this.models[s.key] || [];
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
const isMissing = !!currentVal && !models.includes(currentVal);
const targetOpts = s.nodes
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
.join("");
const extrasHtml = (s.extras || []).map((ex) => {
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
data-key="${s.key}" data-input-key="${ex.inputKey}"
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
style="width:80px;">
</div>`;
}).join("");
return `
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
<label>${s.label}</label>
<input type="text" class="wfm-input wfm-model-filter" placeholder="Filter..." data-target="wfm-model-${s.key}" style="margin-bottom:4px;">
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
</select>
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
${extrasHtml}
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply &amp; Generate)">Apply</button>
</div>
return `
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
</div>
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
</select>
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
${extrasHtml}
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply &amp; Generate)">Apply</button>
</div>
`;
})
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
};
const mainHtml = mainSections.map((s) => {
const hasNodes = (s.nodes?.length || 0) > 0;
return `
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
<label class="${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</label>
${renderSectionBody(s)}
</div>`;
}).join("");
const teHtml = '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
const collapsibleHtml = collapsibleSections.map((s) => {
const hasNodes = (s.nodes?.length || 0) > 0;
return `
<details class="wfm-settings-section">
<summary class="wfm-settings-summary ${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</summary>
<div class="wfm-form-group" style="margin-bottom:0;">
${renderSectionBody(s)}
</div>
</details>`;
}).join("");
el.innerHTML = mainHtml + teHtml + collapsibleHtml;
// Filter inputs
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
input.addEventListener("input", () => {
const targetId = input.dataset.target;
const targetId = input.dataset.target;
const rebuild = () => {
const select = document.getElementById(targetId);
if (!select) return;
const filter = input.value.toLowerCase();
@@ -570,7 +704,9 @@ export const comfyEditor = {
.filter((m) => m.toLowerCase().includes(filter))
.map((m) => `<option value="${m}">${m}</option>`)
.join("");
});
};
input.addEventListener("input", rebuild);
setupSearchClearBtn(input.id, `${input.id}-clear`, rebuild);
});
// Apply buttons
@@ -606,13 +742,34 @@ export const comfyEditor = {
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
},
async renderLoraPane(analysis, containerId) {
async renderLoraPane(analysis, containerId, opts = {}) {
const el = document.getElementById(containerId);
if (!el) return;
const loraNodes = analysis.lora_nodes || [];
document.getElementById("wfm-gen-lora-col-title")?.classList.toggle("wfm-model-label-active", loraNodes.length > 0);
const loras = this.models.loras || [];
const defaultStackTarget = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
const hasLoraManager = loraNodes.some((n) => n.is_lora_manager);
// Use Stack Group チェックボックスの現在状態を先読み(Stack表示の取得元判定に使う)。
// resetStackMode(新規読み込み)時は常にOFF扱い。
const stackModeChecked = hasLoraManager && !opts.resetStackMode && (el.querySelector("#wfm-lora-stack-mode-toggle")?.checked ?? false);
// ワークフロー新規読み込み時のみ: LoraManagerノードのloras/text状態をスナップショットし、
// Stackグループ使用チェックボックスをOFFへリセットする(既存のGenUI状態への影響を避けるため
// タブ切替やStack再読込などの単なる再描画では行わない)。
if (opts.resetStackMode) {
comfyUI.loraManagerSnapshots = {};
for (const n of loraNodes) {
if (!n.is_lora_manager) continue;
const wfNode = comfyUI.currentWorkflow?.[n.id];
if (!wfNode?.inputs) continue;
comfyUI.loraManagerSnapshots[n.id] = {
loras: Array.isArray(wfNode.inputs.loras?.__value__) ? JSON.parse(JSON.stringify(wfNode.inputs.loras.__value__)) : [],
text: wfNode.inputs.text ?? "",
};
}
}
const nodeOpts = loraNodes
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
.join("");
@@ -637,26 +794,53 @@ export const comfyEditor = {
}
}
// Fetch Stack group, metadata, CivitAI cache in parallel
// Stack表示の取得元: ON(Use Stack Group)ならModelsタブの登録済みStackグループ、
// OFF(既定)ならLoraManagerノードに実際に設定されているLoRAをそのまま表示する。
let stackModels = [];
let metadata = {};
let civitaiCache = {};
const useWorkflowLoraState = hasLoraManager && !stackModeChecked;
try {
const [grpRes, metaRes, civRes] = await Promise.all([
fetch("/api/wfm/models/groups?type=lora"),
const [metaRes, civRes] = await Promise.all([
fetch("/api/wfm/models/metadata"),
fetch("/api/wfm/models/civitai/cache"),
]);
const groups = grpRes.ok ? await grpRes.json() : {};
stackModels = groups["Stack"] || [];
metadata = metaRes.ok ? await metaRes.json() : {};
civitaiCache = civRes.ok ? await civRes.json() : {};
} catch { /* ignore */ }
stackModels.forEach((m) => {
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
if (_stackActive[m] === undefined) _stackActive[m] = true;
});
if (stackModeChecked) {
try {
const grpRes = await fetch("/api/wfm/models/groups?type=lora");
const groups = grpRes.ok ? await grpRes.json() : {};
stackModels = groups["Stack"] || [];
} catch { /* ignore */ }
stackModels.forEach((m) => {
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
if (_stackActive[m] === undefined) _stackActive[m] = true;
});
} else if (useWorkflowLoraState) {
const wfNode = comfyUI.currentWorkflow?.[defaultStackTarget];
const wfLoras = wfNode?.inputs?.loras?.__value__;
// resetStackMode(新規読み込み)/syncFromWorkflow(チェックボックスOFF切替直後)の時だけ
// ワークフローの実データで強制上書きする。タブ切替等の単なる再描画では、
// 既にメモリ上に値があればそれを維持し、Stackパネルでの一時的なON/OFF・強度編集を
// 再描画のたびに消してしまわないようにする。
const shouldSync = opts.resetStackMode || opts.syncFromWorkflow;
if (Array.isArray(wfLoras)) {
stackModels = wfLoras.map((l) => {
const found = (this.models.loras || []).find((m) => _loraBasename(m) === l.name);
const full = found || l.name;
if (shouldSync || _stackStrengths[full] === undefined) {
_stackStrengths[full] = { m: parseFloat(l.strength) || 1.0, c: parseFloat(l.clipStrength ?? l.strength) || 1.0 };
}
if (shouldSync || _stackActive[full] === undefined) {
_stackActive[full] = l.active !== false;
}
return full;
});
}
}
// Build Stack trigger words
const activeTriggerWords = [];
@@ -673,7 +857,9 @@ export const comfyEditor = {
? activeTriggerWords.map((w) => `<span class="wfm-lora-trigger-word">${w}</span>`).join(" ")
: `<span style="color:var(--wfm-text-secondary);font-size:12px;">—</span>`;
const stackLoraSyntax = _buildLoraSyntax(stackModels);
const allActive = stackModels.every((m) => _stackActive[m] !== false);
// stackModelsが空だと every() が空配列に対し true を返す(空虚な真)ため、
// 何も無いのに「トグルオール」チェックボックスがON表示になるのを防ぐ
const allActive = stackModels.length > 0 && stackModels.every((m) => _stackActive[m] !== false);
const anyActive = stackModels.some((m) => _stackActive[m] !== false);
const stackModelRows = stackModels.map((m) => {
@@ -691,7 +877,10 @@ export const comfyEditor = {
</div>`;
}).join("");
const _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
let _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
// 新規読み込み時、LoraManagerノードに実際に複数LoRAが設定されている場合は
// Single(1件しか表示できない)ではなくStackタブを初期表示にする
if (opts.resetStackMode && useWorkflowLoraState && stackModels.length > 1) _prevActiveTab = "stack";
// applyToGenUIで設定したSingle表示の状態を保存(再描画後に復元)
const _prevSingleSyntax = document.getElementById("wfm-lora-single-syntax")?.textContent || "";
const _prevSingleTriggers = document.getElementById("wfm-lora-single-triggers")?.innerHTML || "";
@@ -700,11 +889,19 @@ export const comfyEditor = {
<div class="wfm-lora-tab-header">
<button class="wfm-lora-tab-btn active" data-tab="single">Single</button>
<button class="wfm-lora-tab-btn" data-tab="stack">Stack</button>
${hasLoraManager ? `
<label class="wfm-lora-stack-mode-label" style="margin-left:auto;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;" title="ON: 登録済みStackグループを読み込む / OFF: ワークフローに実際に設定されているLoRAを表示・編集する">
<input type="checkbox" id="wfm-lora-stack-mode-toggle" ${stackModeChecked ? "checked" : ""}>
Use Stack Group
</label>` : ""}
</div>
<!-- Single tab -->
<div class="wfm-lora-tab-content" id="wfm-lora-panel-single">
<input type="text" class="wfm-input" id="wfm-lora-filter" placeholder="Filter...">
<div class="wfm-search-wrap" style="width:100%;">
<input type="text" class="wfm-input wfm-search-input" id="wfm-lora-filter" placeholder="Filter...">
<button type="button" class="wfm-search-clear-btn" id="wfm-lora-filter-clear" title="Clear search">✕</button>
</div>
<select class="wfm-select" id="wfm-lora-select">
${loras.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
</select>
@@ -731,6 +928,7 @@ export const comfyEditor = {
<!-- Stack tab -->
<div class="wfm-lora-tab-content" id="wfm-lora-panel-stack" style="display:none;">
${hasLoraManager ? `<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:6px;">${useWorkflowLoraState ? "Showing: LoRAs currently in workflow" : "Showing: registered Stack group"}</div>` : ""}
<div class="wfm-lora-stack-header">
<select class="wfm-select" id="wfm-lora-stack-target" style="flex:1;min-width:0;">${stackTargetOpts}</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-lora-stack-apply" title="Apply Stack to node and sync to Positive prompt">Apply</button>
@@ -765,7 +963,7 @@ export const comfyEditor = {
<div id="wfm-lora-stack-triggers" class="wfm-lora-stack-triggers">${stackTriggerHtml}</div>
</div>
<div class="wfm-lora-stack-models">
${stackModelRows || `<p class="wfm-placeholder">No models in Stack group</p>`}
${stackModelRows || `<p class="wfm-placeholder">${useWorkflowLoraState ? "No LoRAs set on this node" : "No models in Stack group"}</p>`}
</div>
</div>
`;
@@ -803,9 +1001,63 @@ export const comfyEditor = {
document.getElementById("wfm-lora-panel-stack").style.display = "";
}
// ── Stackグループ使用チェックボックス ─────────────────
// ON: LoraManagerノードへStackグループを(クリアしてから)読み込む(既存のStack Applyを流用)
// OFF: ワークフロー読み込み時点のloras/text状態へ(クリアしてから)戻す
document.getElementById("wfm-lora-stack-mode-toggle")?.addEventListener("change", async (e) => {
const nodeId = document.getElementById("wfm-lora-stack-target")?.value || defaultStackTarget;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
if (e.target.checked) {
// stackModelsはこの時点では「ワークフロー state」を指したままのため、
// 先に再描画してStackグループを取得させてからApplyする
await this.renderLoraPane(analysis, containerId);
document.getElementById("wfm-lora-stack-apply")?.click();
return;
}
const snap = comfyUI.loraManagerSnapshots?.[nodeId] || { loras: [], text: "" };
comfyUI.currentWorkflow[nodeId].inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras)) };
comfyUI.currentWorkflow[nodeId].inputs.text = snap.text;
_syncRawJson();
// ON時にPositiveプロンプトへ挿入されたStack由来の<lora:...>構文とトリガーワードを除去する
// (新規に追加はしない。ワークフロー本来のプロンプトへ戻すだけ)
const posTextarea = document.getElementById("wfm-prompt-pos-text");
if (posTextarea && stackModels.length > 0) {
let cleaned = posTextarea.value;
for (const m of stackModels) {
const stem = _loraBasename(m).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
cleaned = cleaned.replace(new RegExp(`,?\\s*<lora:${stem}:[^>]*>`, "gi"), "");
}
const stackWords = new Set();
stackModels.forEach((m) => {
const sha = (metadata[m] || {}).sha256;
const civInfo = sha && civitaiCache[sha];
civInfo?.trainedWords?.forEach((w) => stackWords.add(w.trim().toLowerCase()));
});
if (stackWords.size > 0) {
cleaned = cleaned.split(",").map((p) => p.trim()).filter((p) => p && !stackWords.has(p.toLowerCase())).join(", ");
}
posTextarea.value = cleaned.replace(/,\s*$/, "").trim();
const posNodeId = document.getElementById("wfm-prompt-pos-target")?.value;
if (posNodeId && comfyUI.currentWorkflow?.[posNodeId]) {
const promptNode = comfyUI.currentAnalysis?.prompt_nodes?.find((n) => String(n.id) === String(posNodeId));
comfyUI.currentWorkflow[posNodeId].inputs[promptNode?.textKey || "text"] = posTextarea.value;
_syncRawJson();
}
}
// syncFromWorkflow: OFFへ戻した直後は、たった今書き戻したloras/text状態と
// Stackパネルの強度/有効状態表示を一致させるため強制同期する
this.renderLoraPane(analysis, containerId, { syncFromWorkflow: true });
});
// ── Single: filter ───────────────────────────────────
document.getElementById("wfm-lora-filter")?.addEventListener("input", (e) => {
const filter = e.target.value.toLowerCase();
const loraFilterInput = document.getElementById("wfm-lora-filter");
const rebuildLoraSingleList = () => {
const filter = loraFilterInput?.value.toLowerCase() || "";
const select = document.getElementById("wfm-lora-select");
if (!select) return;
select.innerHTML = loras
@@ -813,7 +1065,9 @@ export const comfyEditor = {
.map((m) => `<option value="${m}">${m}</option>`)
.join("");
_refreshLoraSingleDynamic(metadata, civitaiCache);
});
};
loraFilterInput?.addEventListener("input", rebuildLoraSingleList);
setupSearchClearBtn("wfm-lora-filter", "wfm-lora-filter-clear", rebuildLoraSingleList);
// ── Single: update SYNTAX/TRIGGERS on model or strength change ──
document.getElementById("wfm-lora-select")?.addEventListener("change", () => _refreshLoraSingleDynamic(metadata, civitaiCache));
@@ -1030,23 +1284,39 @@ export const comfyEditor = {
const sampler = analysis.sampler_nodes?.[0];
const latent = analysis.latent_nodes?.[0];
// "Advanced Sampling" workflows (Flux.1/Flux.2, SD3.5, Chroma...) spread seed/steps/cfg/
// sampler/scheduler/denoise across RandomNoise + CFGGuider + KSamplerSelect + a *Scheduler
// node instead of one KSampler. sampler.*NodeId (set in analyzeWorkflow) says which real
// node each field writes back to; null/"" means that workflow has no such control at all
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
el.innerHTML = `
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
<div style="flex:1;min-width:0;padding-right:14px;border-right:1px solid var(--wfm-border);">
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">${sampler?.advanced ? "Sampler (Advanced)" : "KSampler"}</h3>
${sampler ? `
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
<input type="hidden" id="wfm-settings-sampler-id"
data-seed-key="${sampler.seedKey || "seed"}"
data-seed-node-id="${sampler.seedNodeId ?? sampler.id}"
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
data-cfg-key="${sampler.cfgKey || "cfg"}"
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
<div class="wfm-form-group">
<label>Seed</label>
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? ""}" placeholder="${sampler.seed === undefined ? "linked" : ""}">
</div>
<div class="wfm-form-group">
<label>Steps</label>
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? 20}" min="1" max="200">
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? ""}" placeholder="${sampler.steps === undefined ? "linked" : ""}" min="1" max="200">
</div>
<div class="wfm-form-group">
<label>CFG</label>
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? 7}" step="0.5" min="0">
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? ""}" placeholder="${sampler.cfg === undefined ? "linked" : ""}" step="0.5" min="0">
</div>
<div class="wfm-form-group">
<label>Sampler</label>
@@ -1056,13 +1326,15 @@ export const comfyEditor = {
</div>
<div class="wfm-form-group">
<label>Scheduler</label>
<select class="wfm-select" id="wfm-settings-scheduler">
${this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")}
<select class="wfm-select" id="wfm-settings-scheduler" ${schedulerAvailable ? "" : "disabled"}>
${schedulerAvailable
? this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")
: `<option value="">N/A</option>`}
</select>
</div>
<div class="wfm-form-group">
<label>Denoise</label>
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? 1.0}" step="0.05" min="0" max="1">
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? ""}" placeholder="${denoiseAvailable && sampler.denoise === undefined ? "linked" : ""}" step="0.05" min="0" max="1" ${denoiseAvailable ? "" : "disabled"}>
</div>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply &amp; Generate)">Apply</button>
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
@@ -1073,17 +1345,18 @@ export const comfyEditor = {
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
<div class="wfm-form-group">
<label>Width</label>
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? 512}" step="8" min="64">
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? ""}" placeholder="${latent.width === undefined ? "linked" : ""}" step="8" min="64">
</div>
<div class="wfm-form-group">
<label>Height</label>
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? 512}" step="8" min="64">
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? ""}" placeholder="${latent.height === undefined ? "linked" : ""}" step="8" min="64">
</div>
<div class="wfm-form-group">
<label>Batch Size</label>
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
</div>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
${[512, 768, 1024, 2048].map((s) => `<button class="wfm-btn wfm-btn-sm wfm-latent-sq-btn" data-size="${s}">${s}</button>`).join("")}
@@ -1109,17 +1382,21 @@ export const comfyEditor = {
`;
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
// KSamplerAdvanced uses noise_seed; fall back to seed
const seedKey = document.getElementById("wfm-settings-sampler-id")?.dataset?.seedKey || "seed";
inputs[seedKey] = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
inputs.steps = parseInt(document.getElementById("wfm-settings-steps")?.value) || 20;
inputs.cfg = parseFloat(document.getElementById("wfm-settings-cfg")?.value) || 7;
inputs.sampler_name = document.getElementById("wfm-settings-sampler-name")?.value;
inputs.scheduler = document.getElementById("wfm-settings-scheduler")?.value;
inputs.denoise = parseFloat(document.getElementById("wfm-settings-denoise")?.value) || 1.0;
const ds = document.getElementById("wfm-settings-sampler-id")?.dataset || {};
const write = (nodeId, key, value) => {
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
comfyUI.currentWorkflow[nodeId].inputs[key] = value;
};
const seedEl = document.getElementById("wfm-settings-seed");
if (seedEl?.value !== "") write(ds.seedNodeId, ds.seedKey || "seed", parseInt(seedEl.value) || -1);
const stepsEl = document.getElementById("wfm-settings-steps");
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
const cfgEl = document.getElementById("wfm-settings-cfg");
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
const denoiseEl = document.getElementById("wfm-settings-denoise");
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
_syncRawJson();
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
});
@@ -1128,8 +1405,11 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-settings-latent-id")?.value;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
inputs.width = parseInt(document.getElementById("wfm-settings-width")?.value) || 512;
inputs.height = parseInt(document.getElementById("wfm-settings-height")?.value) || 512;
const wEl = document.getElementById("wfm-settings-width");
const hEl = document.getElementById("wfm-settings-height");
// Blank = leave the (linked) width/height alone instead of clobbering it with a default
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
_syncRawJson();
});
@@ -1212,8 +1492,34 @@ export const comfyEditor = {
const slots = loadNodes.slice(0, 4);
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
const meoSlots = meoNodes.slice(0, 2);
const phCfg = _loadI2IPlaceholderConfig();
const isColorMode = phCfg.mode !== "image";
el.innerHTML = `
${slots.length === 0 ? "" : `
<details class="wfm-settings-section wfm-i2i-placeholder-config">
<summary class="wfm-settings-summary">${t("i2iPlaceholderTitle")}</summary>
<div class="wfm-i2i-placeholder-mode">
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-color" value="color" ${isColorMode ? "checked" : ""}> ${t("i2iPlaceholderColor")}</label>
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-image" value="image" ${isColorMode ? "" : "checked"}> ${t("i2iPlaceholderImage")}</label>
</div>
<div id="wfm-i2i-ph-color-fields" class="wfm-i2i-placeholder-fields wfm-lab-modal-inline" style="${isColorMode ? "" : "display:none;"}">
<div><label>${t("i2iPlaceholderWidth")}</label><input type="number" min="1" id="wfm-i2i-ph-width" class="wfm-input" value="${Number(phCfg.width) || 512}"></div>
<div><label>${t("i2iPlaceholderHeight")}</label><input type="number" min="1" id="wfm-i2i-ph-height" class="wfm-input" value="${Number(phCfg.height) || 512}"></div>
<div><label>${t("i2iPlaceholderColorLabel")}</label><input type="color" id="wfm-i2i-ph-color" class="wfm-input" value="${escapeHtml(/^#[0-9a-fA-F]{6}$/.test(phCfg.color) ? phCfg.color : "#808080")}"></div>
</div>
<div id="wfm-i2i-ph-image-fields" class="wfm-i2i-placeholder-fields" style="${isColorMode ? "display:none;" : ""}">
<div class="wfm-i2i-preview-wrap" id="wfm-i2i-ph-preview-wrap" style="${phCfg.imageFilename ? "" : "display:none;"}">
<img class="wfm-i2i-preview-img" id="wfm-i2i-ph-preview-img" src="${phCfg.imageFilename ? `/view?filename=${encodeURIComponent(phCfg.imageFilename)}&type=input` : ""}">
</div>
<div class="wfm-i2i-drop-zone" id="wfm-i2i-ph-drop">
<label class="wfm-i2i-drop-label">
${t("i2iPlaceholderDropImage")}
<input type="file" accept="image/*" id="wfm-i2i-ph-file" style="display:none;">
</label>
</div>
<span class="wfm-i2i-status" id="wfm-i2i-ph-status"></span>
</div>
</details>
<div class="wfm-i2i-grid">
${slots.map((node, i) => `
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
@@ -1247,8 +1553,10 @@ export const comfyEditor = {
</div>
</div>
` : ""}
<div style="display:flex;gap:6px;margin-top:6px;">
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-placeholder-btn" data-slot="${i}">${t("i2iPlaceholderApply")}</button>
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-clear" data-slot="${i}">${t("clear")}</button>
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
</div>
</div>
@@ -1409,8 +1717,64 @@ export const comfyEditor = {
applyBtn.disabled = false;
}
});
// Clear: reset this slot's staged/preview state back to empty. Doesn't touch
// the workflow node itself — LoadImage always needs *some* valid filename, so
// there's nothing meaningful to "unset" there; this just lets the user cancel
// a pending pick and start over.
const clearBtn = el.querySelector(`.wfm-i2i-clear[data-slot="${i}"]`);
clearBtn?.addEventListener("click", () => {
pendingFile = null;
previewImg.src = "";
previewWrap.style.display = "none";
filenameEl.textContent = "";
if (fileInput) fileInput.value = "";
applyBtn.disabled = !pendingMaskFile;
statusEl.textContent = "";
});
// Placeholder: generate (color mode) or reuse (image mode) the shared default
// configured at the top of the panel, and replace this slot's image
// immediately — no separate Apply click needed, matching the "one button,
// one slot" behavior described for this feature.
const placeholderBtn = el.querySelector(`.wfm-i2i-placeholder-btn[data-slot="${i}"]`);
placeholderBtn?.addEventListener("click", async () => {
const cfg = _loadI2IPlaceholderConfig();
placeholderBtn.disabled = true;
statusEl.textContent = t("i2iPlaceholderGenerating");
try {
let filename;
if (cfg.mode === "image") {
if (!cfg.imageFilename) throw new Error(t("i2iPlaceholderNoDefaultImage"));
filename = cfg.imageFilename;
} else {
const file = await _generateColorImageFile(cfg.width, cfg.height, cfg.color);
const result = await comfyUI.uploadImage(file, file.name);
if (!result.name) throw new Error("Upload returned no filename");
filename = result.name;
}
if (comfyUI.currentWorkflow?.[node.id]) {
comfyUI.currentWorkflow[node.id].inputs.image = filename;
}
pendingFile = null;
previewImg.src = `/view?filename=${encodeURIComponent(filename)}&type=input`;
previewWrap.style.display = "";
filenameEl.textContent = filename;
applyBtn.disabled = !pendingMaskFile;
statusEl.textContent = `✓ ${filename}`;
statusEl.style.color = "var(--wfm-success)";
_syncRawJson();
} catch (err) {
statusEl.textContent = `✗ ${err.message}`;
statusEl.style.color = "var(--wfm-danger)";
} finally {
placeholderBtn.disabled = false;
}
});
});
_wireI2IPlaceholderConfig(el);
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
meoSlots.forEach((node, i) => {
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
@@ -1752,7 +2116,7 @@ async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
section.innerHTML = `
<label>Text Encoder</label>
<label class="wfm-model-label-active">Text Encoder</label>
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
+470 -86
View File
@@ -18,31 +18,42 @@ let _lastBypassedNodes = [];
let _lastMutedNodes = [];
/**
* Trace a link's source through any Bypass-mode (mode 4) nodes, matching each bypassed
* node's output slot to an input slot of the same type and following the connection
* upstream (recursively, for chained bypasses). Mirrors ComfyUI's own Bypass behavior,
* where a bypassed node is removed but its wires are reconnected end-to-end.
* Trace a link's source through any Bypass-mode (mode 4) nodes AND Reroute nodes, matching each
* one's output slot to an input slot of the same type and following the connection upstream
* (recursively, for chains of either/both). Mirrors ComfyUI's own Bypass behavior, where a
* bypassed node is removed but its wires are reconnected end-to-end — Reroute needs the identical
* treatment because it's a UI-only passthrough node with no backend node class at all: leaving it
* in the API payload makes ComfyUI reject the whole prompt with "missing_node_type" (Reroute
* always has to be resolved through, regardless of its mode).
* Returns null when no pass-through source exists (the matching input is unconnected),
* in which case the caller should treat the input as unlinked.
*/
function _resolveBypassSource(nodeById, linkMap, nodeId, slot, visited) {
const idStr = String(nodeId);
const node = nodeById[idStr];
if (!node || node.mode !== 4) return [idStr, slot];
if (visited.has(idStr)) return null; // circular bypass chain guard
if (!node || (node.mode !== 4 && node.type !== "Reroute")) return [idStr, slot];
if (visited.has(idStr)) return null; // circular bypass/reroute chain guard
visited.add(idStr);
const outputs = node.outputs || [];
const inputs = node.inputs || [];
const outType = outputs[slot]?.type;
let inIdx = -1;
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
inIdx = slot;
let inIdx;
if (node.type === "Reroute") {
// Reroute is always exactly one input -> one output. Its stored input type is commonly
// the generic "*" even when the output shows the propagated concrete type (e.g. MODEL),
// so the type-match search used for Bypass below would fail to find it — just take the
// sole input.
inIdx = 0;
} else {
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
const outType = outputs[slot]?.type;
if (inputs[slot]?.type === outType && linkMap[idStr]?.[slot]) {
inIdx = slot;
} else {
inIdx = inputs.findIndex((inp, i) => inp.type === outType && linkMap[idStr]?.[i]);
}
}
if (inIdx === -1) return null;
if (inIdx === -1 || inIdx === undefined) return null;
const upstream = linkMap[idStr][inIdx];
if (!upstream) return null;
@@ -74,6 +85,9 @@ function _getWidgetInputNames(objectInfo, classType) {
// Required inputs: skip types that are linked (have uppercase first letter = node output types)
for (const [name, spec] of Object.entries(required)) {
const type = Array.isArray(spec) ? spec[0] : spec;
// forceInput: true — link-only socket even though its type is a widget type
// (e.g. CLIPTextEncodeEditPlus's text1/text2). Never has a widgets_values entry.
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
// Link types are strings like "MODEL", "CLIP", "CONDITIONING", "LATENT", "IMAGE", etc.
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", or an array of choices
if (Array.isArray(type)) {
@@ -91,6 +105,7 @@ function _getWidgetInputNames(objectInfo, classType) {
// Optional inputs that are widgets
for (const [name, spec] of Object.entries(optional)) {
const type = Array.isArray(spec) ? spec[0] : spec;
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
if (Array.isArray(type)) {
names.push(name);
} else if (typeof type === "string") {
@@ -117,6 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
for (const [name, spec] of Object.entries(required)) {
const type = Array.isArray(spec) ? spec[0] : spec;
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
if (Array.isArray(type)) {
types.push("COMBO");
} else if (typeof type === "string") {
@@ -128,6 +144,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
}
for (const [name, spec] of Object.entries(optional)) {
const type = Array.isArray(spec) ? spec[0] : spec;
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
if (Array.isArray(type)) {
types.push("COMBO");
} else if (typeof type === "string") {
@@ -143,6 +160,13 @@ function _getWidgetInputTypes(objectInfo, classType) {
/**
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
*
* Includes both required AND optional sub-fields: ComfyUI serializes every sub-field of the
* selected option into widgets_values regardless of which bucket it's declared in (e.g.
* TextGenerate's "on" option puts temperature/top_k/.../seed under required but
* presence_penalty under optional — all 7 still occupy a widgets_values slot). Dropping the
* optional ones here under-counts how many slots the combo actually consumed, misaligning
* every widget declared after it in the node's schema.
*/
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
const info = objectInfo[classType];
@@ -150,7 +174,10 @@ function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey
const options = spec?.[1]?.options;
if (!Array.isArray(options)) return [];
const opt = options.find((o) => o.key === selectedKey) || options[0];
return Object.keys(opt?.inputs?.required || {});
return [
...Object.keys(opt?.inputs?.required || {}),
...Object.keys(opt?.inputs?.optional || {}),
];
}
/**
@@ -170,6 +197,140 @@ function _isExtraWidgetValue(val, expectedType) {
return false;
}
/**
* True when a subgraph-template node's widget input (by name) is actually wired end-to-end —
* either redirected from an outer parent-graph link, or wired to another internal node within the
* same subgraph template (boundary links from the subgraph's own input ports, origin_id === -10,
* don't count: those resolve to a plain injected widget value instead, handled separately).
*/
function _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name) {
const inputDef = (remappedNode.inputs || []).find((i) => i.widget && i.name === name);
if (!inputDef) return false;
const slotIdx = remappedNode.inputs.indexOf(inputDef);
if (redirectedTargets.has(`${remappedNode.id}:${slotIdx}`)) return true;
return inputDef.link != null && (sgDef.links || []).some(
(l) => l.id === inputDef.link && String(l.target_id) === origInternalId && l.target_slot === slotIdx && l.origin_id !== -10
);
}
/**
* Simulate consuming widgets_values against object_info's widget name/type order, accounting for
* frontend-only "control_after_generate" extras (e.g. "fixed" after a seed) that occupy one extra
* slot and shift every later index by one. When skipLinked is true, widgets the caller's
* isLinkedName() flags as linked are treated as consuming zero widgets_values entries (the
* "modern" ComfyUI format); with it false, every widget is assumed present (the "legacy full"
* format some subgraph templates store). Returns each widget's actual widgets_values index
* (or -1 if the array ran out), plus consumedIndices — every index a widget's own turn advanced
* past (normally just its value, but two when a control_after_generate extra was skipped) — and
* how many entries were consumed in total (the latter is how callers detect which of the two
* formats a given widgets_values array actually follows).
*
* consumedIndices matters because convertUiToApi's own widget-mapping loop only skips a
* control_after_generate extra while advancing past the *next* widget's turn (see its
* `!linkedSlotNames.has(name)` branch) — if that next widget turns out to be linked, the loop
* `continue`s before ever looking at the extra, leaving it unconsumed and silently misread as the
* value of whatever non-linked widget comes after. Stripping a linked widget's position alone
* (without its swallowed extra) reproduces exactly that bug one level up, in the injection step.
*
* comboExpander(name, selectedKey) — when a widget's type is COMFY_DYNAMICCOMBO_V3, its own turn
* only consumes the *selector* slot (the chosen option's key); the selected option's sub-fields
* (e.g. TextGenerate's sampling_mode → temperature/top_k/.../seed/presence_penalty) each occupy
* one more widgets_values slot right after it. Without expanding those here too, every widget
* declared later in the node's schema (e.g. TextGenerate's "thinking") gets matched against a
* slot that's actually still inside the combo's sub-field span, corrupting both values.
*/
function _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, skipLinked, comboExpander) {
let idx = 0;
const positions = {};
const consumedIndices = {};
for (let nIdx = 0; nIdx < widgetNames.length; nIdx++) {
const name = widgetNames[nIdx];
if (skipLinked && isLinkedName(name)) {
positions[name] = -1;
consumedIndices[name] = [];
continue;
}
if (idx >= widgetsValues.length) {
positions[name] = -1;
consumedIndices[name] = [];
continue;
}
const consumed = [idx];
let p = idx;
idx++;
if (_isExtraWidgetValue(widgetsValues[p], widgetTypes[nIdx]) && idx < widgetsValues.length) {
consumed.push(idx);
p = idx;
idx++;
}
if (widgetTypes[nIdx] === "COMFY_DYNAMICCOMBO_V3" && comboExpander) {
const subNames = comboExpander(name, widgetsValues[p]);
for (let s = 0; s < subNames.length && idx < widgetsValues.length; s++) {
consumed.push(idx);
idx++;
}
}
positions[name] = p;
consumedIndices[name] = consumed;
}
return { positions, consumedIndices, consumed: idx };
}
/**
* Strip a subgraph-template node's widgets_values entries belonging to widget slots that are
* actually linked (see _isLinkedWidgetName), so convertUiToApi's own widget-mapping loop — which
* assumes the "modern" format where linked widgets contribute zero widgets_values entries — maps
* every remaining widget to the right value instead of drifting by however many linked entries
* were left in place. No-op when widgets_values already follows the modern format (nothing to
* strip) or the node has no widget inputs at all.
*/
function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets) {
const widgetsValues = remappedNode.widgets_values;
const allWidgetInputs = (remappedNode.inputs || []).filter((i) => i.widget);
if (!widgetsValues || allWidgetInputs.length === 0) return;
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
// If skipping linked widgets already accounts for every entry, widgets_values is already in
// the modern (no dangling linked entries) format — nothing to strip.
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
if (skipSim.consumed === widgetsValues.length) return;
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
// indices the linked ones occupy — including any control_after_generate extra a linked
// widget's own turn would have consumed, which must be dropped too (see _simulateWidgetValues).
const fullSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, false, comboExpander);
const linkedIdx = new Set();
for (const name of widgetNames) {
if (isLinkedName(name)) {
for (const i of fullSim.consumedIndices[name] || []) linkedIdx.add(i);
}
}
if (linkedIdx.size > 0) {
remappedNode.widgets_values = widgetsValues.filter((_, i) => !linkedIdx.has(i));
}
}
/**
* Find the widgets_values array index that corresponds to a given widget input name on a
* subgraph-template node, for injecting an outer subgraph-node's un-linked widget value into it.
* Assumes _stripLegacyLinkedWidgetValues has already normalized widgets_values to the modern
* format (linked widgets contribute zero entries), matching what convertUiToApi's own
* widget-mapping loop expects.
*/
function _findInjectedWidgetIndex(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetName) {
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
const widgetsValues = remappedNode.widgets_values || [];
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
const sim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
return sim.positions[targetName] ?? -1;
}
/**
* Flatten a subgraph-containing UI workflow into a plain UI workflow.
*
@@ -226,16 +387,20 @@ function _flattenSubgraphs(workflow, objectInfo) {
}
// === Map subgraph input ports → internal target {nodeId, slot} ===
// A single boundary port can fan out to MULTIPLE internal targets (e.g. a "width" pin
// feeding both EmptyFlux2LatentImage.width as a widget AND a PreviewAny.source wildcard
// link purely for canvas display) — collect all of them, not just the last one seen.
const inputPortTargets = {};
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
const port = sgDef.inputs[portIdx];
for (const linkId of (port.linkIds || [])) {
const iLink = (sgDef.links || []).find(l => l.id === linkId);
if (iLink && iLink.origin_id === -10) {
inputPortTargets[portIdx] = {
if (!inputPortTargets[portIdx]) inputPortTargets[portIdx] = [];
inputPortTargets[portIdx].push({
targetNodeId: nodeIdRemap[iLink.target_id],
targetSlot: iLink.target_slot,
};
});
}
}
}
@@ -270,7 +435,11 @@ function _flattenSubgraphs(workflow, objectInfo) {
? (sgDef.inputs || []).findIndex(p => p.name === inputName)
: -1;
if (portIdx === -1) portIdx = dstSlot;
const target = inputPortTargets[portIdx];
// Only the port's first fanned-out target is redirected here (a real outer link
// feeding a fanned-out port is rare — not seen in any known template so far).
// The far more common case (an outer *widget value*, no real link, fanning out to
// multiple internal targets) is handled fully below, for every target.
const target = inputPortTargets[portIdx]?.[0];
if (target) {
link[3] = target.targetNodeId;
link[4] = target.targetSlot;
@@ -289,80 +458,102 @@ function _flattenSubgraphs(workflow, objectInfo) {
// === Add internal nodes (remapped IDs) ===
const remappedByOrigId = {};
const normalizedNodeIds = new Set();
for (const iNode of sgDef.nodes) {
const remapped = JSON.parse(JSON.stringify(iNode));
remapped.id = nodeIdRemap[iNode.id];
// Some subgraph-template nodes store widgets_values in the "legacy full" format
// (one entry per widget input, including linked ones), while convertUiToApi's
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
// entry at all). Detect the full format by comparing lengths and strip the
// entries belonging to inputs that are *actually* linked end-to-end (per
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
// links that terminate in a plain widget value (e.g. prompt text) are NOT
// stripped — their value lives in the outer node's widgets_values instead and
// is injected below.
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
const linkedIdx = new Set();
allWidgetInputs.forEach((inp, i) => {
const slotIdx = remapped.inputs.indexOf(inp);
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
});
if (linkedIdx.size > 0) {
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
normalizedNodeIds.add(remapped.id);
}
// Propagate the outer subgraph-node's Mute/Bypass mode onto every internal node.
// Without this, muting or bypassing a whole subgraph instance (e.g. disabling one of
// several parallel Flux.2 sampling pipelines via Ctrl-B) leaves its internal nodes at
// their template-authored mode (normally 0/enabled), so convertUiToApi's mode check
// below never excludes them — they end up in the API payload and can be picked up by
// node-existence-based UI logic (e.g. "first matching node found") ahead of the nodes
// actually driving the enabled pipeline.
if (node.mode === 2 || node.mode === 4) {
remapped.mode = node.mode;
}
// Some subgraph-template nodes store widgets_values in the "legacy full" format (one
// entry per widget input, including ones that are actually linked — either redirected
// from an outer parent-graph link, or wired to another internal node within the same
// subgraph template, e.g. EmptyFlux2LatentImage.width <- GetImageSize). Strip those
// entries so convertUiToApi's widget-mapping loop (which assumes the "modern" format:
// linked widgets contribute zero entries) doesn't drift every subsequent widget by
// however many linked entries were left in place — see _stripLegacyLinkedWidgetValues.
// Boundary links that terminate in a plain widget value (e.g. prompt text) are NOT
// treated as linked here — those resolve via the outer-value injection step below.
_stripLegacyLinkedWidgetValues(objectInfo, remapped, sgDef, String(iNode.id), redirectedTargets);
addedNodes.push(remapped);
remappedByOrigId[iNode.id] = remapped;
}
// === Inject outer subgraph-node widget values into internal nodes ===
// Only un-linked widget inputs — linked ones are already redirected above.
// Only un-linked widget inputs — linked ones are already redirected above. A port can fan
// out to several internal targets (see inputPortTargets collection above) — apply the same
// value to every one of them, not just the first. Iterating per PORT (rather than per
// outer widget) matters for unfilled templates whose outer widgets_values is empty ([]) —
// there is then no outer override to apply, but the fan-out's widget-type target (e.g.
// EmptyFlux2LatentImage.width) still holds its own template-authored default, and that
// same value is what a sibling non-widget target (e.g. a PreviewAny used to render the
// value into a text template) needs too.
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
outerWidgetInputs.forEach((inp, widgetIdx) => {
if (inp.link != null) return;
if (widgetIdx >= (node.widgets_values || []).length) return;
const outerValue = node.widgets_values[widgetIdx];
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
const targets = inputPortTargets[portIdx];
if (!targets || targets.length === 0) continue;
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
if (portIdx === -1) return;
const target = inputPortTargets[portIdx];
if (!target) return;
const port = sgDef.inputs[portIdx];
const outerInp = outerWidgetInputs.find((inp) => inp.name === port.name);
const outerWidgetIdx = outerInp ? outerWidgetInputs.indexOf(outerInp) : -1;
const hasOuterOverride = !!outerInp && outerInp.link == null && outerWidgetIdx !== -1
&& outerWidgetIdx < (node.widgets_values || []).length;
const outerValue = hasOuterOverride ? node.widgets_values[outerWidgetIdx] : undefined;
const origInternalId = String(target.targetNodeId).split(":").pop();
const remappedNode = remappedByOrigId[origInternalId];
if (!remappedNode) return;
// Value to apply to non-widget (wildcard) fan-out targets: the outer override if
// present, otherwise whatever a sibling widget-type target ends up holding (its own
// unmodified template default, when there is no outer override).
let fallbackLiteral = outerValue;
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
if (!targetInputDef?.widget) return;
for (const target of targets) {
const origInternalId = String(target.targetNodeId).split(":").pop();
const remappedNode = remappedByOrigId[origInternalId];
if (!remappedNode) continue;
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
if (!targetInputDef?.widget) continue;
// When the internal node's widgets_values was normalized above (stripped of
// linked-slot entries), the index must be computed against that same reduced
// set of widget names — object_info's full name order (which still includes
// e.g. width/height) would be off by however many entries were stripped.
let iWidgetIdx;
if (normalizedNodeIds.has(remappedNode.id)) {
const remainingNames = (remappedNode.inputs || [])
.filter((i) => i.widget)
.filter((i) => {
const slotIdx = remappedNode.inputs.indexOf(i);
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
})
.map((i) => i.name);
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
} else {
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
// Compute where in widgets_values this widget's value actually lives — accounting
// for linked slots (which consume no entry) and control_after_generate-style
// extras (which shift later indices by one). See _findInjectedWidgetIndex for why
// a plain name-index lookup silently corrupts sibling widgets here.
const iWidgetIdx = _findInjectedWidgetIndex(
objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetInputDef.name
);
if (iWidgetIdx === -1) continue;
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
if (hasOuterOverride) remappedNode.widgets_values[iWidgetIdx] = outerValue;
if (fallbackLiteral === undefined) fallbackLiteral = remappedNode.widgets_values[iWidgetIdx];
}
if (iWidgetIdx === -1) return;
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
remappedNode.widgets_values[iWidgetIdx] = outerValue;
});
if (fallbackLiteral === undefined) continue;
for (const target of targets) {
const origInternalId = String(target.targetNodeId).split(":").pop();
const remappedNode = remappedByOrigId[origInternalId];
if (!remappedNode) continue;
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
if (!targetInputDef || targetInputDef.widget) continue;
// Not a widget-representable input — typically a required wildcard ("*") link slot
// with no widget UI (e.g. a "Preview as Text" node used to render a width/height
// value into a prompt template string). There is no upstream node to link it to
// once flattened (the value only ever existed as this outer widget or a sibling
// target's own default), so stash it for convertUiToApi() to inject as a plain
// literal value instead of a link.
if (!remappedNode._wfmLiteralInputs) remappedNode._wfmLiteralInputs = {};
remappedNode._wfmLiteralInputs[targetInputDef.name] = fallbackLiteral;
}
}
// === Add internal links (object format → array format) ===
for (const iLink of (sgDef.links || [])) {
@@ -459,6 +650,10 @@ export const comfyWorkflow = {
_lastBypassedNodes.push({ nodeId: String(node.id), title: node.title || node.type });
continue;
}
// Reroute: UI-only passthrough node, no backend node class — including it in the API
// payload triggers a "missing_node_type" 400 from ComfyUI. Its wires are already
// resolved through via _resolveBypassSource() below, same as a Bypassed node's.
if (node.type === "Reroute") continue;
// Skip note/display-only nodes that are unknown to ComfyUI backend
if (!objectInfo[node.type] && _isDisplayOnlyNode(node)) continue;
const nodeId = String(node.id);
@@ -606,6 +801,15 @@ export const comfyWorkflow = {
inputs["wildcard_text"] = widgets[0];
}
// Literal values for non-widget (e.g. wildcard "*") inputs that a subgraph boundary
// widget fanned out to — see the injection step in _flattenSubgraphs for why these
// can't be represented as widgets_values entries or resolved as a link.
if (node._wfmLiteralInputs) {
for (const [k, v] of Object.entries(node._wfmLiteralInputs)) {
if (!(k in inputs)) inputs[k] = v;
}
}
api[nodeId] = {
class_type: node.type,
inputs,
@@ -715,15 +919,48 @@ export const comfyWorkflow = {
// KSamplerAdvanced uses noise_seed instead of seed
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
const seedVal = inputs.seed ?? inputs.noise_seed;
// seed/steps/cfg/denoise are sometimes wired to Primitive nodes (e.g. a shared
// "steps" value switched between two LoRA presets) instead of holding a direct
// number — leave those undefined so the settings-tab UI shows "linked" instead of
// a link-reference array, and Apply doesn't clobber the link with a literal value.
result.sampler_nodes.push({
id, type: ct, title,
seed: inputs.seed ?? inputs.noise_seed,
seedKey,
steps: inputs.steps, cfg: inputs.cfg,
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
denoise: inputs.denoise,
seed: typeof seedVal === "number" ? seedVal : undefined,
seedKey, seedNodeId: id,
steps: typeof inputs.steps === "number" ? inputs.steps : undefined, stepsNodeId: id,
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined, cfgNodeId: id,
sampler_name: inputs.sampler_name, samplerNodeId: id,
scheduler: inputs.scheduler, schedulerNodeId: id,
denoise: typeof inputs.denoise === "number" ? inputs.denoise : undefined, denoiseNodeId: id,
});
}
// CFGGuider / BasicGuider / DualCFGGuider — used by "Advanced Sampling" workflows
// (Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.) in place of KSampler's
// positive/negative inputs. These feed SamplerCustomAdvanced rather than driving a
// sampler themselves, so role propagation must start here too, or CLIPTextEncode
// nodes downstream stay "unknown" role.
if (ct === "CFGGuider") {
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
}
if (ct === "BasicGuider") {
if (Array.isArray(inputs.conditioning)) samplerPositiveRef[inputs.conditioning[0]] = true;
}
// DualCFGGuider (HiDream E1 instruct-pix2pix style edit): cond1/cond2 both derive from
// the positive text (often via InstructPixToPixConditioning), negative is separate —
// cond2 is left unmarked since it doesn't cleanly map to either role.
if (ct === "DualCFGGuider") {
if (Array.isArray(inputs.cond1)) samplerPositiveRef[inputs.cond1[0]] = true;
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
}
// SamplerCustom (older/simpler counterpart to SamplerCustomAdvanced): holds
// positive/negative directly instead of going through a separate Guider node.
if (ct === "SamplerCustom") {
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
}
}
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
@@ -737,6 +974,7 @@ export const comfyWorkflow = {
"ControlNetApply", "ControlNetApplyAdvanced",
"IPAdapterApply", "IPAdapterApplyFaceID",
"StyleModelApply",
"FluxGuidance", "FluxKontextMultiReferenceLatentMethod",
]);
for (let iter = 0; iter < 5; iter++) {
@@ -791,6 +1029,60 @@ export const comfyWorkflow = {
}
}
}
// InstructPixToPixConditioning (HiDream E1 edit) — unlike COND_PASSTHROUGH nodes,
// this has *separate* positive/negative inputs feeding separate outputs, so each
// role must route only to its own input (not both) or the negative CLIPTextEncode
// would incorrectly get marked positive too via the positive-output branch.
if (ct === "InstructPixToPixConditioning") {
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
}
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — used by Ernie
// Image's prompt-enhancement toggle to pick between the raw PrimitiveStringMultiline
// text and an LLM-generated (TextGenerate) rewrite. The rewrite can't be known
// statically, so propagate the role through both branches — only the branch that
// resolves to a literal string source (e.g. PrimitiveStringMultiline) will end up
// producing a prompt_nodes entry in Pass 2.
if (ct === "ComfySwitchNode") {
for (const key of ["on_false", "on_true"]) {
const tv = inputs[key];
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
// TextEncodeQwenImageEditPlus/TextEncodeQwenImageEdit — propagate role through its
// linked "prompt" input when prompt-enhancement wiring feeds it via a switch chain
// instead of a literal string (e.g. Krea-2's image-reference workflow).
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
const tv = inputs.prompt;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
// PreviewAny — generic value tap/relay used mid-chain in prompt-enhancement wiring
// (e.g. Krea-2's "Refine Prompt?" switch output tap); pass role through its
// "source" input unchanged.
if (ct === "PreviewAny") {
const sv = inputs.source;
if (Array.isArray(sv)) {
if (isPos) samplerPositiveRef[sv[0]] = true;
if (isNeg) samplerNegativeRef[sv[0]] = true;
}
}
// StringConcatenate — appends e.g. a LoRA trigger word onto the prompt text
// (Krea-2); propagate role through both operands so the upstream literal is found.
if (ct === "StringConcatenate") {
for (const key of ["string_a", "string_b"]) {
const tv = inputs[key];
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
}
}
@@ -812,6 +1104,72 @@ export const comfyWorkflow = {
return "unknown";
};
// --- Advanced Sampler: SamplerCustomAdvanced ---
// "Advanced Sampling" pattern used by Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.:
// instead of one KSampler node holding seed/steps/cfg/sampler/scheduler, those values
// are spread across RandomNoise (noise_seed) → a Guider node (cfg) → KSamplerSelect
// (sampler_name) → a *Scheduler node (steps/denoise), all feeding SamplerCustomAdvanced.
// Collect them into one composite entry so Settings-tab editing still works; each
// value's *NodeId tracks which real node to write back to (null = no such control
// exists on this workflow, e.g. Flux2Scheduler has no named "scheduler" combo).
if (ct === "SamplerCustomAdvanced") {
const noiseId = Array.isArray(inputs.noise) ? String(inputs.noise[0]) : null;
const guiderId = Array.isArray(inputs.guider) ? String(inputs.guider[0]) : null;
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
const noiseInputs = (noiseId && workflow[noiseId]?.inputs) || {};
const guiderNode = guiderId ? workflow[guiderId] : null;
const guiderInputs = guiderNode?.inputs || {};
// DualCFGGuider (HiDream E1 instruct-pix2pix edit) has no plain "cfg" input — its
// two CFG knobs are cfg_conds/cfg_cond2_negative. Use cfg_conds as the
// representative value so Settings still shows/edits *something* meaningful.
const guiderCfgKey = guiderNode?.class_type === "DualCFGGuider" ? "cfg_conds" : "cfg";
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
result.sampler_nodes.push({
id, type: ct, title, advanced: true,
seed: typeof noiseInputs.noise_seed === "number" ? noiseInputs.noise_seed : undefined,
seedKey: "noise_seed", seedNodeId: noiseId,
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
stepsNodeId: schedulerId,
cfg: typeof guiderInputs[guiderCfgKey] === "number" ? guiderInputs[guiderCfgKey] : undefined,
cfgNodeId: guiderCfgKey in guiderInputs ? guiderId : null,
cfgKey: guiderCfgKey,
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
scheduler: undefined, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
});
}
// --- Advanced Sampler: SamplerCustom ---
// Older/simpler counterpart to SamplerCustomAdvanced: no RandomNoise/Guider
// indirection — noise_seed and cfg are its own widgets, and positive/negative feed it
// directly. sampler_name/steps/scheduler/denoise still come from separate
// KSamplerSelect/*Scheduler nodes via its sampler/sigmas inputs.
if (ct === "SamplerCustom") {
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
result.sampler_nodes.push({
id, type: ct, title, advanced: true,
seed: typeof inputs.noise_seed === "number" ? inputs.noise_seed : undefined,
seedKey: "noise_seed", seedNodeId: id,
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
stepsNodeId: schedulerId,
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined,
cfgNodeId: id,
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
scheduler: schedulerInputs.scheduler, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
});
}
// --- Checkpoint nodes ---
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
@@ -878,13 +1236,34 @@ export const comfyWorkflow = {
}
}
// Qwen-based image+text encoder
if (ct === "TextEncodeQwenImageEditPlus") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: inputs.prompt || "",
textKey: "prompt",
});
// Qwen-based image+text encoder (Plus variant takes up to 3 reference images;
// the base variant takes one — both share the same "prompt" widget). Only add when
// "prompt" is a direct string; if linked (e.g. Krea-2's prompt-enhancement wiring),
// the upstream literal node is resolved instead via the role propagation above.
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
const textVal = inputs.prompt;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "prompt",
});
}
}
// TextEncodeBooguEdit (Boogu image-edit model) — prompt + negative_prompt in one node
if (ct === "TextEncodeBooguEdit") {
if (typeof inputs.prompt === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.prompt, textKey: "prompt",
});
}
if (typeof inputs.negative_prompt === "string") {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.negative_prompt, textKey: "negative_prompt",
});
}
}
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
@@ -957,10 +1336,15 @@ export const comfyWorkflow = {
}
// --- Latent nodes ---
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
// EmptyFlux2LatentImage (Flux.2 templates) often has width/height wired to a
// GetImageSize node (auto-follows the reference image) rather than a direct number —
// leave those as undefined, same treatment as TextEncodeMageFlowEdit below, so the
// settings-tab UI shows "linked" instead of a stale default and Apply doesn't clobber it.
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage") {
result.latent_nodes.push({
id, type: ct, title,
width: inputs.width, height: inputs.height,
width: typeof inputs.width === "number" ? inputs.width : undefined,
height: typeof inputs.height === "number" ? inputs.height : undefined,
batch_size: inputs.batch_size,
});
}
+120 -2
View File
@@ -7,10 +7,13 @@ import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
import { comfyWorkflow } from "./comfyui-workflow.js";
import { comfyUI } from "./comfyui-client.js";
import { comfyEditor } from "./comfyui-editor.js";
import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
// ── 定数 ─────────────────────────────────────────────────────
@@ -126,6 +129,87 @@ window._wfmReceiveI2IRunRequest = async (imageBlob, params, workflowData, workfl
}
};
// ComfyUI Comic Creater からiframe越しにコマの設定(シーン・要素・セリフ等)を受け取り、
// AIタブの設定タブと同じLLM接続設定(Ollama/LM Studio)で画像生成プロンプトの下書きを
// 生成して返す(半自動マンガ作成の「L」ボタン連携用)。
// Comic Creater側のスクリプトタブ「L」ボタンから
// iframe.contentWindow._wfmReceiveLLMPromptRequest(context) として呼ばれる。
// context: { scene, elements, dialogues, existingPrompt }(いずれも文字列、空文字可)
window._wfmReceiveLLMPromptRequest = async (context) => {
try {
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || getAiBackendDefaultUrl(backend);
if (!isValidBackendUrl(url)) return { ok: false, message: "AI settings: invalid backend URL (configure it in the Settings tab)" };
if (!model) return { ok: false, message: "AI settings: no model selected (configure it in the Settings tab)" };
const { scene = "", elements = "", dialogues = "", existingPrompt = "" } = context || {};
const prompt = [
"Create a concise Stable Diffusion image generation prompt (English, comma-separated tags/phrases describing scene, composition, character appearance and action) for one manga panel, based on the following context.",
"Output only the prompt text, nothing else.",
scene ? `Scene: ${scene}` : null,
elements ? `Characters/elements: ${elements}` : null,
dialogues ? `Dialogue: ${dialogues}` : null,
existingPrompt ? `Current draft prompt (revise/improve it): ${existingPrompt}` : null,
].filter(Boolean).join("\n");
const result = await callLLM(url, backend, model, prompt);
const text = (result || "").trim();
if (!text) return { ok: false, message: "LLM returned an empty response" };
return { ok: true, text };
} catch (e) {
return { ok: false, message: e.message };
}
};
// ComfyUI Comic Creater からiframe越しに画像プロンプト・サイズを受け取り、Generate UIで
// テキストから画像を生成して結果URLを返す(半自動マンガ作成のコマ単位バッチ画像生成用)。
// Comic Creater側のスクリプトタブ「画像を一括生成」から
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height, negative, workflowData?, workflowFilename?)
// として呼ばれる。I2I/Inpaintと異なり画像アップロードは行わない(txt2img)。
// workflowData が渡された場合(Comic Creater側のT2I設定でデフォルトワークフローが有効な時)は、
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, workflowData, workflowFilename) => {
try {
if (!window._wfmGenerateTab?.generate) return { ok: false, message: "Generate UI is not ready yet" };
if (workflowData) {
try {
if (!comfyUI.connected) {
await comfyUI.checkConnection();
}
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
await comfyEditor.loadModelLists();
}
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
} catch (e) {
console.warn("[T2I] failed to load default workflow:", e);
}
}
const analysis = comfyUI.currentAnalysis;
if (!comfyUI.currentWorkflow || !analysis) return { ok: false, message: "No workflow loaded in Generate UI" };
const workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
const latent = analysis.latent_nodes?.[0];
if (latent && width && height && workflow[latent.id]) {
workflow[latent.id].inputs.width = width;
workflow[latent.id].inputs.height = height;
}
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
if (negative) comfyEditor.setPromptText("negative", negative, { workflow, analysis });
await window._wfmGenerateTab.generate(workflow);
const resultUrl = document.getElementById("wfm-gen-result-img")?.src;
if (!resultUrl) return { ok: false, message: "No result image produced" };
return { ok: true, url: resultUrl };
} catch (e) {
return { ok: false, message: e.message };
}
};
// ── ページング ────────────────────────────────────────────────
const PAGE_SIZE = 50;
let _renderedCount = 0;
@@ -319,7 +403,7 @@ function renderTreeNode(node, container, depth, isRoot) {
// フォルダ名
const label = document.createElement("span");
label.className = "wfm-gallery-tree-label";
label.textContent = isRoot ? `[root] ${node.name}` : node.name;
label.textContent = isRoot ? "[root]" : node.name;
if (node.image_count > 0) {
const badge = document.createElement("span");
badge.className = "wfm-gallery-tree-count";
@@ -829,15 +913,29 @@ async function loadImageDetail(img) {
state.embeddedWorkflow = wfRes.has_workflow ? wfRes.workflow : null;
renderWorkflowJson(state.embeddedWorkflow);
_updateCopyCanvasBtn();
renderImagePromptSection(metaRes.image_prompt);
} catch (e) {
renderWorkflowJson(null);
_updateCopyCanvasBtn();
renderImagePromptSection(null);
}
// グループタブ更新
renderDetailGroup(img);
}
function renderImagePromptSection(imagePrompt) {
const section = document.getElementById("wfm-gallery-image-prompt-section");
const text = document.getElementById("wfm-gallery-image-prompt-text");
if (!section || !text) return;
if (imagePrompt) {
text.textContent = imagePrompt;
section.style.display = "";
} else {
section.style.display = "none";
}
}
function renderTagsDisplay(tags) {
const container = document.getElementById("wfm-gallery-tags-display");
container.innerHTML = "";
@@ -1524,6 +1622,9 @@ export function initGalleryTab() {
}
bindEvents();
_initGallerySubtabToggle();
initImagePromptTab();
initStyleCatalogTab();
}
let _initialized = false;
@@ -1535,6 +1636,23 @@ function onGalleryTabActivated() {
loadGroups();
}
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog) ────────────────────────
function _initGallerySubtabToggle() {
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const target = btn.dataset.gallerySubtab;
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
document.getElementById("wfm-gallery-panel-output")?.classList.toggle("active", target === "output");
document.getElementById("wfm-gallery-panel-imageprompt")?.classList.toggle("active", target === "imageprompt");
document.getElementById("wfm-gallery-panel-styleCatalog")?.classList.toggle("active", target === "styleCatalog");
if (target === "imageprompt") activateImagePromptTab();
if (target === "styleCatalog") activateStyleCatalogTab();
});
});
}
function bindEvents() {
// リフレッシュ
document.getElementById("wfm-gallery-refresh-btn")?.addEventListener("click", () => {
+213 -49
View File
@@ -9,50 +9,8 @@ import { comfyEditor } from "./comfyui-editor.js";
import { t } from "./i18n.js";
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
// ============================================
// Eagle Auto-Save
// ============================================
function getEagleSettings() {
const s = getSettings();
return {
url: s.eagleUrl || "http://localhost:41595",
autoSave: !!s.eagleAutoSave,
};
}
async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
const eagle = getEagleSettings();
if (!eagle.autoSave) return;
try {
const res = await fetch("/api/wfm/eagle/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eagleUrl: eagle.url,
url: imageUrl,
name,
tags: ["wfm-comfyui", ...tags],
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
filename: fileInfo?.filename || "",
subfolder: fileInfo?.subfolder || "",
type: fileInfo?.type || "output",
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
localPath: fileInfo?.localPath || "",
}),
});
const data = await res.json();
if (data.status === "success") {
console.log("[Eagle] Saved:", name);
} else {
console.warn("[Eagle] Save failed:", data.message);
}
} catch (err) {
console.warn("[Eagle] Save error:", err.message);
}
}
import { initLabTab, refreshLabLiveDefaults } from "./lab-tab.js";
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
// ============================================
// Gallery Metadata - ワークフロー保存
@@ -273,7 +231,9 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
// Render editor tabs
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow);
// resetStackMode: 新規ワークフロー読み込み時のみ、LoraManagerノードのloras/text状態を
// スナップショットし、Stackグループ使用チェックボックスをOFFへリセットする
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow, { resetStackMode: true });
// Update raw JSON with highlight
const rawTextarea = document.getElementById("wfm-gen-raw-json");
@@ -1305,6 +1265,12 @@ async function _loadStyles() {
_rebuildStyleList();
}
// Promptタブ「Style」サブタブでスタイルを作成・編集・削除した後に呼ばれる。
// GenerateUIのStyleドロップダウン・Batchタブのチェックリストを最新の状態へ再同期する。
export async function refreshStylesList() {
await _loadStyles();
}
function _renderStyleDropdown() {
const sel = document.getElementById("wfm-style-select");
if (!sel) return;
@@ -1333,6 +1299,21 @@ function _rebuildStyleList() {
);
}
// Style Catalogギャラリーの「Select as Style」から呼ばれる。名前が一致する登録済み
// Styleを探し、GenerateUIのStyleドロップダウンをそれに切り替えてタブへ移動する。
// 画像に埋め込まれたプロンプトを直接適用するのではなく、既存Styleシステムへの
// 視覚的なショートカットとして機能する(元のStyle JSONが変更・削除されても影響を受けない)。
export function selectStyleByName(name) {
const style = _stylesData.find((s) => s.name === name);
if (!style) return false;
const sel = document.getElementById("wfm-style-select");
if (sel) sel.value = name;
const enabled = document.getElementById("wfm-style-enabled");
if (enabled) enabled.checked = true;
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
return true;
}
function _applyNamedStyle(workflow, style) {
if (!style) return workflow;
const analysis = comfyUI.currentAnalysis;
@@ -1433,7 +1414,7 @@ async function _expandWildcardText(text) {
const _IMPACT_WC_TYPES = new Set(["ImpactWildcardEncode", "ImpactWildcardProcessor"]);
async function _expandWildcardsInWorkflow(workflow) {
export async function _expandWildcardsInWorkflow(workflow) {
let hasWildcard = false;
outer: for (const node of Object.values(workflow)) {
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
@@ -1581,7 +1562,9 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
// ============================================
// 汎用バッチループ: items を順に applyFn(item) → _coreGenerate() で処理する
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
// onResultFn(item, {images, seed}) が渡された場合、各アイテムの生成結果ごとに呼び出す
// (Style Catalogのカタログ作成が、生成画像をスタイル名で保存するために使用)
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x), onResultFn = null) {
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
const batchCount = document.getElementById("wfm-ckpt-batch-count");
@@ -1616,7 +1599,8 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
try {
await applyFn(item);
await _coreGenerate(true);
const result = await _coreGenerate(true);
if (onResultFn) await onResultFn(item, result);
completed++;
} catch (err) {
if (_ckptBatch.aborted) break;
@@ -1645,6 +1629,157 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
}
}
// ============================================
// Style Catalog 作成
// ============================================
// Batchタブ「Style」サブタブで選択済みのスタイル(_batchStyleSelected)を対象に、
// 現在ロード中のワークフローでそれぞれ生成し、結果をスタイル名でws_style_catalog
// 配下の指定フォルダへ保存する(Style Catalogギャラリーからの視覚的スタイル選択に使う)。
function _blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function _flattenFolderTree(node, acc = [], isRoot = true) {
acc.push({ path: isRoot ? "[root]" : node.path, abs_path: node.abs_path });
(node.children || []).forEach((c) => _flattenFolderTree(c, acc, false));
return acc;
}
async function openCatalogCreateModal() {
const list = _stylesData.filter((s) => _batchStyleSelected.has(s.name));
if (list.length === 0) {
showToast(t("catalogCreateNoStyles"), "error");
return;
}
let catalogRoot = null;
let folderTree = null;
try {
const rootRes = await fetch("/wfm/gallery/style-catalog/root");
const rootData = await rootRes.json();
catalogRoot = rootData.root;
if (!catalogRoot) throw new Error(rootData.error || "Style Catalog root not resolved");
const treeRes = await fetch(`/wfm/gallery/folders?root=${encodeURIComponent(catalogRoot)}`);
folderTree = await treeRes.json();
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
return;
}
const folderOptions = folderTree && !folderTree.error ? _flattenFolderTree(folderTree) : [];
const html = `
<p style="margin:0 0 10px;font-size:13px;color:var(--wfm-text-secondary);">${t("catalogCreateSelectedCount", list.length)}</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<label style="display:flex;align-items:center;gap:6px;">
<input type="radio" name="wfm-catalog-dest" value="new" checked>
${t("catalogCreateNewFolder")}
</label>
<input type="text" id="wfm-catalog-new-folder-name" class="wfm-input" placeholder="folder name" style="margin-left:22px;">
<label style="display:flex;align-items:center;gap:6px;">
<input type="radio" name="wfm-catalog-dest" value="existing" ${folderOptions.length === 0 ? "disabled" : ""}>
${t("catalogCreateExistingFolder")}
</label>
<select id="wfm-catalog-existing-folder" class="wfm-input" style="margin-left:22px;" ${folderOptions.length === 0 ? "disabled" : ""}>
${folderOptions.map((f) => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.path)}</option>`).join("")}
</select>
<hr class="wfm-group-divider" style="margin:4px 0;">
<label style="display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="wfm-catalog-skip-output">
${t("catalogCreateSkipOutput")}
</label>
</div>
<button id="wfm-catalog-run-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:14px;">${t("catalogCreateRun")}</button>
`;
openModal(t("catalogCreateTitle"), html);
document.getElementById("wfm-catalog-run-btn")?.addEventListener("click", async () => {
const destType = document.querySelector('input[name="wfm-catalog-dest"]:checked')?.value;
let destFolder = null;
if (destType === "new") {
const name = document.getElementById("wfm-catalog-new-folder-name")?.value.trim();
if (!name) {
showToast(t("catalogCreateFolderNameRequired"), "error");
return;
}
try {
const res = await fetch("/wfm/gallery/folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent: catalogRoot, name }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || "Failed to create folder");
destFolder = data.path;
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
return;
}
} else {
destFolder = document.getElementById("wfm-catalog-existing-folder")?.value || null;
if (!destFolder) {
showToast(t("catalogCreateFolderRequired"), "error");
return;
}
}
const skipOutput = document.getElementById("wfm-catalog-skip-output")?.checked || false;
closeModal();
await _runCatalogCreate(list, destFolder, skipOutput);
});
}
async function _runCatalogCreate(list, destFolder, skipOutput) {
try {
await _runBatchLoop(
list,
(style) => { _batchStyleOverride = style; },
(s) => s.name,
async (style, result) => {
const outputImages = (result?.images || []).filter((img) => img.type !== "temp");
if (outputImages.length === 0) return;
const srcImage = outputImages[0];
const blob = await comfyUI.getImageBlob(srcImage);
const dataUrl = await _blobToDataUrl(blob);
const res = await fetch("/wfm/gallery/image/save-to-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder: destFolder, filename: style.name, imageData: dataUrl }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || "Failed to save catalog image");
// Outputフォルダに元画像を残さないオプション: カタログへのコピー保存が
// 成功した後にのみ、ComfyUIのOutputフォルダ側の元ファイルを削除する
// (コピー失敗時は削除せず、元画像を保持したままにする)
if (skipOutput) {
try {
await fetch("/wfm/gallery/output-image/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: srcImage.filename,
subfolder: srcImage.subfolder || "",
type: srcImage.type || "output",
}),
});
} catch { /* 削除失敗はカタログ作成自体を止めない */ }
}
}
);
} finally {
_batchStyleOverride = null;
}
}
// アクティブなバッチタイプに応じてバッチを実行するディスパッチャ
async function _runBatchGenerate() {
switch (_activeBatchType) {
@@ -1933,6 +2068,25 @@ export async function initGenerateTab() {
}
});
// Style Catalog 作成ボタン
const catalogCreateBtn = document.getElementById("wfm-gen-catalog-create-btn");
if (catalogCreateBtn) {
catalogCreateBtn.textContent = t("catalogCreateBtn");
catalogCreateBtn.addEventListener("click", () => {
openCatalogCreateModal().catch((e) => showToast(t("errorWithMsg", e.message), "error"));
});
}
// Style Catalog を開くボタン(Gallery タブ → Style_Catalog サブタブへ遷移)
const catalogOpenBtn = document.getElementById("wfm-gen-catalog-open-btn");
if (catalogOpenBtn) {
catalogOpenBtn.textContent = t("catalogOpenBtn");
catalogOpenBtn.addEventListener("click", () => {
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="styleCatalog"]')?.click();
});
}
// Generate button
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
handleGenerate().catch(() => {});
@@ -1961,7 +2115,7 @@ export async function initGenerateTab() {
function moveRawJsonToTab(tabKey) {
const widget = document.getElementById("wfm-gen-rawjson-widget");
if (!widget) return;
if (tabKey === "feeder" || tabKey === "batch") {
if (tabKey === "feeder" || tabKey === "batch" || tabKey === "lab") {
widget.style.display = "none";
return;
}
@@ -1985,6 +2139,13 @@ export async function initGenerateTab() {
if (target === "model" && comfyUI.currentAnalysis) {
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
}
// Re-render Lab's column grid so Setting 1's live-reflected cells (checkpoint/
// VAE/prompt/KSampler) pick up whatever is currently loaded in GenerateUI, not
// whatever was loaded the last time Lab was rendered.
if (target === "lab") refreshLabLiveDefaults();
// Lab has its own Run/Results panel — hide the shared Generate/Seed/Batch column
const rightCol = document.querySelector(".wfm-gen-right");
if (rightCol) rightCol.style.display = (target === "lab") ? "none" : "";
});
});
@@ -2161,6 +2322,9 @@ export async function initGenerateTab() {
// Feeder tab
await initFeederTab();
// Lab tab (experimental I2I batch generation)
initLabTab();
// Style dropdown
await _loadStyles();
+554 -24
View File
@@ -149,6 +149,7 @@ const LANGUAGES = {
saveWorkflowTitle: "Save Workflow",
filenameLabel: "Filename",
pleaseEnterFilename: "Please enter a filename",
pleaseEnterStyleName: "Please enter a style name",
saveFailed: (msg) => msg ? `Save failed: ${msg}` : "Save failed",
savedAs: (f) => `Saved: ${f}`,
overwriteConfirm: (f) => `"${f}" already exists. Overwrite?`,
@@ -217,6 +218,83 @@ const LANGUAGES = {
batchStopped: (completed, failed) => `Batch stopped — ${completed} completed, ${failed} failed`,
batchComplete: (completed, total, failed) => failed > 0 ? `Batch complete: ${completed}/${total} (${failed} failed)` : `Batch complete: ${completed}/${total}`,
batchLoraMissing: (count, total, sample) => `${count}/${total} selected LoRAs are not recognized by ComfyUI yet (e.g. ${sample}). Click Refresh in ComfyUI or restart the server, then try again.`,
catalogCreateBtn: "Create Catalog",
catalogOpenBtn: "Catalog",
catalogCreateTitle: "Create Style Catalog",
catalogCreateNoStyles: "No styles selected — select styles in the Batch tab's Style sub-tab first",
catalogCreateSelectedCount: (n) => `${n} style${n === 1 ? "" : "s"} selected`,
catalogCreateNewFolder: "Create new folder",
catalogCreateExistingFolder: "Use existing folder",
catalogCreateRun: "Run",
catalogCreateFolderNameRequired: "Enter a folder name",
catalogCreateFolderRequired: "Select a destination folder",
catalogCreateSkipOutput: "Don't keep a copy in the Output folder",
// -- Lab tab --
labImageUploaded: (name) => `Image uploaded: ${name}`,
labUploadFailed: "Upload failed",
labBatchCountTooSmall: "Increase the Batch count first to add a keyframe beyond it",
labKeyframesExceedBatch: (maxIter, batchCount) => `Some keyframes are set up to iteration #${maxIter}, beyond the current Batch count (${batchCount}) — they won't be applied until Batch count is raised again`,
labUseWorkflowDefault: "(workflow default)",
labInheritsFrom: (val) => `(inherits: ${val})`,
labLoraNoNode: "No LoRA node found in the workflow — LoRA setting was skipped",
labAppliedAt: (n) => `Applied: ${n}`,
labRevertToBase: "↩ back to #1",
labApplyFromIteration: "Applies from iteration #",
labRevertCheckboxLabel: "Revert to iteration #1's setting",
labBypassed: "⏭ Bypassed",
labBypassCheckboxLabel: "Bypass this setting (keep whatever was active before it)",
labSeedEmptyRandom: "Seed (empty = random)",
labPause: "Pause",
labResume: "Resume",
labNoWorkflowLoaded: "Load a workflow into GenerateUI first",
labNoSourceImage: "Drop a source image first",
labNoResultsYet: "No results yet",
labResultPreview: "Preview",
labEnterPlanName: "Plan name:",
labPlanSaved: "Plan saved",
labPlanSaveFailed: "Failed to save plan",
labPlanLoaded: "Plan loaded",
labPlanLoadFailed: "Failed to load plan",
labPlanInvalidFile: "Drop a .json plan file or its .png index image",
labConfirmClear: "Clear all Lab settings (image, columns, note, results)?",
labGetFromGenUI: "Get from GenerateUI",
labGetFromImage: "Get from Image",
labGetFromPrevious: "Get from Previous",
labClearPrompt: "Clear",
labPromptCleared: "Prompt cleared",
labPromptCopiedFromPrevious: "Copied from the previous setting",
labLiveHint: "Showing the current GenerateUI settings. Edit and save to override.",
labStyle: "Style",
labApply: "Apply",
labNoStyles: "No styles",
labEnableStyleFirst: "Pick a style in the Style dropdown at the top of the GenerateUI tab first",
labPlanJsonRefreshed: "Refreshed from the current Setting tab",
labPlanJsonApplied: "Plan JSON applied to Setting",
labPlanJsonInvalid: "Invalid JSON",
labStyleApplied: "Style applied",
labPromptFetched: "Prompt loaded",
labNoPromptInImage: "No prompt found in that file",
labMetadataExtractFailed: "Failed to read metadata",
labConfirmLoadWorkflow: (filename) => `Load "${filename}" into GenerateUI? This replaces the currently loaded workflow.`,
labWorkflowLoadFailed: "Failed to load the workflow",
labT2IImageIgnored: "T2I mode is on — image upload is not needed and was skipped",
labIndexImageSaved: (filename) => `Index image saved to Output: ${filename}`,
labIndexImageSaveFailed: "Failed to save the index image to Output",
// -- GenerateUI Input tab: Image sub-panel placeholder --
i2iPlaceholderTitle: "Placeholder Image",
i2iPlaceholderColor: "Color",
i2iPlaceholderImage: "Image",
i2iPlaceholderWidth: "Width",
i2iPlaceholderHeight: "Height",
i2iPlaceholderColorLabel: "Color",
i2iPlaceholderDropImage: "Drop default image or click to select",
i2iPlaceholderApply: "Placeholder",
i2iPlaceholderGenerating: "Generating...",
i2iPlaceholderNoDefaultImage: "No default image set — drop one above first",
i2iPlaceholderUploading: "Uploading...",
connectedToComfyUI: "Connected to ComfyUI",
failedToConnect: "Failed to connect",
modelListsRefreshed: "Model lists refreshed",
@@ -243,6 +321,9 @@ const LANGUAGES = {
jsonColorCyan: "Prompts / Text",
jsonColorRed: "Images / Files",
jsonColorReset: "Reset to defaults",
modelTabColorSection: "GenerateUI Model Tab Highlight",
modelTabColorLabel: "Color for items present in the workflow",
modelTabColorHint: "In the GenerateUI Model tab, labels for items actually present in the loaded workflow (Checkpoint, VAE, etc.) are shown in this color.",
// -- Prompt tab --
tabPrompt: "Prompt",
@@ -482,6 +563,31 @@ const LANGUAGES = {
galleryCCNotAvailable: "Open this Gallery from ComfyUI Comic Creator to use Send CC",
gallerySentCC: "Sent to ComfyUI Comic Creator",
galleryNoEmbeddedWorkflow: "No ComfyUI workflow embedded in this image.",
galleryTabOutput: "Output",
galleryTabImagePrompt: "ImagePrompt",
galleryTabStyleCatalog: "Style_Catalog",
galleryNoSelection: "No selection",
imagePromptSelectedPrompt: "Prompt",
imagePromptNoPrompt: "No prompt data for this image.",
imagePromptAddBtn: "+ Add",
imagePromptChipsTitle: "Selected Prompts",
imagePromptClearBtn: "Clear All",
imagePromptFinalPromptTitle: "Final Prompt",
imagePromptSaved: "Prompt saved",
imagePromptAlreadyAdded: "Already added",
imagePromptNothingToCopy: "Nothing to copy yet",
imagePromptCopiedToast: "Copied to clipboard",
stylecatalogSearchPlaceholder: "Search...",
stylecatalogPositive: "Positive",
stylecatalogNegative: "Negative",
stylecatalogSelectStyleBtn: "Select as Style",
stylecatalogLoadGenuiBtn: "Load in GenerateUI",
stylecatalogOpenMetadataBtn: "Open in Metadata Tab",
stylecatalogNoPromptFound: "No prompt found in this image.",
stylecatalogNoMatch: "No matching style found",
stylecatalogStyleSelected: (name) => `Style set to "${name}"`,
stylecatalogNewFolderCreated: "Folder created",
stylecatalogSaved: (name) => `Saved: ${name}`,
galleryBulkSelected: "selected",
galleryBulkDeselectAll: "Deselect All",
galleryBulkSelectAll: "Select All",
@@ -581,6 +687,10 @@ const LANGUAGES = {
aiSettingsModelSection: "Model",
aiSettingsRefreshBtn: "Refresh",
aiSettingsModelPlaceholder: "-- Select Model --",
aiSettingsGenerationTitle: "Generation",
aiSettingsThinkingMode: "Thinking mode",
aiSettingsMaxTokens: "Max tokens",
aiSettingsMaxTokensPlaceholder: "0 = unlimited",
aiSettingsFreeLang: "Free Language",
aiSettingsInputLang: "Input",
aiSettingsOutputLang: "Output",
@@ -596,6 +706,7 @@ const LANGUAGES = {
aiStatusConnectOk: "Connected",
aiStatusConnectFail: "Connection failed: ",
aiToastTransFailed: "Translation failed: ",
aiToastMaybeNotTranslated: "The model returned the text unchanged — it may not have translated it. Try a different model.",
aiToastNoCopyText: "No text to copy",
aiToastCopied: "Copied",
aiToastNoModels: "No models found",
@@ -645,10 +756,14 @@ const LANGUAGES = {
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, send to canvas, load in GenerateUI, set as default, delete",
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
helpGen1: "Connect/disconnect ComfyUI and check connection status",
helpGen2: "Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
helpGen2: "Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
helpGen25: "Create Catalog button (next to the Style dropdown): generates one preview image per Style selected in the Batch tab's Style sub-tab (using the currently loaded workflow, typically T2I), then saves each result into a chosen ws_style_catalog subfolder with the Style's own name as the filename (existing files with the same name are overwritten); a modal lets you create a new destination folder or pick an existing one — separate folders are useful for grouping catalogs by checkpoint or by purpose; requires at least one Style checked in Batch tab → Style sub-tab first. Catalog button (right next to it): jumps straight to the Gallery tab's Style_Catalog sub-tab to browse the results",
helpGen26: "Create Catalog modal — \"Don't keep a copy in the Output folder\" checkbox: each generation still writes a normal file to ComfyUI's Output folder first (that's simply how the workflow's own Save Image node works), but once its copy has been saved into the catalog folder, checking this deletes that Output-folder original right away — useful for large catalog runs (e.g. all 200+ styles) so the Output folder isn't flooded with near-duplicate files; if a particular style's catalog copy fails to save, its Output file is left alone rather than being deleted",
helpGen27: "Input tab → Image sub-panel — each LoadImage card now has three buttons: Apply (unchanged), Clear (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself, since LoadImage always needs some valid filename), and Placeholder. A Placeholder Image section — collapsed by default since it's a low-frequency feature, click to expand — sits at the top of the panel with a shared default: Color mode (set Width/Height/Color; generates a solid-color PNG on the fly) or Image mode (drop one default image, reused as-is). Clicking a card's own Placeholder button immediately replaces just that card's image with the configured default — no separate Apply click needed, and with several LoadImage cards each can be replaced individually. The Placeholder config (mode, size, color, default image) is remembered across sessions",
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",
helpGen5: "Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a 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",
helpGen28: "Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab",
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; 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",
@@ -663,6 +778,11 @@ const LANGUAGES = {
helpGen17: "Style batch: select styles from the Style tab in the left pane, enable the Style column in Batch Queue, then Generate — each selected style is applied in sequence to the positive and negative prompts; applied to workflow copies so the loaded workflow is not modified; style files are re-read on each page load from user/default/Workflow-Studio/style/*.json",
helpGen18: "Alt+Click any Apply button (Positive Prompt / Checkpoint / KSampler) to Apply & Generate in one step — applies the parameter to the workflow then immediately queues a generation; only fires when the target node exists in the loaded workflow",
helpGen19: "Lora batch: for each LoRA, its syntax (<lora:name:strength_model:strength_clip>) and CivitAI trained words are automatically appended to the Positive prompt — rebuilt from the original prompt each time, not accumulated; the prompt is automatically restored to its pre-batch state when the batch completes or is stopped; if a selected LoRA is not yet recognized by ComfyUI's model list, a warning toast is shown (Refresh or restart ComfyUI)",
helpGen20: "Two ways LoRAs are attached: a chain of standard LoraLoader nodes (one LoRA per node — add more by chaining additional nodes) or a single Lora Loader (LoraManager) node (from the ComfyUI-Lora-Manager custom node pack) that holds a list of LoRAs internally, each with its own active/model-strength/clip-strength; the Single/Stack tabs below adapt automatically depending on which type the target node is",
helpGen21: "Single tab: pick one LoRA + Str M/Str C, then Apply — for a Lora Manager node this replaces its entire internal LoRA list with just this one entry, discarding any others it had",
helpGen22: "Stack tab — Use Stack Group checkbox (shown only when a Lora Manager node exists in the workflow): OFF (the default whenever a workflow is loaded) shows and edits the LoRAs actually configured in the workflow's Lora Manager node itself; ON shows and edits the \"Stack\" group registered in the Models tab instead; switching either direction clears the node's LoRA list first, then loads it from the newly selected source; switching back to OFF restores exactly the LoRA list that was present when the workflow was loaded (captured as a snapshot at load time)",
helpGen23: "Stack tab controls: each row has an active checkbox and Str M / Str C fields; the header \"Stack\" checkbox toggles all rows at once; the +/- step buttons adjust the strength of all currently active rows together; a \"Showing: LoRAs currently in workflow\" / \"Showing: registered Stack group\" label indicates which source is currently displayed; Apply writes the full list back to the target node and also appends LoRA syntax + CivitAI trigger words to the Positive prompt",
helpGen24: "Caution for CLIP Text Encode edit+ (CLIPTextEncodeEditPlus) nodes: when a Lora Manager node's trigger_words output feeds this node's text1 input (RAW/EDIT/front/back modes combine text1 with the editable text_edit field), trigger words are already included automatically at generation time; clicking a LoRA Apply button or turning on Use Stack Group additionally writes LoRA syntax/trigger words into text_edit, duplicating them — leave Use Stack Group off and avoid the LoRA Apply buttons if you want the workflow's original prompt and LoRA composition to stay unmodified",
helpFeederTitle: "GenerateUI Tab — Feeder subtab",
helpFeederDesc: "Two modes switchable via the [Image Loop] / [Gallery] toggle at the top of the left pane.",
helpFeederImgloopTitle: "Image Loop mode",
@@ -684,6 +804,42 @@ const LANGUAGES = {
helpFeederGal4: "Right pane — Preview: displays the currently selected input image; updates when a thumbnail is clicked",
helpFeederGal5: "__Feeder__ group: reserved group pre-created automatically; cannot be renamed or deleted; add images to it from the Gallery tab using the F overlay button on each thumbnail",
helpFeederGal6: "WFS_GalleryFeeder canvas controls: the node also has Run / Stop / After Gen widgets added directly on the ComfyUI canvas — these work the same as the WFM UI controls and allow running the gallery feeder loop without opening WFM",
helpLabTitle: "GenerateUI Tab — Lab subtab",
helpLabDesc: "Experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA/VAE) / Prompt / KSampler each change independently starting at a chosen iteration. Two subtabs: Setting and Results.",
helpLabCardColumnsTitle: "Setting subtab — Image & Columns",
helpLab1: "Left pane — Image: drop or click to select an I2I source image; it is uploaded to ComfyUI's input folder and used for every iteration unless \"Use generated image for next\" is checked",
helpLab2: "Left pane — Use generated image for next: from iteration 2 onward, feeds the previous iteration's first output image back in as the I2I source instead of the original dropped image (iteration 1 always uses the original)",
helpLab3: "Center pane — 3 independent columns: Model, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid",
helpLab4: "+ / − per column: + appends a new keyframe at the next free iteration number (up to the Batch count) and opens its edit modal immediately; − removes the last keyframe (iteration #1 can never be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal via its Delete button, not just the last one",
helpLab5: "Click a cell to edit it: iteration #1 sets the baseline value (leave it blank to keep the loaded workflow's own setting); iteration #2+ also lets you choose which iteration it starts applying from, plus a \"revert to #1's setting\" checkbox",
helpLab6: "Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; \"revert to #1\" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one; lowering the Batch count (by hand, or by loading/applying a plan) below an existing keyframe's iteration number doesn't delete or renumber that keyframe — a warning toast appears, and the keyframe simply stays dormant (never reached by the run loop) until Batch count is raised again. This whole-row resolution applies as-is to Prompt and KSampler; the Model column instead resolves its three fields (Checkpoint/LoRA/VAE) independently — see below",
helpLab7: "Lab never edits the workflow shown in Input/Model/Settings — every iteration runs on a fresh clone of the loaded workflow, so keyframes here never affect the other subtabs",
helpLab8: "Prompt cell editor extras: Get from GenerateUI fills Positive/Negative from the workflow currently loaded in GenerateUI; Get from Image extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); Style row — the Apply button appends whichever style is currently selected in the Style dropdown at the top of the GenerateUI toolbar to the Positive/Negative text below, comma-separated after any existing text (never clears it), then checks the Style checkbox and shows the applied style's name next to it (not on the button) — the style NAME always comes from that top dropdown (Lab has no picker of its own), and clicking Apply works regardless of the top toolbar's own Style enable checkbox; unchecking the Style checkbox only clears that name label — it does not remove the already-appended text from Positive/Negative, which stays there for you to edit or delete by hand",
helpLab9: "Left pane — T2I workflow (no source image): check this when the loaded workflow is text-to-image (no LoadImage node). It skips the \"drop a source image first\" requirement, ignores any dropped image, and the Model/Prompt/KSampler keyframes still apply normally; the Image drop zone and \"Use generated image for next\" are greyed out while it's on (any previously dropped image is kept, not deleted, in case you switch back to I2I); the setting is saved with the plan as t2i_mode",
helpLab10: "Wildcards: Positive/Negative prompt text in Prompt column keyframes supports the same A1111-style __name__ syntax as the main GenerateUI — each keyframe's text is expanded fresh right before that iteration runs, so every iteration can draw a different random line even when reusing the same keyframe",
helpLab11: "Prompt cell editor also has Clear (empties both Positive and Negative) and Get from Previous (copies both Positive and Negative from the keyframe immediately before this one in the same column — e.g. editing #3 copies #2's prompt wholesale; not shown on keyframe #1, which has no earlier row to copy from)",
helpLab12: "Keyframe #1 in every column shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only (LoRA is never live-reflected, since there's no reliable way to infer \"the\" active LoRA back out of a workflow node) — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI",
helpLab13: "Bypass checkbox (every keyframe, including #1): skips that row entirely when picking which setting applies at a given iteration — as if the row didn't exist — so whatever was active before it (an earlier keyframe, or ultimately nothing) just keeps running; Lab-internal only, it never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (the two are mutually exclusive); the cell shows an amber \"⏭ Bypassed\" label, which takes priority over both the Revert and live-value display",
helpLab14: "Model column header's C / L / V buttons pick which single field (Checkpoint / LoRA / VAE — LoRA is Single-only, no Stack support here) its cells summarize and its edit modal shows; the two you're not currently viewing are left exactly as already saved when you Save. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it",
helpLab15: "Model column fields carry forward independently: a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE to the workflow default — each of the three keeps whatever an earlier keyframe last set until a later keyframe explicitly changes it. A field left blank in the edit modal shows \"(inherits: ...)\" as its placeholder instead of \"(workflow default)\" when there's something to inherit, so it's clear what actually applies. \"Revert to #1's setting\" resets all three fields at once back to keyframe #1's row, not just the one you're currently viewing",
helpLabCardPlanTitle: "Setting subtab — Run & Plan files",
helpLabPlan1: "Right pane — Batch: number of iterations to run; Note: free-text memo saved together with the plan",
helpLabPlan2: "Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop",
helpLabPlan3: "Plan Save vs Save As: Plan Save silently overwrites the currently loaded plan (or asks for a name the first time); Save As always asks for a new name, even while a plan is already loaded",
helpLabPlan4: "Plan Load: drop a plan's .json file, or any of its index-image PNGs, onto the PLAN box (or click it to browse) — a dropped .json is read directly; a dropped .png is read directly too if it carries embedded plan data (every index image since v0.3.90 does, in a wfm_lab_plan chunk), falling back to fetching a same-named .json from the server for older index images that predate this",
helpLabPlan5: "Plan Clear resets the Setting tab to an empty plan (asks for confirmation first)",
helpLabPlan6: "Plan files are stored in user/default/Workflow-Studio/lab_plan/ as ws_labplan_<name>.json plus a ws_labplan_<name>.png index image — a contact sheet of up to 9 result thumbnails (3 per row) generated automatically whenever a plan with results is saved. The ws_labplan_ prefix is added automatically by Plan Save/Save As (type just the plain name when prompted) so plan files are easy to pick out among other files in that folder",
helpLabPlan7: "Eagle auto-save (configured in the Settings tab) applies to every image Lab generates, the same as GenerateUI's own Generate button",
helpLabPlan8: "Plan JSON subtab: shows the current Setting tab's plan as raw JSON; Refresh re-reads it from the Setting tab (discarding any unapplied text edits), Apply to Setting parses your edits and writes them back into the Setting tab (columns, note, batch count, chain-image, source image, results) — the same effect as loading that JSON as a plan file, without needing to save it first",
helpLabPlan9: "Each saved plan records the filename of the workflow that was loaded in GenerateUI at save time (workflow_filename in the Plan JSON). After loading a plan, an Open Workflow button appears in the PLAN box — click it to load that exact workflow file back into GenerateUI (asks for confirmation, since it replaces whatever is currently loaded)",
helpLabPlan10: "Right pane — Save index image to Output on Run: when checked, every time Run finishes it builds the same contact-sheet index image used for Plan files and saves it directly into ComfyUI's own Output folder as Lab_index_<counter>_.png (auto-numbered like a normal generated image, never overwriting a previous run's). Once saved, it also goes through Eagle auto-save (if enabled in the Settings tab) just like every other Lab-generated image — no separate checkbox for that, it follows the same global toggle. This is independent of Plan Save, which still always writes its own index image next to the plan file in lab_plan/ regardless of this checkbox; this setting is saved with the plan as save_index_on_run",
helpLabPlan11: "Every index image PNG — whether from Plan Save/Save As or from Save index image to Output on Run — carries the entire plan (columns, note, batch count, chain-image/T2I settings, workflow filename, results) embedded in it directly as a PNG wfm_lab_plan chunk, so the Output-folder copy is self-contained and works as a Plan Load source too even though it has no matching .json next to it anywhere",
helpLabCardResultsTitle: "Results subtab",
helpLabResults1: "Left pane shows the source image used for the run",
helpLabResults2: "Center pane shows up to 9 generated images in generation order; click a thumbnail to view it enlarged",
helpLabResults3: "Loading a saved plan restores its Results grid from the image list recorded in the plan file",
helpSearchPlaceholder: "Search help...",
helpPrompt1: "AI assistant (Ollama / LM Studio / Lemonade): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
helpPrompt2: "Apply AI responses to GenerateUI prompts",
@@ -694,6 +850,11 @@ const LANGUAGES = {
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
helpPrompt8: "Wildcard toolbar: one-click buttons to insert wildcard syntax ({|}, {n$|}, __|__, <lora:>, etc.); wraps selected text",
helpPrompt9: "Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor",
helpPrompt10: "Right pane's Wildcard/Style tab bar switches the whole panel between the wildcard tools above and this Style manager — same right pane, two modes",
helpPrompt11: "Create/edit/delete registered Styles (the same ones used by GenerateUI's Style dropdown, Batch tab, and the Style Catalog): click a name in the list to edit it, or \"+ New\" for a blank entry; Name, Positive prompt (supports the {prompt} placeholder — replaced with the user's own prompt, or appended after it if omitted) and Negative prompt (optional)",
helpPrompt12: "Renaming a style keeps its position in whichever file it was originally defined in; new styles are saved into a dedicated custom.json inside the style folder so the bundled style packs are never modified directly; a name must be unique across all styles",
helpPrompt13: "Saving, renaming, or deleting a style immediately refreshes GenerateUI's Style dropdown, the Batch tab's style checklist, and the Style Catalog's name list — no reload needed",
helpPrompt14: "Each entry in the list shows its source file as a small badge next to the name (e.g. sdxl_styles_diva.json); while editing an existing style, that filename also appears above the Positive prompt field, and a \"+ Add to this file\" button appears next to Save — click it to clear the form for a brand-new style that gets appended to that same file instead of the default custom.json",
helpSettings1: "UI language and summary language",
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
helpSettings3: "Workflow data folder path",
@@ -707,6 +868,7 @@ const LANGUAGES = {
helpSettings11: "Text Size: one slider (10–28 px) adjusts font size for all prompt and chat textareas at once — Generate UI positive/negative prompts, AI Assistant chat input, Preset positive/negative, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings",
helpSettings12: "RAW JSON Colors: customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI — Default Text, Name/Scheduler, Title, Width/Height, Prompt/Text, Image/File; changes apply immediately on color pick; Reset Defaults restores the original color scheme",
helpSettings13: "CivitAI Host: choose between civitai.com (SFW only) and civitai.red (unrestricted) — determines which site opens when clicking a model link in the Models tab CivitAI panel",
helpSettings14: "GenerateUI Model Tab Highlight: pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it",
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
helpNodes2: "Card and Table view modes with pagination",
helpNodes3: "Favorite nodes and add custom tags for organization",
@@ -744,6 +906,14 @@ const LANGUAGES = {
helpGallery15: "F button (cyan overlay, top-left of each thumbnail): toggle the image in/out of the __Feeder__ group with a single click; cyan and always visible when in group, visible on hover only when not; quickly queue images for the Feeder tab Gallery mode",
helpGallery16: "Image Edit button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1",
helpGallery17: "Send GenUI Image button (toolbar, next to Image Edit): uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded first",
helpGallery18: "Second sub-tab next to Output (top of the Gallery tab) for browsing a visual prompt library — manages the ws_image_prompt folder inside the ComfyUI output directory (created automatically)",
helpGallery19: "Same 3-column layout: folder tree (left) | thumbnails (center) | prompt builder (right). Click a thumbnail to preview it and its prompt text — this does not add it to the prompt yet",
helpGallery20: "\"+ Add\" appends the previewed image's prompt as a chip under \"Selected Prompts\"; chips can be removed individually (×) or all at once (\"Clear All\"); \"Save\" edits and persists the prompt text for that image",
helpGallery21: "\"Final Prompt\" combines all chips into one comma-separated text (also freely editable); \"Copy\" copies it to the clipboard. Prompt text is stored as a plain .txt file next to each image, so anyone can add their own entries by dropping an image + matching .txt file into a subfolder",
helpGallery22: "Third sub-tab (top of the Gallery tab) for visually picking a registered Style by its preview image — manages the ws_style_catalog folder inside the ComfyUI output directory (created automatically); populated by the GenerateUI tab's Create Catalog button (see GenerateUI Tab help)",
helpGallery23: "Same 3-column layout: folder tree (left) | thumbnails (center) | Positive/Negative prompt panel (right). Click a thumbnail to read the Positive/Negative prompt actually embedded in that generated image, each with its own Copy button",
helpGallery24: "Select as Style button: matches the selected image's filename (without extension) against a registered Style name and switches the GenerateUI tab's Style dropdown to it — a visual shortcut into the existing named-Style system, not a one-off apply of the embedded prompt (if the Style was renamed or deleted since the catalog image was created, a \"no matching style\" toast is shown instead)",
helpGallery25: "Load in GenerateUI / Open in Metadata Tab buttons: read the workflow embedded in the catalog image itself (the catalog copy carries the same PNG metadata as the original output image) and either load it straight into the GenerateUI editor or open the image in the Metadata tab for inspection — no need to hunt down the original file in the Output folder",
// -- Tagger Tab --
helpTaggerTitle: "Tagger Tab",
@@ -773,14 +943,14 @@ const LANGUAGES = {
helpSidepanel13: "I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF, QuadrupleCLIPLoader, and Lora Loader (LoraManager) node types",
helpSidepanel14: "Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center",
helpSidepanel15: "Information tab — LoRA sub-tab: detects LoRAs from LoraLoader, LoraLoaderModelOnly, and Lora Loader (LoraManager) nodes (API format supported); drag individual LoRA to place LoraLoader; \"Multiple LORA\" section places all LoRAs in a single Lora Loader (LoraManager) node with LoRA syntax pre-filled; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy",
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
helpSidepanel17: "Send to Canvas: clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab) sends the workflow directly to the ComfyUI canvas — both UI and API formats are supported; if Workflow Studio was not opened from the ComfyUI toolbar (window.opener unavailable), UI-format workflows fall back to title drag (title turns blue with a green ●)",
helpAiTitle: "AI Tab (A)",
helpAi1: "4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously",
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image (\"generate a cat picture\", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the \"Allow image generation\" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)",
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button",
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",
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires \"Auto model switching (OpenAI API)\" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a think flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); 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",
helpAi8: "Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, or delete .md skill files, stored in user/default/Workflow-Studio/ai_skills/; a file's first line block between --- markers can define name/description shown in the dropdown; two starter skills are included by default: \"SVG Icon Generator\" (decomposes the subject into parts before drawing, for more recognizable SVG output) and \"Skill Creator\" (interviews you conversationally and drafts a new skill file, ready to save); when a reply contains a ```skill code block, a \"Save as new skill\" button appears below it that opens the manager with the draft pre-filled",
helpAi9: "Tips for writing your own skills: give a clear frontmatter name/description so it reads well in the dropdown and tooltip; state the exact output format you want and, if there is a deliverable to extract later (like SVG or another skill file), wrap it in a distinctive fenced code block; write concrete, imperative instructions rather than vague ones - smaller local models follow explicit rules and a short worked example far better than general guidance; state hard constraints (forbidden content, security, length limits) explicitly rather than assuming they are implied; when in doubt, select the \"Skill Creator\" skill in the dropdown and describe what you want - it will interview you and draft the file for you",
@@ -804,10 +974,12 @@ const LANGUAGES = {
helpGenCardSeed: "Seed & Generation",
helpGenCardBatchSetup: "Batch Tab — Setup",
helpGenCardBatchTypes: "Batch Tab — Batch Types",
helpGenCardLora: "Model Tab — LoRA Details",
helpPromptTitle: "Prompt Tab",
helpPromptCardAi: "AI Assistant & Translation",
helpPromptCardPresets: "Presets",
helpPromptCardWildcards: "Wildcards",
helpPromptCardStyleTitle: "Style",
helpMetadataCardOverview: "Overview",
helpMetadataCardExtracted: "Extracted Information",
helpMetadataCardFormats: "Supported Formats",
@@ -822,6 +994,8 @@ const LANGUAGES = {
helpNodesCardGroups: "Groups & Sets",
helpNodesCardPanel: "Side Panel & Multi-select",
helpGalleryCardBrowse: "Browsing",
helpGalleryCardStyleprompt: "ImagePrompt Gallery",
helpGalleryCardStylecatalog: "Style_Catalog Gallery",
helpGalleryCardFolder: "Folder & Selection",
helpGalleryCardDetail: "Detail Panel",
helpGalleryCardActions: "Actions Toolbar",
@@ -869,7 +1043,8 @@ const LANGUAGES = {
helpImageEditExportTitle: "Export & Navigation",
helpImageEdit17: "Save PNG: composites all visible layers and downloads the result as a PNG file",
helpImageEdit18: "Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root",
helpImageEdit19: "Send to ComfyUI: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes",
helpImageEdit19: "Save to Input: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes",
helpImageEdit19b: "Send to Workflow: uploads the composite PNG (same as Save to Input) and additionally writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable",
helpImageEdit20: "Canvas navigation: scroll wheel to zoom, Space + drag to pan",
helpImageEdit21: "Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)",
helpImageEditKbdTitle: "Image Edit Keyboard Shortcuts",
@@ -902,6 +1077,7 @@ const LANGUAGES = {
helpImageEditMask11: "Text sub-tool: click on the canvas to open a text stamp overlay; enter text, configure Font / Size / Bold / Italic / Align, choose Mode (Add = white stamp / Erase = cut mask), then click Stamp or press Ctrl+Enter; multi-line text supported (newline in textarea)",
helpImageEditMask12: "Vector sub-tool: click to place control points connected by a Catmull-Rom spline; click the first point again (or press Enter) to close and fill the polygon; Backspace removes the last point; Esc resets all points; Properties pane shows current point count and Mode (Add / Erase)",
helpImageEditMask13: "Shape sub-tool: drag to draw a rectangle or ellipse mask; hold Shift for a perfect square or circle; a blue dashed outline previews the shape while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)",
helpImageEditMask14: "Edit in Mask Editor One (Paint mode only): click \"Edit in Mask Editor One →\" in the MASK EDITOR ONE section to send the current canvas composite — plus the active mask layer's content, if one is selected — to a MaskEditorOne node on the ComfyUI canvas (the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph) and open its full editor (SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; after clicking Apply there, the result is imported back here as a new mask layer; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable, and at least one MaskEditorOne node to exist in the ComfyUI graph",
helpImageEditBlurTitle: "Blur Tool (≈)",
helpImageEditBlur1: "Whole Blur: applies a Gaussian blur to the entire active layer — adjust the intensity slider (1–50 px) and click Apply",
helpImageEditBlur2: "Whole Mosaic: applies a pixelation mosaic to the entire active layer — adjust the block size slider (5–100 px) and click Apply",
@@ -939,6 +1115,7 @@ const LANGUAGES = {
helpTrouble6: "Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.",
helpTrouble7: "Tagger tab shows no models: verify the model folder exists at ComfyUI/models/tagger/<model-name>/ and contains a .onnx or .h5 file; if onnxruntime is not installed, run: python_embeded\\python.exe -m pip install onnxruntime",
helpTrouble8: "Model tab shows a checkpoint you didn't expect (e.g. always the same model regardless of the loaded workflow): the checkpoint saved in the workflow file is not installed, so it was silently replaced with another available model during loading — an error toast reports the original (missing) model name when this happens. Select the correct checkpoint manually in the Model tab, or enable \"Default Checkpoint\" in Settings to force a known-good model on every load.",
helpTrouble9: "Generate runs a different workflow than the one just loaded: a Batch type checkbox (Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler) is left ON in the Batch subtab. While any Batch type is active, the Generate button runs the batch selection instead of the currently loaded workflow. Open the Batch subtab and turn off the active batch type checkbox.",
helpSupportTitle: "Support",
helpSupportDesc: "If you find this plugin useful, please consider supporting its development!",
helpGithubDesc: "Report issues, request features, and contribute",
@@ -1078,14 +1255,14 @@ const LANGUAGES = {
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG (parameters)",
metaFmtFooocus: "Fooocus — PNG (fooocus_scheme)",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image subgraph workflows supported",
helpMetadataTitle: "Metadata Tab",
helpMetadataDesc: "Drop a ComfyUI-generated PNG/WebP or workflow JSON to instantly extract and view the embedded model and prompt information.",
helpMetadata1: "Left column: drop zone (image preview for PNG/WebP) and file info; click or drop to load",
helpMetadata2: "Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow",
helpMetadata3: "Right column: LoRA list (with strength values) and Prompt list (click to view full text below)",
helpMetadata4: "Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative",
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus.",
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image subgraph workflows), SD WebUI, SD Forge, Fooocus.",
},
ja: {
@@ -1231,6 +1408,7 @@ const LANGUAGES = {
saveWorkflowTitle: "ワークフローを保存",
filenameLabel: "ファイル名",
pleaseEnterFilename: "ファイル名を入力してください",
pleaseEnterStyleName: "スタイル名を入力してください",
saveFailed: (msg) => msg ? `保存に失敗しました: ${msg}` : "保存に失敗しました",
savedAs: (f) => `保存しました: ${f}`,
overwriteConfirm: (f) => `「${f}」は既に存在します。上書きしますか?`,
@@ -1299,6 +1477,83 @@ const LANGUAGES = {
batchStopped: (completed, failed) => `バッチ停止 — 完了${completed}件、失敗${failed}件`,
batchComplete: (completed, total, failed) => failed > 0 ? `バッチ完了: ${completed}/${total}件 (${failed}件失敗)` : `バッチ完了: ${completed}/${total}件`,
batchLoraMissing: (count, total, sample) => `選択したLoRAのうち${count}/${total}件がComfyUIに認識されていません(例: ${sample})。ComfyUIでRefreshするかサーバーを再起動してから再実行してください。`,
catalogCreateBtn: "カタログ作成",
catalogOpenBtn: "カタログ",
catalogCreateTitle: "スタイルカタログ作成",
catalogCreateNoStyles: "スタイルが選択されていません。先にBatchタブのStyleサブタブでスタイルを選択してください",
catalogCreateSelectedCount: (n) => `${n}件のスタイルが選択されています`,
catalogCreateNewFolder: "新規フォルダを作成",
catalogCreateExistingFolder: "既存フォルダを使用",
catalogCreateRun: "実行",
catalogCreateFolderNameRequired: "フォルダ名を入力してください",
catalogCreateFolderRequired: "保存先フォルダを選択してください",
catalogCreateSkipOutput: "Outputフォルダにコピーを残さない",
// -- Lab タブ --
labImageUploaded: (name) => `画像をアップロードしました: ${name}`,
labUploadFailed: "アップロードに失敗しました",
labBatchCountTooSmall: "先にBatch数を増やしてからキーフレームを追加してください",
labKeyframesExceedBatch: (maxIter, batchCount) => `${maxIter}回目まで設定されたキーフレームがありますが、現在のBatch数(${batchCount})を超えているため、Batch数を増やすまで適用されません`,
labUseWorkflowDefault: "(ワークフローの設定のまま)",
labInheritsFrom: (val) => `(引き継ぎ: ${val})`,
labLoraNoNode: "ワークフロー内にLoRAノードが見つかりません — LoRA設定はスキップされました",
labAppliedAt: (n) => `適用:${n}`,
labRevertToBase: "↩ 1回目に戻す",
labApplyFromIteration: "何回目から適用するか",
labRevertCheckboxLabel: "1回目の設定に戻す",
labBypassed: "⏭ バイパス中",
labBypassCheckboxLabel: "この設定をバイパス(直前まで有効だった設定を維持)",
labSeedEmptyRandom: "Seed(空欄でランダム)",
labPause: "一時停止",
labResume: "再開",
labNoWorkflowLoaded: "先にGenerateUIでワークフローを読み込んでください",
labNoSourceImage: "先に元になる画像をドロップしてください",
labNoResultsYet: "まだ結果がありません",
labResultPreview: "プレビュー",
labEnterPlanName: "プラン名:",
labPlanSaved: "プランを保存しました",
labPlanSaveFailed: "プランの保存に失敗しました",
labPlanLoaded: "プランを読み込みました",
labPlanLoadFailed: "プランの読み込みに失敗しました",
labPlanInvalidFile: "プランの.jsonファイル、またはそのインデックス画像(.png)をドロップしてください",
labConfirmClear: "Labの設定(画像・列・メモ・結果)をすべてクリアしますか?",
labGetFromGenUI: "GenerateUIから取得",
labGetFromImage: "画像から取得",
labGetFromPrevious: "前の設定から取得",
labClearPrompt: "クリア",
labPromptCleared: "プロンプトをクリアしました",
labPromptCopiedFromPrevious: "前の設定からコピーしました",
labLiveHint: "現在のGenerateUIの設定を表示しています。編集して保存すると上書きされます。",
labStyle: "スタイル",
labApply: "適用",
labNoStyles: "スタイルがありません",
labEnableStyleFirst: "先にGenerateUIタブ上部のStyleドロップダウンでスタイルを選択してください",
labPlanJsonRefreshed: "現在のSettingタブの内容で更新しました",
labPlanJsonApplied: "Plan JSONをSettingに適用しました",
labPlanJsonInvalid: "JSONが不正です",
labStyleApplied: "スタイルを適用しました",
labPromptFetched: "プロンプトを取得しました",
labNoPromptInImage: "このファイルにはプロンプトが見つかりませんでした",
labMetadataExtractFailed: "メタデータの読み取りに失敗しました",
labConfirmLoadWorkflow: (filename) => `「${filename}」をGenerateUIに読み込みますか?現在読み込まれているワークフローは置き換えられます。`,
labWorkflowLoadFailed: "ワークフローの読み込みに失敗しました",
labT2IImageIgnored: "T2Iモードが有効なため、画像のアップロードは不要としてスキップしました",
labIndexImageSaved: (filename) => `インデックス画像をOutputへ保存しました: ${filename}`,
labIndexImageSaveFailed: "インデックス画像のOutputへの保存に失敗しました",
// -- GenerateUIタブ Inputサブタブ: Image内プレースホルダ --
i2iPlaceholderTitle: "プレースホルダ画像",
i2iPlaceholderColor: "色",
i2iPlaceholderImage: "画像",
i2iPlaceholderWidth: "幅",
i2iPlaceholderHeight: "高さ",
i2iPlaceholderColorLabel: "色",
i2iPlaceholderDropImage: "デフォルト画像をドロップまたはクリックして選択",
i2iPlaceholderApply: "プレースホルダ",
i2iPlaceholderGenerating: "生成中...",
i2iPlaceholderNoDefaultImage: "デフォルト画像が未設定です — 先に上部でドロップしてください",
i2iPlaceholderUploading: "アップロード中...",
connectedToComfyUI: "ComfyUIに接続しました",
failedToConnect: "接続に失敗しました",
modelListsRefreshed: "モデルリストを更新しました",
@@ -1325,6 +1580,9 @@ const LANGUAGES = {
jsonColorCyan: "プロンプト / テキスト",
jsonColorRed: "画像 / ファイル",
jsonColorReset: "デフォルトに戻す",
modelTabColorSection: "GenerateUI Modelタブ ハイライト色",
modelTabColorLabel: "ワークフローに含まれる項目の色",
modelTabColorHint: "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します。",
// -- Prompt tab --
tabPrompt: "プロンプト",
@@ -1564,6 +1822,31 @@ const LANGUAGES = {
galleryCCNotAvailable: "Send CCはComfyUI Comic Creator内のギャラリーから開いた場合のみ使用できます",
gallerySentCC: "ComfyUI Comic Creatorへ送信しました",
galleryNoEmbeddedWorkflow: "この画像にはComfyUIワークフローが埋め込まれていません。",
galleryTabOutput: "Output",
galleryTabImagePrompt: "ImagePrompt",
galleryTabStyleCatalog: "Style_Catalog",
galleryNoSelection: "選択なし",
imagePromptSelectedPrompt: "プロンプト",
imagePromptNoPrompt: "この画像にはプロンプトデータがありません。",
imagePromptAddBtn: "+ 追加",
imagePromptChipsTitle: "選択済みプロンプト",
imagePromptClearBtn: "全クリア",
imagePromptFinalPromptTitle: "最終プロンプト",
imagePromptSaved: "プロンプトを保存しました",
imagePromptAlreadyAdded: "すでに追加済みです",
imagePromptNothingToCopy: "コピーする内容がありません",
imagePromptCopiedToast: "クリップボードにコピーしました",
stylecatalogSearchPlaceholder: "検索...",
stylecatalogPositive: "Positive",
stylecatalogNegative: "Negative",
stylecatalogSelectStyleBtn: "スタイルとして選択",
stylecatalogLoadGenuiBtn: "GenerateUIへ読み込む",
stylecatalogOpenMetadataBtn: "Metadataタブで開く",
stylecatalogNoPromptFound: "この画像にはプロンプトが見つかりません。",
stylecatalogNoMatch: "一致するスタイルが見つかりません",
stylecatalogStyleSelected: (name) => `スタイルを「${name}」に設定しました`,
stylecatalogNewFolderCreated: "フォルダを作成しました",
stylecatalogSaved: (name) => `保存しました: ${name}`,
galleryBulkSelected: "件選択中",
galleryBulkDeselectAll: "選択解除",
galleryBulkSelectAll: "全選択",
@@ -1663,6 +1946,10 @@ const LANGUAGES = {
aiSettingsModelSection: "モデル選択",
aiSettingsRefreshBtn: "更新",
aiSettingsModelPlaceholder: "-- モデルを選択 --",
aiSettingsGenerationTitle: "生成設定",
aiSettingsThinkingMode: "Thinking mode(思考モード)",
aiSettingsMaxTokens: "Max tokens(最大トークン数)",
aiSettingsMaxTokensPlaceholder: "0 = 無制限",
aiSettingsFreeLang: "Free 言語設定",
aiSettingsInputLang: "入力言語",
aiSettingsOutputLang: "翻訳言語",
@@ -1678,6 +1965,7 @@ const LANGUAGES = {
aiStatusConnectOk: "接続成功",
aiStatusConnectFail: "接続失敗: ",
aiToastTransFailed: "翻訳に失敗しました: ",
aiToastMaybeNotTranslated: "モデルが原文をそのまま返しました。翻訳されていない可能性があります。別のモデルをお試しください。",
aiToastNoCopyText: "コピーするテキストがありません",
aiToastCopied: "コピーしました",
aiToastNoModels: "モデルが見つかりません",
@@ -1727,10 +2015,14 @@ const LANGUAGES = {
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、バッジ付与、タグ、メモ、AI要約、キャンバスへ送る、生成UIに読み込み、デフォルト設定、削除",
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
helpGen2: "Workflowタブからワークフローを読み込み(サブグラフを含むワークフローにも対応 — Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等のテンプレート含む;ComfyUIの簡略化App形式 [.app.json / Linear Mode] のみ非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
helpGen2: "Workflowタブからワークフローを読み込み(サブグラフを含むワークフローにも対応 — Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 等のテンプレート含む;ComfyUIの簡略化App形式 [.app.json / Linear Mode] のみ非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
helpGen25: "カタログ作成ボタン(Styleドロップダウンの隣):Batchタブ→Styleサブタブで選択中の各スタイルについて、現在読み込み中のワークフロー(通常はT2I)でプレビュー画像を1枚ずつ生成し、指定したws_style_catalogサブフォルダへスタイル名をファイル名として保存(同名ファイルは上書き)。実行時、新規フォルダ作成または既存フォルダ選択のモーダルが表示される — フォルダを分けることでチェックポイント別・用途別にカタログを整理できる。実行にはBatchタブ→Styleサブタブで最低1つスタイルを選択しておく必要がある。カタログボタン(その隣):Galleryタブ Style_Catalogサブタブへ直接移動して結果を確認できる",
helpGen26: "カタログ作成モーダルの「Outputフォルダにコピーを残さない」チェックボックス:各生成は最初は必ずComfyUIのOutputフォルダへ通常通りファイルを書き出す(ワークフロー自体のSave Imageノードの仕様のため)。このチェックを入れておくと、カタログフォルダへのコピー保存が成功した直後に、そのOutput側の元ファイルを即座に削除する — 200件超の全スタイルなど大規模なカタログ作成でOutputフォルダがほぼ重複したファイルで溢れるのを防げる。特定のスタイルでカタログへのコピー保存自体に失敗した場合、そのOutputファイルは削除されずそのまま残る",
helpGen27: "Inputタブ → Imageサブパネル — 各LoadImageカードにApply(変更なし)・Clear・Placeholderの3ボタンを追加。Clearはそのカードの保留中の選択・プレビューを空に戻す(LoadImageは常に有効なファイル名が必須なため、ワークフローノード自体には触れない)。パネル上部の「Placeholder Image」セクション(使用頻度が低い機能のためデフォルトで折りたたみ、クリックで展開)で共通のデフォルトを設定できる:Colorモード(幅・高さ・色を指定し、その場で単色PNGを生成)またはImageモード(デフォルト画像を1枚ドロップし、そのまま再利用)。各カード自身のPlaceholderボタンを押すと、そのカードの画像だけを設定済みデフォルトへ即座に差し替える(別途Applyクリック不要)— LoadImageが複数ある場合も個別に差し替え可能。Placeholder設定(モード・サイズ・色・デフォルト画像)はセッションをまたいで記憶される",
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入力付き)のセレクタ(フィルタ検索付き)",
helpGen5: "Modelタブ:Checkpoint/VAE/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)は常時表示;ControlNetとHypernetwork(Strength入力付き)は使用頻度が低いためデフォルトで折りたたみ — 見出しをクリックで展開;LoRA(Single/Stackタブ)は右側に独立した列を表示;すべてのセレクタにフィルタ検索付き;LoRA Stack — Applyで有効な全LoRAを対象ノードに書き込み、同時にLoRA構文とCivitAIトリガーワードをPositiveプロンプトへ挿入;再Applyでは非アクティブなLoRAを削除し、アクティブなLoRAを再追加してプロンプトを更新",
helpGen28: "Modelタブ — ハイライト色:読み込んだワークフローに実際に該当ノードが含まれる場合、各モデル種別(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork)のラベルとLoRA列見出しが指定色(デフォルトは明るい緑)で表示される;ControlNet/Hypernetworkが折りたたまれた状態でも色は見えるため、展開しなくても使用有無が一目で分かる;色はSettingsタブでカスタマイズ可能",
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ)",
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
@@ -1745,6 +2037,11 @@ const LANGUAGES = {
helpGen17: "スタイルバッチ:左ペインのStyleタブでスタイルを選択し、バッチキューのStyle列を有効化してGenerateを実行 — 選択した各スタイルをPositiveおよびNegativeプロンプトに順番に適用;ワークフローコピーに適用されるため読み込み済みワークフローは変更されない;スタイルファイルはページロードのたびに user/default/Workflow-Studio/style/*.json から再読み込み",
helpGen18: "Applyボタンを Alt+Click すると Apply & Generate を1操作で実行 — 対象:Positive Prompt・Checkpoint・KSamplerの各Applyボタン;ワークフローに値を書き込んだ後、即座に生成をキューに追加;対象ノードが存在しない場合は何もしない",
helpGen19: "Loraバッチ:各LoRA適用時にLora構文(<lora:名前:strength_model:strength_clip>)とCivitAIトリガーワードをPositiveプロンプトへ自動追加 — 毎回元のプロンプトを起点に再構築するため蓄積しない;バッチ完了・停止後はプロンプトを開始前の状態へ自動復元;選択したLoRAがComfyUIのモデル一覧にまだ認識されていない場合は警告トーストを表示(ComfyUIのRefreshまたは再起動が必要)",
helpGen20: "LoRAの接続方式は2通り:標準の LoraLoader ノードをチェーン接続する方式(1ノードにつき1LoRA — 増やすにはノードを追加接続)と、単一の Lora Loader (LoraManager) ノード(ComfyUI-Lora-Manager由来のカスタムノード)にLoRA一覧を内部保持する方式(各LoRAごとに有効/モデル強度/CLIP強度を個別管理);下のSingle/Stackタブは対象ノードがどちらのタイプかによって自動的に挙動が変わる",
helpGen21: "Singleタブ:LoRAを1つ選びStr M/Str Cを設定してApply — Lora Managerノードが対象の場合、内部のLoRA一覧を丸ごとこの1件だけに置き換え、既存の他のLoRAは失われる",
helpGen22: "Stackタブ — Use Stack Groupチェックボックス(ワークフロー内にLora Managerノードがある場合のみ表示):OFF(ワークフロー読み込み時の既定)はLora Managerノードに実際に設定されているLoRAをそのまま表示・編集;ONにするとModelsタブに登録済みの「Stack」グループを表示・編集;どちらへの切替もまずノードのLoRA一覧をクリアしてから切替先のソースを読み込む;OFFへ戻すとワークフロー読み込み時点のLoRA一覧(読み込み時にスナップショットとして保存済み)へ正確に復元される",
helpGen23: "Stackタブの操作:各行に有効チェックボックスとStr M / Str Cフィールド;ヘッダーの「Stack」チェックボックスで全行を一括切替;上部の+/-ステップボタンで現在有効な全行の強度をまとめて増減;「Showing: LoRAs currently in workflow」/「Showing: registered Stack group」のラベルで現在どちらのソースを表示中か分かる;Applyで対象ノードへ一覧全体を書き込み、同時にLoRA構文とCivitAIトリガーワードをPositiveプロンプトへ追加",
helpGen24: "CLIP Text Encode edit+(CLIPTextEncodeEditPlus)ノードを使う場合の注意:Lora Managerノードのtrigger_words出力がこのノードのtext1入力に接続されている構成(RAW/EDIT/front/backモードでtext1と編集可能なtext_editを結合)では、生成時にトリガーワードは既に自動的に反映済み;LoRAのApplyボタンをクリックしたりUse Stack GroupをONにしたりすると、LoRA構文・トリガーワードがtext_editへも追加で書き込まれ二重になる — ワークフロー本来のプロンプト・LoRA構成を変更せず維持したい場合は、Use Stack GroupをOFFのままにしLoRAのApply系ボタンには触れないこと",
helpFeederTitle: "GenerateUI Tab — Feeder サブタブ",
helpFeederDesc: "左ペイン上部の [Image Loop] / [Gallery] トグルで2つのモードを切り替えられます。",
helpFeederImgloopTitle: "Image Loop モード",
@@ -1766,6 +2063,42 @@ const LANGUAGES = {
helpFeederGal4: "右ペイン — プレビュー:現在選択中の入力画像を表示;サムネイルをクリックすると更新",
helpFeederGal5: "__Feeder__ グループ:自動で作成される予約グループ;名前変更・削除不可;ギャラリータブの各サムネイル上の F ボタンでこのグループに画像を追加",
helpFeederGal6: "WFS_GalleryFeeder キャンバスコントロール:このノードには ComfyUI キャンバス上に直接 Run / Stop / After Gen ウィジェットが追加されており、WFM を開かずにギャラリーフィーダーループを実行できます",
helpLabTitle: "生成UIタブ — Labサブタブ",
helpLabDesc: "実験的なI2Iバッチ生成機能です。GenerateUIに現在読み込まれているワークフローをN回実行し、Model(Checkpoint/LoRA/VAE)・Prompt・KSamplerをそれぞれ独立して任意の回数目から変更できます。Setting/Resultsの2つのサブタブがあります。",
helpLabCardColumnsTitle: "Settingサブタブ — 画像と列",
helpLab1: "左ペイン — Image:I2Iの元になる画像をドロップまたはクリックして選択します。ComfyUIのinputフォルダにアップロードされ、「Use generated image for next」がオフの間は毎回この画像が使われます",
helpLab2: "左ペイン — Use generated image for next:オンにすると2回目以降、直前の回で生成された1枚目の画像を次のI2I入力として使用します(1回目は常に元画像を使用)",
helpLab3: "中央ペイン — Model・Prompt・KSamplerの3列は独立しています。共通の行グリッドではなく、列ごとに個別のキーフレーム(設定変更点)のリストを持ちます",
helpLab4: "各列の+/-:+は次に空いている回数目(Batch数まで)にキーフレームを追加し、すぐに編集モーダルを開きます。-は末尾のキーフレームを削除します(1回目は削除できません)。1回目以外のキーフレームは、末尾でなくてもそれぞれの編集モーダル内の「削除」ボタンから個別に削除できます",
helpLab5: "セルをクリックすると編集できます。1回目は基準となる設定値です(空欄のままにするとワークフロー本来の設定を使用)。2回目以降はさらに「何回目から適用するか」の指定と「1回目の設定に戻す」チェックボックスがあります",
helpLab6: "N回目時点での実効値は、N以下で最も大きい回数目のキーフレームが次のキーフレームまで引き継がれます。「1回目に戻す」を使うと、新しい値を指定する代わりに1回目の値(空欄ならワークフロー本来の設定)がそのまま使われます。既存キーフレームの回数目より小さくBatch数を減らしても(手入力・プラン読込/適用いずれの場合も)、そのキーフレームは削除・繰り上げされず警告トーストが表示されるだけで、Batch数を再び増やすまで実行ループに到達しない「休眠」状態になります。この「行まるごと」の解決方式はPrompt・KSamplerにそのまま当てはまりますが、Model列だけはCheckpoint/LoRA/VAEの3項目をそれぞれ独立して解決します(詳細は後述)",
helpLab7: "LabはInput/Model/Settingsに表示されているワークフローを直接編集しません。各回の実行はワークフローの複製に対して行われるため、Labでのキーフレーム設定が他のサブタブに影響することはありません",
helpLab8: "Promptセル編集モーダルの追加機能:「Get from GenerateUI」で現在GenerateUIに読み込まれているワークフローからPositive/Negativeを取得、「Get from Image」でLab左ペインのIMAGEに現在読み込まれている画像からプロンプトを抽出(Metadataタブと同じ検出ロジック。別途ファイル選択は不要)、「Style」行の「適用」ボタンをクリックするとGenerateUIツールバー上部のStyleドロップダウンで現在選択されているスタイル名を、既存のPositive/Negativeテキストを消さずにカンマ区切りで追記し、Styleチェックボックスを自動でONにして適用したスタイル名をボタンではなく横に別表示します(スタイル名の選択は上部ドロップダウンのみを使用し、Lab独自のスタイル選択はありません。適用ボタンは上部のStyle有効化チェックボックスの状態に関係なく動作します)。Styleチェックボックスを手動でOFFにすると名前表示のみクリアされます(追記済みのPositive/Negativeテキストは自動削除されないため、不要な場合は手動で編集・削除してください)",
helpLab9: "左ペイン — 「T2I workflow (no source image)」:読み込んでいるワークフローがT2I(LoadImageノードなし)の場合にオンにします。「先に元になる画像をドロップしてください」という要求をスキップし、ドロップ済みの画像があっても無視します。Model/Prompt/KSamplerのキーフレームは通常どおり適用されます。オンの間はImageドロップ領域と「Use generated image for next」がグレーアウトします(既にドロップ済みの画像は削除されず、I2Iに戻したときのために保持されます)。この設定はプランに t2i_mode として保存されます",
helpLab10: "ワイルドカード:Promptカラムのキーフレームに入力するPositive/Negativeテキストは、通常のGenerateUIと同じA1111形式の __name__ 記法に対応しています。各キーフレームのテキストは、そのイテレーションが実行される直前に毎回展開されるため、同じキーフレームを使い回していてもイテレーションごとに異なるランダム行が選ばれます",
helpLab11: "プロンプトセルの編集モーダルにはClear(Positive/Negative両方を空にする)とGet from Previous(同じカラムの直前のキーフレームからPositive/Negative両方をコピー — 例:#3を編集中に押すと#2のプロンプトをそのままコピー)も用意されています。1回目(#1)には直前の行が無いため表示されません",
helpLab12: "各カラムの1回目(キーフレーム#1)は、明示的に上書き保存されていない間、現在GenerateUIで読み込まれているワークフロー本来の設定を、空欄プレースホルダの代わりにグリッドセル(破線枠)と編集モーダルの両方に表示します(Model列はCheckpointとVAEのみが対象 — LoRAはワークフローのノードから「どれが有効か」を安全に逆算する手段が無いため、ライブ表示の対象外です)。何も変更せずに保存しただけでも実際の上書き値として確定し、破線・ライブ表示は解除されます。Labサブタブに切り替えるたびに、その時点でGenerateUIに読み込まれている内容へ再同期されます",
helpLab13: "Bypassチェックボックス(1回目を含む全キーフレームに搭載):そのキーフレームの回数目判定において、行自体が存在しないかのように完全にスキップします — その結果、直前まで有効だった設定(それ以前のキーフレーム、なければ何も適用されない状態)がそのまま使われ続けます。Labタブ内だけの概念で、実際のComfyUIノードのmodeには一切触れません。チェック中はその行の値フィールドと「1回目に戻す」チェックボックスが無効化されます(両者は同時に使えない排他的な関係)。セルには琥珀色の「⏭ Bypassed」ラベルが表示され、Revert表示・ライブ表示のどちらよりも優先されます",
helpLab14: "Model列ヘッダーのC/L/Vボタンは、セル表示と編集モーダルがCheckpoint/LoRA/VAE(LoRAはSingleのみ、Stack非対応)のどれを表示・編集対象にするかを切り替えます。今見ていない残り2項目は、保存してもそれまでの保存値のまま変更されません。各ボタンは、読み込み中のワークフローに該当するノードが実際に存在する場合、GenerateUIのModelタブと同じハイライト色(Settingsで変更可)になります。LoRAを設定していてもワークフロー内にLoRAノードが1つも無い場合は、Run実行中に1回だけ警告し、その設定は適用されません — VAEと異なりLoRAノードの自動追加は行いません(CheckpointのModel/CLIP出力の配線を組み替える必要があるため)",
helpLab15: "Model列の3項目(Checkpoint/LoRA/VAE)はそれぞれ独立して引き継がれます:LoRAだけを設定したキーフレームがあっても、Checkpoint/VAEはワークフロー本来の設定に戻るのではなく、それ以前のキーフレームで最後に設定された値がそのまま維持されます(さらに後のキーフレームで明示的に変更されるまで)。編集モーダルで未設定のままの項目は、引き継ぐ値がある場合プレースホルダが「(ワークフローの設定のまま)」ではなく「(引き継ぎ: ...)」と表示され、実際に適用される値が分かるようになっています。「1回目の設定に戻す」は、今見ている項目だけでなく3項目すべてを一括でキーフレーム#1の内容に戻します",
helpLabCardPlanTitle: "Settingサブタブ — 実行とプランファイル",
helpLabPlan1: "右ペイン — Batch:実行する回数。Note:プランと一緒に保存される自由記述のメモ",
helpLabPlan2: "Run/Pause/Cancel:Runでバッチループを開始、Pause/Resumeで回と回の間を一時停止・再開、Cancelで現在の生成を中断してループを止めます",
helpLabPlan3: "Plan SaveとSave Asの違い:Plan Saveは読込済みのプランがあればダイアログなしで上書き保存します(未読込の場合のみ名前を確認)。Save Asはプラン読込中でも常に新しい名前を確認します",
helpLabPlan4: "Plan Load:プランの.jsonファイル、またはそのインデックス画像(.png)をPLAN欄にドロップします(クリックでファイル選択も可)。.jsonをドロップした場合はそのまま読み込みます。.pngをドロップした場合も、v0.3.90以降のインデックス画像であればwfm_lab_planチャンクに埋め込まれたプランデータを直接読み込みます。それより古いインデックス画像の場合のみ、同名の.jsonをサーバーから取得するフォールバック動作になります",
helpLabPlan5: "Plan ClearはSettingタブの内容を空のプランにリセットします(確認あり)",
helpLabPlan6: "プランファイルは user/default/Workflow-Studio/lab_plan/ に ws_labplan_<名前>.json として保存され、結果がある場合は最大9枚(1行3枚)のサムネイルを並べたインデックス画像 ws_labplan_<名前>.png も自動生成されます。ws_labplan_ の接頭辞はPlan Save/Save Asが自動的に付与するため、プロンプトには接頭辞なしの名前だけを入力すればよく、フォルダ内でプランファイルを見分けやすくなります",
helpLabPlan7: "設定タブで構成したEagle自動保存は、GenerateUI本来のGenerateボタンと同様にLabで生成したすべての画像に適用されます",
helpLabPlan8: "Plan JSONサブタブ:現在のSettingタブのプラン内容をそのままJSONで表示します。「Refresh」でSettingタブの現在の内容から再読込(未適用の編集は破棄)、「Apply to Setting」で編集内容を解析しSettingタブへ書き戻します(列・メモ・Batch数・画像連鎖・元画像・結果)。保存せずにそのままプランファイルとして読み込んだのと同じ効果があります",
helpLabPlan9: "保存されたプランには、保存時にGenerateUIで読み込まれていたワークフローのファイル名が記録されます(Plan JSON内のworkflow_filename)。プランを読み込むとPLANボックスに「Open Workflow」ボタンが表示され、クリックするとその記録された正確なワークフローファイルをGenerateUIへ読み込み直せます(現在読み込まれているワークフローを置き換えるため、確認が入ります)",
helpLabPlan10: "右ペイン — 「Save index image to Output on Run」:チェックすると、Run実行が終わるたびにPlanファイル用と同じコンタクトシート形式のインデックス画像を作成し、ComfyUI本来のOutputフォルダへ Lab_index_<counter>_.png として直接保存します(通常の生成画像と同様に自動採番され、前回の実行結果を上書きしません)。保存後は他のLab生成画像と同様にEagle自動保存(設定タブで有効な場合)にも渡されます——これ専用のチェックボックスはなく、同じ設定タブのグローバルなトグルに従います。Plan Saveの動作とは独立しており、Plan Saveは常にこのチェックボックスに関係なく lab_plan/ 内のプランファイルの隣に自分専用のインデックス画像を書き込みます。この設定はプランに save_index_on_run として保存されます",
helpLabPlan11: "全てのインデックス画像PNG(Plan Save/Save Asで作成されたものも、Save index image to Output on Runで作成されたものも)には、プラン全体(列・メモ・Batch数・画像連鎖/T2I設定・ワークフローファイル名・結果一覧)がPNGのwfm_lab_planチャンクとして直接埋め込まれています。そのためOutputフォルダ側のコピーは単体で完結しており、隣に対応する.jsonが一切存在しなくてもPlan Loadの読込元として使えます",
helpLabCardResultsTitle: "Resultsサブタブ",
helpLabResults1: "左ペインには実行に使用した元画像が表示されます",
helpLabResults2: "中央ペインには生成順に最大9枚の画像が表示されます。サムネイルをクリックすると拡大表示されます",
helpLabResults3: "保存済みプランを読み込むと、プランファイルに記録された画像一覧からResultsの表示が復元されます",
helpSearchPlaceholder: "ヘルプを検索...",
helpPrompt1: "AIアシスタント(Ollama / LM Studio / Lemonade):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
@@ -1776,6 +2109,11 @@ const LANGUAGES = {
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
helpPrompt8: "ワイルドカードツールバー:{|}・{n$|}・__|__・<lora:>などの構文をワンクリック挿入。選択テキストのラップも対応",
helpPrompt9: "ワイルドカードファイルマネージャー:.txt/.yamlファイルの作成・編集・削除。ファイル名をクリックすると__ファイル名__をカーソル位置に挿入",
helpPrompt10: "右ペインのWildcard/Styleタブバーで、パネル全体をワイルドカードツールとこのStyle管理の2モード間で切り替える(同じ右ペインを共用)",
helpPrompt11: "登録済みStyle(GenerateUIのStyleドロップダウン・Batchタブ・Style Catalogで使われるものと同一)の作成・編集・削除。リストの名前をクリックすると編集、「+ New」で空の新規エントリを作成。項目はName・Positive prompt({prompt}プレースホルダー対応 — ユーザー自身のプロンプトに置き換わる、省略時はその後ろに追加される)・Negative prompt(省略可)",
helpPrompt12: "リネームしても、元々定義されていたファイル内での位置は保たれる。新規作成したStyleはstyleフォルダ内の専用custom.jsonへ保存されるため、同梱のスタイルパックファイルは直接変更されない。名前は全スタイル中で一意である必要がある",
helpPrompt13: "Styleの保存・リネーム・削除は、GenerateUIのStyleドロップダウン、Batchタブのスタイルチェックリスト、Style Catalogの名前一覧に即座に反映される(再読み込み不要)",
helpPrompt14: "一覧の各項目には、名前の横に定義元ファイル名が小さなバッジで表示される(例: sdxl_styles_diva.json)。既存スタイルを編集中は、そのファイル名がPositive promptフィールドの上にも表示され、Saveの隣に「+ Add to this file」ボタンが現れる。クリックするとフォームが空になり、デフォルトのcustom.jsonではなく同じファイルへ追加される新規スタイルを作成できる",
helpSettings1: "UI言語と要約言語の設定",
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
@@ -1789,6 +2127,7 @@ const LANGUAGES = {
helpSettings11: "テキストサイズ:スライダー1本(10〜28px)でプロンプト・チャット全テキストエリアのフォントサイズを一括調整 — GenerateUI ポジティブ/ネガティブプロンプト、AIアシスタントチャット入力、プリセット ポジティブ/ネガティブ、ワイルドカードプロンプト・ファイルエディタ、メタデータプロンプト全文プレビュー;即時反映・保存で確定",
helpSettings12: "RAW JSON カラー:GenerateUI の Raw JSON エディタの構文ハイライト色(6色)をカスタマイズ — デフォルトテキスト・名前/スケジューラー・タイトル・幅/高さ・プロンプト/テキスト・画像/ファイル;カラーピッカーで即時反映;「デフォルトにリセット」で元の配色に戻す",
helpSettings13: "CivitAI ホスト:civitai.com(SFWのみ)と civitai.red(制限なし)を切り替え — ModelsタブのCivitAIパネルでモデルリンクをクリックしたときに開くサイトを指定",
helpSettings14: "GenerateUI Modelタブ ハイライト色:GenerateUIのModelタブで、読み込んだワークフローに実際に該当ノードが含まれる場合にCheckpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetworkのラベルおよび LoRA 列見出しを彩色する色を指定;デフォルトは明るい緑;変更は即時反映、「デフォルトにリセット」で元に戻す",
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
@@ -1826,6 +2165,13 @@ const LANGUAGES = {
helpGallery15: "Fボタン(シアン色オーバーレイ、各サムネイル左上):クリック一つで画像を __Feeder__ グループに追加/削除;グループ内の場合は常にシアン色で表示、非グループ時はホバー時のみ表示;FeederタブのGalleryモードへ素早く画像を追加できる",
helpGallery16: "Image Editボタン(ツールバー、Refreshボタン横):選択中の画像をImage Editタブに送信;既にレイヤーが存在する場合はキャンバスに合わせてスケールして新規レイヤーとして追加、存在しない場合はLayer 1になる",
helpGallery17: "Send GenUI Imageボタン(ツールバー、Image Editボタン横):選択画像をComfyUIにアップロードし、GenerateUIタブの最初のLoadImageノードへ入力として設定;GenerateUI → Input → Imageへ自動切り替え;事前にLoadImageノードを含むワークフローの読み込みが必要",
helpGallery18: "GalleryタブのOutputと並ぶ2つ目のサブタブ。視覚的なプロンプトライブラリを閲覧できる — ComfyUI outputフォルダ内のws_image_promptフォルダを管理(未作成時は自動生成)",
helpGallery19: "同じ3カラムレイアウト:フォルダツリー(左)|サムネイル一覧(中央)|プロンプトビルダー(右)。サムネイルクリックでプレビューとプロンプトテキストを表示 — この時点ではまだプロンプトに追加されない",
helpGallery20: "「+ 追加」で、プレビュー中の画像のプロンプトを「選択済みプロンプト」にチップとして追加;チップは個別(×)または一括(「全クリア」)で削除可能;「Save」でその画像のプロンプトテキストを編集・保存",
helpGallery21: "「最終プロンプト」は全チップをカンマ区切りで結合したテキスト(自由に編集も可能);「Copy」でクリップボードへコピー。プロンプトは各画像と同名の.txtファイルとして保存されるため、サブフォルダに画像+対応する.txtファイルを置くだけで誰でも自作データを追加できる",
helpGallery22: "Galleryタブ先頭にある3つ目のサブタブ。登録済みStyleをプレビュー画像から視覚的に選べる — ComfyUI outputフォルダ内のws_style_catalogフォルダを管理(未作成時は自動生成)。GenerateUIタブの「カタログ作成」ボタンで生成される(GenerateUIタブのヘルプ参照)",
helpGallery23: "同じ3カラムレイアウト:フォルダツリー(左)|サムネイル一覧(中央)|Positive/Negativeプロンプトパネル(右)。サムネイルクリックで、その生成画像に実際に埋め込まれているPositive/Negativeプロンプトを表示、それぞれ個別のCopyボタン付き",
helpGallery24: "Select as Styleボタン:選択中の画像のファイル名(拡張子除く)と同名の登録済みStyleを探し、GenerateUIタブのStyleドロップダウンをそれに切り替える — 既存Styleシステムへの視覚的なショートカットであり、埋め込みプロンプトをその場限りで適用するものではない(カタログ画像作成後にStyleがリネーム・削除されていた場合は「一致するスタイルが見つかりません」トーストを表示)",
// -- Tagger Tab --
helpTaggerTitle: "Taggerタブ",
@@ -1855,14 +2201,14 @@ const LANGUAGES = {
helpSidepanel13: "I タブ(Information):ComfyUI の PNG / WebP / JSON をドロップするとサイドパネル内でモデル・LoRA・プロンプトのメタデータを確認可能 — サブタブ: model / lora / Prompts;UnetLoaderGGUF・QuadrupleCLIPLoader・Lora Loader (LoraManager) ノードタイプに対応",
helpSidepanel14: "Information タブ — キャンバスへドラッグ:model サブタブのアイテムをドラッグすると対応ローダーノードを配置(Checkpoint → CheckpointLoaderSimple、VAE → VAELoader、Diffusion Model → UNETLoader、Text Encoder → CLIPLoader);ダブルクリックでもキャンバス中央に配置可能",
helpSidepanel15: "Information タブ — lora サブタブ:LoraLoader・LoraLoaderModelOnly・Lora Loader (LoraManager) ノード(APIフォーマット含む)から LoRA を検出;個別 LoRA をドラッグで LoraLoader を配置;「Multiple LORA」セクションで全 LoRA を Lora Loader (LoraManager) ノードに一括配置(LoRA構文自動入力);Prompts サブタブ:プロンプトをドラッグするとテキスト入力済みの CLIPTextEncode ノードを配置;クリックで全文表示・コピー可能",
helpSidepanel16: "A タブ(AI ツール):翻訳・Chat・TOOLS・設定のサブタブ;Chat は LLM とのマルチターン会話;TOOLS は VLM 画像解析とワイルドカード生成に対応;設定は SPA の AI タブと localStorage で共有される",
helpSidepanel16: "A タブ(AI ツール):翻訳・Chat・TOOLS・設定のサブタブ。Ollama、LM Studio、Lemonade、Unsloth に対応;Chat は LLM とのマルチターン会話;TOOLS は VLM 画像解析とワイルドカード生成に対応;設定は SPA の AI タブと localStorage で共有される",
helpSidepanel17: "Send to Canvas:WorkflowタブのSend to CanvasまたはGalleryタブのCopy & Send Canvasをクリックするとワークフローをキャンバスへ直接送信(UI形式・API形式どちらも対応);ComfyUIのツールバーから開いていない場合はUI形式のみタイトルドラッグのフォールバック(タイトルが青くなり緑の●が表示される — ドラッグで読み込み)",
helpAiTitle: "AI タブ(A)",
helpAi1: "4ペインレイアウト — Translation | Chat | TOOLS | Settings — 全ペインが常時表示される",
helpAi2: "翻訳ペイン:Ollama、LM Studio、または Lemonade を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
helpAi2: "翻訳ペイン:Ollama、LM Studio、Lemonade、または Unsloth を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット;モデルがtool callingに対応している場合、「猫の画像を作って」のように頼むとGenerateUIに読み込み中のワークフローで生成を実行し、結果をチャット内に表示する;ペインヘッダーの「画像生成を許可する」チェックボックスでこの機能のON/OFFを切り替えられる;画像が添付されている場合(TOOLSペインにドロップした画像)はLLMにもビジョン入力として送信され、generate_image呼び出し時はT2Iの代わりにI2I(image-to-image)の元画像として使われる;tool callingとビジョンの動作を確認済みのモデル: Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される",
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade)の選択、APIのURL設定、接続テスト、モデル選択、Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade / Unsloth)の選択、APIのURL設定、接続テスト、モデル選択;Unslothはローカルアクセスでも常にAPIキー認証が必須——プラグインフォルダの .env.example を .env にコピーして UNSLOTH_API_KEY を設定し ComfyUI を再起動する(キーはサーバー側でのみ読み込まれ、ブラウザには一切送信されない);Unsloth Desktopアプリ側の設定画面で「モデル自動切り替え (OpenAI API)」もONにする必要がある——OFFのままだと、Unsloth Studio上で事前にモデルを手動ロードしていない限り HTTP 404 で接続に失敗する;Unslothのreasoningモデルは思考内容をインラインの<think>タグではなく別のreasoning_contentフィールドで返すため、Max tokensが少ないとモデルが思考だけでトークンを使い切り、空の返信になることがある——返信が空になる場合はMax tokensを増やす(2048以上を推奨);Generationセクション — Thinking modeトグル(デフォルトOFF;OllamaにはthinkフラグとしてOFF/ONを送信、バックエンドを問わずOFF時は出力中の<think>...</think>ブロックをクライアント側で除去するため、推論モデルの思考過程がTranslation/Chat/TOOLSの結果に混入しない)とMax tokens(Ollama: num_predict、LM Studio/Lemonade/Unsloth: max_tokens;0で無制限);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非対応のモデルでも動作する",
helpAi8: "Chat ペイン — スキル:メッセージ一覧上部のドロップダウンでスキルを選ぶと、その指示内容が system prompt として会話の先頭に付加される(表示上のチャット履歴には含まれない);鉛筆ボタンでスキル管理パネルを開き、.mdスキルファイルを作成・編集・削除できる(保存先: user/default/Workflow-Studio/ai_skills/);ファイル先頭の---で囲まれた部分にname/descriptionを書くとドロップダウンの表示に反映される;初期状態で2つのスキルを同梱: 「SVG Icon Generator」(描く前に対象を部位分解することで認識しやすいSVGを生成)と「Skill Creator」(対話形式で質問しながら新しいスキルファイルを下書きする);応答に```skillコードブロックが含まれる場合、メッセージ下部に「スキルとして保存」ボタンが表示され、内容が入った状態で管理パネルが開く",
helpAi9: "スキル作成のヒント:ドロップダウンやツールチップで分かりやすいよう、frontmatterのname/descriptionは明確に書く;期待する出力形式を具体的に指示し、SVGや別のスキルファイルのように後で抽出したい成果物がある場合は専用のコードフェンスで囲むよう指示する;曖昧な指示より具体的で命令形の指示の方がローカルの小型モデルには効きやすく、短い実例を1つ添えるとさらに効果的;禁止事項(不適切な内容、セキュリティ、文字数制限など)は暗黙に期待せず明示的に書く;迷ったらドロップダウンで「Skill Creator」スキルを選び、作りたいスキルの概要を伝えれば対話形式で下書きしてくれる",
@@ -1886,10 +2232,12 @@ const LANGUAGES = {
helpGenCardSeed: "シード・生成",
helpGenCardBatchSetup: "Batchタブ — セットアップ",
helpGenCardBatchTypes: "Batchタブ — バッチ種別",
helpGenCardLora: "Modelタブ — LoRA詳細",
helpPromptTitle: "プロンプトタブ",
helpPromptCardAi: "AIアシスタント・翻訳",
helpPromptCardPresets: "プリセット",
helpPromptCardWildcards: "ワイルドカード",
helpPromptCardStyleTitle: "Style",
helpMetadataCardOverview: "概要",
helpMetadataCardExtracted: "抽出情報",
helpMetadataCardFormats: "対応フォーマット",
@@ -1904,6 +2252,8 @@ const LANGUAGES = {
helpNodesCardGroups: "グループ・セット",
helpNodesCardPanel: "サイドパネル・複数選択",
helpGalleryCardBrowse: "閲覧",
helpGalleryCardStyleprompt: "ImagePrompt ギャラリー",
helpGalleryCardStylecatalog: "Style_Catalog ギャラリー",
helpGalleryCardFolder: "フォルダ・選択",
helpGalleryCardDetail: "詳細パネル",
helpGalleryCardActions: "アクションツールバー",
@@ -1951,7 +2301,8 @@ const LANGUAGES = {
helpImageEditExportTitle: "エクスポートとナビゲーション",
helpImageEdit17: "Save PNG:全可視レイヤーを合成してPNGファイルとしてダウンロード",
helpImageEdit18: "Save to Gallery:ファイル名を入力(デフォルト: wfs-image-YYYYMMDDHHmmss)してギャラリーのルートフォルダに合成PNGを保存(設定 > Gallery Output Folderで設定);Galleryタブを一度開いてルートフォルダを設定する必要あり",
helpImageEdit19: "Send to ComfyUI:合成PNGをComfyUIのinputフォルダに /upload/image でアップロードし、Load Imageノードで選択可能にする",
helpImageEdit19: "Save to Input:合成PNGをComfyUIのinputフォルダに /upload/image でアップロードし、Load Imageノードで選択可能にする",
helpImageEdit19b: "Send to Workflow:合成PNGをアップロードする点はSave to Inputと同じだが、さらにアップロード結果のファイル名をComfyUIグラフ内で選択中のノード(無ければ最初に見つかったLoadImage系ノード)のimageウィジェットへ書き込む;ComfyUIのトップメニューボタンからWorkflow Studioを起動している必要がある",
helpImageEdit20: "キャンバスナビゲーション:スクロールホイールでズーム、Space + ドラッグでパン",
helpImageEdit21: "Undo / Redo:Ctrl+Z / Ctrl+Y(またはツールバーの ↩ ↪ ボタン)",
helpImageEditKbdTitle: "Image Edit キーボードショートカット",
@@ -1983,6 +2334,7 @@ const LANGUAGES = {
helpImageEditMask11: "Text サブツール:キャンバスをクリックするとテキストスタンプオーバーレイを表示;テキストを入力し Font / Size / Bold / Italic / Align と Mode(Add = 白スタンプ / Erase = マスク削除)を設定して「Stamp」またはCtrl+Enterで確定;テキストエリア内の改行で複数行対応",
helpImageEditMask12: "Vector サブツール:クリックでCatmull-Romスプラインの制御点を追加;最初の点を再クリック(またはEnter)で閉じてポリゴンを塗りつぶし;Backspaceで直前の点を削除;Escで全点をリセット;プロパティペインに現在の点数とMode(Add / Erase)を表示",
helpImageEditMask13: "Shape サブツール:ドラッグで矩形または楕円のマスクを描画;Shift押下で正方形/正円;ドラッグ中は青の点線でプレビュー表示;プロパティペイン:Mode(Add / Erase)・Shape(Rect / Ellipse)",
helpImageEditMask14: "Edit in Mask Editor One(Paint モードのみ):MASK EDITOR ONE セクションの「Edit in Mask Editor One →」をクリックすると、現在のキャンバス合成画像(アクティブレイヤーがマスクレイヤーの場合はその内容も)が、ComfyUI キャンバス上の MaskEditorOne ノード(選択中のノードがそれなら優先、無ければグラフ内で最初に見つかったノード)へ送信され、ComfyUI 側のブラウザタブで実際のエディタ(SAM3・BiRefNet・シェイプなど)が開く;そこで Apply をクリックすると結果がこちら側に新規マスクレイヤーとして取り込まれる;ComfyUI のトップメニューボタンから Workflow Studio を起動していること、ComfyUI のグラフに MaskEditorOne ノードが1つ以上存在することが必要",
helpImageEditBlurTitle: "Blur ツール (≈)",
helpImageEditBlur1: "Whole Blur:アクティブレイヤー全体にガウスぼかしを適用 — 強度スライダー(1〜50 px)で調整してApplyをクリック",
helpImageEditBlur2: "Whole Mosaic:アクティブレイヤー全体にピクセル化モザイクを適用 — ブロックサイズスライダー(5〜100 px)で調整してApplyをクリック",
@@ -2020,6 +2372,7 @@ const LANGUAGES = {
helpTrouble6: "モデルの移動・削除後にモデル読み込みエラーが発生する:ComfyUIがファイルパスをキャッシュしており、古い場所を参照することがあります。ComfyUIメニューのHard Reset(Queue → Hard Reset)またはComfyUIを再起動してキャッシュをクリアしてください。",
helpTrouble7: "Taggerタブにモデルが表示されない:ComfyUI/models/tagger/<モデル名>/ フォルダの存在と .onnx または .h5 ファイルの有無を確認;onnxruntimeが未インストールの場合は python_embeded\\python.exe -m pip install onnxruntime を実行",
helpTrouble8: "ModelタブのCheckpointが想定と違うモデルになっている(どのワークフローを読み込んでも同じモデルになる等):ワークフローファイルに保存されたCheckpointが実際にはインストールされておらず、読み込み時に無警告で別のインストール済みモデルに差し替えられています。この場合、元の(見つからなかった)モデル名を含むエラートーストが表示されます。Modelタブで正しいCheckpointを手動選択するか、設定タブの「デフォルトCheckpoint」を有効にして、読み込み時に常に動作確認済みのモデルを強制適用してください。",
helpTrouble9: "Generateを押すと、読み込んだワークフローとは別のワークフローで実行される:Batchサブタブでバッチタイプ(Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler)のチェックボックスがONのまま残っています。いずれかのバッチタイプが有効な間は、Generateボタンは現在読み込まれているワークフローではなくバッチ選択された内容を実行します。Batchサブタブを開き、有効になっているバッチタイプのチェックボックスをOFFにしてください。",
helpSupportTitle: "サポート",
helpSupportDesc: "このプラグインが役に立ったら、開発を支援していただけると嬉しいです!",
helpGithubDesc: "バグ報告、機能リクエスト、コントリビュート",
@@ -2159,14 +2512,14 @@ const LANGUAGES = {
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters チャンク)",
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme チャンク)",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image サブグラフ形式に対応済み",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image サブグラフ形式に対応済み",
helpMetadataTitle: "Metadata タブ",
helpMetadataDesc: "ComfyUI で生成した PNG / WebP または ワークフロー JSON をドロップすると、埋め込まれたモデル・プロンプト情報を即座に抽出・表示します。",
helpMetadata1: "左列: ドロップゾーン(PNG/WebP は画像プレビュー表示)+ファイル情報・対応フォーマット説明",
helpMetadata2: "中列: ワークフローから抽出した Checkpoint・VAE・Diffusion Model・Text Encoder の一覧",
helpMetadata3: "右列: LoRA 一覧(強度値付き)+プロンプト一覧(クリックで下部に全文表示)",
helpMetadata4: "プロンプトアクション — Copy: クリップボードにコピー; GenUI:P/N: GenerateUI のポジティブ/ネガティブプロンプトにセット; Prompt:P/N: Prompt タブ Presets のポジティブ/ネガティブにセット",
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
},
zh: {
@@ -2312,6 +2665,7 @@ const LANGUAGES = {
saveWorkflowTitle: "保存工作流",
filenameLabel: "文件名",
pleaseEnterFilename: "请输入文件名",
pleaseEnterStyleName: "请输入风格名称",
saveFailed: (msg) => msg ? `保存失败: ${msg}` : "保存失败",
savedAs: (f) => `已保存: ${f}`,
overwriteConfirm: (f) => `「${f}」已存在。要覆盖吗?`,
@@ -2380,6 +2734,83 @@ const LANGUAGES = {
batchStopped: (completed, failed) => `批处理已停止 — 完成${completed}个,失败${failed}个`,
batchComplete: (completed, total, failed) => failed > 0 ? `批处理完成: ${completed}/${total}个 (${failed}个失败)` : `批处理完成: ${completed}/${total}个`,
batchLoraMissing: (count, total, sample) => `所选LoRA中有${count}/${total}个尚未被ComfyUI识别(例如:${sample})。请在ComfyUI中点击Refresh或重启服务器后再试。`,
catalogCreateBtn: "创建目录",
catalogOpenBtn: "目录",
catalogCreateTitle: "创建风格目录",
catalogCreateNoStyles: "未选择风格 — 请先在Batch标签的Style子标签中选择风格",
catalogCreateSelectedCount: (n) => `已选择${n}个风格`,
catalogCreateNewFolder: "创建新文件夹",
catalogCreateExistingFolder: "使用现有文件夹",
catalogCreateRun: "运行",
catalogCreateFolderNameRequired: "请输入文件夹名称",
catalogCreateFolderRequired: "请选择目标文件夹",
catalogCreateSkipOutput: "不在Output文件夹中保留副本",
// -- Lab 标签页 --
labImageUploaded: (name) => `图片已上传: ${name}`,
labUploadFailed: "上传失败",
labBatchCountTooSmall: "请先增加Batch数量再添加关键帧",
labKeyframesExceedBatch: (maxIter, batchCount) => `存在设置到第${maxIter}次的关键帧,超过了当前Batch数量(${batchCount}),在增加Batch数量之前不会被应用`,
labUseWorkflowDefault: "(保持工作流原设置)",
labInheritsFrom: (val) => `(继承: ${val})`,
labLoraNoNode: "工作流中未找到LoRA节点 — 已跳过LoRA设置",
labAppliedAt: (n) => `应用:${n}`,
labRevertToBase: "↩ 恢复第1次的设置",
labApplyFromIteration: "从第几次开始应用",
labRevertCheckboxLabel: "恢复第1次的设置",
labBypassed: "⏭ 已跳过",
labBypassCheckboxLabel: "跳过此设置(保持它之前生效的设置)",
labSeedEmptyRandom: "Seed(留空为随机)",
labPause: "暂停",
labResume: "继续",
labNoWorkflowLoaded: "请先在GenerateUI中加载工作流",
labNoSourceImage: "请先拖入源图片",
labNoResultsYet: "暂无结果",
labResultPreview: "预览",
labEnterPlanName: "方案名称:",
labPlanSaved: "方案已保存",
labPlanSaveFailed: "方案保存失败",
labPlanLoaded: "方案已加载",
labPlanLoadFailed: "方案加载失败",
labPlanInvalidFile: "请拖入方案的.json文件或其索引图片(.png)",
labConfirmClear: "确定要清除所有Lab设置(图片、列、备注、结果)吗?",
labGetFromGenUI: "从GenerateUI获取",
labGetFromImage: "从图片获取",
labGetFromPrevious: "从上一设置获取",
labClearPrompt: "清除",
labPromptCleared: "提示词已清除",
labPromptCopiedFromPrevious: "已从上一设置复制",
labLiveHint: "正在显示当前GenerateUI的设置。编辑并保存即可覆盖。",
labStyle: "风格",
labApply: "应用",
labNoStyles: "暂无风格",
labEnableStyleFirst: "请先在GenerateUI标签页顶部的Style下拉菜单中选择一个风格",
labPlanJsonRefreshed: "已根据当前Setting标签页内容刷新",
labPlanJsonApplied: "已将Plan JSON应用到Setting",
labPlanJsonInvalid: "JSON格式无效",
labStyleApplied: "已应用风格",
labPromptFetched: "已获取提示词",
labNoPromptInImage: "该文件中未找到提示词",
labMetadataExtractFailed: "读取元数据失败",
labConfirmLoadWorkflow: (filename) => `将"${filename}"加载到GenerateUI?这将替换当前已加载的工作流。`,
labWorkflowLoadFailed: "工作流加载失败",
labT2IImageIgnored: "T2I模式已开启,无需上传图片,已跳过",
labIndexImageSaved: (filename) => `索引图片已保存到Output:${filename}`,
labIndexImageSaveFailed: "索引图片保存到Output失败",
// -- GenerateUI标签页 Input子标签: Image内占位图 --
i2iPlaceholderTitle: "占位图片",
i2iPlaceholderColor: "颜色",
i2iPlaceholderImage: "图片",
i2iPlaceholderWidth: "宽度",
i2iPlaceholderHeight: "高度",
i2iPlaceholderColorLabel: "颜色",
i2iPlaceholderDropImage: "拖放默认图片或点击选择",
i2iPlaceholderApply: "占位图",
i2iPlaceholderGenerating: "生成中...",
i2iPlaceholderNoDefaultImage: "尚未设置默认图片 — 请先在上方拖入一张",
i2iPlaceholderUploading: "上传中...",
connectedToComfyUI: "已连接到ComfyUI",
failedToConnect: "连接失败",
modelListsRefreshed: "模型列表已刷新",
@@ -2406,6 +2837,9 @@ const LANGUAGES = {
jsonColorCyan: "提示词 / 文本",
jsonColorRed: "图像 / 文件",
jsonColorReset: "恢复默认",
modelTabColorSection: "GenerateUI Model 标签高亮颜色",
modelTabColorLabel: "工作流中包含的项目颜色",
modelTabColorHint: "在 GenerateUI 的 Model 标签中,当前工作流实际包含的项目(Checkpoint / VAE 等)的标签将以此颜色显示。",
// -- Prompt tab --
tabPrompt: "提示词",
@@ -2645,6 +3079,31 @@ const LANGUAGES = {
galleryCCNotAvailable: "仅当从ComfyUI Comic Creator内的图库打开时才能使用Send CC",
gallerySentCC: "已发送至ComfyUI Comic Creator",
galleryNoEmbeddedWorkflow: "此图像中未嵌入ComfyUI工作流。",
galleryTabOutput: "Output",
galleryTabImagePrompt: "ImagePrompt",
galleryTabStyleCatalog: "Style_Catalog",
galleryNoSelection: "未选择",
imagePromptSelectedPrompt: "提示词",
imagePromptNoPrompt: "此图像没有提示词数据。",
imagePromptAddBtn: "+ 添加",
imagePromptChipsTitle: "已选提示词",
imagePromptClearBtn: "全部清除",
imagePromptFinalPromptTitle: "最终提示词",
imagePromptSaved: "提示词已保存",
imagePromptAlreadyAdded: "已添加",
imagePromptNothingToCopy: "暂无内容可复制",
imagePromptCopiedToast: "已复制到剪贴板",
stylecatalogSearchPlaceholder: "搜索...",
stylecatalogPositive: "Positive",
stylecatalogNegative: "Negative",
stylecatalogSelectStyleBtn: "选为风格",
stylecatalogLoadGenuiBtn: "载入GenerateUI",
stylecatalogOpenMetadataBtn: "在Metadata标签中打开",
stylecatalogNoPromptFound: "此图像中未找到提示词。",
stylecatalogNoMatch: "未找到匹配的风格",
stylecatalogStyleSelected: (name) => `风格已设为「${name}」`,
stylecatalogNewFolderCreated: "文件夹已创建",
stylecatalogSaved: (name) => `已保存: ${name}`,
galleryBulkSelected: "项已选择",
galleryBulkDeselectAll: "取消全选",
galleryBulkSelectAll: "全选",
@@ -2744,6 +3203,10 @@ const LANGUAGES = {
aiSettingsModelSection: "选择模型",
aiSettingsRefreshBtn: "刷新",
aiSettingsModelPlaceholder: "-- 请选择模型 --",
aiSettingsGenerationTitle: "生成设置",
aiSettingsThinkingMode: "Thinking mode(思考模式)",
aiSettingsMaxTokens: "Max tokens(最大令牌数)",
aiSettingsMaxTokensPlaceholder: "0 = 无限制",
aiSettingsFreeLang: "Free 语言设置",
aiSettingsInputLang: "输入语言",
aiSettingsOutputLang: "翻译语言",
@@ -2759,6 +3222,7 @@ const LANGUAGES = {
aiStatusConnectOk: "连接成功",
aiStatusConnectFail: "连接失败:",
aiToastTransFailed: "翻译失败:",
aiToastMaybeNotTranslated: "模型原样返回了原文,可能未进行翻译。请尝试更换模型。",
aiToastNoCopyText: "没有可复制的文字",
aiToastCopied: "已复制",
aiToastNoModels: "未找到模型",
@@ -2808,10 +3272,14 @@ const LANGUAGES = {
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、徽章分配、标签、备注、AI摘要、发送到画布、加载到生成UI、设为默认、删除",
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
helpGen1: "连接/断开ComfyUI,检查连接状态",
helpGen2: "从工作流标签页加载工作流(支持含子图的工作流,例如 Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等模板;仅不支持ComfyUI的简化App格式 [.app.json / Linear Mode])。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
helpGen2: "从工作流标签页加载工作流(支持含子图的工作流,例如 Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 等模板;仅不支持ComfyUI的简化App格式 [.app.json / Linear Mode])。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
helpGen25: "创建目录按钮(Style下拉框旁):为Batch标签→Style子标签中选中的每个风格,使用当前加载的工作流(通常为T2I)各生成一张预览图,并以风格名称为文件名保存到指定的ws_style_catalog子文件夹(同名文件将被覆盖);会弹出对话框让你新建目标文件夹或选择已有文件夹 — 使用多个文件夹便于按checkpoint或用途分类整理目录;执行前需先在Batch标签→Style子标签中至少勾选一个风格。目录按钮(紧邻其右):直接跳转到Gallery标签的Style_Catalog子标签查看结果",
helpGen26: "创建目录对话框的「不在Output文件夹中保留副本」复选框:每次生成仍会先按工作流自身Save Image节点的机制,正常写入一份文件到ComfyUI的Output文件夹;勾选此项后,一旦该图像的副本已成功保存到目录文件夹,就会立即删除Output文件夹中的原始文件 — 适合大批量创建目录(例如全部200多个风格)时,避免Output文件夹被大量近似重复的文件淹没;若某个风格的目录副本保存失败,其Output文件不会被删除,会保留原样",
helpGen27: "Input标签页 → Image子面板 — 每张LoadImage卡片现在有三个按钮:Apply(不变)、Clear(将该卡片待处理的选择/预览重置为空;不会改动工作流节点本身,因为LoadImage始终需要一个有效的文件名)和Placeholder。面板顶部的「Placeholder Image」区域(属于低频功能,默认折叠,点击可展开)用于配置共享的默认值:Color模式(设置宽度/高度/颜色,即时生成纯色PNG)或Image模式(拖入一张默认图片,之后原样复用)。点击某张卡片自己的Placeholder按钮,会立即只替换该卡片的图片为已配置的默认值——无需再点Apply;当有多张LoadImage卡片时,每张都可以单独替换。Placeholder配置(模式、尺寸、颜色、默认图片)会跨会话记住",
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输入)选择器(含筛选)",
helpGen5: "Model标签:Checkpoint/VAE/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)始终显示;ControlNet和Hypernetwork(带Strength输入)因使用频率较低默认折叠 — 点击标题展开;LoRA(Single/Stack标签)拥有独立的列;所有选择器均含筛选;LoRA Stack — 点击Apply会将所有启用的LoRA写入目标节点,并同时将LoRA语法和CivitAI触发词插入Positive提示词;再次Apply会移除未启用的LoRA并重新添加启用的LoRA以更新提示词",
helpGen28: "Model标签 — 高亮颜色:当前工作流实际包含某类型节点时(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork),对应标签及LoRA列标题会以指定颜色(默认亮绿色)显示;即使ControlNet/Hypernetwork处于折叠状态该颜色依然可见,无需展开即可判断是否在使用;颜色可在Settings标签中自定义",
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致)",
helpGen8: "种子管理:随机/固定/递增/递减",
@@ -2826,6 +3294,11 @@ const LANGUAGES = {
helpGen17: "样式批处理:在左窗格Style标签中选择样式,启用批次队列Style列后点击Generate — 每个选中样式依次应用到正向和负向提示词;样式应用于工作流副本,不会修改已加载的工作流;每次页面加载时从 user/default/Workflow-Studio/style/*.json 重新读取样式文件",
helpGen18: "Alt+单击Apply按钮(Positive Prompt / Checkpoint / KSampler)可一步完成Apply & Generate — 将参数写入工作流后立即排队生成;仅当工作流中存在目标节点时才触发生成",
helpGen19: "Lora批处理:应用每个LoRA时,会自动将其语法(<lora:名称:strength_model:strength_clip>)和CivitAI触发词追加到Positive提示词 — 每次都基于原始提示词重新构建,不会累积;批处理完成或停止后提示词会自动恢复到批处理前的状态;若所选LoRA尚未被ComfyUI模型列表识别,会显示警告提示(需在ComfyUI中Refresh或重启)",
helpGen20: "LoRA有两种连接方式:链式连接标准 LoraLoader 节点(每个节点1个LoRA — 需要更多则继续串联节点),或使用单个 Lora Loader (LoraManager) 节点(来自ComfyUI-Lora-Manager自定义节点包,内部保存一个LoRA列表,每个LoRA各自管理启用/模型强度/CLIP强度);下方的Single/Stack标签会根据目标节点类型自动调整行为",
helpGen21: "Single标签:选择一个LoRA并设置Str M/Str C后点击Apply — 若目标是Lora Manager节点,则会将其内部LoRA列表整体替换为仅这一项,其余原有LoRA将丢失",
helpGen22: "Stack标签 — Use Stack Group复选框(仅当工作流中存在Lora Manager节点时显示):OFF(加载工作流时的默认状态)显示并编辑Lora Manager节点中实际配置的LoRA;ON则改为显示并编辑Models标签中已注册的\"Stack\"分组;切换任一方向都会先清空节点的LoRA列表,再从新选择的来源加载;切回OFF会精确恢复为加载工作流那一刻的LoRA列表(加载时已保存快照)",
helpGen23: "Stack标签的操作:每行都有启用复选框及Str M / Str C字段;表头的\"Stack\"复选框可一次性切换所有行;顶部的+/-步进按钮可同时调整所有当前启用行的强度;\"Showing: LoRAs currently in workflow\" / \"Showing: registered Stack group\"标签指示当前显示的是哪个来源;点击Apply会将完整列表写回目标节点,并同时将LoRA语法和CivitAI触发词追加到Positive提示词",
helpGen24: "使用 CLIP Text Encode edit+(CLIPTextEncodeEditPlus)节点时的注意事项:当Lora Manager节点的trigger_words输出连接到该节点的text1输入时(RAW/EDIT/front/back模式会将text1与可编辑的text_edit字段组合),触发词在生成时已自动包含;此时点击LoRA的Apply按钮或开启Use Stack Group会额外把LoRA语法/触发词写入text_edit,造成重复 — 如果想保持工作流原始的提示词和LoRA配置不被修改,请保持Use Stack Group为OFF,并避免点击LoRA的Apply类按钮",
helpFeederTitle: "GenerateUI Tab — Feeder 子标签",
helpFeederDesc: "通过左侧面板顶部的 [图片循环] / [图库] 切换按钮可在两种模式间切换。",
helpFeederImgloopTitle: "图片循环模式",
@@ -2847,6 +3320,42 @@ const LANGUAGES = {
helpFeederGal4: "右侧面板 — 预览:显示当前选中的输入图片;点击缩略图时更新",
helpFeederGal5: "__Feeder__ 分组:自动预先创建的保留分组;不可重命名或删除;在图库标签页中点击每个缩略图上的 F 按钮可将图片添加到此分组",
helpFeederGal6: "WFS_GalleryFeeder 画布控件:该节点在 ComfyUI 画布上直接添加了 Run / Stop / After Gen 组件——与 WFM 界面中的控件功能相同,无需打开 WFM 即可运行图库供给循环",
helpLabTitle: "GenerateUI标签页 — Lab子标签",
helpLabDesc: "实验性的I2I批量生成功能。将GenerateUI当前加载的工作流运行N次,可分别独立地从任意指定次数开始更改Model(Checkpoint/LoRA/VAE)、Prompt、KSampler。包含Setting和Results两个子标签。",
helpLabCardColumnsTitle: "Setting子标签 — 图片与列",
helpLab1: "左侧面板 — Image:拖入或点击选择作为I2I源图片;会上传到ComfyUI的input文件夹,只要未勾选\"Use generated image for next\",每次迭代都会使用该图片",
helpLab2: "左侧面板 — Use generated image for next:勾选后,从第2次开始使用上一次迭代生成的第1张图片作为I2I输入,而不是原始拖入的图片(第1次始终使用原始图片)",
helpLab3: "中间面板 — Model、Prompt、KSampler这3列相互独立;每列各自维护自己的关键帧列表,而不是共享同一个行网格",
helpLab4: "每列的+/-:+会在下一个空闲的迭代序号(不超过Batch数)添加关键帧并立即打开编辑弹窗;-会删除最后一个关键帧(第1个关键帧无法删除);除第1个之外的任何关键帧,也可以在其自己的编辑弹窗中通过「删除」按钮单独删除,不再限于最后一个",
helpLab5: "点击单元格即可编辑:第1个关键帧设置基准值(留空则保持工作流本身的设置);第2个及以后的关键帧还可以选择从第几次开始应用,以及是否勾选\"恢复第1次的设置\"",
helpLab6: "第N次迭代的实际生效值:使用迭代序号≤N中序号最大的关键帧,并一直延续到下一个关键帧改变它为止;勾选\"恢复第1次的设置\"时会直接复用第1次的值(若第1次为空则使用工作流默认值),而不是指定新值;将Batch数量减少到低于现有关键帧的迭代序号时(无论是手动修改,还是加载/应用方案),该关键帧不会被删除或重新编号,只会弹出警告提示,并在Batch数量再次提高之前一直处于运行循环无法到达的\"休眠\"状态。这种「整行」解决方式对Prompt和KSampler完全适用,但Model列例外——它会将Checkpoint/LoRA/VAE三个字段分别独立解决(详见下文)",
helpLab7: "Lab不会修改Input/Model/Settings中显示的工作流——每次迭代都在加载工作流的一个副本上运行,因此这里的关键帧设置不会影响其他子标签",
helpLab8: "Prompt单元格编辑弹窗的附加功能:「Get from GenerateUI」从GenerateUI当前加载的工作流中获取Positive/Negative;「Get from Image」从Lab左侧IMAGE区域当前加载的图片中提取提示词(与Metadata标签相同的检测逻辑,无需另外选择文件);「Style」行点击「应用」按钮后,会将GenerateUI工具栏顶部Style下拉菜单中当前选中的风格名称,以逗号分隔追加到下方的Positive/Negative文本之后(不会清空已有内容),并自动勾选Style复选框、在按钮旁边(而非按钮上)显示已应用的风格名称——风格名称只能通过顶部下拉菜单选择,Lab没有自己独立的风格选择器;点击应用按钮与顶部工具栏自己的Style启用复选框无关;手动取消勾选Style复选框只会清除该名称显示(不会自动删除已追加到Positive/Negative中的文本,如不需要请手动编辑删除)",
helpLab9: "左侧面板 — 「T2I workflow (no source image)」:当加载的工作流是文生图(T2I,没有LoadImage节点)时勾选此项。它会跳过\"请先拖入源图片\"的要求,忽略任何已拖入的图片,Model/Prompt/KSampler的关键帧仍会正常应用;开启期间Image拖放区域和「Use generated image for next」会变灰(之前拖入的图片不会被删除,以便切回I2I时使用);该设置会作为 t2i_mode 保存在方案中",
helpLab10: "通配符:Prompt列关键帧中输入的Positive/Negative文本,支持与主GenerateUI相同的A1111风格 __name__ 语法——每个关键帧的文本会在该次迭代运行前才展开,因此即使复用同一个关键帧,每次迭代也可能抽到不同的随机行",
helpLab11: "Prompt单元格编辑弹窗中还提供Clear(将Positive和Negative都清空)和Get from Previous(从同一列紧邻的上一个关键帧复制Positive和Negative——例如编辑#3时会整段复制#2的提示词);关键帧#1没有更早的行可复制,因此不显示该按钮",
helpLab12: "每一列的关键帧#1,只要还没有被显式保存覆盖,就会用当前GenerateUI中已加载工作流的实际设置代替空白占位显示(Model列仅覆盖Checkpoint和VAE两项——LoRA不会被实时反映,因为无法从工作流节点中安全地反推出\"当前生效的\"LoRA),同时体现在其网格单元格(虚线边框)和编辑弹窗的预填内容中;哪怕不做任何修改直接保存,也会将该值锁定为真正的覆盖值,虚线/实时显示随之消失;每次切换到Lab子标签页,都会重新根据当前GenerateUI中加载的内容刷新",
helpLab13: "Bypass复选框(包括#1在内的每个关键帧都有):在判定某次迭代应使用哪一行设置时,会把该行完全跳过,如同它不存在——于是它之前生效的设置(更早的关键帧,或最终什么都不应用)会继续保持;这只是Lab内部的概念,绝不会改动实际ComfyUI节点的mode;勾选后会禁用该行的取值字段以及其Revert复选框(二者互斥,不能同时生效);单元格会显示琥珀色的「⏭ Bypassed」标签,其优先级高于Revert显示和实时显示",
helpLab14: "Model列表头的C/L/V按钮用于切换单元格显示与编辑弹窗当前针对Checkpoint/LoRA/VAE(LoRA仅支持Single,不支持Stack)中的哪一项。未在查看的另外两项即使保存也会保持原值不变。当前加载的工作流中若确实存在对应类型的节点,相应按钮会变为与GenerateUI的Model标签页相同的高亮颜色(可在Settings中调整)。若设置了LoRA但工作流中完全没有LoRA节点,Run执行期间只会警告一次并跳过该项设置——与VAE不同,Lab不会自动插入LoRA节点,因为那需要重新连接Checkpoint的Model/CLIP输出线路",
helpLab15: "Model列的三个字段(Checkpoint/LoRA/VAE)各自独立向后延续:即使某个关键帧只设置了LoRA,Checkpoint/VAE也不会恢复为工作流原始设置,而是保持更早的关键帧最后设置的值(直到之后的关键帧明确修改它为止)。在编辑弹窗中,若某字段留空且存在可继承的值,其占位符会显示「(继承: ...)」而不是「(保持工作流原设置)」,方便确认实际会应用的值。「恢复第1次的设置」会一次性将三个字段全部恢复为关键帧#1的内容,而不仅仅是当前正在查看的这一项",
helpLabCardPlanTitle: "Setting子标签 — 运行与方案文件",
helpLabPlan1: "右侧面板 — Batch:要运行的迭代次数;Note:与方案一起保存的自由备注",
helpLabPlan2: "Run/Pause/Cancel:Run开始批量循环;Pause/Resume在各次迭代之间暂停/继续;Cancel会中断当前生成并停止循环",
helpLabPlan3: "Plan Save与Save As的区别:Plan Save会在已加载方案时静默覆盖保存(未加载时才会询问名称);Save As无论是否已加载方案都会始终询问新名称",
helpLabPlan4: "Plan Load:将方案的.json文件或其任意索引图片(.png)拖入PLAN区域(也可点击浏览选择)——拖入.json会直接读取;拖入.png时,如果是v0.3.90之后生成的索引图片(都会内嵌wfm_lab_plan数据块),也会直接读取其中的方案数据;仅对更早期、不含此数据的索引图片才会回退为从服务器获取同名的.json",
helpLabPlan5: "Plan Clear会将Setting标签重置为空方案(会先确认)",
helpLabPlan6: "方案文件保存在 user/default/Workflow-Studio/lab_plan/ 下,命名为ws_labplan_<名称>.json,若方案含有结果,还会自动生成ws_labplan_<名称>.png索引图片——最多9张结果缩略图组成的联系表(每行3张)。ws_labplan_前缀由Plan Save/Save As自动添加,输入时只需填写不带前缀的名称,方便在该文件夹中辨认方案文件",
helpLabPlan7: "设置标签页中配置的Eagle自动保存,会像GenerateUI自身的Generate按钮一样应用于Lab生成的每一张图片",
helpLabPlan8: "Plan JSON子标签:以原始JSON形式显示当前Setting标签页的方案内容。「Refresh」会从Setting标签页重新读取(放弃尚未应用的编辑);「Apply to Setting」会解析你的编辑并写回Setting标签页(列、备注、Batch数量、图片链式生成、源图片、结果)——效果与直接将该JSON作为方案文件加载相同,无需先保存",
helpLabPlan9: "每个已保存的方案都会记录保存时GenerateUI中加载的工作流文件名(Plan JSON中的workflow_filename)。加载方案后,PLAN框中会出现「Open Workflow」按钮——点击即可将该确切的工作流文件重新加载到GenerateUI中(会先弹出确认,因为这会替换当前已加载的工作流)",
helpLabPlan10: "右侧面板 — 「Save index image to Output on Run」:勾选后,每次Run结束都会生成与方案文件相同的联系表索引图片,并直接保存到ComfyUI自身的Output文件夹,命名为 Lab_index_<counter>_.png(像普通生成图片一样自动编号,不会覆盖上一次运行的结果)。保存后还会像其他Lab生成的图片一样经过Eagle自动保存(若在设置标签页中启用)——没有单独的复选框,遵循同一个全局开关。这与Plan Save的行为相互独立——无论是否勾选此项,Plan Save都会照常在 lab_plan/ 中的方案文件旁写入自己的索引图片;该设置会作为 save_index_on_run 保存在方案中",
helpLabPlan11: "每一张索引图片PNG——无论是来自Plan Save/Save As,还是来自Save index image to Output on Run——都会将完整的方案数据(列、备注、Batch数量、图片链式生成/T2I设置、工作流文件名、结果列表)直接嵌入为PNG的wfm_lab_plan数据块。因此Output文件夹中的那份副本是完全自包含的,即使旁边没有任何对应的.json文件,也可以作为Plan Load的加载来源",
helpLabCardResultsTitle: "Results子标签",
helpLabResults1: "左侧面板显示本次运行使用的源图片",
helpLabResults2: "中间面板按生成顺序显示最多9张生成图片;点击缩略图可放大查看",
helpLabResults3: "加载已保存的方案时,会根据方案文件中记录的图片列表恢复Results网格",
helpSearchPlaceholder: "搜索帮助...",
helpPrompt1: "AI助手(Ollama / LM Studio / Lemonade):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
helpPrompt2: "将AI回复应用到生成UI的提示词",
@@ -2857,6 +3366,11 @@ const LANGUAGES = {
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
helpPrompt8: "通配符工具栏:一键插入通配符语法({|}、{n$|}、__|__、<lora:>等);支持包裹选中文本",
helpPrompt9: "通配符文件管理:创建/编辑/删除.txt/.yaml通配符文件;点击文件名在光标处插入__文件名__",
helpPrompt10: "右侧面板的Wildcard/Style标签栏可在上方的通配符工具与此Style管理器之间切换整个面板(共用同一个右侧面板)",
helpPrompt11: "创建/编辑/删除已注册的Style(与GenerateUI的Style下拉菜单、Batch标签、Style Catalog使用的是同一批数据):点击列表中的名称进行编辑,或点击「+ New」创建空白条目;字段包括Name、Positive prompt(支持{prompt}占位符 — 会被替换为用户自己的提示词,若省略则附加在其后)和Negative prompt(可选)",
helpPrompt12: "重命名后仍保留其在原定义文件中的位置;新建的Style会保存到style文件夹内专用的custom.json中,因此内置的风格包文件不会被直接修改;名称在所有Style中必须唯一",
helpPrompt13: "保存、重命名或删除Style会立即刷新GenerateUI的Style下拉菜单、Batch标签的风格勾选列表以及Style Catalog的名称列表 — 无需重新加载",
helpPrompt14: "列表中每个条目的名称旁都会以小徽章显示其来源文件(例如sdxl_styles_diva.json);编辑现有Style时,该文件名也会显示在Positive prompt字段上方,并且Save按钮旁会出现「+ Add to this file」按钮 — 点击后表单会清空,可创建一个新Style并追加到同一文件,而不是默认的custom.json",
helpSettings1: "UI语言和摘要语言设置",
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
helpSettings3: "工作流数据文件夹路径更改/重置",
@@ -2870,6 +3384,7 @@ const LANGUAGES = {
helpSettings11: "文字大小:单个滑块(10–28 px)可一次性调整所有提示词和聊天文本框的字体大小 — GenerateUI正/负向提示词、AI助手聊天输入、预设正/负向提示、通配符提示和文件编辑器、元数据提示词全文预览;即时生效并通过「保存设置」保存",
helpSettings12: "RAW JSON 配色:自定义GenerateUI中Raw JSON编辑器的6种语法高亮颜色 — 默认文本、名称/调度器、标题、宽度/高度、提示词/文本、图像/文件;颜色选择即时生效;「重置默认」恢复原始配色",
helpSettings13: "CivitAI 站点:在 civitai.com(仅SFW)和 civitai.red(无限制)之间切换 — 决定在Models标签CivitAI面板中点击模型链接时打开的站点",
helpSettings14: "GenerateUI Model标签高亮颜色:设置当前工作流实际包含对应类型节点时,GenerateUI Model标签中Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork标签及LoRA列标题的显示颜色;默认为亮绿色;更改即时生效,「重置默认」可恢复",
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
helpNodes2: "卡片和表格视图模式切换(带分页)",
helpNodes3: "收藏节点,添加自定义标签进行整理",
@@ -2907,6 +3422,14 @@ const LANGUAGES = {
helpGallery15: "F按钮(青色悬浮按钮,每个缩略图左上角):单击即可将图像添加到/从__Feeder__组中移除;图像在组中时始终显示青色,不在组中时仅悬停时可见;可快速为Feeder标签Gallery模式添加队列图像",
helpGallery16: "Image Edit按钮(工具栏,Refresh按钮旁):将当前选中的图像发送到Image Edit标签;如果已有图层则按比例缩放添加为新图层,否则将成为Layer 1",
helpGallery17: "Send GenUI Image按钮(工具栏,Image Edit按钮旁):将选中图像上传至ComfyUI并设置为GenerateUI标签中第一个LoadImage节点的输入;自动切换至GenerateUI → Input → Image;需要预先加载包含至少一个LoadImage节点的工作流",
helpGallery18: "Gallery标签页中与Output并列的第二个子标签,用于浏览可视化提示词库 — 管理ComfyUI output文件夹内的ws_image_prompt文件夹(未创建时自动生成)",
helpGallery19: "相同的三栏布局:文件夹树(左)|缩略图(中)|提示词构建器(右)。点击缩略图可预览图像及其提示词文本 — 此时尚未添加到提示词中",
helpGallery20: "「+ 添加」将预览中图像的提示词作为标签添加到「已选提示词」;标签可单独删除(×)或一次性清除(「全部清除」);「Save」用于编辑并保存该图像的提示词文本",
helpGallery21: "「最终提示词」将所有标签以逗号连接为一段文本(也可自由编辑);「Copy」将其复制到剪贴板。提示词以纯文本.txt文件形式保存在对应图像旁,因此任何人只需在子文件夹中放入图像+对应的.txt文件即可添加自己的数据",
helpGallery22: "Gallery标签页顶部的第三个子标签,可通过预览图直观地选择已注册的Style — 管理ComfyUI output文件夹内的ws_style_catalog文件夹(未创建时自动生成)。由GenerateUI标签的「创建目录」按钮生成(参见GenerateUI标签帮助)",
helpGallery23: "相同的三栏布局:文件夹树(左)|缩略图(中)|Positive/Negative提示词面板(右)。点击缩略图可查看该生成图像中实际嵌入的Positive/Negative提示词,各自带有独立的Copy按钮",
helpGallery24: "Select as Style按钮:以选中图像的文件名(不含扩展名)匹配已注册的Style名称,并将GenerateUI标签的Style下拉框切换为该风格 — 这是通往现有命名Style系统的可视化快捷方式,而非一次性应用嵌入的提示词(如果目录图像生成后该Style已被重命名或删除,则会显示「未找到匹配的风格」提示)",
helpGallery25: "载入GenerateUI / 在Metadata标签中打开按钮:读取目录图像本身内嵌的工作流(目录副本携带与原始输出图像相同的PNG元数据),可直接载入GenerateUI编辑器,或在Metadata标签中打开该图像以供查看 — 无需在Output文件夹中寻找原始文件",
// -- Tagger Tab --
helpTaggerTitle: "Tagger标签",
@@ -2936,14 +3459,14 @@ const LANGUAGES = {
helpSidepanel13: "I 标签(Information):拖放 ComfyUI PNG/WebP/JSON 即可在侧面板内查看模型、LoRA 和提示词元数据 — 子标签:model / lora / Prompts;支持 UnetLoaderGGUF、QuadrupleCLIPLoader 和 Lora Loader (LoraManager) 节点类型",
helpSidepanel14: "Information 标签 — 拖拽到画布:model 子标签拖拽可放置对应加载节点(Checkpoint → CheckpointLoaderSimple、VAE → VAELoader、Diffusion Model → UNETLoader、Text Encoder → CLIPLoader);双击也可放置到画布中央",
helpSidepanel15: "Information 标签 — lora 子标签:可检测 LoraLoader、LoraLoaderModelOnly 及 Lora Loader (LoraManager) 节点(含API格式)的 LoRA;拖拽单个 LoRA 放置 LoraLoader;「Multiple LORA」区域可将全部 LoRA 一次性放入 Lora Loader (LoraManager) 节点并自动填入LoRA语法;Prompts 子标签:拖拽提示词放置预填文本的 CLIPTextEncode 节点;点击可查看全文并复制",
helpSidepanel16: "A 标签(AI 工具):翻译、Chat、TOOLS 和设置子标签;Chat 支持与 LLM 的多轮对话;TOOLS 包含 VLM 图像分析和通配符生成;设置通过 localStorage 与 SPA 的 AI 标签共享",
helpSidepanel16: "A 标签(AI 工具):翻译、Chat、TOOLS 和设置子标签,支持 Ollama、LM Studio、Lemonade 或 Unsloth;Chat 支持与 LLM 的多轮对话;TOOLS 包含 VLM 图像分析和通配符生成;设置通过 localStorage 与 SPA 的 AI 标签共享",
helpSidepanel17: "Send to Canvas:在Workflow标签点击'Send to Canvas'或Gallery标签点击'Copy & Send Canvas',工作流直接发送到ComfyUI画布(支持UI和API所有格式);若Workflow Studio不是从ComfyUI工具栏打开,UI格式工作流将回退到标题拖拽(标题变蓝并显示绿色●——拖拽到画布加载)",
helpAiTitle: "AI 标签页(A)",
helpAi1: "4 面板布局 — Translation | Chat | TOOLS | Settings — 所有面板始终可见",
helpAi2: "翻译面板:使用 Ollama、LM Studio 或 Lemonade 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
helpAi2: "翻译面板:使用 Ollama、LM Studio、Lemonade 或 Unsloth 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史;如果模型支持 tool calling,当你要求生成图像(例如\"生成一张猫的图片\")时,会使用 GenerateUI 中当前加载的工作流执行生成,并在对话中显示结果;面板标题栏的\"允许生成图像\"复选框可开关此功能;如果附加了图像(在 TOOLS 面板中拖放的图像),该图像会作为视觉输入发送给 LLM,调用 generate_image 时会以该图像为基础执行图生图(I2I),而不是文生图;已验证可正常支持 tool calling 与视觉功能的模型:Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条);拖放的图像也会作为 Chat 面板的附件共享(在该处显示并用于图生图生成),直到点击 ✕ 按钮清除",
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade)、设置 API URL、测试连接、选择模型、配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade / Unsloth)、设置 API URL、测试连接、选择模型;Unsloth 即使在本地访问时也始终需要 API 密钥认证——将插件文件夹中的 .env.example 复制为 .env 并设置 UNSLOTH_API_KEY,然后重启 ComfyUI(密钥仅在服务器端读取,绝不会发送到浏览器);还需要在 Unsloth Desktop 应用自身的设置界面中开启「Auto model switching (OpenAI API)」——若保持关闭,除非已在 Unsloth Studio 中手动加载过模型,否则请求会以 HTTP 404 失败;Unsloth 的推理模型会将思考内容放在单独的 reasoning_content 字段中,而不是内联的 <think> 标签里,因此 Max tokens 太小会导致模型把全部配额都用在思考上,返回空白回复——如果回复为空,请调高 Max tokens(建议 2048 以上);Generation 部分 — Thinking mode 开关(默认关闭;Ollama会收到think标志,无论使用哪个后端,关闭时都会从输出中去除<think>...</think>思考块,避免推理模型的思考过程混入Translation/Chat/TOOLS的结果)以及 Max tokens(Ollama: num_predict,LM Studio/Lemonade/Unsloth: max_tokens;0为不限);配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
helpAi7: "Chat 面板 — SVG:LLM 还可以直接以文本形式生成 SVG 图形,完全不涉及 ComfyUI 工作流;只需要求它用 SVG 绘制内容,如果回复中包含 SVG 代码,会自动渲染为内嵌预览,并提供\"保存到图库\"按钮,将其保存为 .svg 文件到 Gallery 标签页的输出文件夹;该功能不使用 tool calling(函数调用),因此不支持 tool calling 的模型也可以使用",
helpAi8: "Chat 面板 — 技能:在消息列表上方的下拉菜单中选择一个技能,其内容会作为 system prompt 添加到对话开头(不会显示在可见的聊天记录中);点击铅笔按钮打开技能管理面板,可创建、编辑、删除 .md 技能文件(保存路径:user/default/Workflow-Studio/ai_skills/);文件开头 --- 之间的 name/description 字段会显示在下拉菜单中;默认内置两个技能:「SVG Icon Generator」(在绘制前先将对象拆解为多个部件,以生成更易识别的 SVG)和「Skill Creator」(通过对话向你提问并起草新的技能文件);当回复中包含```skill代码块时,消息下方会出现「保存为技能」按钮,点击后会打开管理面板并预填内容",
helpAi9: "编写技能的小贴士:为 frontmatter 的 name/description 写清楚的内容,便于在下拉菜单和提示中阅读;明确说明期望的输出格式,如果有需要后续提取的成果物(如 SVG 或另一个技能文件),指示它用专门的代码围栏包裹;具体、祈使句式的指令比笼统的说明对本地小模型更有效,附上一个简短示例效果更佳;明确写出硬性约束(禁止内容、安全性、长度限制等),不要指望模型会自行推断;如果不确定怎么写,可以在下拉菜单中选择「Skill Creator」技能并描述你想要的技能,它会通过提问帮你起草",
@@ -2967,10 +3490,12 @@ const LANGUAGES = {
helpGenCardSeed: "种子与生成",
helpGenCardBatchSetup: "Batch 标签 — 设置",
helpGenCardBatchTypes: "Batch 标签 — 批量类型",
helpGenCardLora: "Model 标签 — LoRA 详情",
helpPromptTitle: "提示词标签页",
helpPromptCardAi: "AI 助手与翻译",
helpPromptCardPresets: "预设",
helpPromptCardWildcards: "通配符",
helpPromptCardStyleTitle: "Style",
helpMetadataCardOverview: "概述",
helpMetadataCardExtracted: "提取的信息",
helpMetadataCardFormats: "支持的格式",
@@ -2985,6 +3510,8 @@ const LANGUAGES = {
helpNodesCardGroups: "分组与节点集",
helpNodesCardPanel: "侧边面板与多选",
helpGalleryCardBrowse: "浏览",
helpGalleryCardStyleprompt: "ImagePrompt 图库",
helpGalleryCardStylecatalog: "Style_Catalog 图库",
helpGalleryCardFolder: "文件夹与选择",
helpGalleryCardDetail: "详情面板",
helpGalleryCardActions: "操作工具栏",
@@ -3032,7 +3559,8 @@ const LANGUAGES = {
helpImageEditExportTitle: "导出与导航",
helpImageEdit17: "Save PNG:合并所有可见图层并下载为PNG文件",
helpImageEdit18: "Save to Gallery:输入文件名(默认:wfs-image-YYYYMMDDHHmmss)将合成PNG保存到Gallery根文件夹(在设置 > Gallery Output Folder中配置);需要至少打开过一次Gallery标签以设置根目录",
helpImageEdit19: "Send to ComfyUI:通过/upload/image将合成PNG上传到ComfyUI的输入文件夹,使其可在Load Image节点中选择",
helpImageEdit19: "Save to Input:通过/upload/image将合成PNG上传到ComfyUI的输入文件夹,使其可在Load Image节点中选择",
helpImageEdit19b: "Send to Workflow:与 Save to Input 一样会上传合成PNG,但还会将上传结果的文件名写入 ComfyUI 图中当前选中节点(若无选中节点则回退到找到的第一个 LoadImage 类节点)的 image 部件;需要通过 ComfyUI 顶部菜单按钮打开 Workflow Studio",
helpImageEdit20: "画布导航:滚轮缩放,Space+拖动平移",
helpImageEdit21: "撤销/重做:Ctrl+Z / Ctrl+Y(或工具栏的 ↩ ↪ 按钮)",
helpImageEditKbdTitle: "图像编辑键盘快捷键",
@@ -3064,6 +3592,7 @@ const LANGUAGES = {
helpImageEditMask11: "Text子工具:点击画布打开文字图章面板;输入文字,设置Font / Size / Bold / Italic / Align及Mode(Add = 白色图章 / Erase = 消除遮罩),点击Stamp或按Ctrl+Enter确认;支持多行文字(在文本框中换行)",
helpImageEditMask12: "Vector子工具:点击添加Catmull-Rom样条控制点;点击第一个控制点(或按Enter)关闭并填充多边形;Backspace删除最后一个点;Esc重置所有点;属性面板显示当前点数及Mode(Add / Erase)",
helpImageEditMask13: "Shape子工具:拖动绘制矩形或椭圆遮罩;按住Shift可绘制正方形/正圆;拖动时蓝色虚线预览形状;属性面板:Mode(Add / Erase)、Shape(Rect / Ellipse)",
helpImageEditMask14: "Edit in Mask Editor One(仅限 Paint 模式):点击 MASK EDITOR ONE 区域中的“Edit in Mask Editor One →”,会将当前画布合成图像(若当前激活图层是遮罩图层,则一并发送其内容)发送到 ComfyUI 画布上的 MaskEditorOne 节点(优先使用当前选中的节点,否则使用图中找到的第一个该类型节点),并在 ComfyUI 浏览器标签页中打开完整编辑器(SAM3、BiRefNet、形状等);在那里点击 Apply 后,结果会作为新遮罩图层导入到这里;需要已通过 ComfyUI 顶部菜单按钮打开 Workflow Studio,且 ComfyUI 图中至少存在一个 MaskEditorOne 节点",
helpImageEditBlurTitle: "Blur 工具 (≈)",
helpImageEditBlur1: "Whole Blur:对整个活动图层应用高斯模糊 — 调整强度滑块(1–50 px),点击Apply",
helpImageEditBlur2: "Whole Mosaic:对整个活动图层应用像素化马赛克 — 调整块大小滑块(5–100 px),点击Apply",
@@ -3101,6 +3630,7 @@ const LANGUAGES = {
helpTrouble6: "移动或删除模型后出现模型加载错误:ComfyUI会缓存文件路径,可能仍引用旧位置。请通过ComfyUI菜单执行Hard Reset(Queue → Hard Reset)或重启ComfyUI以清除缓存。",
helpTrouble7: "Tagger标签不显示模型:确认ComfyUI/models/tagger/<模型名>/文件夹存在且包含.onnx或.h5文件;如未安装onnxruntime,请运行:python_embeded\\python.exe -m pip install onnxruntime",
helpTrouble8: "Model 标签页显示的 Checkpoint 与预期不符(例如无论加载哪个工作流都显示相同模型):工作流文件中保存的 Checkpoint 实际未安装,加载时已被静默替换为其他已安装模型,此时会弹出包含原始(未找到)模型名称的错误提示。请在 Model 标签页手动选择正确的 Checkpoint,或在设置标签页启用\"默认 Checkpoint\",在每次加载时强制应用一个已验证可用的模型。",
helpTrouble9: "点击 Generate 后,执行的是与刚加载的工作流不同的工作流:Batch 子标签页中的某个批量类型(Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler)复选框仍处于开启状态。只要有任意批量类型处于启用状态,Generate 按钮执行的就是批量选择的内容,而不是当前加载的工作流。请打开 Batch 子标签页,关闭已启用的批量类型复选框。",
helpSupportTitle: "支持",
helpSupportDesc: "如果您觉得这个插件有用,请考虑支持它的开发!",
helpGithubDesc: "报告问题、请求功能和贡献代码",
@@ -3240,14 +3770,14 @@ const LANGUAGES = {
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters)",
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme)",
metaFormatTodo: "已支持 Flux.2 / Qwen-Image / Z-Image 子图工作流",
metaFormatTodo: "已支持 Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 子图工作流",
helpMetadataTitle: "Metadata 标签页",
helpMetadataDesc: "拖放 ComfyUI 生成的 PNG/WebP 或工作流 JSON,即可提取并显示其中的模型和提示词信息。",
helpMetadata1: "左列:拖放区(PNG/WebP 显示图片预览)及文件信息与支持格式说明",
helpMetadata2: "中列:从工作流中提取的 Checkpoint、VAE、扩散模型、文本编码器列表",
helpMetadata3: "右列:LoRA 列表(含强度值)和提示词列表(点击在下方显示全文)",
helpMetadata4: "提示词操作 — Copy:复制到剪贴板;GenUI:P/N:设置 GenerateUI 正/负向提示;Prompt:P/N:设置 Prompt 标签页预设正/负向提示",
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow 子图工作流)、SD WebUI、SD Forge、Fooocus。",
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Ernie Image 子图工作流)、SD WebUI、SD Forge、Fooocus。",
},
};
+102 -1173
View File
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
/**
* Image Edit Tab - Background Removal
* Two backends: a lightweight in-browser model (@imgly/background-removal)
* and BiRefNet via the Mask Editor One server endpoints.
*/
import { showToast } from "../app.js";
export class BgRemove {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(newLayer: object) => void} callbacks.onLayerAdded
*/
constructor(callbacks) {
this._cb = callbacks;
this.birefnetAvailable = false;
}
async checkAvailability() {
try {
const resp = await fetch("/mask_editor/birefnet/status");
if (!resp.ok) return;
const json = await resp.json();
this.birefnetAvailable = json.loaded === true || json.model_found === true;
} catch {
this.birefnetAvailable = false;
}
}
renderPanel(el) {
const birefnetDisabled = this.birefnetAvailable ? "" : "disabled";
const birefnetLabel = this.birefnetAvailable
? "BiRefNet (Mask Editor One)"
: "BiRefNet (Mask Editor One required)";
el.innerHTML = `
<div class="ie-opt-group">
<label>Model</label>
<select id="ie-bgremove-model" class="ie-opt-select">
<option value="imgly">Lightweight (@imgly)</option>
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
</select>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;cursor:pointer;">
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
</label>
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm" id="ie-bgremove-btn">Remove BG</button>
</div>
<span id="ie-bgremove-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;"></span>
`;
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this.apply());
}
async apply() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
const statusEl = document.getElementById("ie-bgremove-status");
const btn = document.getElementById("ie-bgremove-btn");
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
if (btn) btn.disabled = true;
setStatus("Starting...");
try {
const dataUrl = layer.canvas.toDataURL("image/png");
let resultDataUrl;
if (model === "imgly") {
resultDataUrl = await this._removeImgly(dataUrl, setStatus);
} else {
resultDataUrl = await this._removeBiRefNet(dataUrl, setStatus);
}
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Result image load failed"));
i.src = resultDataUrl;
});
this._cb.saveUndo();
if (asNew) {
const newL = layerMgr.addLayer("image", layer.name + " (no bg)", {
contentW: img.width, contentH: img.height,
displayW: layer.displayW, displayH: layer.displayH,
x: layer.x, y: layer.y,
});
newL.ctx.drawImage(img, 0, 0);
layerMgr.setActive(newL.id);
this._cb.onLayerAdded(newL);
} else {
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
}
this._cb.updateCompositeView();
this._cb.refreshLayerList();
setStatus("Done!");
setTimeout(() => setStatus(""), 3000);
showToast("Background removed", "success");
} catch (err) {
setStatus("Error: " + err.message);
showToast("BG remove failed: " + err.message, "error");
} finally {
if (btn) btn.disabled = false;
}
}
async _removeImgly(dataUrl, onStatus) {
if (!window._wfmImglyRemoveBg) {
onStatus("Loading model...");
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
window._wfmImglyRemoveBg = mod.removeBackground;
}
onStatus("Processing...");
const res = await fetch(dataUrl);
const blob = await res.blob();
const resultBlob = await window._wfmImglyRemoveBg(blob, {
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
});
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(resultBlob);
});
}
async _removeBiRefNet(dataUrl, onStatus) {
const NODE_ID = "wfs_bgremove";
// 1. アクティブレイヤー画像を Mask Editor One のキャッシュに登録
onStatus("Sending image...");
const storeResp = await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
// 2. BiRefNet 推論実行
onStatus("Running BiRefNet...");
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
// 3. グレースケールマスク (白=前景) → RGBA PNG に変換して返す
onStatus("Compositing...");
return await this._applyMaskToImage(dataUrl, json.mask_b64);
}
async _applyMaskToImage(imageB64, maskB64) {
const loadImage = src => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
img.src = src;
});
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
const w = origImg.naturalWidth;
const h = origImg.naturalHeight;
// マスク: グレースケール輝度値 → アルファチャンネル (RGBA 白マスク)
const maskCanvas = document.createElement("canvas");
maskCanvas.width = w;
maskCanvas.height = h;
const mc = maskCanvas.getContext("2d");
mc.drawImage(maskImg, 0, 0, w, h);
const maskData = mc.getImageData(0, 0, w, h);
const md = maskData.data;
for (let i = 0; i < md.length; i += 4) {
md[i + 3] = md[i]; // 輝度 → アルファ
md[i] = md[i + 1] = md[i + 2] = 255;
}
mc.putImageData(maskData, 0, 0);
// 元画像にマスクを destination-in で適用
const out = document.createElement("canvas");
out.width = w;
out.height = h;
const ctx = out.getContext("2d");
ctx.drawImage(origImg, 0, 0, w, h);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(maskCanvas, 0, 0, w, h);
return out.toDataURL("image/png");
}
}
+259
View File
@@ -0,0 +1,259 @@
/**
* Image Edit Tab - Blur / Mosaic Tool
* Whole-layer blur/mosaic buttons plus a drag-to-select rectangle mode
* that applies blur or mosaic to just the dragged region.
*/
import { showToast } from "../app.js";
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
if (w <= 0 || h <= 0 || size < 1) return;
const imgData = ctx.getImageData(x, y, w, h);
const d = imgData.data;
for (let py = 0; py < h; py += size) {
for (let px = 0; px < w; px += size) {
const i = (py * w + px) * 4;
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
for (let by = py; by < Math.min(py + size, h); by++) {
for (let bx = px; bx < Math.min(px + size, w); bx++) {
const j = (by * w + bx) * 4;
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
}
}
}
}
ctx.putImageData(imgData, x, y);
}
export class BlurTool {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => number} callbacks.getZoom
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(toolId: string) => void} callbacks.renderToolOptions
*/
constructor(callbacks) {
this._cb = callbacks;
this.rectMode = null; // null | 'blur' | 'mosaic'
this._dragging = false;
this._dragStart = null;
this._dragCur = null;
}
activate() {
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.style.cursor = this.rectMode ? "crosshair" : "default";
}
deactivate() {
this.rectMode = null;
this._dragging = false;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) {
overlay.style.cursor = "";
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
}
renderPanel(el) {
const blurOn = this.rectMode === "blur";
const mosaicOn = this.rectMode === "mosaic";
el.innerHTML = `
<div class="ie-opt-group">
<span style="font-size:11px;color:var(--wfm-text-secondary);">Whole:</span>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">Blur</label>
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
<button class="wfm-btn wfm-btn-sm" id="ie-whole-blur-apply">Apply</button>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">Mosaic</label>
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
<button class="wfm-btn wfm-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
</div>
<div style="width:1px;height:22px;background:var(--wfm-border);margin:0 6px;flex-shrink:0;"></div>
<div class="ie-opt-group">
<span style="font-size:11px;color:var(--wfm-text-secondary);">Rect:</span>
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle"
style="${blurOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
Rect Blur: ${blurOn ? "ON" : "OFF"}
</button>
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle"
style="${mosaicOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
</button>
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
</div>
`;
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
});
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
});
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
this.applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
});
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
this.applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
});
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
this.rectMode = this.rectMode === "blur" ? null : "blur";
this._cb.renderToolOptions("blur");
const ov = document.getElementById("ie-canvas-overlay");
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
});
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
this.rectMode = this.rectMode === "mosaic" ? null : "mosaic";
this._cb.renderToolOptions("blur");
const ov = document.getElementById("ie-canvas-overlay");
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
});
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
});
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
});
}
onMouseDown(pos) {
if (!this.rectMode) return;
this._dragging = true;
this._dragStart = { x: pos.x, y: pos.y };
this._dragCur = { x: pos.x, y: pos.y };
}
onMouseMove(pos) {
if (!this._dragging) return;
this._dragCur = pos;
this._drawPreview();
}
onMouseUp() {
if (!this._dragging) return;
this._dragging = false;
this._applyRectEffect();
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
onMouseLeave() {
if (!this._dragging) return;
this._dragging = false;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
applyWholeBlur(amount) {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) return;
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
this._cb.saveUndo();
const w = layer.canvas.width, h = layer.canvas.height;
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
const tc = tmp.getContext("2d");
tc.filter = `blur(${amount}px)`;
tc.drawImage(layer.canvas, 0, 0);
layer.ctx.clearRect(0, 0, w, h);
layer.ctx.drawImage(tmp, 0, 0);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
}
applyWholeMosaic(size) {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) return;
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
this._cb.saveUndo();
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
}
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
_canvasToLayerCoords(layer, cx, cy) {
const centerX = layer.x + layer.displayW / 2;
const centerY = layer.y + layer.displayH / 2;
const dx = cx - centerX, dy = cy - centerY;
const angle = -(layer.rotation || 0) * Math.PI / 180;
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
const scaleX = layer.displayW / layer.canvas.width;
const scaleY = layer.displayH / layer.canvas.height;
let lx = rdx / scaleX + layer.canvas.width / 2;
let ly = rdy / scaleY + layer.canvas.height / 2;
if (layer.flipX) lx = layer.canvas.width - lx;
if (layer.flipY) ly = layer.canvas.height - ly;
return { x: lx, y: ly };
}
_drawPreview() {
const overlay = document.getElementById("ie-canvas-overlay");
if (!overlay) return;
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const s = this._dragStart, c = this._dragCur;
if (!s || !c) return;
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
const zoom = this._cb.getZoom();
ctx.strokeStyle = this.rectMode === "blur" ? "#4af" : "#fa4";
ctx.lineWidth = 1 / zoom;
ctx.setLineDash([4 / zoom, 2 / zoom]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
_applyRectEffect() {
const layer = this._cb.getLayerManager()?.activeLayer;
if (!layer || !this._dragStart || !this._dragCur) return;
const s = this._dragStart, c = this._dragCur;
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
const p1 = this._canvasToLayerCoords(layer, minX, minY);
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
if (lw <= 0 || lh <= 0) return;
this._cb.saveUndo();
if (this.rectMode === "blur") {
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
const tmp = document.createElement("canvas");
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
const tc = tmp.getContext("2d");
tc.filter = `blur(${amount}px)`;
tc.drawImage(layer.canvas, 0, 0);
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
} else {
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
}
this._cb.updateCompositeView();
this._cb.refreshLayerList();
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Image Edit Tab - File Export
* Composites the visible layers (respecting mask-apply clipping) into a
* flat canvas and offers three destinations: local PNG download, WFS
* Gallery, and ComfyUI's own /upload/image endpoint.
*/
import { showToast } from "../app.js";
import { Layer } from "./LayerManager.js";
export class FileExport {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => {w: number, h: number}} callbacks.getCanvasSize
* @param {() => string} callbacks.getBaseName
* @param {(ctx: CanvasRenderingContext2D, drawCanvas: HTMLCanvasElement, maskLayer: object, targetLayer: object, showOverlay: boolean) => void} callbacks.renderMaskedLayer
*/
constructor(callbacks) {
this._cb = callbacks;
}
_buildCompositeCanvas() {
const { w, h } = this._cb.getCanvasSize();
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) return canvas;
this._compositeForExport(canvas, layerMgr);
return canvas;
}
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
_compositeForExport(target, layerMgr) {
const ctx = target.getContext("2d");
ctx.clearRect(0, 0, target.width, target.height);
const layers = layerMgr.layers;
const maskedIndices = new Set();
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
maskedIndices.add(i + 1);
}
}
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i];
if (!layer.visible) continue;
if (maskedIndices.has(i)) continue;
if (layer.type === "mask") {
if (layer.maskApply && i + 1 < layers.length) {
this._cb.renderMaskedLayer(ctx, target, layer, layers[i + 1], false);
}
// maskApply=false のマスクはエクスポートに含めない
} else {
ctx.save();
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode;
Layer.applyTransform(ctx, layer);
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
ctx.restore();
}
}
}
savePng() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
const canvas = this._buildCompositeCanvas();
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = (this._cb.getBaseName() || "wfs-edit") + "-output.png";
a.click();
showToast("PNG saved", "success");
}
async saveToGallery() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
// デフォルトファイル名: wfs-image-YYYYMMDDHHmmss
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const defaultName = `wfs-image-${ts}`;
const filename = window.prompt("Save to Gallery — file name (without extension):", defaultName);
if (filename === null) return; // キャンセル
const safeName = filename.trim() || defaultName;
const canvas = this._buildCompositeCanvas();
const imageData = canvas.toDataURL("image/png");
try {
const r = await fetch("/wfm/gallery/image/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: safeName, imageData }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.error || r.statusText);
}
showToast(`Saved to Gallery: ${safeName}.png`, "success");
} catch (err) {
showToast(`Gallery save failed: ${err.message}`, "error");
}
}
async uploadToComfyUI() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
const canvas = this._buildCompositeCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
const form = new FormData();
form.append("image", file);
form.append("overwrite", "true");
try {
const r = await fetch("/upload/image", { method: "POST", body: form });
const data = await r.json();
showToast(`Uploaded: ${data.name}`, "success");
} catch {
showToast("Upload failed", "error");
}
}
// Uploads the composite like uploadToComfyUI(), then (via window.opener,
// since WFS runs in its own window — same cross-window pattern as
// wfmReceiveWorkflow/wfmOpenMaskEditorForNode) writes the resulting
// filename into the "image" widget of the currently selected ComfyUI
// node (falling back to the first node in the graph with one), mirroring
// the chat_TE custom node's "Send to workflow" button.
async sendToWorkflow() {
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
return;
}
const canvas = this._buildCompositeCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
const form = new FormData();
form.append("image", file);
form.append("overwrite", "true");
try {
const r = await fetch("/upload/image", { method: "POST", body: form });
const data = await r.json();
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
window.opener.wfmSendImageToSelectedNode(filename);
showToast(`Sent "${filename}" to workflow`, "success");
} catch (err) {
showToast(`Send to workflow failed: ${err.message}`, "error");
}
}
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Image Edit Tab - G'MIC Integration
* Launches the G'MIC GUI on the server for the active layer, polls job
* status, and applies the resulting image back onto the layer.
*/
import { showToast } from "../app.js";
export class GmicIntegration {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.syncActiveLayerFromCanvas
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(toolId: string) => void} callbacks.renderToolOptions
*/
constructor(callbacks) {
this._cb = callbacks;
this.state = {
lastResultJobId: null,
processing: false,
aborted: false
};
}
renderPanel(el) {
const openBtnDisabled = this.state.processing ? "disabled" : "";
const applyBtnDisabled = (!this.state.lastResultJobId || this.state.processing) ? "disabled" : "";
const progressStyle = this.state.processing ? "display:flex" : "display:none";
el.innerHTML = `
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-gmic-open-btn" ${openBtnDisabled}>G'MIC GUIで編集</button>
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-apply-btn" ${applyBtnDisabled}>結果を反映</button>
</div>
<div class="ie-opt-group" id="ie-gmic-progress-area" style="${progressStyle}; align-items:center; gap:6px;">
<span id="ie-gmic-progress-lbl" style="font-size:11px; color:var(--wfm-text-secondary);">G'MIC GUIを起動中...</span>
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-abort-btn" style="background:#ea4335;color:#fff;">中断</button>
</div>
`;
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this.openGui());
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this.applyResult());
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this.abort());
}
async openGui() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
const layer = layerMgr.activeLayer;
if (!layer) { showToast("No active layer", "error"); return; }
this._cb.syncActiveLayerFromCanvas();
if (this.state.processing) return;
const dataUrl = layer.canvas.toDataURL("image/png");
this.state.processing = true;
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
const applyBtn = document.getElementById("ie-gmic-apply-btn");
if (openBtn) openBtn.disabled = true;
if (applyBtn) applyBtn.disabled = true;
if (progressArea) progressArea.style.display = "flex";
if (progressLbl) progressLbl.textContent = "画像をサーバーへ送信中...";
try {
const res = await fetch("/api/wfm/gmic/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_b64: dataUrl })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const data = await res.json();
this.state.lastResultJobId = data.job_id;
if (progressLbl) progressLbl.textContent = "G'MIC GUIで編集中... フィルターを選択して「OK」を押すと適用可能になります";
await this._waitForJob(data.job_id);
} catch (e) {
if (e.message !== "__aborted__") {
showToast("G'MIC Error: " + e.message, "error");
}
this.state.processing = false;
this.state.aborted = false;
if (openBtn) openBtn.disabled = false;
if (progressArea) progressArea.style.display = "none";
}
}
abort() {
this.state.aborted = true;
this.state.processing = false;
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
if (progressArea) progressArea.style.display = "none";
if (openBtn) openBtn.disabled = false;
}
async _waitForJob(jobId) {
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
const applyBtn = document.getElementById("ie-gmic-apply-btn");
const maxWait = 600, interval = 2000;
const start = Date.now();
this.state.aborted = false;
while (true) {
if (this.state.aborted) throw new Error("__aborted__");
if ((Date.now() - start) / 1000 > maxWait) throw new Error("Timeout");
await new Promise(r => setTimeout(r, interval));
if (this.state.aborted) throw new Error("__aborted__");
try {
const res = await fetch(`/api/wfm/gmic/status/${jobId}`);
if (res.status === 404) throw new Error("__aborted__");
if (!res.ok) continue;
const status = await res.json();
if (status.status === "completed") {
this.state.lastResultJobId = jobId;
if (applyBtn) applyBtn.disabled = false;
if (progressLbl) progressLbl.textContent = "処理完了 → 「結果を反映」で画像に適用";
this.state.processing = false;
showToast("G'MIC filtering complete. Click Apply to insert result.", "success");
return;
}
if (status.status === "failed") {
if (progressLbl) progressLbl.textContent = status.error || "G'MIC GUIがキャンセルされました";
throw new Error("__aborted__");
}
if (progressLbl) progressLbl.textContent = status.message || "G'MIC GUIで編集中...";
} catch (e) {
if (e.message === "__aborted__" || e.message.includes("Timeout")) throw e;
}
}
}
async applyResult() {
if (!this.state.lastResultJobId) {
showToast("No G'MIC result to apply", "error");
return;
}
const applyBtn = document.getElementById("ie-gmic-apply-btn");
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
if (applyBtn) applyBtn.disabled = true;
try {
const statusRes = await fetch(`/api/wfm/gmic/status/${this.state.lastResultJobId}`);
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
const statusData = await statusRes.json();
if (!statusData.result_path) throw new Error("No result path found");
const b64res = await fetch("/api/wfm/gmic/result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result_path: statusData.result_path })
});
if (!b64res.ok) throw new Error(`HTTP ${b64res.status}`);
const { image_b64: dataUrl } = await b64res.json();
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load result image"));
i.src = dataUrl;
});
this._cb.saveUndo();
const layerMgr = this._cb.getLayerManager();
const layer = layerMgr?.activeLayer;
if (!layer) throw new Error("No active layer");
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
showToast("G'MIC filter applied successfully", "success");
// Reset G'MIC status
this.state.lastResultJobId = null;
this.state.processing = false;
if (openBtn) openBtn.disabled = false;
if (progressArea) progressArea.style.display = "none";
this._cb.renderToolOptions("filter");
} catch (err) {
showToast("Failed to apply G'MIC result: " + err.message, "error");
if (applyBtn) applyBtn.disabled = false;
}
}
}
+303
View File
@@ -0,0 +1,303 @@
/**
* Image Edit Tab - Inpaint / I2I Actions
* Composites the active mask layer against the background and runs it
* through GenerateUI's currently loaded workflow (or a dedicated one) for
* inpainting, plus a mask-less I2I variant. Also exposes the external
* entry points used by other same-origin panels (e.g. Comic Creator).
*/
import { showToast } from "../app.js";
import { comfyUI } from "../comfyui-client.js";
import { comfyEditor } from "../comfyui-editor.js";
import { comfyWorkflow } from "../comfyui-workflow.js";
export class InpaintI2IActions {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
*/
constructor(callbacks) {
this._cb = callbacks;
this.positive = "";
this.negative = "";
this.growMaskBy = null; // null = 未初期化(ワークフローの現在値から初期化)
this.denoise = null; // null = 未初期化(ワークフローの現在値から初期化)
this.running = false;
this.resultUrl = null;
this.i2iExternalRunning = false;
// Inpaint 専用ワークフロー(OFF時は GenerateUI で読み込み中のワークフローを使用)
this.useDedicated = false;
this.dedicatedFilename = null;
this.workflowList = [];
}
async fetchWorkflowList(onLoaded) {
try {
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows`);
if (!resp.ok) return;
const list = await resp.json();
this.workflowList = (list || []).map((w) => w.filename).filter(Boolean);
onLoaded?.();
} catch {
this.workflowList = [];
}
}
renderPanel() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = "Inpaint";
// 未初期化ならワークフローの現在値から初期化(VAEEncodeForInpaint.grow_mask_by / KSampler.denoise)
if (this.growMaskBy == null) {
this.growMaskBy = comfyUI.currentAnalysis?.inpaint_encode_nodes?.[0]?.grow_mask_by ?? 6;
}
if (this.denoise == null) {
const samplerNode = (comfyUI.currentAnalysis?.sampler_nodes || []).find((n) => n.denoise !== undefined);
this.denoise = samplerNode?.denoise ?? 1.0;
}
body.innerHTML = `
<div class="ie-props-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="ie-inpaint-use-dedicated" ${this.useDedicated ? "checked" : ""}>
Use dedicated workflow
</label>
</div>
<div class="ie-props-row" id="ie-inpaint-dedicated-row" style="${this.useDedicated ? "" : "display:none;"}">
<select id="ie-inpaint-dedicated-select" style="flex:1;">
<option value="">-- select workflow --</option>
${this.workflowList.map((f) => `<option value="${f}" ${f === this.dedicatedFilename ? "selected" : ""}>${f}</option>`).join("")}
</select>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Positive Prompt</label>
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.positive}</textarea>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Negative Prompt</label>
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.negative}</textarea>
</div>
<div class="ie-props-row">
<label>Grow Mask By</label>
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this.growMaskBy}" style="width:60px;">
</div>
<div class="ie-props-row">
<label>Denoise</label>
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this.denoise}" style="width:60px;">
</div>
<div class="ie-props-row">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this.running ? "disabled" : ""}>
${this.running ? "Running..." : "Run"}
</button>
</div>
<span id="ie-inpaint-status" style="font-size:11px;color:var(--wfm-text-secondary);"></span>
${this.resultUrl ? `
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Result</label>
<img src="${this.resultUrl}" style="max-width:100%;border-radius:var(--wfm-radius-sm);border:1px solid var(--wfm-border);">
</div>
` : ""}
`;
document.getElementById("ie-inpaint-use-dedicated")?.addEventListener("change", e => {
this.useDedicated = e.target.checked;
this.renderPanel();
});
document.getElementById("ie-inpaint-dedicated-select")?.addEventListener("change", e => {
this.dedicatedFilename = e.target.value || null;
});
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this.positive = e.target.value; });
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this.negative = e.target.value; });
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
this.growMaskBy = parseInt(e.target.value) || 0;
});
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
this.denoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
if (Number.isNaN(this.denoise)) this.denoise = 1.0;
});
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this.run());
}
_exportMaskCanvas(maskLayer) {
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(maskLayer.canvas, 0, 0);
return canvas;
}
async run() {
if (this.running) return;
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
const maskLayer = (layerMgr.activeLayer?.type === "mask" && layerMgr.activeLayer.visible)
? layerMgr.activeLayer
: layerMgr.layers.find(l => l.type === "mask" && l.visible);
if (!maskLayer) { showToast("Create or show a mask layer first (Mask tool)", "info"); return; }
if (this.useDedicated) {
if (!this.dedicatedFilename) { showToast("Select a dedicated workflow first", "info"); return; }
} else if (!comfyUI.currentAnalysis) {
showToast("Load a workflow in GenerateUI first", "info");
return;
}
const runBtn = document.getElementById("ie-inpaint-run-btn");
const statusEl = document.getElementById("ie-inpaint-status");
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
this.running = true;
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
setStatus(this.useDedicated ? "Loading dedicated workflow..." : "Compositing...");
try {
// 専用ワークフロー使用時は GenerateUI の comfyUI.currentWorkflow/currentAnalysis には
// 一切触れず、ここでロード・解析したローカルなワークフロー/解析結果だけを使う。
let overrideOpts = {};
if (this.useDedicated) {
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(this.dedicatedFilename)}`);
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
let dedicatedWorkflow = await resp.json();
const format = comfyWorkflow.detectFormat(dedicatedWorkflow, this.dedicatedFilename);
if (format === "ui") {
dedicatedWorkflow = await comfyWorkflow.convertUiToApi(dedicatedWorkflow);
} else if (format !== "api") {
throw new Error("Unsupported or unrecognized workflow format");
}
overrideOpts = { workflow: dedicatedWorkflow, analysis: comfyWorkflow.analyzeWorkflow(dedicatedWorkflow) };
}
setStatus("Compositing...");
const bgCanvas = this._cb.buildBgCanvas();
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
const maskCanvas = this._exportMaskCanvas(maskLayer);
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
const resultUrl = await this._runWithImages(imageBlob, maskBlob, {
positive: this.positive,
negative: this.negative,
growMaskBy: this.growMaskBy,
denoise: this.denoise,
overrideOpts,
onStatus: setStatus,
});
if (resultUrl) this.resultUrl = resultUrl;
setStatus("Done");
showToast("Inpaint generation complete", "success");
} catch (err) {
setStatus("Error");
showToast(`Inpaint failed: ${err.message}`, "error");
} finally {
this.running = false;
this.renderPanel();
}
}
// 共通処理: 画像+マスクBlobを対象ノード(LoadImage/MaskEditorOne)へ反映し、
// プロンプト/grow_mask_by/denoiseを設定してGenerate UIで実行、結果URLを返す。
// UI駆動の run() と外部(Comic Creator等)向けの runExternal() の両方から使う。
async _runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise, overrideOpts = {}, onStatus } = {}) {
const setStatus = onStatus || (() => {});
// LoadImage(の MASK 出力が使われている)スロットを優先し、無ければ Mask Editor One
// ノード(LoadImage を介さず画像/マスクを内包する custom node)にフォールバックする
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
const slotIndex = (analysisForLookup.load_image_nodes || []).findIndex(n => n.mask_used);
const meoNode = (analysisForLookup.mask_editor_one_nodes || [])[0];
if (slotIndex === -1 && !meoNode) {
throw new Error("No inpaint-capable LoadImage or Mask Editor One node found in the workflow");
}
setStatus("Uploading...");
if (slotIndex !== -1) {
await comfyEditor.applyImageAndMaskToSlot(imageBlob, maskBlob, slotIndex, overrideOpts);
} else {
await comfyEditor.applyImageAndMaskToMaskEditorOneNode(imageBlob, maskBlob, meoNode.id, overrideOpts);
}
comfyEditor.setPromptText("positive", positive, overrideOpts);
comfyEditor.setPromptText("negative", negative, overrideOpts);
comfyEditor.setInpaintParams({ growMaskBy, denoise, ...overrideOpts });
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
setStatus("Generating...");
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
const resultImg = document.getElementById("wfm-gen-result-img");
return resultImg?.src || null;
}
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント。
// 専用ワークフロー選択(useDedicated)は扱わず、常にGenerate UIに現在
// ロード中のワークフロー(comfyUI.currentWorkflow/currentAnalysis)を対象にする。
async runExternal(imageBlob, maskBlob, { positive = "", negative = "", growMaskBy = 6, denoise = 1.0 } = {}) {
if (this.running) throw new Error("Inpaint is already running");
this.running = true;
try {
const resultUrl = await this._runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise });
if (!resultUrl) throw new Error("No result image produced");
return { ok: true, url: resultUrl };
} finally {
this.running = false;
}
}
// 共通処理: 画像Blob(マスクなし)を対象LoadImageノード(slot 0)へ反映し、
// プロンプト/denoiseを設定してGenerate UIで実行、結果URLを返す。
// _runWithImages のマスクなし版(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版)。
// runExternal と対になる、マスク不要のシンプルな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;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Image Edit Tab - Mask Editor One 連携ブリッジ
* WFS Image Edit タブの合成画像を、ComfyUIキャンバス上のMaskEditorOneノード(選択中のノードを
* 優先、無ければグラフ内で最初に見つかったもの)へ送り、window.opener(ComfyUIタブ)側で実際の
* モーダルを開いて編集させ、Apply後の結果マスクを新規マスクレイヤーとしてWFS側に取り込む。
* 「Send to Workflow」の image ウィジェット探索と同じ「選択中優先・フォールバック」方式のため、
* WFS側のワークフロー読み込み状況とは無関係に動作する。
* モーダル自体は ComfyUI ページの LiteGraph ノードに強く依存するため、WFS 側からは
* window.opener.wfmOpenMaskEditorForNode()(web/comfyui/node_sets_menu.js 側で定義)を介して操作する。
*/
import { showToast } from "../app.js";
export class MaskEditorOneBridge {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
*/
constructor(callbacks) {
this._cb = callbacks;
this.running = false;
}
get bridgeReady() {
return !!(window.opener && typeof window.opener.wfmOpenMaskEditorForNode === "function");
}
async openEditor() {
if (this.running) return;
if (!this.bridgeReady) {
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
return;
}
const layerMgr = this._cb.getLayerManager();
if (!layerMgr) { showToast("No image loaded", "error"); return; }
this.running = true;
try {
const dataUrl = this._cb.buildBgCanvas().toDataURL("image/png");
// アクティブレイヤーが既存のマスクレイヤーなら、その内容もそのまま Mask Editor One の
// 初期マスクとして渡す(画像だけでなく、今描いているマスクも引き継ぐ)
const activeLayer = layerMgr.activeLayer;
const existingMaskDataUrl = activeLayer?.type === "mask" ? activeLayer.canvas.toDataURL("image/png") : null;
window.opener.focus();
showToast("Switch to the ComfyUI tab, edit the mask, then click Apply", "info");
const maskDataUrl = await window.opener.wfmOpenMaskEditorForNode(dataUrl, existingMaskDataUrl);
if (!maskDataUrl) {
showToast("Mask Editor One: cancelled (no changes)", "info");
return;
}
this._cb.saveUndo();
const maskLayer = layerMgr.addLayer("mask", "Mask Editor One", {
contentW: layerMgr.width, contentH: layerMgr.height,
displayW: layerMgr.width, displayH: layerMgr.height,
x: 0, y: 0,
});
layerMgr.setActive(maskLayer.id);
await this._applyMask(maskLayer, maskDataUrl);
this._cb.updateCompositeView();
this._cb.refreshLayerList();
showToast("Mask Editor One: mask imported", "success");
} catch (err) {
showToast("Mask Editor One error: " + err.message, "error");
} finally {
this.running = false;
}
}
// グレースケール輝度 → アルファ白マスクに変換して書き込む(Sam3Segmentation._applyMask と同じ変換)
_applyMask(maskLayer, maskDataUrl) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const W = maskLayer.canvas.width;
const H = maskLayer.canvas.height;
const off = document.createElement("canvas");
off.width = W; off.height = H;
const mc = off.getContext("2d");
mc.drawImage(img, 0, 0, W, H);
const imgData = mc.getImageData(0, 0, W, H);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const lum = d[i];
d[i] = d[i + 1] = d[i + 2] = 255;
d[i + 3] = lum;
}
mc.putImageData(imgData, 0, 0);
maskLayer.ctx.drawImage(off, 0, 0);
resolve();
};
img.onerror = resolve;
img.src = maskDataUrl;
});
}
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Image Edit Tab - SAM3 Segmentation (Mask Editor One integration)
* Text-prompted segmentation via the Mask Editor One SAM3 server endpoints.
* Rendered as a subtool of the Mask tool; the toolbar shell itself stays in
* ImageEditTab (shared with the other mask subtools), but the prompt/run
* controls and the result thumbnail grid are owned here.
*/
import { showToast } from "../app.js";
export class Sam3Segmentation {
/**
* @param {object} callbacks
* @param {() => object|null} callbacks.getLayerManager
* @param {() => void} callbacks.saveUndo
* @param {() => void} callbacks.updateCompositeView
* @param {() => void} callbacks.refreshLayerList
* @param {(toolId: string) => void} callbacks.renderToolOptions
* @param {(sub: string) => void} callbacks.renderMaskProps
*/
constructor(callbacks) {
this._cb = callbacks;
this.available = false;
this.results = []; // [{mask_b64, score, area}, ...]
this.prompt = "";
this.maxMasks = 9;
this.loading = false;
this.mode = "add"; // "add" | "erase"
this.selected = new Set(); // 選択中の結果インデックス
}
async checkAvailability() {
try {
const resp = await fetch("/mask_editor/sam3/status");
if (!resp.ok) return;
const json = await resp.json();
this.available = json.loaded === true || json.ckpt_found === true;
} catch {
this.available = false;
}
}
// マスクツールバー内、SAM3選択時にのみ表示するprompt/max/runコントロール
renderToolbarExtra(activeSub) {
if (!this.available || activeSub !== "sam3") return "";
return `
<div class="ie-opt-group">
<input type="text" id="ie-sam3-prompt" class="ie-opt-input"
placeholder="e.g. cat, person..."
value="${this.prompt}"
style="width:160px;font-size:11px;padding:2px 6px;border:1px solid var(--wfm-border);border-radius:3px;background:var(--wfm-surface);color:var(--wfm-text);">
</div>
<div class="ie-opt-group">
<label style="font-size:11px;color:var(--wfm-text-secondary);">Max</label>
<select id="ie-sam3-max" class="ie-opt-select" style="width:44px;">
${[3,6,9,12].map(n => `<option value="${n}"${n === this.maxMasks ? " selected" : ""}>${n}</option>`).join("")}
</select>
</div>
<div class="ie-opt-group">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-run-btn" ${this.loading ? "disabled" : ""}>
${this.loading ? "Running..." : "Segment"}
</button>
</div>
<span id="ie-sam3-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;">
${this.results.length > 0 ? `${this.results.length} masks found` : ""}
</span>
`;
}
bindToolbarEvents() {
document.getElementById("ie-sam3-prompt")?.addEventListener("input", e => {
this.prompt = e.target.value;
});
document.getElementById("ie-sam3-max")?.addEventListener("change", e => {
this.maxMasks = parseInt(e.target.value);
});
document.getElementById("ie-sam3-run-btn")?.addEventListener("click", () => this.runSegment());
}
// props ペイン("sam3" サブツール選択時): モード切替 + 結果サムネイルグリッド
renderResultsPanel(body) {
const modeAdd = this.mode === "add";
const selCount = this.selected.size;
const hasResult = this.results.length > 0;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="wfm-btn wfm-btn-sm${modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-add" style="flex:1;">Add</button>
<button class="wfm-btn wfm-btn-sm${!modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-erase" style="flex:1;">Erase</button>
</div>
</div>
${hasResult ? `
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);">Click to select / deselect:</div>
<div id="ie-sam3-results" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
${this.results.map((r, i) => {
const sel = this.selected.has(i);
const borderColor = sel ? "var(--wfm-primary,#4682e6)" : "var(--wfm-border)";
const bg = sel ? "color-mix(in srgb,var(--wfm-primary,#4682e6) 15%,transparent)" : "transparent";
return `<div class="ie-sam3-thumb" data-idx="${i}"
style="cursor:pointer;border:2px solid ${borderColor};border-radius:4px;overflow:hidden;text-align:center;background:${bg};position:relative;">
<img src="${r.mask_b64}" style="width:100%;display:block;background:#000;">
<div style="font-size:10px;padding:2px 0;color:var(--wfm-text-secondary);">
${Math.round((r.score ?? 0) * 100)}%
</div>
${sel ? '<div style="position:absolute;top:2px;right:3px;font-size:12px;line-height:1;color:var(--wfm-primary,#4682e6);">✓</div>' : ""}
</div>`;
}).join("")}
</div>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-apply-btn"
${selCount === 0 ? "disabled" : ""} style="margin-top:4px;">
Apply Selected${selCount > 0 ? ` (${selCount})` : ""}
</button>
</div>` : `
<div style="font-size:11px;color:var(--wfm-text-secondary);padding:4px 0;">
${this.loading ? "Segmenting..." : "Enter a prompt and press Segment"}
</div>`}
`;
document.getElementById("ie-sam3-mode-add")?.addEventListener("click", () => {
this.mode = "add";
this._cb.renderMaskProps("sam3");
});
document.getElementById("ie-sam3-mode-erase")?.addEventListener("click", () => {
this.mode = "erase";
this._cb.renderMaskProps("sam3");
});
body.querySelectorAll(".ie-sam3-thumb").forEach(el => {
el.addEventListener("click", () => {
const idx = parseInt(el.dataset.idx);
if (this.selected.has(idx)) this.selected.delete(idx);
else this.selected.add(idx);
this._cb.renderMaskProps("sam3");
});
});
document.getElementById("ie-sam3-apply-btn")?.addEventListener("click", () => {
this.applySelectedMasks();
});
}
async runSegment() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr || this.loading) return;
const prompt = this.prompt.trim();
if (!prompt) { showToast("Please enter a prompt", "warning"); return; }
// 推論対象: アクティブレイヤー(マスクなら下のイメージレイヤーを探す)
let imageLayer = layerMgr.activeLayer;
if (!imageLayer || imageLayer.type === "mask") {
imageLayer = layerMgr.layers.find(l => l.type !== "mask" && l.visible);
}
if (!imageLayer) { showToast("No image layer found", "error"); return; }
const NODE_ID = "wfs_sam3";
this.loading = true;
this.results = [];
this.selected = new Set();
this._cb.renderToolOptions("mask");
this._cb.renderMaskProps("sam3");
try {
const dataUrl = imageLayer.canvas.toDataURL("image/png");
await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
const resp = await fetch("/mask_editor/sam3/segment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, prompt, max_masks: this.maxMasks }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
this.results = json.masks || [];
if (this.results.length === 0) showToast("No masks found", "warning");
else showToast(`${this.results.length} mask(s) found`, "success");
} catch (err) {
showToast("SAM3 error: " + err.message, "error");
} finally {
this.loading = false;
this._cb.renderToolOptions("mask");
this._cb.renderMaskProps("sam3");
}
}
async applySelectedMasks() {
const layerMgr = this._cb.getLayerManager();
if (!layerMgr || this.selected.size === 0) return;
const indices = [...this.selected].sort((a, b) => a - b);
const masks = indices.map(i => this.results[i]).filter(Boolean);
if (masks.length === 0) return;
let maskLayer = layerMgr.activeLayer;
if (!maskLayer || maskLayer.type !== "mask") {
const ref = layerMgr.activeLayer;
maskLayer = layerMgr.addLayer("mask", "SAM3 Mask", {
contentW: ref?.canvas.width ?? layerMgr.width,
contentH: ref?.canvas.height ?? layerMgr.height,
displayW: ref?.displayW ?? layerMgr.width,
displayH: ref?.displayH ?? layerMgr.height,
x: ref?.x ?? 0, y: ref?.y ?? 0,
});
layerMgr.setActive(maskLayer.id);
}
this._cb.saveUndo();
for (const r of masks) {
await this._applyMask(maskLayer, r.mask_b64, this.mode);
}
this._cb.updateCompositeView();
this._cb.refreshLayerList();
showToast(`SAM3: ${masks.length} mask(s) applied (${this.mode})`, "success");
}
_applyMask(maskLayer, maskB64, mode = "add") {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const W = maskLayer.canvas.width;
const H = maskLayer.canvas.height;
// グレースケール輝度 → アルファ白マスクに変換
const off = document.createElement("canvas");
off.width = W; off.height = H;
const mc = off.getContext("2d");
mc.drawImage(img, 0, 0, W, H);
const imgData = mc.getImageData(0, 0, W, H);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const lum = d[i];
d[i] = d[i+1] = d[i+2] = 255;
d[i+3] = lum;
}
mc.putImageData(imgData, 0, 0);
// モードに合わせてマスクキャンバスに合成
maskLayer.ctx.save();
maskLayer.ctx.globalCompositeOperation = mode === "erase" ? "destination-out" : "source-over";
maskLayer.ctx.drawImage(off, 0, 0);
maskLayer.ctx.restore();
resolve();
};
img.onerror = resolve;
img.src = maskB64;
});
}
}
+363
View File
@@ -0,0 +1,363 @@
/**
* ImagePrompt Gallery — Gallery タブのサブタブ。
* ws_image_prompt フォルダ配下の画像+.txtサイドカーを閲覧し、
* クリックで選択→「追加」でプロンプトを組み立てて最終的にコピーする。
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
const API = {
root: () => `/wfm/gallery/image-prompt/root`,
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
imageMeta: (path) => `/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`,
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
savePrompt: `/wfm/gallery/image/image-prompt`,
};
const state = {
root: null,
currentFolder: null,
search: "",
images: [],
selectedImage: null,
chips: [], // [{ path, filename, prompt }]
};
let _initialized = false;
async function apiFetch(url, opts = {}) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
// ── 初期化 ────────────────────────────────────────────────────
export function initImagePromptTab() {
document.getElementById("wfm-imageprompt-refresh-btn")?.addEventListener("click", () => {
loadFolderTree();
loadImages();
});
setupSearchClearBtn("wfm-imageprompt-search", "wfm-imageprompt-search-clear-btn", () => {
state.search = "";
loadImages();
});
const searchInput = document.getElementById("wfm-imageprompt-search");
searchInput?.addEventListener("input", debounce(() => {
state.search = searchInput.value.trim();
loadImages();
}, 300));
document.getElementById("wfm-imageprompt-add-btn")?.addEventListener("click", addSelectedToChips);
document.getElementById("wfm-imageprompt-save-prompt-btn")?.addEventListener("click", saveSelectedPrompt);
document.getElementById("wfm-imageprompt-clear-btn")?.addEventListener("click", clearAllChips);
document.getElementById("wfm-imageprompt-copy-btn")?.addEventListener("click", copyFinalPrompt);
}
export async function activateImagePromptTab() {
if (_initialized) return;
_initialized = true;
try {
const data = await apiFetch(API.root());
state.root = data.root;
await loadFolderTree();
} catch (e) {
const tree = document.getElementById("wfm-imageprompt-tree");
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
// ── フォルダツリー ────────────────────────────────────────────
async function loadFolderTree() {
if (!state.root) return;
const tree = document.getElementById("wfm-imageprompt-tree");
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
try {
const data = await apiFetch(API.folders(state.root));
if (data.error) {
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
return;
}
tree.innerHTML = "";
renderTreeNode(data, tree, 0, true);
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
if (firstLabel) firstLabel.click();
} catch (e) {
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderTreeNode(node, container, depth, isRoot) {
const item = document.createElement("div");
item.className = "wfm-gallery-tree-item";
item.style.paddingLeft = `${depth * 12}px`;
item.dataset.path = node.abs_path;
const hasChildren = node.children && node.children.length > 0;
const absPath = node.abs_path;
const arrow = document.createElement("span");
arrow.className = "wfm-gallery-tree-arrow";
if (hasChildren) {
arrow.textContent = "▶";
arrow.dataset.expanded = "false";
} else {
arrow.textContent = " ";
arrow.style.visibility = "hidden";
}
item.appendChild(arrow);
const label = document.createElement("span");
label.className = "wfm-gallery-tree-label";
label.textContent = isRoot ? "[root]" : node.name;
// サブフォルダ内も含めた合計件数(クリックで再帰的に全画像を表示するため)
const totalCount = node.image_count_total ?? node.image_count;
if (totalCount > 0) {
const badge = document.createElement("span");
badge.className = "wfm-gallery-tree-count";
badge.textContent = totalCount;
label.appendChild(badge);
}
item.appendChild(label);
label.addEventListener("click", () => {
document.querySelectorAll("#wfm-imageprompt-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
item.classList.add("selected");
state.currentFolder = absPath;
loadImages();
});
if (hasChildren) {
const childContainer = document.createElement("div");
childContainer.className = "wfm-gallery-tree-children";
childContainer.style.display = "none";
arrow.addEventListener("click", (e) => {
e.stopPropagation();
const expanded = arrow.dataset.expanded === "true";
if (!expanded) {
arrow.dataset.expanded = "true";
arrow.textContent = "▼";
childContainer.style.display = "";
if (childContainer.children.length === 0) {
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
}
} else {
arrow.dataset.expanded = "false";
arrow.textContent = "▶";
childContainer.style.display = "none";
}
});
container.appendChild(item);
container.appendChild(childContainer);
} else {
container.appendChild(item);
}
}
// ── 画像一覧 ─────────────────────────────────────────────────
async function loadImages() {
if (!state.currentFolder) return;
const grid = document.getElementById("wfm-imageprompt-grid");
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
if (state.search) params.search = state.search;
try {
const data = await apiFetch(API.images(params));
state.images = data.images || [];
document.getElementById("wfm-imageprompt-count").textContent = `${state.images.length} images`;
renderImages();
} catch (e) {
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderImages() {
const grid = document.getElementById("wfm-imageprompt-grid");
if (state.images.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
return;
}
grid.innerHTML = "";
const fragment = document.createDocumentFragment();
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
grid.appendChild(fragment);
}
function createThumbCard(img) {
const card = document.createElement("div");
card.className = "wfm-gallery-thumb-card";
card.title = img.filename;
card.dataset.path = img.path;
if (state.selectedImage && state.selectedImage.path === img.path) {
card.classList.add("selected");
}
const imgEl = document.createElement("img");
imgEl.className = "wfm-gallery-thumb-img";
imgEl.loading = "lazy";
imgEl.src = API.thumb(img.path);
imgEl.alt = img.filename;
imgEl.onerror = () => {
imgEl.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "wfm-gallery-thumb-placeholder";
placeholder.textContent = "?";
card.insertBefore(placeholder, imgEl.nextSibling);
};
card.appendChild(imgEl);
card.addEventListener("click", () => selectImage(img));
return card;
}
// ── 選択画像 / プロンプト表示 ─────────────────────────────────
async function selectImage(img) {
state.selectedImage = img;
document.querySelectorAll("#wfm-imageprompt-grid .wfm-gallery-thumb-card").forEach(el => {
el.classList.toggle("selected", el.dataset.path === img.path);
});
document.getElementById("wfm-imageprompt-selected-name").textContent = img.filename;
const preview = document.getElementById("wfm-imageprompt-selected-preview");
preview.innerHTML = "";
const previewImg = document.createElement("img");
previewImg.src = API.serveImage(img.path);
previewImg.alt = img.filename;
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;";
preview.appendChild(previewImg);
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
promptBox.value = "";
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = true;
document.getElementById("wfm-imageprompt-add-btn").disabled = true;
try {
const meta = await apiFetch(API.imageMeta(img.path));
promptBox.value = meta.image_prompt || "";
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = false;
document.getElementById("wfm-imageprompt-add-btn").disabled = !meta.image_prompt;
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
async function saveSelectedPrompt() {
if (!state.selectedImage) return;
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
try {
const res = await apiFetch(API.savePrompt, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.selectedImage.path, prompt: promptBox.value }),
});
if (res.ok) {
showToast(t("imagePromptSaved"), "success");
document.getElementById("wfm-imageprompt-add-btn").disabled = !promptBox.value.trim();
} else {
showToast(t("saveFailed"), "error");
}
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
// ── プロンプトビルダー (チップ / 最終プロンプト) ─────────────────
function cleanPromptText(text) {
return String(text || "")
.replace(/^[,\s]+|[,\s]+$/g, "")
.replace(/\s*,\s*/g, ", ")
.trim();
}
function addSelectedToChips() {
const img = state.selectedImage;
if (!img) return;
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
const prompt = cleanPromptText(promptBox.value);
if (!prompt) return;
if (state.chips.some(c => c.path === img.path)) {
showToast(t("imagePromptAlreadyAdded"), "info");
return;
}
state.chips.push({ path: img.path, filename: img.filename, prompt });
renderChips();
rebuildFinalPrompt();
}
function removeChip(path) {
state.chips = state.chips.filter(c => c.path !== path);
renderChips();
rebuildFinalPrompt();
}
function clearAllChips() {
state.chips = [];
renderChips();
document.getElementById("wfm-imageprompt-final-prompt").value = "";
}
function renderChips() {
const container = document.getElementById("wfm-imageprompt-chips");
container.innerHTML = "";
state.chips.forEach(chip => {
const el = document.createElement("span");
el.className = "wfm-imageprompt-chip";
el.title = chip.prompt;
const text = document.createElement("span");
text.className = "wfm-imageprompt-chip-text";
text.textContent = chip.filename.replace(/\.[^.]+$/, "");
el.appendChild(text);
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-imageprompt-chip-remove";
removeBtn.textContent = "×";
removeBtn.addEventListener("click", () => removeChip(chip.path));
el.appendChild(removeBtn);
container.appendChild(el);
});
}
function rebuildFinalPrompt() {
const combined = state.chips.map(c => c.prompt).join(", ");
document.getElementById("wfm-imageprompt-final-prompt").value = cleanPromptText(combined);
}
async function copyFinalPrompt() {
const text = document.getElementById("wfm-imageprompt-final-prompt").value;
if (!text.trim()) {
showToast(t("imagePromptNothingToCopy"), "info");
return;
}
try {
await navigator.clipboard.writeText(text);
showToast(t("imagePromptCopiedToast"), "success");
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
+1516
View File
File diff suppressed because it is too large Load Diff
+169 -34
View File
@@ -75,7 +75,7 @@ function parseITXtChunk(data, latin1, utf8) {
pos = findNull(data, pos); if (pos === -1) return null; pos++;
return { keyword, text: utf8.decode(data.slice(pos)) };
}
async function readAllPNGTextChunks(file) {
export async function readAllPNGTextChunks(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
@@ -188,11 +188,67 @@ function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
function resolveEditPlusText(mode, textEdit, text1, text2) {
const t1 = typeof text1 === "string" ? text1 : "";
const edit = typeof textEdit === "string" ? textEdit : "";
const insert = typeof text2 === "string" && text2 !== "" ? text2 : edit;
switch (mode) {
case "RAW": return t1;
case "EDIT": return edit;
case "front": return t1 ? `${insert}, ${t1}` : insert;
case "back": return t1 ? `${t1}, ${insert}` : insert;
default: return t1 || edit;
}
}
function extractPrompts(wf) {
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
}
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
function resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
if (depth > 6) return null;
const srcNode = nodeMap.get(srcId);
if (!srcNode) return null;
const srcType = srcNode.type ?? "";
if (isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[slot];
return (v && typeof v === "string") ? v : null;
}
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
for (const name of ["on_false", "on_true"]) {
const inp = srcNode.inputs.find(i => i.name === name);
if (inp?.link == null) continue;
const originId = linkOrigin.get(inp.link);
const originSlot = linkSlot.get(inp.link) ?? 0;
if (originId == null) continue;
const text = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
if (text) return text;
}
return null;
}
// WFS_PromptText, PrimitiveStringMultiline 等、任意の STRING 出力ノード
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") return v;
// さらにリンクされている場合(StringReplace等の中継ノード)は追跡する
// "source" = PreviewAny(値タップ中継), "string_a" = StringConcatenate(LoRAトリガーワード
// 連結等)— Krea-2のプロンプト強化配線で使われる中継ノード
if (Array.isArray(srcNode.inputs)) {
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string", "source", "string_a"].includes(i.name));
if (nextInput?.link != null) {
const originId = linkOrigin.get(nextInput.link);
const originSlot = linkSlot.get(nextInput.link) ?? 0;
if (originId != null) return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
}
}
return null;
}
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
function extractPromptsFromNodeSet(nodes, links) {
@@ -212,7 +268,22 @@ function extractPromptsFromNodeSet(nodes, links) {
}
const textMap = new Map();
for (const n of nodes) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const type = n.type ?? "";
if (type === "CLIPTextEncodeEditPlus") {
const resolveNamedInput = (name) => {
if (!Array.isArray(n.inputs)) return null;
const inp = n.inputs.find(i => i.name === name);
if (inp?.link == null) return null;
const originId = linkOrigin.get(inp.link);
const originSlot = linkSlot.get(inp.link) ?? 0;
if (originId == null) return null;
return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
};
const combined = resolveEditPlusText(n.widgets_values?.[1], n.widgets_values?.[0], resolveNamedInput("text1"), resolveNamedInput("text2"));
if (combined) textMap.set(n.id, combined);
continue;
}
if (!isTextEncoderNode(type)) continue;
const text = n.widgets_values?.[0];
if (text && typeof text === "string") {
textMap.set(n.id, text);
@@ -221,18 +292,8 @@ function extractPromptsFromNodeSet(nodes, links) {
if (textInput?.link != null) {
const originId = linkOrigin.get(textInput.link);
const originSlot = linkSlot.get(textInput.link) ?? 0;
const srcNode = originId != null ? nodeMap.get(originId) : null;
if (srcNode) {
const srcType = srcNode.type ?? "";
if (isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[originSlot];
if (v && typeof v === "string") textMap.set(n.id, v);
} else {
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") textMap.set(n.id, v);
}
}
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
if (text2) textMap.set(n.id, text2);
}
}
}
@@ -241,24 +302,46 @@ function extractPromptsFromNodeSet(nodes, links) {
for (const n of nodes) {
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
foundSampler = true;
for (const inp of n.inputs) {
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
// node's inputs instead so the loop below can find the positive/negative-role slots.
let inputsToScan = n.inputs;
const guiderInput = n.inputs.find(inp => inp.name === "guider");
if (guiderInput?.link != null) {
const guiderId = linkOrigin.get(guiderInput.link);
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
}
for (const inp of inputsToScan) {
if (!inp || inp.link == null) continue;
const originId = linkOrigin.get(inp.link);
if (originId == null) continue;
const name = inp.name ?? "";
const isPos = name === "positive" || name.startsWith("positive");
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
const isNeg = name === "negative" || name.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
// directly, so textMap (single text per node) can't represent both roles.
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
// negative_prompt directly, so textMap (single text per node) can't represent both
// roles.
const srcNode = nodeMap.get(originId);
if (srcNode?.type === "TextEncodeMageFlowEdit") {
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
const text = srcNode.widgets_values?.[widgetIdx];
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
continue;
}
const text = textMap.get(originId);
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
// conditioning through its own positive/negative inputs — follow one more hop.
let resolvedOriginId = originId;
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
if (innerInput?.link != null) {
const innerId = linkOrigin.get(innerInput.link);
if (innerId != null) resolvedOriginId = innerId;
}
}
const text = textMap.get(resolvedOriginId);
if (!text) continue;
if (isPos) pos.add(text); else neg.add(text);
}
@@ -312,7 +395,8 @@ function extractMarkdownNoteModels(wf) {
}
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
function resolveLinkedText(wf, srcId, slot) {
function resolveLinkedText(wf, srcId, slot, depth = 0) {
if (depth > 6) return null;
const src = wf[String(srcId)];
if (!src || typeof src !== "object") return null;
const ct = src.class_type ?? "";
@@ -321,13 +405,34 @@ function resolveLinkedText(wf, srcId, slot) {
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
return (v && typeof v === "string") ? v : null;
}
// 汎用テキストキー
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
// リテラルへ解決できた方を採用する。
if (ct === "ComfySwitchNode") {
for (const key of ["on_false", "on_true"]) {
const v = src.inputs?.[key];
if (Array.isArray(v)) {
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
if (text) return text;
} else if (typeof v === "string" && v) {
return v;
}
}
return null;
}
// 汎用テキストキー("value" は PrimitiveString/PrimitiveStringMultiline、"source" は
// PreviewAny の値タップ中継、"string_a" は StringConcatenate の主オペランド — いずれも
// Krea-2のプロンプト強化配線で使われる中継ノード)
const keys = slot === 0
? ["text_positive", "text", "text_g", "prompt"]
: ["text_negative", "text_l"];
? ["text_positive", "text", "text_g", "prompt", "value", "source", "string_a"]
: ["text_negative", "text_l", "source", "string_a"];
for (const k of keys) {
const v = src.inputs?.[k];
if (v && typeof v === "string") return v;
if (typeof v === "string" && v) return v;
if (Array.isArray(v)) {
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
if (text) return text;
}
}
return null;
}
@@ -341,8 +446,23 @@ function extractPromptsAPI(wf) {
}
const textMap = new Map();
for (const [id, n] of Object.entries(wf)) {
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
if (!n) continue;
const ct = n.class_type ?? "";
if (ct === "CLIPTextEncodeEditPlus") {
const resolveField = (key) => {
const v = n.inputs?.[key];
if (typeof v === "string") return v;
if (Array.isArray(v)) return resolveLinkedText(wf, v[0], v[1] ?? 0);
return null;
};
const combined = resolveEditPlusText(n.inputs?.mode, n.inputs?.text_edit, resolveField("text1"), resolveField("text2"));
if (combined) textMap.set(id, combined);
continue;
}
if (!isTextEncoderNode(ct)) continue;
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
// model text encoders use this key instead of "text"/"text_g".
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
if (raw && typeof raw === "string") {
textMap.set(id, raw);
} else if (Array.isArray(raw)) {
@@ -356,20 +476,35 @@ function extractPromptsAPI(wf) {
for (const n of Object.values(wf)) {
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
foundSampler = true;
for (const [key, val] of Object.entries(n.inputs ?? {})) {
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
// node's inputs instead so the loop below can find the positive/negative-role keys.
let inputsToScan = n.inputs ?? {};
if (Array.isArray(n.inputs?.guider)) {
const guiderNode = wf[String(n.inputs.guider[0])];
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
}
for (const [key, val] of Object.entries(inputsToScan)) {
if (!Array.isArray(val)) continue;
const isPos = key === "positive" || key.startsWith("positive");
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
const isNeg = key === "negative" || key.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
// directly, so textMap (single text per node) can't represent both roles.
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
// negative_prompt directly, so textMap (single text per node) can't represent both
// roles.
const srcNode = wf[String(val[0])];
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
continue;
}
const text = textMap.get(String(val[0]));
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
// conditioning through its own positive/negative inputs — follow one more hop.
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
: String(val[0]);
const text = textMap.get(resolvedId);
if (!text) continue;
if (isPos) pos.add(text); else neg.add(text);
}
@@ -468,7 +603,7 @@ function parseFooocusMetadata(raw) {
}
// ── Master extractAllMetadata ─────────────────────────────────
async function extractAllMetadata(file) {
export async function extractAllMetadata(file) {
const name = file.name.toLowerCase();
const isJSON = file.type === "application/json" || name.endsWith(".json");
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
+17 -1947
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
/**
* Models Tab - Badge palette, badge rendering, badge edit modal, badge filter bar
*/
import { openModal } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml, readJsonStorage } from "../util.js";
import { state } from "./state.js";
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
// out yet); registered once from initModelsTab() to avoid a circular import.
let _onGridChange = () => {};
export function setGridChangeCallback(fn) { _onGridChange = fn; }
export function getBadgePalette() {
return readJsonStorage("wfm_models_badge_palette");
}
export function saveBadgePalette(palette) {
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
}
export function badgeHtml(label) {
const palette = getBadgePalette();
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
}
export function modelBadgesHtml(modelName, clickable = false) {
const meta = state.modelMetadata[modelName] || {};
const badges = meta.badges || [];
if (badges.length === 0) return "";
return badges.map((label) => {
const palette = getBadgePalette();
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
const dataAttr = clickable ? ` data-badge-filter="${escapeHtml(label)}"` : "";
return `<span class="wfm-badge wfm-badge-model${clickable ? " wfm-badge-clickable" : ""}"${style}${dataAttr}>${escapeHtml(label)}</span>`;
}).join("");
}
export function openBadgeEditModal(onPaletteChange = null) {
const palette = getBadgePalette();
const labels = Object.keys(palette).sort();
const rowsHtml = labels.map((label) => {
const color = palette[label] || "#6366f1";
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
${badgeHtml(label)}
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">&times;</button>
</div>`;
}).join("");
const html = `
<div style="min-width:320px;">
<div id="wfm-badge-list">${rowsHtml || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`}</div>
<div style="border-top:1px solid var(--wfm-border);margin-top:12px;padding-top:12px;display:flex;gap:6px;align-items:center;">
<input type="text" id="wfm-badge-new-label" class="wfm-input" style="flex:1;" placeholder="${t("badgeNewLabel")}">
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
</div>
</div>`;
openModal(t("badgeManage"), html);
bindBadgeModalEvents(onPaletteChange);
}
function bindBadgeModalEvents(onPaletteChange = null) {
const afterChange = () => {
renderBadgeFilter();
_onGridChange();
if (onPaletteChange) onPaletteChange();
};
const refreshList = () => {
const palette = getBadgePalette();
const labels = Object.keys(palette).sort();
const listEl = document.getElementById("wfm-badge-list");
if (!listEl) return;
listEl.innerHTML = labels.map((label) => {
const color = palette[label] || "#6366f1";
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
${badgeHtml(label)}
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">&times;</button>
</div>`;
}).join("") || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`;
bindBadgeRowEvents(refreshList, afterChange);
afterChange();
};
bindBadgeRowEvents(refreshList, afterChange);
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
const labelInput = document.getElementById("wfm-badge-new-label");
const colorInput = document.getElementById("wfm-badge-new-color");
const label = labelInput?.value.trim();
if (!label) return;
const palette = getBadgePalette();
palette[label] = colorInput?.value || "#6366f1";
saveBadgePalette(palette);
if (labelInput) labelInput.value = "";
refreshList();
});
}
function bindBadgeRowEvents(refreshList, afterChange = null) {
document.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
input.addEventListener("input", (e) => {
const label = e.target.dataset.badgeLabel;
const palette = getBadgePalette();
palette[label] = e.target.value;
saveBadgePalette(palette);
refreshList();
});
});
document.querySelectorAll(".wfm-badge-delete-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const label = btn.dataset.badgeLabel;
const palette = getBadgePalette();
delete palette[label];
saveBadgePalette(palette);
refreshList();
});
});
}
// ── Badge Filter Bar ──────────────────────────────────────
export function renderBadgeFilter() {
const container = document.getElementById("wfm-models-badge-filter-bar");
if (!container) return;
const palette = getBadgePalette();
const labels = Object.keys(palette).sort();
if (labels.length === 0) {
container.innerHTML = "";
return;
}
container.innerHTML = labels.map((label) => {
const color = palette[label] || "";
const isActive = state.badgeFilter === label;
const style = color ? `background:${color};color:#fff;` : "";
return `<button class="wfm-badge-filter-btn${isActive ? " active" : ""}" data-badge="${escapeHtml(label)}" style="${style}">${escapeHtml(label)}</button>`;
}).join("");
container.querySelectorAll(".wfm-badge-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const label = btn.dataset.badge;
state.badgeFilter = state.badgeFilter === label ? "" : label;
state.currentPage = 0;
renderBadgeFilter();
_onGridChange();
});
});
}
+898
View File
@@ -0,0 +1,898 @@
/**
* Models Tab - Detail modal (double-click) and the side panel's
* Info / Group / CivitAI tabs.
*
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
* import triangle — see the note at the top of grid-view.js.
*/
import { showToast, openModal, closeModal } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
import { renderModelGrid } from "./grid-view.js";
import {
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
fetchModelMetadata, saveModelGroups,
} from "../models-tab.js";
// ── Detail Modal (double-click, with badges) ──────────────
export function openDetailModal(modelName) {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const isFav = !!meta.favorite;
const tagsStr = (meta.tags || []).join(", ");
const selectedBadges = meta.badges || [];
const palette = getBadgePalette();
const allBadgeLabels = Object.keys(palette).sort();
const badgeCheckboxes = allBadgeLabels.map((label) => {
const checked = selectedBadges.includes(label) ? " checked" : "";
const color = palette[label] || "";
const style = color ? `background:${color};color:#fff;` : "";
return `<label class="wfm-badge-check-label">
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
<span class="wfm-badge wfm-badge-model" style="${style}">${escapeHtml(label)}</span>
</label>`;
}).join("");
const html = `
<div class="wfm-modal-thumb-section">
<img class="wfm-modal-thumb-img" style="display:none" />
<div class="wfm-modal-thumb-placeholder">${t("modelsNoPreview")}</div>
</div>
<div style="text-align:center;margin-bottom:8px;">
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-change-thumb">${t("changeThumbnail")}</button>
<input type="file" id="wfm-modal-thumb-file" accept="image/*" style="display:none">
</div>
<div class="wfm-modal-two-col">
<div class="wfm-modal-left">
<section>
<h4>${t("modelsInfo")}</h4>
<div><span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>${dir ? ` <span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}</div>
</section>
<section>
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-modal-badge-manage" style="margin-left:6px;font-size:10px;">&#9881; ${t("badgeManage")}</button></h4>
<div id="wfm-modal-badge-checkboxes" class="wfm-badge-checkboxes">
${allBadgeLabels.length === 0
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
: badgeCheckboxes}
</div>
</section>
<section>
<h4>${t("modelsTags")} <span style="font-weight:normal;font-size:11px;">${t("modelsTagsHint")}</span></h4>
<input type="text" class="wfm-input" id="wfm-modal-model-tags" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
</section>
<section>
<h4>${t("modelsMemo")}</h4>
<textarea class="wfm-textarea" id="wfm-modal-model-memo" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
</section>
<div class="wfm-modal-actions">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-modal-model-save">${t("modelsSave")}</button>
${state.activeModelType === "embedding"
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-pp" title="Positive Promptにエンベッディング追加">GenUI PP</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-np" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
: (GENUI_TYPE_MAP[state.activeModelType]
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-model" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
: "")}
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-modal-model-delete" style="margin-left:auto;">${t("modelsDelete")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-model-close">${t("close")}</button>
</div>
</div>
</div>`;
openModal(getStem(name), html);
// Load preview image
const modalImg = document.querySelector(".wfm-modal-thumb-img");
const modalPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
if (modalImg) loadPreviewImage(modalImg, modalPlaceholder, modelName);
// Add favorite button in header
const titleEl = document.getElementById("wfm-modal-title");
if (titleEl) {
titleEl.parentNode.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
const favBtn = document.createElement("button");
favBtn.className = isFav ? "wfm-fav-btn active" : "wfm-fav-btn";
favBtn.style.cssText = "position:static;font-size:18px;margin-right:8px;";
favBtn.textContent = isFav ? "★" : "☆";
favBtn.addEventListener("click", async () => {
const newVal = !meta.favorite;
await saveModelMetadata(modelName, { favorite: newVal });
meta.favorite = newVal;
favBtn.textContent = newVal ? "★" : "☆";
favBtn.classList.toggle("active", newVal);
renderModelGrid();
});
titleEl.parentNode.insertBefore(favBtn, titleEl);
}
// Badge manage button → open badge edit modal
document.getElementById("wfm-modal-badge-manage")?.addEventListener("click", () => {
openBadgeEditModal();
});
// Save
document.getElementById("wfm-modal-model-save")?.addEventListener("click", async () => {
const tagsInput = document.getElementById("wfm-modal-model-tags");
const memoInput = document.getElementById("wfm-modal-model-memo");
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
const memo = memoInput ? memoInput.value : "";
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map((cb) => cb.value);
await saveModelMetadata(modelName, { tags, memo, badges });
showToast(t("modelsSaved"), "success");
renderTagFilter();
renderDirFilter();
renderModelGrid();
});
// GenUI Model / Embedding buttons
document.getElementById("wfm-modal-genui-model")?.addEventListener("click", () => {
applyToGenUI(modelName, state.activeModelType);
});
document.getElementById("wfm-modal-genui-pp")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "positive");
});
document.getElementById("wfm-modal-genui-np")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "negative");
});
// Close button (連続作業時にXボタンまでマウス移動しなくて済むように)
document.getElementById("wfm-modal-model-close")?.addEventListener("click", () => {
closeModal();
});
// Delete model button
document.getElementById("wfm-modal-model-delete")?.addEventListener("click", async () => {
const confirmMsg = t("modelBulkDeleteConfirm").replace("{count}", "1");
if (!confirm(confirmMsg)) return;
try {
const res = await fetch("/api/wfm/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names: [modelName] }),
});
const data = await res.json();
if (data.errors?.length > 0) {
showToast(`${t("modelBulkDeleteError")}: ${data.errors[0].error}`, "error");
return;
}
showToast(`${getStem(name)} ${t("modelBulkDeleteDone")}`, "success");
closeModal();
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(modelName);
if (idx !== -1) list.splice(idx, 1);
delete state.modelMetadata[modelName];
const ds = state.disabledModels[state.activeModelType];
if (ds) ds.delete(modelName);
state.selectedModels.delete(modelName);
if (state.selectedModel === modelName) {
state.selectedModel = null;
const titleEl2 = document.getElementById("wfm-models-panel-title");
if (titleEl2) titleEl2.textContent = "";
}
renderModelGrid();
} catch (err) {
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
}
});
// Change thumbnail
const changeBtn = document.getElementById("wfm-modal-change-thumb");
const thumbFile = document.getElementById("wfm-modal-thumb-file");
if (changeBtn && thumbFile) {
changeBtn.addEventListener("click", () => { thumbFile.value = ""; thumbFile.click(); });
thumbFile.addEventListener("change", async () => {
const file = thumbFile.files?.[0];
if (!file) return;
changeBtn.disabled = true;
changeBtn.textContent = t("uploading");
try {
const fd = new FormData();
fd.append("type", state.activeModelType);
fd.append("name", modelName);
fd.append("file", file);
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
const data = await res.json();
if (data.error) throw new Error(data.error);
// Reload preview in modal
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
const mImg = document.querySelector(".wfm-modal-thumb-img");
const mPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
if (mImg) { mImg.src = newUrl; mImg.style.display = ""; }
if (mPlaceholder) mPlaceholder.style.display = "none";
renderModelGrid();
if (state.selectedModel === modelName) renderSideInfo(modelName);
showToast(t("thumbnailChanged"), "success");
} catch (err) {
showToast(t("thumbnailError") + ": " + err.message, "error");
} finally {
changeBtn.disabled = false;
changeBtn.textContent = t("changeThumbnail");
thumbFile.value = "";
}
});
}
}
// ── Side Panel ────────────────────────────────────────────
export function showSidePanel(modelName) {
state.selectedModel = modelName;
// Highlight selected
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
el.classList.toggle("wfm-card-selected", el.dataset.modelName === modelName);
});
const { name } = parseModelPath(modelName);
document.getElementById("wfm-models-panel-title").textContent = name;
// Reset to CivitAI tab
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelector('.wfm-models-side-tab-btn[data-side-tab="civitai"]')?.classList.add("active");
document.querySelectorAll(".wfm-models-side-content").forEach((c) => (c.style.display = "none"));
const civitaiEl = document.getElementById("wfm-models-side-civitai");
if (civitaiEl) civitaiEl.style.display = "block";
const genuiNavBtn = document.getElementById("wfm-side-genui-nav-btn");
const genuiNpBtn = document.getElementById("wfm-side-genui-np-btn");
if (genuiNavBtn) {
if (state.activeModelType === "embedding") {
genuiNavBtn.textContent = "GenUI PP";
genuiNavBtn.title = "Positive Promptにエンベッディング追加";
genuiNavBtn.style.display = "";
if (genuiNpBtn) {
genuiNpBtn.textContent = "GenUI NP";
genuiNpBtn.title = "Negative Promptにエンベッディング追加";
genuiNpBtn.style.display = "";
}
} else if (GENUI_TYPE_MAP[state.activeModelType]) {
genuiNavBtn.textContent = t("modelsGenUIBtn");
genuiNavBtn.title = t("modelsGenUITitle");
genuiNavBtn.style.display = "";
if (genuiNpBtn) genuiNpBtn.style.display = "none";
} else {
genuiNavBtn.style.display = "none";
if (genuiNpBtn) genuiNpBtn.style.display = "none";
}
}
renderSideInfo(modelName);
renderSideGroup(modelName);
renderSideCivitai(modelName);
}
export function closeSidePanel() {
state.selectedModel = null;
const titleEl = document.getElementById("wfm-models-panel-title");
if (titleEl) titleEl.textContent = "";
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
el.classList.remove("wfm-card-selected");
});
const npBtn = document.getElementById("wfm-side-genui-np-btn");
if (npBtn) npBtn.style.display = "none";
const ppBtn = document.getElementById("wfm-side-genui-nav-btn");
if (ppBtn) ppBtn.style.display = "none";
}
// ── Side Panel: Info Tab ──────────────────────────────────
export function renderSideInfo(modelName) {
const el = document.getElementById("wfm-models-side-info");
if (!el) return;
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const tagsStr = (meta.tags || []).join(", ");
const userBadgesHtml = modelBadgesHtml(modelName, false);
el.innerHTML = `
<div class="wfm-side-thumb-container">
<div class="wfm-side-thumb-img-wrap">
<img style="display:none" />
<span class="wfm-side-thumb-placeholder">${t("modelsNoPreview")}</span>
</div>
<div class="wfm-side-thumb-info">
<div class="wfm-side-thumb-name wfm-model-name-copy" title="${t("modelsCopyName")}">${escapeHtml(name)}</div>
<div class="wfm-side-thumb-meta">
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
${userBadgesHtml}
</div>
</div>
</div>
<div class="wfm-node-detail-section">
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
<div class="wfm-node-detail-value">
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
</div>
</div>
<div class="wfm-node-detail-section">
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
<div class="wfm-node-detail-value">
<input type="text" id="wfm-models-side-tags" class="wfm-search-input" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
</div>
</div>
<div class="wfm-node-detail-section">
<div class="wfm-node-detail-label">${t("modelsMemo")}</div>
<div class="wfm-node-detail-value">
<textarea id="wfm-models-side-memo" class="wfm-textarea" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
</div>
</div>
<div class="wfm-node-detail-section" style="display:flex;gap:6px;">
<button id="wfm-models-side-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsSave")}</button>
${state.activeModelType === "embedding"
? `<button id="wfm-models-side-genui-pp-btn" class="wfm-btn wfm-btn-sm" title="Positive Promptにエンベッディング追加">GenUI PP</button>
<button id="wfm-models-side-genui-np-btn" class="wfm-btn wfm-btn-sm" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
: (GENUI_TYPE_MAP[state.activeModelType]
? `<button id="wfm-models-side-genui-btn" class="wfm-btn wfm-btn-sm" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
: "")}
</div>`;
// Load preview image
const sideImg = el.querySelector(".wfm-side-thumb-img-wrap img");
const sidePlaceholder = el.querySelector(".wfm-side-thumb-placeholder");
if (sideImg) loadPreviewImage(sideImg, sidePlaceholder, modelName);
// Copy model name on click
el.querySelector(".wfm-model-name-copy")?.addEventListener("click", () => {
navigator.clipboard.writeText(modelName).then(() => {
showToast(t("modelsCopiedName"), "success");
});
});
// Fetch and display file path
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
.then((r) => r.json())
.then((data) => {
const fpEl = document.getElementById("wfm-models-side-filepath");
if (fpEl && data.path) {
fpEl.textContent = data.path;
fpEl.title = t("modelsCopyPath");
} else if (fpEl) {
fpEl.textContent = modelName;
}
})
.catch(() => {
const fpEl = document.getElementById("wfm-models-side-filepath");
if (fpEl) fpEl.textContent = modelName;
});
// Copy file path on click
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
const fpEl = document.getElementById("wfm-models-side-filepath");
if (fpEl) {
navigator.clipboard.writeText(fpEl.textContent).then(() => {
showToast(t("modelsCopiedPath"), "success");
});
}
});
// Save button
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
const tagsInput = document.getElementById("wfm-models-side-tags");
const memoInput = document.getElementById("wfm-models-side-memo");
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
const memo = memoInput ? memoInput.value : "";
saveModelMetadata(modelName, { tags, memo }).then(() => {
showToast(t("modelsSaved"), "success");
renderTagFilter();
renderModelGrid();
});
});
// GenUI Model / Embedding buttons
el.querySelector("#wfm-models-side-genui-btn")?.addEventListener("click", () => {
applyToGenUI(modelName, state.activeModelType);
});
el.querySelector("#wfm-models-side-genui-pp-btn")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "positive");
});
el.querySelector("#wfm-models-side-genui-np-btn")?.addEventListener("click", () => {
applyEmbeddingToPrompt(modelName, "negative");
});
}
// ── Side Panel: Group Tab ─────────────────────────────────
export function renderSideGroup(modelName) {
const el = document.getElementById("wfm-models-side-group");
if (!el) return;
// Find groups this model belongs to
const memberOf = [];
for (const [gName, members] of Object.entries(state.modelGroups)) {
if (members.includes(modelName)) memberOf.push(gName);
}
// All group names for the assign dropdown
const allGroups = Object.keys(state.modelGroups).sort();
const availableGroups = allGroups.filter((g) => !memberOf.includes(g));
el.innerHTML = `
<div style="padding:0 4px;">
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
${memberOf.length === 0
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
: memberOf.map((g) => `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
<span style="font-size:13px;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-group-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-models-group-assign" class="wfm-select" style="flex:1;font-size:12px;">
${availableGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: availableGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-assign-btn" ${availableGroups.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div>
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
<div style="display:flex;gap:4px;">
<input type="text" id="wfm-models-group-new" class="wfm-search-input" style="flex:1;font-size:12px;" placeholder="${t("modelsGroupName")}">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-create-btn">${t("modelsCreate")}</button>
</div>
</div>
<div style="margin-top:16px;border-top:1px solid var(--wfm-border);padding-top:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;margin-bottom:6px;">
<select id="wfm-models-group-manage-select" class="wfm-select" style="flex:1;font-size:12px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
</div>
<div style="display:flex;gap:4px;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
</div>
</div>
</div>
`;
// Remove from group
el.querySelectorAll(".wfm-group-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const g = btn.dataset.group;
const members = state.modelGroups[g] || [];
state.modelGroups[g] = members.filter((m) => m !== modelName);
if (state.modelGroups[g].length === 0) delete state.modelGroups[g];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
renderModelGrid();
});
});
});
// Assign to group
document.getElementById("wfm-models-group-assign-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-assign");
const g = sel?.value;
if (!g) return;
if (!state.modelGroups[g]) state.modelGroups[g] = [];
if (!state.modelGroups[g].includes(modelName)) state.modelGroups[g].push(modelName);
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
});
});
// Create new group
document.getElementById("wfm-models-group-create-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-models-group-new");
const name = input?.value.trim();
if (!name) return;
if (state.modelGroups[name]) {
showToast(t("modelsGroupExists"), "warning");
return;
}
state.modelGroups[name] = [modelName];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
});
});
// Rename group
document.getElementById("wfm-models-group-rename-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const oldName = sel?.value;
if (!oldName) return;
if (RESERVED_GROUPS.includes(oldName)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
const newName = prompt(t("modelsRenamePrompt"), oldName);
if (!newName || newName === oldName) return;
if (state.modelGroups[newName]) {
showToast(t("modelsGroupExists"), "warning");
return;
}
state.modelGroups[newName] = state.modelGroups[oldName];
delete state.modelGroups[oldName];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
renderModelGrid();
});
});
// Delete group
document.getElementById("wfm-models-group-delete-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
if (RESERVED_GROUPS.includes(g)) {
showToast(t("modelsGroupReserved"), "warning");
return;
}
if (!confirm(t("modelsDeleteGroupConfirm").replace("{name}", g))) return;
delete state.modelGroups[g];
saveModelGroups(state.modelGroups).then(() => {
renderSideGroup(modelName);
renderModelGrid();
});
});
// Enable all models in selected group
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
});
// Disable all models in selected group
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
});
}
// ── Side Panel: CivitAI Tab ───────────────────────────────
export function renderSideCivitai(modelName) {
const el = document.getElementById("wfm-models-side-civitai");
if (!el) return;
// Check if we have cached civitai data via sha256
const meta = state.modelMetadata[modelName] || {};
const sha256 = meta.sha256;
const cached = sha256 && state.civitaiCache[sha256];
if (cached) {
renderCivitaiInfo(el, cached, modelName);
} else if (sha256) {
// SHA256 is known but model was not found on CivitAI
el.innerHTML = `
<div style="padding:0 4px;text-align:center;">
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
${t("civitaiNotFoundDesc")}
</p>
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-fetch-btn">
${t("civitaiRefetchBtn")}
</button>
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
</div>`;
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
fetchCivitaiForModel(modelName, el);
});
} else {
el.innerHTML = `
<div style="padding:0 4px;text-align:center;">
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
${t("civitaiFetchDesc")}
</p>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-civitai-fetch-btn">
${t("civitaiFetch")}
</button>
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
</div>`;
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
fetchCivitaiForModel(modelName, el);
});
}
}
export async function fetchCivitaiForModel(modelName, el) {
const statusEl = document.getElementById("wfm-civitai-status");
const fetchBtn = document.getElementById("wfm-civitai-fetch-btn");
if (fetchBtn) fetchBtn.disabled = true;
if (statusEl) statusEl.textContent = t("civitaiHashing");
try {
const res = await fetch("/api/wfm/models/civitai/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: state.activeModelType, name: modelName }),
});
const data = await res.json();
if (data.status === "ok" && data.civitai) {
// Update caches
if (data.sha256) {
state.civitaiCache[data.sha256] = data.civitai;
// Update metadata with sha256
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
state.modelMetadata[modelName].sha256 = data.sha256;
}
renderCivitaiInfo(el, data.civitai, modelName);
showToast(t("civitaiFound"), "success");
// Always refresh preview: local file if saved, otherwise civitai image as fallback
const sidePanel = document.getElementById("wfm-models-side-panel");
if (sidePanel) {
const sideImg = sidePanel.querySelector(".wfm-side-thumb-img-wrap img");
const sidePh = sidePanel.querySelector(".wfm-side-thumb-placeholder");
if (sideImg) {
if (data.preview_saved) {
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
} else {
const civitaiImg = data.civitai.images && data.civitai.images[0];
if (civitaiImg) sideImg.src = civitaiImg;
}
sideImg.style.display = "";
if (sidePh) sidePh.style.display = "none";
}
}
renderModelGrid();
} else if (data.status === "not_found") {
if (statusEl) statusEl.textContent = t("civitaiNotFound");
if (fetchBtn) fetchBtn.disabled = false;
} else {
if (statusEl) statusEl.textContent = data.error || t("civitaiError");
if (fetchBtn) fetchBtn.disabled = false;
}
} catch (err) {
console.error("CivitAI fetch error:", err);
if (statusEl) statusEl.textContent = t("civitaiError");
if (fetchBtn) fetchBtn.disabled = false;
}
}
export function renderCivitaiInfo(el, info, modelName) {
// URL: ユーザー設定ホスト(localStorage)を使用、modelId なし時は /model-versions/ にフォールバック
const civitaiHost = localStorage.getItem("wfm_civitai_host") || "civitai.com";
const modelUrl = info.modelId && info.versionId
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
: info.versionId
? `https://${civitaiHost}/model-versions/${info.versionId}`
: (info.modelUrl || "#");
// Hash: BLAKE3 優先、なければ SHA256
const fileHashes = info.fileHashes || {};
const blake3 = fileHashes.BLAKE3 || fileHashes.Blake3 || "";
const sha256 = fileHashes.SHA256 || (state.modelMetadata[modelName] || {}).sha256 || "";
const hashType = blake3 ? "BLAKE3" : (sha256 ? "SHA256" : "");
const hashFull = blake3 || sha256;
// Detail rows
const ROW = "display:flex;align-items:center;font-size:12px;margin-bottom:5px;";
const LABEL = "color:var(--wfm-text-secondary);min-width:80px;flex-shrink:0;";
const typeRow = info.type ? `
<div style="${ROW}">
<span style="${LABEL}">${t("civitaiType")}</span>
<span style="font-size:10px;font-weight:700;background:var(--wfm-bg-tertiary,rgba(255,255,255,0.1));padding:2px 7px;border-radius:3px;letter-spacing:0.6px;">${escapeHtml(info.type.toUpperCase())}</span>
</div>` : "";
const baseModelRow = info.baseModel ? `
<div style="${ROW}">
<span style="${LABEL}">${t("civitaiBaseModel")}</span>
<span>${escapeHtml(info.baseModel)}</span>
</div>` : "";
const hashRow = hashFull ? `
<div style="${ROW}">
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
</div>` : "";
const detailSection = (typeRow || baseModelRow || hashRow)
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
const tagsHtml = (info.tags || []).map((tag) =>
`<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`
).join(" ");
const trainedWordsHtml = (info.trainedWords || []).map((w) =>
`<code style="font-size:11px;background:var(--wfm-bg-secondary);padding:1px 4px;border-radius:3px;cursor:pointer;" class="wfm-trained-word" title="${t("civitaiCopyWord")}">${escapeHtml(w)}</code>`
).join(" ");
// Sample images — all go to Sample pane
const images = info.images || [];
const sampleImagesHtml = images.map((url) =>
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
).join("");
el.innerHTML = `
<div style="padding:0 4px;">
<div style="display:flex;border-bottom:1px solid var(--wfm-border);margin-bottom:10px;">
<button class="wfm-civitai-subtab-btn" data-pane="info"
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid var(--wfm-primary);color:var(--wfm-primary);font-weight:600;margin-bottom:-1px;">
${t("civitaiTabInfo")}
</button>
<button class="wfm-civitai-subtab-btn" data-pane="sample"
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;color:var(--wfm-text-secondary);margin-bottom:-1px;">
${t("civitaiTabSample")}${images.length ? ` (${images.length})` : ""}
</button>
</div>
<div id="wfm-civitai-pane-info">
<div style="margin-bottom:10px;">
<div style="font-weight:700;font-size:14px;margin-bottom:2px;">
<a href="${escapeHtml(modelUrl)}" target="_blank" style="color:var(--wfm-primary);text-decoration:none;">${escapeHtml(info.modelName)}</a>
</div>
<div style="font-size:12px;color:var(--wfm-text-secondary);">
${escapeHtml(info.versionName)}${info.creator ? ` · by ${escapeHtml(info.creator)}` : ""}
</div>
</div>
${detailSection}
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
<div style="margin-top:10px;">
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
</div>
</div>
<div id="wfm-civitai-pane-sample" style="display:none;">
${sampleImagesHtml || `<p style="color:var(--wfm-text-secondary);font-size:13px;text-align:center;margin-top:20px;">${t("civitaiNoImages")}</p>`}
</div>
</div>`;
// Sub-tab switching
const subtabBtns = el.querySelectorAll(".wfm-civitai-subtab-btn");
subtabBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const pane = btn.dataset.pane;
subtabBtns.forEach((b) => {
const isActive = b === btn;
b.style.borderBottom = isActive ? "2px solid var(--wfm-primary)" : "2px solid transparent";
b.style.color = isActive ? "var(--wfm-primary)" : "var(--wfm-text-secondary)";
b.style.fontWeight = isActive ? "600" : "400";
});
el.querySelector("#wfm-civitai-pane-info").style.display = pane === "info" ? "" : "none";
el.querySelector("#wfm-civitai-pane-sample").style.display = pane === "sample" ? "" : "none";
});
});
// Copy trigger word on click
el.querySelectorAll(".wfm-trained-word").forEach((wordEl) => {
wordEl.addEventListener("click", () => {
navigator.clipboard.writeText(wordEl.textContent).then(() => {
showToast(t("civitaiWordCopied"), "success");
});
});
});
// Copy hash on click
el.querySelectorAll(".wfm-hash-value").forEach((hashEl) => {
hashEl.addEventListener("click", () => {
navigator.clipboard.writeText(hashEl.dataset.hash).then(() => {
showToast(t("civitaiHashCopied"), "success");
});
});
});
// Refresh button
document.getElementById("wfm-civitai-refresh-btn")?.addEventListener("click", () => {
const meta = state.modelMetadata[modelName] || {};
if (meta.sha256) delete state.civitaiCache[meta.sha256];
delete meta.sha256;
fetchCivitaiForModel(modelName, el);
});
}
// ── CivitAI cache / batch fetch ───────────────────────────
export async function fetchCivitaiCache() {
try {
const res = await fetch("/api/wfm/models/civitai/cache");
return res.ok ? await res.json() : {};
} catch { return {}; }
}
export async function batchFetchCivitai() {
const models = getCurrentModels();
if (models.length === 0) {
showToast(t("modelsNoModels"), "warning");
return;
}
// Filter out models that already have CivitAI data
const meta = state.modelMetadata;
const uncached = models.filter((m) => {
const sha = meta[m]?.sha256;
return !sha || !state.civitaiCache[sha];
});
if (uncached.length === 0) {
showToast(t("civitaiBatchAllCached"), "info");
return;
}
const btn = document.getElementById("wfm-models-civitai-batch-btn");
const progressEl = document.getElementById("wfm-models-civitai-progress");
if (btn) btn.disabled = true;
try {
const res = await fetch("/api/wfm/models/civitai/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: state.activeModelType, models: uncached }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let eventType = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (eventType === "progress") {
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
const statusText = data.status === "hashing" ? t("civitaiHashing2")
: data.status === "fetching" ? t("civitaiFetching")
: data.status === "cached" ? "✓"
: data.status === "found" ? "✓"
: data.status === "not_found" ? "—"
: "";
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
} else if (eventType === "done") {
if (progressEl) progressEl.textContent = "";
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
// Reload caches
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
// returns before the server has flushed updated metadata to disk
if (data.hashes) {
for (const [modelName, sha256] of Object.entries(data.hashes)) {
if (!newMeta[modelName]) newMeta[modelName] = {};
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
}
}
state.modelMetadata = newMeta;
state.civitaiCache = newCache;
renderModelGrid();
// Refresh side panel if open
if (state.selectedModel) renderSideCivitai(state.selectedModel);
}
}
}
}
} catch (err) {
console.error("Batch CivitAI error:", err);
showToast(t("civitaiError"), "error");
} finally {
if (btn) btn.disabled = false;
if (progressEl) progressEl.textContent = "";
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Models Tab - Filtering, sorting, and the tag/group/directory filter dropdowns
*/
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state } from "./state.js";
import { parseModelPath, getExtension } from "./helpers.js";
export function getCurrentModels() {
return state.modelsByType[state.activeModelType] || [];
}
export function isModelDisabled(modelName) {
const s = state.disabledModels[state.activeModelType];
return s ? s.has(modelName) : false;
}
export function filterModels() {
let models = getCurrentModels();
if (state.statusFilter === "enabled") {
models = models.filter((m) => !isModelDisabled(m));
} else if (state.statusFilter === "disabled") {
models = models.filter((m) => isModelDisabled(m));
}
if (state.showFavoritesOnly) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
return meta && meta.favorite;
});
}
if (state.showBatchOnly) {
const batchMembers = state.modelGroups["Batch"] || [];
models = models.filter((m) => batchMembers.includes(m));
}
if (state.tagFilter) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
return meta && meta.tags && meta.tags.includes(state.tagFilter);
});
}
if (state.badgeFilter) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
return meta && meta.badges && meta.badges.includes(state.badgeFilter);
});
}
if (state.dirFilter) {
models = models.filter((m) => {
const { dir } = parseModelPath(m);
return dir === state.dirFilter;
});
}
if (state.groupFilter) {
const members = state.modelGroups[state.groupFilter] || [];
models = models.filter((m) => members.includes(m));
}
if (state.searchText) {
const q = state.searchText.toLowerCase();
models = models.filter((m) => {
const meta = state.modelMetadata[m];
const searchable = [m, ...(meta?.tags || []), meta?.memo || ""]
.join(" ")
.toLowerCase();
return searchable.includes(q);
});
}
return sortModels(models);
}
export function sortKeyOf(modelName) {
const meta = state.modelMetadata[modelName] || {};
switch (state.sortColumn) {
case "fav":
return meta.favorite ? 1 : 0;
case "filename":
return parseModelPath(modelName).name.toLowerCase();
case "subdir":
return parseModelPath(modelName).dir.toLowerCase();
case "civtype": {
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
return (civ?.type || "").toLowerCase();
}
case "basemodel": {
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
return (civ?.baseModel || "").toLowerCase();
}
case "ext":
return getExtension(parseModelPath(modelName).name).toLowerCase();
case "tags":
return (meta.tags || []).join(", ").toLowerCase();
case "memo":
return (meta.memo || "").toLowerCase();
case "enabled":
return isModelDisabled(modelName) ? 1 : 0;
default:
return 0;
}
}
export function sortModels(models) {
if (!state.sortColumn) return models;
// ソートキーを1モデル1回だけ計算(比較ごとのparseModelPath等の再計算を回避)
const dir = state.sortDir === "asc" ? 1 : -1;
return models
.map((m) => [sortKeyOf(m), m])
.sort((a, b) => (a[0] < b[0] ? -dir : a[0] > b[0] ? dir : 0))
.map((pair) => pair[1]);
}
export function getAllTags() {
const set = new Set();
const models = getCurrentModels();
models.forEach((m) => {
const meta = state.modelMetadata[m];
if (meta?.tags) meta.tags.forEach((tag) => set.add(tag));
});
return [...set].sort();
}
// ── Render: Tag Filter ────────────────────────────────────
export function renderTagFilter() {
const select = document.getElementById("wfm-models-tag-filter");
if (!select) return;
const tags = getAllTags();
select.innerHTML =
`<option value="">${t("modelsAllTags")}</option>` +
tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
select.value = state.tagFilter;
}
export function renderGroupFilter() {
const select = document.getElementById("wfm-models-group-filter");
if (!select) return;
const groups = state.allModelGroups[state.activeModelType] || {};
const names = Object.keys(groups).sort();
// 選択中のグループが削除された場合はフィルターをリセット
if (state.groupFilter && !groups[state.groupFilter]) {
state.groupFilter = "";
}
const currentValue = state.groupFilter
? `${state.activeModelType}::${state.groupFilter}`
: "";
select.innerHTML =
`<option value="">${t("modelsAllGroups")}</option>` +
names.map((name) => {
const value = `${state.activeModelType}::${name}`;
const isActive = value === currentValue;
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>${escapeHtml(name)}</option>`;
}).join("");
}
export function renderDirFilter() {
const select = document.getElementById("wfm-models-dir-filter");
if (!select) return;
const dirs = [...new Set(
getCurrentModels()
.map((m) => parseModelPath(m).dir)
.filter((d) => d !== "")
)].sort();
select.innerHTML =
`<option value="">${t("modelsAllDirs")}</option>` +
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
select.value = state.dirFilter;
}
+329
View File
@@ -0,0 +1,329 @@
/**
* Models Tab - Grid rendering (Thumbnail / Table views) and per-card
* Batch/Stack/Favorite toggles.
*
* This module and selection-bulk.js / detail-panel.js form a circular
* import triangle with each other and with models-tab.js: rendering a card
* needs to wire up selection, side-panel, and detail-modal click handlers,
* and those in turn re-render the grid after they change state. All the
* cross-module calls happen inside event handlers (never at module
* evaluation time), and every export here is a `function` declaration
* (hoisted), so the cycle resolves safely under ES Modules.
*/
import { showToast } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state } from "./state.js";
import { filterModels, getCurrentModels, isModelDisabled } from "./filters.js";
import { modelBadgesHtml } from "./badges.js";
import { parseModelPath, getExtension, getStem, loadPreviewImage } from "./helpers.js";
import { toggleModelSelection } from "./selection-bulk.js";
import { showSidePanel, openDetailModal, renderSideInfo } from "./detail-panel.js";
import { saveModelGroups, saveModelMetadata, toggleModelEnable } from "../models-tab.js";
export function renderModelGrid() {
const grid = document.getElementById("wfm-models-grid");
if (!grid) return;
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
const filtered = filterModels();
// Update count
const countEl = document.getElementById("wfm-models-count");
if (countEl) {
const total = getCurrentModels().length;
countEl.textContent = `${filtered.length} / ${total}`;
}
if (filtered.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("modelsNoModels")}</p>`;
return;
}
if (state.viewMode === "table") {
renderTableView(grid, filtered);
} else {
renderThumbView(grid, filtered);
}
}
// ── Thumbnail View (same as Workflow tab) ─────────────────
export function renderThumbView(grid, models) {
grid.innerHTML = "";
models.forEach((modelName) => {
const meta = state.modelMetadata[modelName] || {};
const { name } = parseModelPath(modelName);
const disabled = isModelDisabled(modelName);
const userBadges = modelBadgesHtml(modelName);
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
const favStar = meta.favorite ? "★" : "☆";
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
const showStackBtn = state.activeModelType === "lora";
const inBatch = showBatchBtn && isInBatch(modelName);
const inStack = showStackBtn && isInStack(modelName);
const batchClass = inBatch ? "wfm-batch-btn active" : "wfm-batch-btn";
const stackClass = inStack ? "wfm-stack-btn active" : "wfm-stack-btn";
const card = document.createElement("div");
card.className = "wfm-card";
if (disabled) card.classList.add("wfm-model-disabled");
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
card.dataset.modelName = modelName;
card.innerHTML = `
<div class="wfm-card-thumb">
<img style="display:none" />
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
</div>
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
</div>
${showBatchBtn ? `<button class="${batchClass}" title="${t("modelsBatch")}">B</button>` : ""}
${showStackBtn ? `<button class="${stackClass}" title="Stack (Lora multi-apply)">S</button>` : ""}
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
// Load preview without 404 console spam
const img = card.querySelector(".wfm-card-thumb img");
const placeholder = card.querySelector(".wfm-card-thumb-placeholder");
loadPreviewImage(img, placeholder, modelName);
card.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await toggleBatch(modelName);
if (state.showBatchOnly) {
renderModelGrid();
} else {
btn.classList.toggle("active", isInBatch(modelName));
}
});
card.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await toggleStack(modelName);
btn.classList.toggle("active", isInStack(modelName));
});
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(modelName);
});
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleModelEnable(modelName);
});
if (state.selectMode) {
const isChecked = state.selectedModels.has(modelName);
card.classList.toggle("wfm-card-checked", isChecked);
const checkEl = document.createElement("div");
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
card.appendChild(checkEl);
card.addEventListener("click", (e) => {
if (e.target.closest(".wfm-batch-btn, .wfm-stack-btn, .wfm-fav-btn, .wfm-toggle-btn")) return;
toggleModelSelection(modelName);
});
} else {
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
}
grid.appendChild(card);
});
}
// ── Table View ────────────────────────────────────────────
export function thSortHtml(label, col, extraClass = "", extraStyle = "") {
const isActive = state.sortColumn === col;
const arrow = isActive ? (state.sortDir === "asc" ? " ▲" : " ▼") : "";
const activeStyle = isActive ? "color:var(--wfm-accent,#6366f1);" : "";
const cls = ["wfm-table-th-sortable", extraClass].filter(Boolean).join(" ");
return `<th class="${cls}" data-sort-col="${col}" style="${activeStyle}${extraStyle}">${label}${arrow}</th>`;
}
export function renderTableView(grid, models) {
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
const showStackBtn = state.activeModelType === "lora";
const rows = models
.map((modelName) => {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const sha256 = meta.sha256;
const civitai = sha256 && state.civitaiCache[sha256];
const civitaiType = civitai ? (civitai.type || "") : "";
const civitaiBaseModel = civitai ? (civitai.baseModel || "") : "";
const disabled = isModelDisabled(modelName);
const isChecked = state.selectMode && state.selectedModels.has(modelName);
const favIcon = meta.favorite ? "&#9733;" : "&#9734;";
const batchIcon = !showBatchBtn ? "" : isInBatch(modelName) ? `<button class="wfm-batch-btn active" title="${t("modelsBatch")}">B</button>` : `<button class="wfm-batch-btn" title="${t("modelsBatch")}">B</button>`;
const stackIcon = isInStack(modelName) ? `<button class="wfm-stack-btn active" title="Stack (Lora multi-apply)">S</button>` : `<button class="wfm-stack-btn" title="Stack (Lora multi-apply)">S</button>`;
const tagsStr = (meta.tags || []).join(", ");
const memo = meta.memo || "";
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
const toggleIcon = disabled ? "▶" : "⏸";
const checkCell = state.selectMode
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
: "";
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
${checkCell}
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
<td class="wfm-table-td-filename" title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
<td class="wfm-table-td-subdir">${escapeHtml(dir)}</td>
<td class="wfm-table-td-civtype" title="${escapeHtml(civitaiType)}">${escapeHtml(civitaiType)}</td>
<td class="wfm-table-td-basemodel" title="${escapeHtml(civitaiBaseModel)}">${escapeHtml(civitaiBaseModel)}</td>
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
<td>${escapeHtml(tagsStr)}</td>
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
${showBatchBtn ? `<td class="wfm-table-td-batch">${batchIcon}</td>` : ""}
${showStackBtn ? `<td class="wfm-table-td-stack">${stackIcon}</td>` : ""}
</tr>`;
})
.join("");
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
${checkTh}
${thSortHtml("&#9733;", "fav", "", "width:30px;text-align:center;")}
<th style="width:40px;"></th>
${thSortHtml(t("modelsFileName"), "filename", "wfm-table-th-filename")}
${thSortHtml(t("modelsSubdir"), "subdir", "wfm-table-th-subdir")}
${thSortHtml(t("civitaiType"), "civtype", "wfm-table-th-civtype")}
${thSortHtml(t("civitaiBaseModel"), "basemodel", "wfm-table-th-basemodel")}
${thSortHtml(t("modelsExt"), "ext", "wfm-table-th-ext")}
${thSortHtml(t("modelsTags"), "tags")}
${thSortHtml(t("modelsMemo"), "memo")}
${thSortHtml("E/D", "enabled", "", "width:50px;text-align:center;")}
${showBatchBtn ? `<th style="width:30px;">B</th>` : ""}
${showStackBtn ? `<th style="width:30px;">S</th>` : ""}
</tr></thead><tbody>${rows}</tbody></table>`;
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
const mn = row.dataset.modelName;
const img = row.querySelector(".wfm-table-thumb");
if (img) loadPreviewImage(img, null, mn);
if (state.selectMode) {
row.addEventListener("click", (e) => {
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn, .wfm-batch-btn, .wfm-stack-btn")) return;
toggleModelSelection(mn);
});
} else {
row.addEventListener("click", () => showSidePanel(mn));
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
}
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(mn);
});
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleModelEnable(mn);
});
row.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
await toggleBatch(mn);
if (state.showBatchOnly) {
renderModelGrid();
} else {
e.currentTarget.classList.toggle("active", isInBatch(mn));
}
});
row.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
await toggleStack(mn);
e.currentTarget.classList.toggle("active", isInStack(mn));
});
});
grid.querySelectorAll(".wfm-table-th-sortable").forEach((th) => {
th.addEventListener("click", () => {
const col = th.dataset.sortCol;
if (state.sortColumn === col) {
if (state.sortDir === "asc") {
state.sortDir = "desc";
} else {
state.sortColumn = null;
state.sortDir = "asc";
}
} else {
state.sortColumn = col;
state.sortDir = "asc";
}
renderModelGrid();
});
});
}
// ── Batch group helpers ───────────────────────────────────
export function isInBatch(modelName) {
return (state.modelGroups["Batch"] || []).includes(modelName);
}
export async function toggleBatch(modelName) {
const batch = state.modelGroups["Batch"] || [];
const idx = batch.indexOf(modelName);
if (idx >= 0) {
batch.splice(idx, 1);
} else {
batch.push(modelName);
}
state.modelGroups["Batch"] = batch;
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
}
export async function clearBatchGroup() {
state.modelGroups["Batch"] = [];
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
if (state.showBatchOnly) {
state.showBatchOnly = false;
document.getElementById("wfm-models-batch-filter-btn")?.classList.remove("active");
}
renderModelGrid();
showToast(t("modelsBatchClear"), "success");
}
export function isInStack(modelName) {
return (state.modelGroups["Stack"] || []).includes(modelName);
}
export async function toggleStack(modelName) {
const stack = state.modelGroups["Stack"] || [];
const idx = stack.indexOf(modelName);
if (idx >= 0) {
stack.splice(idx, 1);
} else {
stack.push(modelName);
}
state.modelGroups["Stack"] = stack;
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
}
export async function clearStackGroup() {
state.modelGroups["Stack"] = [];
state.allModelGroups[state.activeModelType] = state.modelGroups;
await saveModelGroups(state.modelGroups);
renderModelGrid();
showToast(t("stackCleared"), "success");
}
// ── Favorite toggle ───────────────────────────────────────
export async function toggleFavorite(modelName) {
const meta = state.modelMetadata[modelName] || {};
const newFav = !meta.favorite;
await saveModelMetadata(modelName, { favorite: newFav });
renderModelGrid();
if (state.selectedModel === modelName) renderSideInfo(modelName);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Models Tab - Small path/preview helpers shared across the models/* modules
*/
import { state } from "./state.js";
export function parseModelPath(fullName) {
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
if (lastSlash === -1) return { dir: "", name: fullName };
return { dir: fullName.substring(0, lastSlash), name: fullName.substring(lastSlash + 1) };
}
export function getExtension(name) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.substring(dot) : "";
}
export function getStem(name) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.substring(0, dot) : name;
}
export function previewUrl(modelName, modelType) {
const type = modelType || state.activeModelType;
return `/api/wfm/models/preview?type=${encodeURIComponent(type)}&name=${encodeURIComponent(modelName)}`;
}
/**
* Load preview image. Uses img onload/onerror instead of HEAD request
* (aiohttp add_get does not auto-handle HEAD method).
* Falls back to CivitAI cached image if no local preview exists.
*/
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
const url = previewUrl(modelName, modelType);
imgEl.onload = () => {
imgEl.style.display = "";
if (placeholderEl) placeholderEl.style.display = "none";
};
imgEl.onerror = () => {
// Fallback: use CivitAI cached image if available
const meta = state.modelMetadata[modelName] || {};
const sha256 = meta.sha256;
const civitai = sha256 && state.civitaiCache[sha256];
const civitaiImg = civitai && civitai.images && civitai.images[0];
if (civitaiImg) {
imgEl.onerror = () => {
imgEl.style.display = "none";
if (placeholderEl) placeholderEl.style.display = "";
};
imgEl.src = civitaiImg;
imgEl.style.display = "";
if (placeholderEl) placeholderEl.style.display = "none";
} else {
imgEl.style.display = "none";
if (placeholderEl) placeholderEl.style.display = "";
}
};
imgEl.src = url;
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Models Tab - Multi-select mode and bulk group/badge/favorite/move/delete
* operations on the current selection.
*
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
* import triangle — see the note at the top of grid-view.js.
*/
import { showToast } from "../app.js";
import { t } from "../i18n.js";
import { escapeHtml } from "../util.js";
import { state, RESERVED_GROUPS } from "./state.js";
import { filterModels, renderDirFilter } from "./filters.js";
import { getBadgePalette } from "./badges.js";
import { renderModelGrid } from "./grid-view.js";
import { saveModelGroups, saveModelMetadata } from "../models-tab.js";
export function toggleSelectMode() {
state.selectMode = !state.selectMode;
if (!state.selectMode) state.selectedModels.clear();
const btn = document.getElementById("wfm-models-select-btn");
if (btn) {
btn.classList.toggle("active", state.selectMode);
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
}
renderModelGrid();
renderBulkActionBar();
}
export function toggleModelSelection(modelName) {
if (state.selectedModels.has(modelName)) {
state.selectedModels.delete(modelName);
} else {
state.selectedModels.add(modelName);
}
// Update DOM directly (avoid full re-render)
document.querySelectorAll("[data-model-name]").forEach((el) => {
if (el.dataset.modelName !== modelName) return;
const checked = state.selectedModels.has(modelName);
el.classList.toggle("wfm-card-checked", checked);
const c = el.querySelector(".wfm-select-check");
if (c) c.classList.toggle("checked", checked);
});
renderBulkActionBar();
}
export function clearSelection() {
state.selectedModels.clear();
renderModelGrid();
renderBulkActionBar();
}
export function selectAll() {
filterModels().forEach(m => state.selectedModels.add(m));
renderModelGrid();
renderBulkActionBar();
}
export function renderBulkActionBar() {
const bar = document.getElementById("wfm-models-bulk-bar");
if (!bar) return;
if (!state.selectMode || state.selectedModels.size === 0) {
bar.style.display = "none";
return;
}
const count = state.selectedModels.size;
const groupNames = Object.keys(state.modelGroups).sort();
const noGroups = groupNames.length === 0;
const groupOptions = noGroups
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
const palette = getBadgePalette();
const badgeLabels = Object.keys(palette).sort();
const noBadges = badgeLabels.length === 0;
const badgeOptions = noBadges
? `<option value="">${t("modelBulkNoBadge")}</option>`
: badgeLabels.map((b) => `<option value="${escapeHtml(b)}">${escapeHtml(b)}</option>`).join("");
bar.style.display = "flex";
bar.innerHTML = `
<div class="wfm-bulk-header">
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-deselect-all-btn">${t("modelBulkDeselectAll")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-select-all-btn">${t("modelBulkSelectAll")}</button>
<span class="wfm-bulk-sep"></span>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-add-btn">${t("modelBulkFavAdd")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-remove-btn">${t("modelBulkFavRemove")}</button>
</div>
<div class="wfm-bulk-rows">
<div class="wfm-bulk-row">
<span class="wfm-bulk-row-label">Group:</span>
<select id="wfm-bulk-group-select" class="wfm-select wfm-bulk-select">${groupOptions}</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
<span class="wfm-bulk-sep"></span>
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelsGroupName")}">
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
</div>
<div class="wfm-bulk-row">
<span class="wfm-bulk-row-label">Badge:</span>
<select id="wfm-bulk-badge-select" class="wfm-select wfm-bulk-select">${badgeOptions}</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-badge-apply-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeApply")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-badge-remove-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeRemove")}</button>
</div>
<div class="wfm-bulk-row">
<span class="wfm-bulk-row-label">File:</span>
<select id="wfm-bulk-move-select" class="wfm-select wfm-bulk-select">
<option value="">${t("modelBulkMoveRoot")}</option>
${state.subdirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("")}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-move-btn">${t("modelBulkMoveBtn")}</button>
<span class="wfm-bulk-sep"></span>
<input type="text" id="wfm-bulk-new-dir-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelBulkMoveNewFolder")}">
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-mkdir-move-btn">${t("modelBulkMoveCreateMove")}</button>
<span style="flex:1"></span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
</div>
</div>
`;
document.getElementById("wfm-bulk-deselect-all-btn")?.addEventListener("click", clearSelection);
document.getElementById("wfm-bulk-select-all-btn")?.addEventListener("click", selectAll);
document.getElementById("wfm-bulk-fav-add-btn")?.addEventListener("click", () => bulkSetFavorite(true));
document.getElementById("wfm-bulk-fav-remove-btn")?.addEventListener("click", () => bulkSetFavorite(false));
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkAddToGroup(g);
});
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkRemoveFromGroup(g);
});
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-bulk-new-group-input");
const name = input?.value.trim();
if (!name) return;
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
bulkAddToGroup(name);
});
document.getElementById("wfm-bulk-badge-apply-btn")?.addEventListener("click", () => {
const label = document.getElementById("wfm-bulk-badge-select")?.value;
if (label) bulkApplyBadge(label, true);
});
document.getElementById("wfm-bulk-badge-remove-btn")?.addEventListener("click", () => {
const label = document.getElementById("wfm-bulk-badge-select")?.value;
if (label) bulkApplyBadge(label, false);
});
document.getElementById("wfm-bulk-move-btn")?.addEventListener("click", () => {
const dest = document.getElementById("wfm-bulk-move-select")?.value || "";
bulkMoveModels(dest);
});
document.getElementById("wfm-bulk-mkdir-move-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-bulk-new-dir-input");
const name = input?.value.trim();
if (!name) return;
bulkMoveModels(name);
});
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
}
export async function bulkAddToGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) groups[groupName] = [];
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
groups[groupName] = [...groups[groupName], ...toAdd];
await saveModelGroups(groups);
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
renderBulkActionBar();
}
export async function bulkRemoveFromGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) return;
const before = groups[groupName].length;
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
const removed = before - groups[groupName].length;
// RESERVED_GROUPS (Batch / Stack) は空になってもキーを保持する
if (groups[groupName].length === 0 && !RESERVED_GROUPS.includes(groupName)) delete groups[groupName];
await saveModelGroups(groups);
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
renderModelGrid();
renderBulkActionBar();
}
export async function bulkSetFavorite(isFav) {
const models = [...state.selectedModels];
let count = 0;
for (const mn of models) {
const meta = state.modelMetadata[mn] || {};
if (meta.favorite === isFav) continue;
await saveModelMetadata(mn, { favorite: isFav });
count++;
}
if (count > 0) {
showToast(`${count} ${isFav ? t("modelBulkFavDone") : t("modelBulkUnfavDone")}`, "success");
renderModelGrid();
}
}
export async function bulkApplyBadge(badgeLabel, add) {
const models = [...state.selectedModels];
let count = 0;
for (const mn of models) {
const meta = state.modelMetadata[mn] || {};
const badges = [...(meta.badges || [])];
if (add) {
if (badges.includes(badgeLabel)) continue;
badges.push(badgeLabel);
} else {
const idx = badges.indexOf(badgeLabel);
if (idx === -1) continue;
badges.splice(idx, 1);
}
await saveModelMetadata(mn, { badges });
count++;
}
if (count > 0) {
showToast(`${count} ${add ? t("modelBulkBadgeApplyDone") : t("modelBulkBadgeRemoveDone")}`, "success");
renderModelGrid();
}
}
export async function bulkDeleteModels() {
const count = state.selectedModels.size;
if (count === 0) return;
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
if (!window.confirm(msg)) return;
const model_names = [...state.selectedModels];
try {
const res = await fetch("/api/wfm/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
});
const data = await res.json();
const okCount = (data.ok || []).length;
const errCount = (data.errors || []).length;
if (errCount > 0) {
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
}
if (okCount > 0) {
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
// Remove deleted models from local state
model_names.forEach((mn) => {
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(mn);
if (idx !== -1) list.splice(idx, 1);
delete state.modelMetadata[mn];
const ds = state.disabledModels[state.activeModelType];
if (ds) ds.delete(mn);
state.selectedModels.delete(mn);
});
state.loaded[state.activeModelType] = false;
renderModelGrid();
renderBulkActionBar();
}
} catch (err) {
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
}
}
export async function fetchSubdirs() {
try {
const res = await fetch(`/api/wfm/models/subdirs?type=${encodeURIComponent(state.activeModelType)}`);
state.subdirs = res.ok ? await res.json() : [];
} catch { state.subdirs = []; }
}
export async function bulkMoveModels(destSubdir) {
const model_names = [...state.selectedModels];
if (model_names.length === 0) return;
try {
const res = await fetch("/api/wfm/models/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names, dest: destSubdir }),
});
const data = await res.json();
const okCount = data.moved?.length || 0;
const errCount = data.errors?.length || 0;
if (okCount > 0) {
data.moved.forEach(({ from, to }) => {
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(from);
if (idx !== -1) list[idx] = to;
if (state.modelMetadata[from]) {
state.modelMetadata[to] = state.modelMetadata[from];
delete state.modelMetadata[from];
}
const ds = state.disabledModels[state.activeModelType];
if (ds && ds.has(from)) { ds.delete(from); ds.add(to); }
state.selectedModels.delete(from);
for (const members of Object.values(state.modelGroups)) {
const gi = members.indexOf(from);
if (gi !== -1) members[gi] = to;
}
});
if (state.selectedModel && data.moved.some((m) => m.from === state.selectedModel)) {
state.selectedModel = null;
const titleEl = document.getElementById("wfm-models-panel-title");
if (titleEl) titleEl.textContent = "";
}
showToast(`${okCount} ${t("modelBulkMoveDone")}`, "success");
await fetchSubdirs();
renderDirFilter();
renderModelGrid();
renderBulkActionBar();
}
if (errCount > 0) {
showToast(`${t("modelBulkMoveError")}: ${data.errors[0].error}`, "error");
}
} catch (err) {
showToast(`${t("modelBulkMoveError")}: ${err.message}`, "error");
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Models Tab - Shared state and static lookup tables
* `state` is a `const` object never reassigned — modules read/write its
* properties directly, which stays within ESM's read-only binding rule
* (only the binding itself, not the object it points to, is immutable).
*/
import { comfyUI } from "../comfyui-client.js";
export const RESERVED_GROUPS = ["Batch", "Stack"];
export const BATCH_MODEL_TYPES = ["checkpoint", "lora"];
export const STACK_MODEL_TYPES = ["lora"];
export const state = {
modelsByType: {
checkpoint: [],
lora: [],
vae: [],
controlnet: [],
unet: [],
textencoder: [],
hypernetwork: [],
embedding: [],
},
modelMetadata: {},
modelGroups: {},
allModelGroups: {}, // { type: { groupName: [models] } } — all types
civitaiCache: {},
disabledModels: {}, // { type: Set<modelName> }
subdirs: [],
selectMode: false,
selectedModels: new Set(),
searchText: "",
tagFilter: "",
badgeFilter: "",
dirFilter: "",
groupFilter: "",
statusFilter: "all", // "all" | "enabled" | "disabled"
showFavoritesOnly: false,
showBatchOnly: false,
viewMode: localStorage.getItem("wfm_models_view") === "table" ? "table" : "thumb",
activeModelType: "checkpoint",
selectedModel: null,
loaded: {},
currentPage: 0,
sortColumn: null, // "fav" | "filename" | "subdir" | "civtype" | "basemodel" | "ext" | "tags" | "memo" | "enabled"
sortDir: "asc", // "asc" | "desc"
};
export const FETCH_MAP = {
checkpoint: () => comfyUI.fetchCheckpoints(),
lora: () => comfyUI.fetchLoras(),
vae: () => comfyUI.fetchVaes(),
controlnet: () => comfyUI.fetchControlNets(),
unet: () => comfyUI.fetchDiffusionModels(),
textencoder: () => comfyUI.fetchTextEncoders(),
hypernetwork: () => comfyUI.fetchHypernetworks(),
embedding: () => comfyUI.fetchEmbeddings(),
};
export const TYPE_LABELS = {
checkpoint: "Checkpoint",
lora: "LoRA",
vae: "VAE",
controlnet: "ControlNet",
unet: "UNET",
textencoder: "TextEncoder",
hypernetwork: "Hypernetwork",
embedding: "Embedding",
};
// Mapping from models-tab type → comfyui-editor key + inputKey
export const GENUI_TYPE_MAP = {
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
lora: { key: "loras", inputKey: "lora_name" },
vae: { key: "vaes", inputKey: "vae_name" },
controlnet: { key: "controlNets", inputKey: "control_net_name" },
unet: { key: "diffusionModels",inputKey: "unet_name" },
textencoder: { key: "textEncoders", inputKey: "clip_name1" },
hypernetwork: { key: "hypernetworks", inputKey: "hypernetwork_name" },
};
+224 -1
View File
@@ -4,7 +4,8 @@
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
import { readJsonStorage, getAiBackendDefaultUrl, setupSearchClearBtn } from "./util.js";
import { refreshStylesList } from "./generate-tab.js";
// ============================================
// State
@@ -903,6 +904,144 @@ function wcUpdateFilePicker() {
}
}
// ============================================
// Style manager (Col3 "Style" タブ)
// ============================================
// Wildcardファイルマネージャーと同じCRUDパターン(一覧→エディタ→保存/削除/キャンセル)。
// 保存・削除後は generate-tab.js の refreshStylesList() を呼び、GenerateUIのStyleドロップダウンや
// Batchタブのチェックリストを再読み込み無しで最新状態へ同期する。
let styleListData = [];
let styleSearchText = "";
let styleEditingOriginalName = null; // null = 新規作成
let styleEditingFile = null; // 編集中/追加先スタイルの定義元ファイル名(style/配下、null = デフォルトのcustom.json)
async function styleFetchList() {
try {
const res = await fetch("/api/wfm/styles");
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function styleApiCreate(name, prompt, negativePrompt, targetFile) {
try {
const body = { name, prompt, negative_prompt: negativePrompt };
if (targetFile) body.file = targetFile;
const res = await fetch("/api/wfm/styles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { ok: false, error: e.message };
}
}
async function styleApiUpdate(originalName, name, prompt, negativePrompt) {
try {
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(originalName)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, prompt, negative_prompt: negativePrompt }),
});
const data = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { ok: false, error: e.message };
}
}
async function styleApiDelete(name) {
try {
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(name)}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { ok: false, error: e.message };
}
}
function styleRenderList() {
const list = document.getElementById("wfm-style-list");
if (!list) return;
list.innerHTML = "";
const q = styleSearchText.trim().toLowerCase();
const filtered = q ? styleListData.filter(s => s.name.toLowerCase().includes(q)) : styleListData;
if (filtered.length === 0) {
list.innerHTML = styleListData.length === 0
? `<div class="wfm-pm-empty" style="font-size:12px;">No styles yet.<br><small>Click "+ New" to create one.</small></div>`
: `<div class="wfm-pm-empty" style="font-size:12px;">No matching styles.</div>`;
return;
}
for (const s of filtered) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item";
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.name)}</span>
${s.file ? `<span class="wfm-wc-file-ext" title="${esc(s.file)}">${esc(s.file)}</span>` : ""}
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-style-edit-btn" title="Edit style">&#9998;</button>
</div>
`;
item.querySelector(".wfm-style-edit-btn").addEventListener("click", (e) => {
e.stopPropagation();
styleOpenEditor(s);
});
item.addEventListener("click", () => styleOpenEditor(s));
list.appendChild(item);
}
}
// forcedFile: 「+ Add to this file」から呼ばれた場合に、既存スタイルではなく
// 特定ファイルを追加先として指定するために使う(styleは渡さずnullにする)
function styleOpenEditor(style, forcedFile) {
styleEditingOriginalName = style ? style.name : null;
styleEditingFile = style ? (style.file || null) : (forcedFile || null);
const editor = document.getElementById("wfm-style-editor");
const nameInput = document.getElementById("wfm-style-editor-name");
const promptTA = document.getElementById("wfm-style-editor-prompt");
const negativeTA = document.getElementById("wfm-style-editor-negative");
const deleteBtn = document.getElementById("wfm-style-editor-delete-btn");
const addFileBtn = document.getElementById("wfm-style-editor-addfile-btn");
const fileLabel = document.getElementById("wfm-style-editor-file-label");
if (!editor) return;
nameInput.value = style?.name || "";
promptTA.value = style?.prompt || "";
negativeTA.value = style?.negative_prompt || "";
deleteBtn.style.display = style ? "" : "none";
// 「このファイルへ追加」は既存スタイルを編集中(=対象ファイルが分かっている)時だけ表示
addFileBtn.style.display = style ? "" : "none";
if (styleEditingFile) {
fileLabel.textContent = `File: ${styleEditingFile}`;
fileLabel.style.display = "";
} else {
fileLabel.style.display = "none";
}
editor.style.display = "";
nameInput.focus();
}
function styleCloseEditor() {
styleEditingOriginalName = null;
styleEditingFile = null;
const editor = document.getElementById("wfm-style-editor");
if (editor) editor.style.display = "none";
}
async function styleRefreshList() {
styleListData = await styleFetchList();
styleRenderList();
}
// ============================================
// Initialize
// ============================================
@@ -1319,4 +1458,88 @@ export function initPromptTab() {
// Initial wildcard file load
wcRefreshFiles();
// ── Col3タブ切り替え (Wildcard / Style) ────────────────────
document.querySelectorAll(".wfm-prompt-col3-tab[data-col3tab]").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".wfm-prompt-col3-tab[data-col3tab]").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const tabId = btn.dataset.col3tab;
document.querySelectorAll(".wfm-prompt-col3-tab-content").forEach(c => { c.style.display = "none"; });
const content = document.getElementById(`wfm-col3-tab-${tabId}`);
if (content) content.style.display = "";
});
});
// ── Style manager ────────────────────────────────
document.getElementById("wfm-style-new-btn")?.addEventListener("click", () => {
styleOpenEditor(null);
});
document.getElementById("wfm-style-refresh-btn")?.addEventListener("click", () => {
styleRefreshList();
});
// 編集中の既存スタイルと同じファイルへ、新しい空のスタイルを追加する
document.getElementById("wfm-style-editor-addfile-btn")?.addEventListener("click", () => {
if (!styleEditingFile) return;
styleOpenEditor(null, styleEditingFile);
});
setupSearchClearBtn("wfm-style-search", "wfm-style-search-clear-btn", () => {
styleSearchText = "";
styleRenderList();
});
document.getElementById("wfm-style-search")?.addEventListener("input", (e) => {
styleSearchText = e.target.value;
styleRenderList();
});
document.getElementById("wfm-style-editor-save-btn")?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-style-editor-name");
const promptTA = document.getElementById("wfm-style-editor-prompt");
const negativeTA = document.getElementById("wfm-style-editor-negative");
const name = (nameInput?.value || "").trim();
const prompt = promptTA?.value || "";
const negativePrompt = negativeTA?.value || "";
if (!name) { showToast(t("pleaseEnterStyleName"), "error"); return; }
const result = styleEditingOriginalName
? await styleApiUpdate(styleEditingOriginalName, name, prompt, negativePrompt)
: await styleApiCreate(name, prompt, negativePrompt, styleEditingFile);
if (result.ok) {
showToast(t("savedAs", name), "success");
styleCloseEditor();
await styleRefreshList();
await refreshStylesList();
} else {
showToast(t("errorWithMsg", result.error), "error");
}
});
// Editor: delete
document.getElementById("wfm-style-editor-delete-btn")?.addEventListener("click", async () => {
if (!styleEditingOriginalName) return;
if (!confirm(`Delete "${styleEditingOriginalName}"?`)) return;
const result = await styleApiDelete(styleEditingOriginalName);
if (result.ok) {
showToast(t("deletedName", styleEditingOriginalName), "success");
styleCloseEditor();
await styleRefreshList();
await refreshStylesList();
} else {
showToast(t("errorWithMsg", result.error), "error");
}
});
// Editor: cancel
document.getElementById("wfm-style-editor-cancel-btn")?.addEventListener("click", () => {
styleCloseEditor();
});
// Initial style list load
styleRefreshList();
}
+46
View File
@@ -366,6 +366,21 @@ export function applyJsonColors(colors) {
].join("\n");
}
// GenerateUI Model tab: highlight color for section labels that match the loaded workflow
const MODEL_ACTIVE_COLOR_STYLE_ID = "wfm-model-active-color-style";
const MODEL_ACTIVE_COLOR_DEFAULT = "#4ade80";
export function applyModelTabActiveColor(color) {
const val = color || MODEL_ACTIVE_COLOR_DEFAULT;
let el = document.getElementById(MODEL_ACTIVE_COLOR_STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
document.head.appendChild(el);
}
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active { color: ${val}; }`;
}
export function applyTheme(themeId) {
if (themeId) {
document.documentElement.setAttribute("data-theme", themeId);
@@ -577,6 +592,21 @@ export async function initSettingsTab() {
</div>
</details>
<!-- GenerateUI Model Tab Highlight Color -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("modelTabColorSection") || "GenerateUI Modelタブ ハイライト色"}</summary>
<div class="wfm-form-group">
<label>${t("modelTabColorLabel") || "ワークフローに含まれる項目の色"}</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="color" id="wfm-model-active-color" value="${settings.modelTabActiveColor || MODEL_ACTIVE_COLOR_DEFAULT}">
<button class="wfm-btn wfm-btn-sm" id="wfm-model-active-color-reset">${t("jsonColorReset") || "デフォルトに戻す"}</button>
</div>
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("modelTabColorHint") || "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します"}
</small>
</div>
</details>
<!-- Workflow Data Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
@@ -1333,6 +1363,22 @@ export async function initSettingsTab() {
showToast(t("settingsSaved"), "success");
});
// --- GenerateUI Model tab highlight color ---
document.getElementById("wfm-model-active-color")?.addEventListener("input", (e) => {
saveLocalSettings({ modelTabActiveColor: e.target.value });
applyModelTabActiveColor(e.target.value);
});
document.getElementById("wfm-model-active-color-reset")?.addEventListener("click", () => {
const cur = loadLocalSettings();
delete cur.modelTabActiveColor;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyModelTabActiveColor(undefined);
const inp = document.getElementById("wfm-model-active-color");
if (inp) inp.value = MODEL_ACTIVE_COLOR_DEFAULT;
showToast(t("settingsSaved"), "success");
});
// Save (local settings)
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
+373
View File
@@ -0,0 +1,373 @@
/**
* Style Catalog Gallery — Gallery タブのサブタブ。
* ws_style_catalog フォルダ配下の画像(GenerateUIの「カタログ作成」で生成した、
* ファイル名=スタイル名のプレビュー画像)を閲覧し、埋め込みPositive/Negative
* プロンプトを表示・コピーする。「Select as Style」でGenerateUIの登録済み
* Styleドロップダウンをファイル名と同名のStyleに切り替える。
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
import { escapeHtml, setupSearchClearBtn } from "./util.js";
import { extractAllMetadata, loadFileIntoMetadataTab } from "./metadata-tab.js";
import { selectStyleByName, loadWorkflowIntoEditor } from "./generate-tab.js";
const API = {
root: () => `/wfm/gallery/style-catalog/root`,
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
workflow: (path) => `/wfm/gallery/image/workflow?path=${encodeURIComponent(path)}`,
};
const state = {
root: null,
currentFolder: null,
search: "",
images: [],
selectedImage: null,
};
let _initialized = false;
async function apiFetch(url, opts = {}) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
// ── 初期化 ────────────────────────────────────────────────────
export function initStyleCatalogTab() {
document.getElementById("wfm-stylecatalog-refresh-btn")?.addEventListener("click", () => {
loadFolderTree();
loadImages();
});
setupSearchClearBtn("wfm-stylecatalog-search", "wfm-stylecatalog-search-clear-btn", () => {
state.search = "";
loadImages();
});
const searchInput = document.getElementById("wfm-stylecatalog-search");
searchInput?.addEventListener("input", debounce(() => {
state.search = searchInput.value.trim();
loadImages();
}, 300));
document.getElementById("wfm-stylecatalog-copy-positive-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-positive-text"));
document.getElementById("wfm-stylecatalog-copy-negative-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-negative-text"));
document.getElementById("wfm-stylecatalog-select-style-btn")?.addEventListener("click", onSelectStyleClick);
document.getElementById("wfm-stylecatalog-load-genui-btn")?.addEventListener("click", onLoadGenUIClick);
document.getElementById("wfm-stylecatalog-open-metadata-btn")?.addEventListener("click", onOpenMetadataClick);
}
export async function activateStyleCatalogTab() {
if (_initialized) return;
_initialized = true;
try {
const data = await apiFetch(API.root());
state.root = data.root;
await loadFolderTree();
} catch (e) {
const tree = document.getElementById("wfm-stylecatalog-tree");
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
// ── フォルダツリー ────────────────────────────────────────────
async function loadFolderTree() {
if (!state.root) return;
const tree = document.getElementById("wfm-stylecatalog-tree");
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
try {
const data = await apiFetch(API.folders(state.root));
if (data.error) {
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
return;
}
tree.innerHTML = "";
renderTreeNode(data, tree, 0, true);
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
if (firstLabel) firstLabel.click();
} catch (e) {
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderTreeNode(node, container, depth, isRoot) {
const item = document.createElement("div");
item.className = "wfm-gallery-tree-item";
item.style.paddingLeft = `${depth * 12}px`;
item.dataset.path = node.abs_path;
const hasChildren = node.children && node.children.length > 0;
const absPath = node.abs_path;
const arrow = document.createElement("span");
arrow.className = "wfm-gallery-tree-arrow";
if (hasChildren) {
arrow.textContent = "▶";
arrow.dataset.expanded = "false";
} else {
arrow.textContent = " ";
arrow.style.visibility = "hidden";
}
item.appendChild(arrow);
const label = document.createElement("span");
label.className = "wfm-gallery-tree-label";
label.textContent = isRoot ? "[root]" : node.name;
const totalCount = node.image_count_total ?? node.image_count;
if (totalCount > 0) {
const badge = document.createElement("span");
badge.className = "wfm-gallery-tree-count";
badge.textContent = totalCount;
label.appendChild(badge);
}
item.appendChild(label);
label.addEventListener("click", () => {
document.querySelectorAll("#wfm-stylecatalog-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
item.classList.add("selected");
state.currentFolder = absPath;
loadImages();
});
if (hasChildren) {
const childContainer = document.createElement("div");
childContainer.className = "wfm-gallery-tree-children";
childContainer.style.display = "none";
arrow.addEventListener("click", (e) => {
e.stopPropagation();
const expanded = arrow.dataset.expanded === "true";
if (!expanded) {
arrow.dataset.expanded = "true";
arrow.textContent = "▼";
childContainer.style.display = "";
if (childContainer.children.length === 0) {
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
}
} else {
arrow.dataset.expanded = "false";
arrow.textContent = "▶";
childContainer.style.display = "none";
}
});
container.appendChild(item);
container.appendChild(childContainer);
} else {
container.appendChild(item);
}
}
// ── 画像一覧 ─────────────────────────────────────────────────
async function loadImages() {
if (!state.currentFolder) return;
const grid = document.getElementById("wfm-stylecatalog-grid");
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
if (state.search) params.search = state.search;
try {
const data = await apiFetch(API.images(params));
state.images = data.images || [];
document.getElementById("wfm-stylecatalog-count").textContent = `${state.images.length} images`;
renderImages();
} catch (e) {
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
}
}
function renderImages() {
const grid = document.getElementById("wfm-stylecatalog-grid");
if (state.images.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
return;
}
grid.innerHTML = "";
const fragment = document.createDocumentFragment();
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
grid.appendChild(fragment);
}
function createThumbCard(img) {
const card = document.createElement("div");
card.className = "wfm-gallery-thumb-card";
card.title = img.filename;
card.dataset.path = img.path;
if (state.selectedImage && state.selectedImage.path === img.path) {
card.classList.add("selected");
}
const imgEl = document.createElement("img");
imgEl.className = "wfm-gallery-thumb-img";
imgEl.loading = "lazy";
imgEl.src = API.thumb(img.path);
imgEl.alt = img.filename;
imgEl.onerror = () => {
imgEl.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "wfm-gallery-thumb-placeholder";
placeholder.textContent = "?";
card.insertBefore(placeholder, imgEl.nextSibling);
};
card.appendChild(imgEl);
card.addEventListener("click", () => selectImage(img));
card.addEventListener("dblclick", () => openLightbox(img));
return card;
}
// ── 選択画像 / Positive・Negative表示 ─────────────────────────
function styleNameFromFilename(filename) {
return filename.replace(/\.[^.]+$/, "");
}
async function selectImage(img) {
state.selectedImage = img;
document.querySelectorAll("#wfm-stylecatalog-grid .wfm-gallery-thumb-card").forEach(el => {
el.classList.toggle("selected", el.dataset.path === img.path);
});
document.getElementById("wfm-stylecatalog-selected-name").textContent = img.filename;
const preview = document.getElementById("wfm-stylecatalog-selected-preview");
preview.innerHTML = "";
const previewImg = document.createElement("img");
previewImg.src = API.serveImage(img.path);
previewImg.alt = img.filename;
previewImg.title = "Double-click to enlarge";
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;cursor:zoom-in;";
previewImg.addEventListener("dblclick", () => openLightbox(img));
preview.appendChild(previewImg);
const positiveText = document.getElementById("wfm-stylecatalog-positive-text");
const negativeText = document.getElementById("wfm-stylecatalog-negative-text");
const copyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
const copyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
const selectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
const loadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
const openMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
positiveText.textContent = "";
negativeText.textContent = "";
copyPositiveBtn.disabled = true;
copyNegativeBtn.disabled = true;
selectStyleBtn.disabled = false; // ファイル名からのStyle選択はメタデータ抽出に依存しない
loadGenuiBtn.disabled = false;
openMetadataBtn.disabled = false;
try {
const res = await fetch(API.serveImage(img.path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
const meta = await extractAllMetadata(file);
const positive = meta?.positives?.[0] || "";
const negative = meta?.negatives?.[0] || "";
if (!positive && !negative) {
positiveText.textContent = t("stylecatalogNoPromptFound");
} else {
positiveText.textContent = positive;
negativeText.textContent = negative;
copyPositiveBtn.disabled = !positive;
copyNegativeBtn.disabled = !negative;
}
} catch (e) {
positiveText.textContent = t("stylecatalogNoPromptFound");
}
}
// gallery-tab.jsのライトボックスと同じCSSクラスを使う(ESCで閉じる処理もgallery-tab.js側の
// グローバルkeydownリスナーが共通で担う)。
function openLightbox(img) {
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox";
overlay.innerHTML = `
<div class="wfm-gallery-lightbox-inner">
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
<button class="wfm-gallery-lightbox-close">&times;</button>
</div>
`;
overlay.addEventListener("click", (e) => {
if (e.target === overlay || e.target.classList.contains("wfm-gallery-lightbox-close")) {
overlay.remove();
}
});
document.body.appendChild(overlay);
}
function copyText(elementId) {
const el = document.getElementById(elementId);
const text = el?.textContent || "";
if (!text.trim()) return;
navigator.clipboard.writeText(text)
.then(() => showToast(t("imagePromptCopiedToast"), "success"))
.catch((e) => showToast(t("errorWithMsg", e.message), "error"));
}
function onSelectStyleClick() {
const img = state.selectedImage;
if (!img) return;
const name = styleNameFromFilename(img.filename);
const ok = selectStyleByName(name);
if (ok) {
showToast(t("stylecatalogStyleSelected", name), "success");
} else {
showToast(t("stylecatalogNoMatch"), "error");
}
}
// カタログ画像は生成元の出力画像と同一バイト列で保存されているため、埋め込みワークフローも
// そのまま残っている。Outputフォルダ内の元画像を探す必要なく、この画像から直接読み込める。
async function onLoadGenUIClick() {
const img = state.selectedImage;
if (!img) return;
try {
const data = await apiFetch(API.workflow(img.path));
if (!data.has_workflow) {
showToast(t("galleryNoEmbeddedWorkflow"), "warning");
return;
}
const loaded = await loadWorkflowIntoEditor(data.workflow, img.filename);
if (loaded !== false) {
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
}
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
async function onOpenMetadataClick() {
const img = state.selectedImage;
if (!img) return;
try {
const res = await fetch(API.serveImage(img.path));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
await loadFileIntoMetadataTab(file);
} catch (e) {
showToast(t("errorWithMsg", e.message), "error");
}
}
+114
View File
@@ -2,6 +2,76 @@
* Shared utilities
*/
// ============================================
// PNG metadata embedding (iTXt chunk)
// ============================================
// Embeds an arbitrary key/UTF-8-text pair into a canvas-generated PNG data URL as
// an iTXt chunk, inserted right after IHDR — same insertion point/CRC approach as
// ComfyUI's own workflow-embedding (web/comfyui/top_menu_extension.js's
// embedWorkflowInPng), but iTXt instead of tEXt so non-ASCII text (Japanese notes,
// prompts, etc.) round-trips correctly through this codebase's PNG chunk reader
// (metadata-tab.js's readAllPNGTextChunks already decodes iTXt text as UTF-8;
// tEXt text there is decoded as Latin-1, which would mangle non-ASCII payloads).
function _n2b(n) {
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
}
function _joinBytes(...arrs) {
const result = new Uint8Array(arrs.reduce((total, a) => total + a.byteLength, 0));
arrs.reduce((offset, a) => { result.set(a, offset); return offset + a.byteLength; }, 0);
return result;
}
let _pngCrcTable = null;
function _pngCrc32(data) {
if (!_pngCrcTable) {
_pngCrcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
_pngCrcTable[n] = c;
}
}
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) crc = (crc >>> 8) ^ _pngCrcTable[(crc ^ data[i]) & 0xff];
return (crc ^ -1) >>> 0;
}
function _dataUrlToBytes(dataUrl) {
const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function _bytesToDataUrl(bytes, mime) {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
}
return `data:${mime};base64,${btoa(binary)}`;
}
export function embedPngTextChunk(dataUrl, keyword, text) {
const bytes = _dataUrlToBytes(dataUrl);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const data = _joinBytes(
new TextEncoder().encode(`iTXt${keyword}`),
new Uint8Array([0, 0, 0, 0, 0]), // keyword-NUL, compression flag(0), compression method(0), empty lang tag+NUL, empty translated keyword+NUL
new TextEncoder().encode(text),
);
const chunk = _joinBytes(_n2b(data.byteLength - 4), data, _n2b(_pngCrc32(data)));
// Insert after IHDR (8-byte signature + 4-byte length + 4-byte type + IHDR data + 4-byte CRC)
const ihdrDataLen = view.getUint32(8);
const insertAt = ihdrDataLen + 20;
const result = _joinBytes(bytes.subarray(0, insertAt), chunk, bytes.subarray(insertAt));
return _bytesToDataUrl(result, "image/png");
}
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
export function escapeHtml(s) {
return String(s)
@@ -32,12 +102,56 @@ export const AI_BACKEND_DEFAULT_URLS = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234",
lemonade: "http://localhost:13305",
unsloth: "http://localhost:8888",
};
export function getAiBackendDefaultUrl(backend) {
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
}
// ============================================
// Eagle Auto-Save
// ============================================
export function getEagleSettings() {
const s = getSettings();
return {
url: s.eagleUrl || "http://localhost:41595",
autoSave: !!s.eagleAutoSave,
};
}
export async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
const eagle = getEagleSettings();
if (!eagle.autoSave) return;
try {
const res = await fetch("/api/wfm/eagle/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eagleUrl: eagle.url,
url: imageUrl,
name,
tags: ["wfm-comfyui", ...tags],
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
filename: fileInfo?.filename || "",
subfolder: fileInfo?.subfolder || "",
type: fileInfo?.type || "output",
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
localPath: fileInfo?.localPath || "",
}),
});
const data = await res.json();
if (data.status === "success") {
console.log("[Eagle] Saved:", name);
} else {
console.warn("[Eagle] Save failed:", data.message);
}
} catch (err) {
console.warn("[Eagle] Save error:", err.message);
}
}
/**
* 検索inputにオーバーレイXボタンを設定する。
* @param {string} inputId - 検索inputのID
+492 -9
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/wfm_static/css/gallery-tab.css">
<link rel="stylesheet" href="/wfm_static/css/tagger-tab.css">
<link rel="stylesheet" href="/wfm_static/css/image-edit-tab.css">
<link rel="stylesheet" href="/wfm_static/css/lab-tab.css">
</head>
<body>
<!-- Header -->
@@ -293,6 +294,8 @@
Style
</label>
<select id="wfm-style-select" class="wfm-input" style="font-size:12px;padding:2px 6px;height:26px;min-width:120px;max-width:220px;"></select>
<button id="wfm-gen-catalog-create-btn" class="wfm-btn wfm-btn-sm">Create Catalog</button>
<button id="wfm-gen-catalog-open-btn" class="wfm-btn wfm-btn-sm">Catalog</button>
</div>
<div class="wfm-toolbar-right" style="display:flex;align-items:center;gap:8px;">
<span id="wfm-gen-wf-name" style="color:var(--wfm-text-secondary);font-size:13px;">No workflow loaded</span>
@@ -310,6 +313,7 @@
<button class="wfm-gen-subtab-btn" data-subtab="settings">Settings</button>
<button class="wfm-gen-subtab-btn" data-subtab="feeder">Feeder</button>
<button class="wfm-gen-subtab-btn" data-subtab="batch">Batch</button>
<button class="wfm-gen-subtab-btn" data-subtab="lab">Lab</button>
<button id="wfm-gen-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" style="margin-left:auto;" title="Save current workflow to Workflow tab">Save</button>
</div>
@@ -346,7 +350,7 @@
</div>
<div class="wfm-gen-lora-col">
<div class="wfm-gen-lora-col-header">
<span>LoRA</span>
<span id="wfm-gen-lora-col-title">LoRA</span>
<button class="wfm-btn wfm-btn-xs wfm-lora-refresh-btn" id="wfm-lora-stack-refresh" title="Refresh Stack group">🔄</button>
</div>
<div id="wfm-gen-lora-fields" class="wfm-gen-lora-col-body">
@@ -501,6 +505,160 @@
</div>
</div>
<!-- Tab: Lab — experimental I2I batch generation with per-column keyframes -->
<div id="wfm-gen-subtab-lab" class="wfm-gen-subtab-content">
<div class="wfm-lab-subtab-nav">
<button class="wfm-lab-subtab-btn active" data-lab-subtab="setting">Setting</button>
<button class="wfm-lab-subtab-btn" data-lab-subtab="results">Results</button>
<button class="wfm-lab-subtab-btn" data-lab-subtab="planjson">Plan JSON</button>
</div>
<!-- Setting panel -->
<div id="wfm-lab-panel-setting" class="wfm-lab-panel active">
<div class="wfm-lab-setting-layout">
<!-- Left: source image + plan load/clear -->
<div class="wfm-lab-pane wfm-lab-left">
<div class="wfm-lab-pane-header">Image</div>
<label class="wfm-lab-checkbox-label">
<input type="checkbox" id="wfm-lab-t2i-mode">
T2I workflow (no source image)
</label>
<div class="wfm-i2i-preview-wrap" id="wfm-lab-preview-wrap" style="display:none;">
<img class="wfm-i2i-preview-img" id="wfm-lab-preview-img" alt="">
</div>
<div class="wfm-i2i-drop-zone" id="wfm-lab-drop-zone">
<label class="wfm-i2i-drop-label">
<input type="file" id="wfm-lab-file-input" accept="image/*" style="display:none;">
Drop image here or click to select
</label>
</div>
<label class="wfm-lab-checkbox-label">
<input type="checkbox" id="wfm-lab-chain-image">
Use generated image for next
</label>
<div class="wfm-lab-pane-header" style="margin-top:10px;">Plan</div>
<div class="wfm-i2i-drop-zone" id="wfm-lab-plan-drop-zone">
<label class="wfm-i2i-drop-label">
<input type="file" id="wfm-lab-plan-file-input" accept="application/json,.json,image/png,.png" style="display:none;">
Drop plan (.json/.png) here or click to select
</label>
</div>
<div class="wfm-lab-plan-workflow-row" id="wfm-lab-plan-workflow-row" style="display:none;">
<span id="wfm-lab-plan-workflow-name" class="wfm-lab-plan-workflow-name"></span>
<button type="button" id="wfm-lab-plan-workflow-load-btn" class="wfm-btn wfm-btn-xs" title="Load this plan's recorded workflow into GenerateUI">Open Workflow</button>
</div>
<div class="wfm-lab-side-btns">
<button id="wfm-lab-plan-clear-btn" class="wfm-btn">Plan Clear</button>
</div>
</div>
<!-- Center: 4 keyframe columns -->
<div class="wfm-lab-pane wfm-lab-center">
<div class="wfm-lab-columns">
<div class="wfm-lab-column" data-col="model">
<div class="wfm-lab-col-header">
<div class="wfm-lab-col-header-left">
<span>model</span>
<div class="wfm-lab-model-view-toggle" id="wfm-lab-model-view-toggle">
<button type="button" class="wfm-lab-model-view-btn active" data-view="checkpoint" title="Show Checkpoint">C</button>
<button type="button" class="wfm-lab-model-view-btn" data-view="lora" title="Show LoRA">L</button>
<button type="button" class="wfm-lab-model-view-btn" data-view="vae" title="Show VAE">V</button>
</div>
</div>
<div class="wfm-lab-col-btns">
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="model" title="Add keyframe">+</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="model" title="Remove last keyframe">-</button>
</div>
</div>
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-model"></div>
</div>
<div class="wfm-lab-column" data-col="prompt">
<div class="wfm-lab-col-header">
<span>プロンプト</span>
<div class="wfm-lab-col-btns">
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="prompt" title="Add keyframe">+</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="prompt" title="Remove last keyframe">-</button>
</div>
</div>
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-prompt"></div>
</div>
<div class="wfm-lab-column" data-col="ksampler">
<div class="wfm-lab-col-header">
<span>Ksampler</span>
<div class="wfm-lab-col-btns">
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="ksampler" title="Add keyframe">+</button>
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="ksampler" title="Remove last keyframe">-</button>
</div>
</div>
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-ksampler"></div>
</div>
</div>
</div>
<!-- Right: run controls -->
<div class="wfm-lab-pane wfm-lab-right">
<div style="display:flex;gap:6px;">
<button id="wfm-lab-run-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Run</button>
<button id="wfm-lab-cancel-btn" class="wfm-btn wfm-btn-danger" style="display:none;">Cancel</button>
</div>
<button id="wfm-lab-pause-btn" class="wfm-btn" style="width:100%;margin-top:6px;" disabled>Pause</button>
<div class="wfm-gen-progress-container" style="margin-top:8px;">
<div class="wfm-gen-progress-track">
<div id="wfm-lab-progress-bar" class="wfm-gen-progress-bar"></div>
</div>
<span id="wfm-lab-progress-text" class="wfm-gen-progress-text">Ready</span>
</div>
<div class="wfm-lab-field-row">
<label>Batch:</label>
<input type="number" id="wfm-lab-batch-count" class="wfm-input" value="4" min="1" max="50">
</div>
<label class="wfm-lab-checkbox-label" style="margin-top:6px;">
<input type="checkbox" id="wfm-lab-save-index-on-run" checked>
Save index image to Output on Run
</label>
<div class="wfm-lab-pane-header" style="margin-top:12px;">Note</div>
<textarea id="wfm-lab-note" class="wfm-input wfm-lab-note" rows="6"></textarea>
<button id="wfm-lab-plan-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:8px;">Plan Save</button>
<button id="wfm-lab-plan-saveas-btn" class="wfm-btn" style="width:100%;margin-top:6px;">Save As</button>
</div>
</div>
</div>
<!-- Results panel -->
<div id="wfm-lab-panel-results" class="wfm-lab-panel">
<div class="wfm-lab-results-layout">
<div class="wfm-lab-pane wfm-lab-results-left">
<div class="wfm-lab-pane-header">Image</div>
<img id="wfm-lab-results-source-img" class="wfm-i2i-preview-img" style="display:none;" alt="">
<p id="wfm-lab-results-t2i-note" class="wfm-placeholder" style="display:none;">T2I workflow (no source image)</p>
</div>
<div class="wfm-lab-pane wfm-lab-results-center">
<div class="wfm-lab-pane-header">Results</div>
<div id="wfm-lab-results-grid" class="wfm-lab-results-grid">
<p class="wfm-placeholder">No results yet</p>
</div>
</div>
</div>
</div>
<!-- Plan JSON panel -->
<div id="wfm-lab-panel-planjson" class="wfm-lab-panel">
<div class="wfm-lab-planjson-toolbar">
<button type="button" id="wfm-lab-planjson-refresh" class="wfm-btn wfm-btn-sm">Refresh</button>
<button type="button" id="wfm-lab-planjson-apply" class="wfm-btn wfm-btn-primary wfm-btn-sm">Apply to Setting</button>
</div>
<div class="wfm-json-container">
<pre id="wfm-lab-planjson-highlight" class="wfm-json-highlight"></pre>
<textarea id="wfm-lab-planjson-editor" class="wfm-json-editor" spellcheck="false"></textarea>
</div>
</div>
</div>
<!-- Tab: Feeder — full-width: settings(left) + library(center) -->
<div id="wfm-gen-subtab-feeder" class="wfm-gen-subtab-content">
<div class="wfm-feeder-layout">
@@ -874,9 +1032,15 @@
</div>
</div>
<!-- Col 3: Wildcard support -->
<!-- Col 3: Wildcard / Style support -->
<div class="wfm-prompt-split-col">
<div class="wfm-prompt-split-header">Wildcard</div>
<div class="wfm-prompt-col3-tabs">
<button class="wfm-prompt-col3-tab active" id="wfm-col3-tab-wildcard-btn" data-col3tab="wildcard">Wildcard</button>
<button class="wfm-prompt-col3-tab" id="wfm-col3-tab-style-btn" data-col3tab="style">Style</button>
</div>
<!-- Wildcard タブ -->
<div id="wfm-col3-tab-wildcard" class="wfm-prompt-col3-tab-content">
<div class="wfm-wc-panel">
<!-- Input assistance section -->
@@ -943,6 +1107,49 @@
</div>
</div>
</div>
<!-- /Wildcard タブ -->
<!-- Style タブ -->
<div id="wfm-col3-tab-style" class="wfm-prompt-col3-tab-content" style="display:none;">
<div class="wfm-wc-panel">
<div class="wfm-wc-files-section" style="margin-top:0;border-top:none;padding-top:0;">
<div class="wfm-wc-files-header">
<span class="wfm-wc-files-title">Styles</span>
<div style="display:flex;gap:4px;">
<button id="wfm-style-new-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New</button>
<button id="wfm-style-refresh-btn" class="wfm-btn wfm-btn-sm" title="Reload style list">&#8635;</button>
</div>
</div>
<div class="wfm-search-wrap" style="margin:8px 0;">
<input type="text" id="wfm-style-search" class="wfm-search-input" placeholder="Search styles...">
<button class="wfm-search-clear-btn" id="wfm-style-search-clear-btn" title="Clear search">✕</button>
</div>
<!-- Style list -->
<div id="wfm-style-list" class="wfm-wc-file-list">
<p class="wfm-placeholder" style="padding:12px;font-size:12px;">Loading...</p>
</div>
<!-- Editor (shown when a style is selected or New is clicked) -->
<div id="wfm-style-editor" class="wfm-wc-editor" style="display:none;">
<div class="wfm-wc-editor-header">
<input type="text" id="wfm-style-editor-name" class="wfm-input" placeholder="Style name" style="flex:1;min-width:0;">
</div>
<div class="wfm-wc-editor-hint" id="wfm-style-editor-file-label" style="display:none;"></div>
<div class="wfm-wc-editor-hint" id="wfm-style-editor-prompt-label">Positive prompt (use {prompt} as a placeholder for the user's own prompt)</div>
<textarea id="wfm-style-editor-prompt" class="wfm-textarea wfm-wc-editor-ta" rows="5" placeholder="e.g. cinematic still, {prompt}, dramatic lighting"></textarea>
<div class="wfm-wc-editor-hint" id="wfm-style-editor-negative-label">Negative prompt (optional)</div>
<textarea id="wfm-style-editor-negative" class="wfm-textarea wfm-wc-editor-ta" rows="3" placeholder="e.g. blurry, low quality"></textarea>
<div class="wfm-wc-editor-actions">
<button id="wfm-style-editor-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Save</button>
<button id="wfm-style-editor-addfile-btn" class="wfm-btn wfm-btn-sm" style="display:none;" title="Add a new style to this same file">+ Add to this file</button>
<button id="wfm-style-editor-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="display:none;">Delete</button>
<button id="wfm-style-editor-cancel-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<!-- /Style タブ -->
</div>
</div>
</section>
@@ -1036,6 +1243,7 @@
<button class="wfm-help-nav-item" data-help-page="workflow">Workflow Tab</button>
<button class="wfm-help-nav-item" data-help-page="generate">GenerateUI Tab</button>
<button class="wfm-help-nav-item" data-help-page="feeder">Feeder subtab</button>
<button class="wfm-help-nav-item" data-help-page="lab">Lab subtab</button>
<button class="wfm-help-nav-item" data-help-page="prompt">Prompt Tab</button>
<button class="wfm-help-nav-item" data-help-page="metadata">Metadata Tab</button>
<button class="wfm-help-nav-item" data-help-page="settings">Settings Tab</button>
@@ -1109,6 +1317,9 @@
<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 (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-25"><b>Create Catalog</b> button (next to the Style dropdown): generates one preview image per Style selected in the Batch tab's Style sub-tab (using the currently loaded workflow, typically T2I), then saves each result into a chosen <b>ws_style_catalog</b> subfolder with the Style's own name as the filename (existing files with the same name are overwritten); a modal lets you create a new destination folder or pick an existing one — separate folders are useful for grouping catalogs by checkpoint or by purpose; requires at least one Style checked in Batch tab → Style sub-tab first. <b>Catalog</b> button (right next to it): jumps straight to the Gallery tab's Style_Catalog sub-tab to browse the results</li>
<li id="wfm-help-gen-26">Create Catalog modal — <b>"Don't keep a copy in the Output folder"</b> checkbox: each generation still writes a normal file to ComfyUI's Output folder first (that's simply how the workflow's own Save Image node works), but once its copy has been saved into the catalog folder, checking this deletes that Output-folder original right away — useful for large catalog runs (e.g. all 200+ styles) so the Output folder isn't flooded with near-duplicate files; if a particular style's catalog copy fails to save, its Output file is left alone rather than being deleted</li>
<li id="wfm-help-gen-27">Input tab → Image sub-panel — each LoadImage card now has three buttons: <b>Apply</b> (unchanged), <b>Clear</b> (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself, since LoadImage always needs some valid filename), and <b>Placeholder</b>. A <b>Placeholder Image</b> section — collapsed by default since it's a low-frequency feature, click to expand — sits at the top of the panel with a shared default: <b>Color</b> mode (set Width/Height/Color; generates a solid-color PNG on the fly) or <b>Image</b> mode (drop one default image, reused as-is). Clicking a card's own <b>Placeholder</b> button immediately replaces just that card's image with the configured default — no separate Apply click needed, and with several LoadImage cards each can be replaced individually. The Placeholder config (mode, size, color, default image) is remembered across sessions</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1116,7 +1327,8 @@
<ul>
<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>
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
<li id="wfm-help-gen-28">Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab</li>
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; 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>
</ul>
@@ -1148,6 +1360,16 @@
<li id="wfm-help-gen-16">SPA-side wildcard expansion (A1111-style): when prompt text in non-ImpactWildcard nodes (e.g. CLIPTextEncode) contains __name__ syntax, the SPA expands it at generation time by randomly picking a line from the matching file in the WFS wildcard folder; nested wildcards are re-expanded up to 5 passes; unresolved wildcards are passed through unchanged; ImpactWildcardEncode/Processor nodes are skipped (handled server-side by Impact Pack); works in both single and batch generation</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gen-card-lora-title">Model Tab — LoRA Details</h3>
<ul>
<li id="wfm-help-gen-20">Two ways LoRAs are attached: a chain of standard <b>LoraLoader</b> nodes (one LoRA per node — add more by chaining additional nodes) or a single <b>Lora Loader (LoraManager)</b> node (from the ComfyUI-Lora-Manager custom node pack) that holds a list of LoRAs internally, each with its own active/model-strength/clip-strength; the Single/Stack tabs below adapt automatically depending on which type the target node is</li>
<li id="wfm-help-gen-21">Single tab: pick one LoRA + Str M/Str C, then Apply — for a Lora Manager node this <b>replaces its entire internal LoRA list with just this one entry</b>, discarding any others it had</li>
<li id="wfm-help-gen-22">Stack tab — Use Stack Group checkbox (shown only when a Lora Manager node exists in the workflow): <b>OFF</b> (the default whenever a workflow is loaded) shows and edits the LoRAs actually configured in the workflow's Lora Manager node itself; <b>ON</b> shows and edits the "Stack" group registered in the Models tab instead; switching either direction clears the node's LoRA list first, then loads it from the newly selected source; switching back to OFF restores exactly the LoRA list that was present when the workflow was loaded (captured as a snapshot at load time)</li>
<li id="wfm-help-gen-23">Stack tab controls: each row has an active checkbox and Str M / Str C fields; the header "Stack" checkbox toggles all rows at once; the +/- step buttons adjust the strength of all currently active rows together; a "Showing: LoRAs currently in workflow" / "Showing: registered Stack group" label indicates which source is currently displayed; Apply writes the full list back to the target node and also appends LoRA syntax + CivitAI trigger words to the Positive prompt</li>
<li id="wfm-help-gen-24">Caution for <b>CLIP Text Encode edit+</b> (CLIPTextEncodeEditPlus) nodes: when a Lora Manager node's trigger_words output feeds this node's text1 input (RAW/EDIT/front/back modes combine text1 with the editable text_edit field), trigger words are already included automatically at generation time; clicking a LoRA Apply button or turning on Use Stack Group additionally writes LoRA syntax/trigger words into text_edit, duplicating them — leave Use Stack Group off and avoid the LoRA Apply buttons if you want the workflow's original prompt and LoRA composition to stay unmodified</li>
</ul>
</div>
</div>
<div class="wfm-help-page" id="wfm-help-page-feeder">
@@ -1187,6 +1409,61 @@
</div>
</div>
<div class="wfm-help-page" id="wfm-help-page-lab">
<div class="wfm-help-article-header">
<h2 id="wfm-help-lab-title">GenerateUI Tab — Lab subtab</h2>
<button type="button" class="wfm-btn wfm-btn-sm wfm-help-jump-btn" data-jump-tab="generate">Open tab &rarr;</button>
</div>
<p id="wfm-help-lab-desc" style="margin-bottom:12px;">Experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration. Two subtabs: Setting and Results.</p>
<div class="wfm-help-card">
<h3 id="wfm-help-lab-card-columns-title">Setting subtab — Image &amp; Columns</h3>
<ul>
<li id="wfm-help-lab-1">Left pane — Image: drop or click to select an I2I source image; it is uploaded to ComfyUI's input folder and used for every iteration unless "Use generated image for next" is checked</li>
<li id="wfm-help-lab-2">Left pane — Use generated image for next: from iteration 2 onward, feeds the previous iteration's first output image back in as the I2I source instead of the original dropped image (iteration 1 always uses the original)</li>
<li id="wfm-help-lab-3">Center pane — 4 independent columns: Checkpoint, VAE, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid</li>
<li id="wfm-help-lab-4">+ / − per column: + appends a new keyframe at the next free iteration number (up to the Batch count) and opens its edit modal immediately; − removes the last keyframe (iteration #1 can never be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal via its <b>Delete</b> button, not just the last one</li>
<li id="wfm-help-lab-5">Click a cell to edit it: iteration #1 sets the baseline value (leave it blank to keep the loaded workflow's own setting); iteration #2+ also lets you choose which iteration it starts applying from, plus a "revert to #1's setting" checkbox</li>
<li id="wfm-help-lab-6">Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; "revert to #1" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one; lowering the Batch count (by hand, or by loading/applying a plan) below an existing keyframe's iteration number doesn't delete or renumber that keyframe — a warning toast appears, and the keyframe simply stays dormant (never reached by the run loop) until Batch count is raised again. This whole-row resolution applies as-is to Prompt and KSampler; the Model column instead resolves its three fields (Checkpoint/LoRA/VAE) independently — see below</li>
<li id="wfm-help-lab-7">Lab never edits the workflow shown in Input/Model/Settings — every iteration runs on a fresh clone of the loaded workflow, so keyframes here never affect the other subtabs</li>
<li id="wfm-help-lab-8">Prompt cell editor extras: <b>Get from GenerateUI</b> fills Positive/Negative from the workflow currently loaded in GenerateUI; <b>Get from Image</b> extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); <b>Style</b> row — the <b>Apply</b> button appends whichever style is currently selected in the Style dropdown at the top of the GenerateUI toolbar to the Positive/Negative text below, comma-separated after any existing text (never clears it), then checks the Style checkbox and shows the applied style's name next to it (not on the button) — the style NAME always comes from that top dropdown (Lab has no picker of its own), and clicking Apply works regardless of the top toolbar's own Style enable checkbox; unchecking the Style checkbox only clears that name label — it does not remove the already-appended text from Positive/Negative, which stays there for you to edit or delete by hand</li>
<li id="wfm-help-lab-9">Left pane — <b>T2I workflow (no source image)</b>: check this when the loaded workflow is text-to-image (no LoadImage node). It skips the "drop a source image first" requirement, ignores any dropped image and the Model/Prompt/KSampler keyframes still apply normally; the Image drop zone and "Use generated image for next" are greyed out while it's on (any previously dropped image is kept, not deleted, in case you switch back to I2I); the setting is saved with the plan as <code>t2i_mode</code></li>
<li id="wfm-help-lab-10">Wildcards: Positive/Negative prompt text in Prompt column keyframes supports the same A1111-style <code>__name__</code> syntax as the main GenerateUI (see Prompt tab's Wildcards help above) — each keyframe's text is expanded fresh right before that iteration runs, so every iteration can draw a different random line even when reusing the same keyframe</li>
<li id="wfm-help-lab-11">Prompt cell editor also has <b>Clear</b> (empties both Positive and Negative) and <b>Get from Previous</b> (copies both Positive and Negative from the keyframe immediately before this one in the same column — e.g. editing #3 copies #2's prompt wholesale; not shown on keyframe #1, which has no earlier row to copy from)</li>
<li id="wfm-help-lab-12">Keyframe #1 in every column shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only (LoRA is never live-reflected, since there's no reliable way to infer "the" active LoRA back out of a workflow node) — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI</li>
<li id="wfm-help-lab-13"><b>Bypass</b> checkbox (every keyframe, including #1): skips that row entirely when picking which setting applies at a given iteration — as if the row didn't exist — so whatever was active before it (an earlier keyframe, or ultimately nothing) just keeps running; Lab-internal only, it never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (the two are mutually exclusive); the cell shows an amber "⏭ Bypassed" label, which takes priority over both the Revert and live-value display</li>
<li id="wfm-help-lab-14"><b>Model</b> column header's <b>C / L / V</b> buttons pick which single field (Checkpoint / LoRA / VAE — LoRA is Single-only, no Stack support here) its cells summarize and its edit modal shows; the two you're not currently viewing are left exactly as already saved when you Save. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it</li>
<li id="wfm-help-lab-15">Model column fields carry forward independently: a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE to the workflow default — each of the three keeps whatever an earlier keyframe last set until a later keyframe explicitly changes it. A field left blank in the edit modal shows "(inherits: ...)" as its placeholder instead of "(workflow default)" when there's something to inherit, so it's clear what actually applies. "Revert to #1's setting" resets all three fields at once back to keyframe #1's row, not just the one you're currently viewing</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-lab-card-plan-title">Setting subtab — Run &amp; Plan files</h3>
<ul>
<li id="wfm-help-lab-plan-1">Right pane — Batch: number of iterations to run; Note: free-text memo saved together with the plan</li>
<li id="wfm-help-lab-plan-2">Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop</li>
<li id="wfm-help-lab-plan-3">Plan Save vs Save As: Plan Save silently overwrites the currently loaded plan (or asks for a name the first time); Save As always asks for a new name, even while a plan is already loaded</li>
<li id="wfm-help-lab-plan-4">Plan Load: drop a plan's .json file, or any of its index-image PNGs, onto the PLAN box (or click it to browse) — a dropped .json is read directly; a dropped .png is read directly too if it carries embedded plan data (every index image since v0.3.90 does, in a <code>wfm_lab_plan</code> chunk — see below), falling back to fetching a same-named .json from the server for older index images that predate this</li>
<li id="wfm-help-lab-plan-5">Plan Clear resets the Setting tab to an empty plan (asks for confirmation first)</li>
<li id="wfm-help-lab-plan-6">Plan files are stored in <code>user/default/Workflow-Studio/lab_plan/</code> as <code>ws_labplan_&lt;name&gt;.json</code> plus a <code>ws_labplan_&lt;name&gt;.png</code> index image — a contact sheet of up to 9 result thumbnails (3 per row) generated automatically whenever a plan with results is saved. The <code>ws_labplan_</code> prefix is added automatically by Plan Save/Save As (type just the plain name when prompted) so plan files are easy to pick out among other files in that folder</li>
<li id="wfm-help-lab-plan-7">Eagle auto-save (configured in the Settings tab) applies to every image Lab generates, the same as GenerateUI's own Generate button</li>
<li id="wfm-help-lab-plan-8">Plan JSON subtab: shows the current Setting tab's plan as raw JSON; <b>Refresh</b> re-reads it from the Setting tab (discarding any unapplied text edits), <b>Apply to Setting</b> parses your edits and writes them back into the Setting tab (columns, note, batch count, chain-image, source image, results) — the same effect as loading that JSON as a plan file, without needing to save it first</li>
<li id="wfm-help-lab-plan-9">Each saved plan records the filename of the workflow that was loaded in GenerateUI at save time (<code>workflow_filename</code> in the Plan JSON). After loading a plan, an <b>Open Workflow</b> button appears in the PLAN box — click it to load that exact workflow file back into GenerateUI (asks for confirmation, since it replaces whatever is currently loaded)</li>
<li id="wfm-help-lab-plan-10">Right pane — <b>Save index image to Output on Run</b>: when checked, every time Run finishes it builds the same contact-sheet index image used for Plan files and saves it directly into ComfyUI's own Output folder as <code>Lab_index_&lt;counter&gt;_.png</code> (auto-numbered like a normal generated image, never overwriting a previous run's). Once saved, it also goes through Eagle auto-save (if enabled in the Settings tab) just like every other Lab-generated image — no separate checkbox for that, it follows the same global toggle. This is independent of Plan Save, which still always writes its own index image next to the plan file in <code>lab_plan/</code> regardless of this checkbox; this setting is saved with the plan as <code>save_index_on_run</code></li>
<li id="wfm-help-lab-plan-11">Every index image PNG — whether from Plan Save/Save As or from <b>Save index image to Output on Run</b> — carries the entire plan (columns, note, batch count, chain-image/T2I settings, workflow filename, results) embedded in it directly as a PNG <code>wfm_lab_plan</code> chunk, so the Output-folder copy is self-contained and works as a Plan Load source too even though it has no matching .json next to it anywhere</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-lab-card-results-title">Results subtab</h3>
<ul>
<li id="wfm-help-lab-results-1">Left pane shows the source image used for the run</li>
<li id="wfm-help-lab-results-2">Center pane shows up to 9 generated images in generation order; click a thumbnail to view it enlarged</li>
<li id="wfm-help-lab-results-3">Loading a saved plan restores its Results grid from the image list recorded in the plan file</li>
</ul>
</div>
</div>
<div class="wfm-help-page" id="wfm-help-page-prompt">
<div class="wfm-help-article-header">
<h2 id="wfm-help-prompt-title">Prompt Tab</h2>
@@ -1216,6 +1493,16 @@
<li id="wfm-help-prompt-9">Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-prompt-card-style-title">Style</h3>
<ul>
<li id="wfm-help-prompt-10">Right pane's Wildcard/Style tab bar switches the whole panel between the wildcard tools above and this Style manager — same right pane, two modes</li>
<li id="wfm-help-prompt-11">Create/edit/delete registered Styles (the same ones used by GenerateUI's Style dropdown, Batch tab, and the Style Catalog): click a name in the list to edit it, or <b>+ New</b> for a blank entry; <b>Name</b>, <b>Positive prompt</b> (supports the <code>{prompt}</code> placeholder — replaced with the user's own prompt, or appended after it if omitted) and <b>Negative prompt</b> (optional)</li>
<li id="wfm-help-prompt-12">Renaming a style keeps its position in whichever file it was originally defined in; new styles are saved into a dedicated <code>custom.json</code> inside the style folder so the bundled style packs are never modified directly; a name must be unique across all styles</li>
<li id="wfm-help-prompt-13">Saving, renaming, or deleting a style immediately refreshes GenerateUI's Style dropdown, the Batch tab's style checklist, and the Style Catalog's name list — no reload needed</li>
<li id="wfm-help-prompt-14">Each entry in the list shows its source file as a small badge next to the name (e.g. <code>sdxl_styles_diva.json</code>); while editing an existing style, that filename also appears above the Positive prompt field, and a <b>+ Add to this file</b> button appears next to Save — click it to clear the form for a brand-new style that gets appended to that same file instead of the default <code>custom.json</code></li>
</ul>
</div>
</div>
<div class="wfm-help-page" id="wfm-help-page-metadata">
@@ -1280,6 +1567,7 @@
<ul>
<li id="wfm-help-settings-11">Text Size: one slider (10–28 px) adjusts font size for all prompt and chat textareas at once — Generate UI positive/negative prompts, AI Assistant chat input, Preset positive/negative, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings</li>
<li id="wfm-help-settings-12">RAW JSON Colors: customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI — Default Text, Name/Scheduler, Title, Width/Height, Prompt/Text, Image/File; changes apply immediately on color pick; Reset Defaults restores the original color scheme</li>
<li id="wfm-help-settings-14">GenerateUI Model Tab Highlight: pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1334,6 +1622,24 @@
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search (filename / tag / prompt), tag filter, group filter, sort by date/name</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-card-styleprompt-title">ImagePrompt Gallery</h3>
<ul>
<li id="wfm-help-gallery-18">Second sub-tab next to Output (top of the Gallery tab) for browsing a visual prompt library — manages the <b>ws_image_prompt</b> folder inside the ComfyUI output directory (created automatically)</li>
<li id="wfm-help-gallery-19">Same 3-column layout: folder tree (left) | thumbnails (center) | prompt builder (right). Click a thumbnail to preview it and its prompt text — this does not add it to the prompt yet</li>
<li id="wfm-help-gallery-20">"+ Add" appends the previewed image's prompt as a chip under "Selected Prompts"; chips can be removed individually (×) or all at once ("Clear All"); "Save" edits and persists the prompt text for that image</li>
<li id="wfm-help-gallery-21">"Final Prompt" combines all chips into one comma-separated text (also freely editable); "Copy" copies it to the clipboard. Prompt text is stored as a plain <b>.txt</b> file next to each image, so anyone can add their own entries by dropping an image + matching .txt file into a subfolder</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-card-stylecatalog-title">Style_Catalog Gallery</h3>
<ul>
<li id="wfm-help-gallery-22">Third sub-tab (top of the Gallery tab) for visually picking a registered Style by its preview image — manages the <b>ws_style_catalog</b> folder inside the ComfyUI output directory (created automatically); populated by the GenerateUI tab's <b>Create Catalog</b> button (see GenerateUI Tab help)</li>
<li id="wfm-help-gallery-23">Same 3-column layout: folder tree (left) | thumbnails (center) | Positive/Negative prompt panel (right). Click a thumbnail to read the Positive/Negative prompt actually embedded in that generated image, each with its own <b>Copy</b> button</li>
<li id="wfm-help-gallery-24"><b>Select as Style</b> button: matches the selected image's filename (without extension) against a registered Style name and switches the GenerateUI tab's Style dropdown to it — a visual shortcut into the existing named-Style system, not a one-off apply of the embedded prompt (if the Style was renamed or deleted since the catalog image was created, a "no matching style" toast is shown instead)</li>
<li id="wfm-help-gallery-25"><b>Load in GenerateUI</b> / <b>Open in Metadata Tab</b> buttons: read the workflow embedded in the catalog image itself (the catalog copy carries the same PNG metadata as the original output image) and either load it straight into the GenerateUI editor or open the image in the Metadata tab for inspection — no need to hunt down the original file in the Output folder</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-card-folder-title">Folder &amp; Selection</h3>
<ul>
@@ -1449,7 +1755,7 @@
<div class="wfm-help-card">
<h3 id="wfm-help-sidepanel-card-ai-title">AI TOOL tab</h3>
<ul>
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1472,7 +1778,7 @@
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-ai-card-translate-title">Translation pane</h3>
<p id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</p>
<p id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</p>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-ai-card-chat-title">Chat pane</h3>
@@ -1496,7 +1802,7 @@
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-ai-card-settings-title">Settings pane</h3>
<p 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</p>
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline &lt;think&gt; tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a `think` flag, and regardless of backend any &lt;think&gt;...&lt;/think&gt; block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); 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</p>
</div>
</div>
@@ -1611,6 +1917,7 @@
<li id="wfm-help-imageedit-mask-11">Text sub-tool: click on the canvas to open a text stamp overlay; enter text, configure Font / Size / Bold / Italic / Align, choose Mode (Add = white stamp / Erase = cut mask), then click Stamp or press Ctrl+Enter; multi-line text supported (newline in textarea)</li>
<li id="wfm-help-imageedit-mask-12">Vector sub-tool: click to place control points connected by a Catmull-Rom spline; click the first point again (or press Enter) to close and fill the polygon; Backspace removes the last point; Esc resets all points; Properties pane shows current point count and Mode (Add / Erase)</li>
<li id="wfm-help-imageedit-mask-13">Shape sub-tool: drag to draw a rectangle or ellipse mask; hold Shift for a perfect square or circle; a blue dashed outline previews the shape while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)</li>
<li id="wfm-help-imageedit-mask-14">Edit in Mask Editor One (Paint mode only): click "Edit in Mask Editor One →" in the MASK EDITOR ONE section to send the current canvas composite — plus the active mask layer's content, if one is selected — to a MaskEditorOne node on the ComfyUI canvas (the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph) and open its full editor (SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; after clicking Apply there, the result is imported back here as a new mask layer; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable, and at least one MaskEditorOne node to exist in the ComfyUI graph</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1643,7 +1950,8 @@
<ul>
<li id="wfm-help-imageedit-17">Save PNG: composites all visible layers and downloads the result as a PNG file</li>
<li id="wfm-help-imageedit-18">Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings &gt; Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root</li>
<li id="wfm-help-imageedit-19">Send to ComfyUI: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes</li>
<li id="wfm-help-imageedit-19">Save to Input: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes</li>
<li id="wfm-help-imageedit-19b">Send to Workflow: uploads the composite PNG (same as Save to Input) and additionally writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable</li>
<li id="wfm-help-imageedit-20">Canvas navigation: scroll wheel to zoom, Space + drag to pan</li>
<li id="wfm-help-imageedit-21">Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)</li>
</ul>
@@ -1710,6 +2018,7 @@
<li id="wfm-help-trouble-4">Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting</li>
<li id="wfm-help-trouble-5">LoRA Stack fails with "No such file or directory" error: The LoRA may not be recognized by comfyui-lora-manager (known issue). Try a different LoRA or check if the file appears in Lora Manager's own UI</li>
<li id="wfm-help-trouble-6">Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.</li>
<li id="wfm-help-trouble-9">Generate runs a different workflow than the one just loaded: a Batch type checkbox (Checkpoint/LoRA/Prompt/Workflow/Sampler/Scheduler) is left ON in the Batch subtab. While any Batch type is active, the Generate button runs the batch selection instead of the currently loaded workflow. Open the Batch subtab and turn off the active batch type checkbox.</li>
</ul>
</div>
<div class="wfm-help-card">
@@ -1765,6 +2074,14 @@
<!-- Gallery Tab -->
<section id="wfm-tab-gallery" class="wfm-tab-content">
<div class="wfm-gallery-subtab-nav">
<button id="wfm-gallery-subtab-output" class="wfm-gallery-subtab-btn active" data-gallery-subtab="output">Output</button>
<button id="wfm-gallery-subtab-imageprompt" class="wfm-gallery-subtab-btn" data-gallery-subtab="imageprompt">ImagePrompt</button>
<button id="wfm-gallery-subtab-styleCatalog" class="wfm-gallery-subtab-btn" data-gallery-subtab="styleCatalog">Style_Catalog</button>
</div>
<!-- Output ギャラリーパネル -->
<div id="wfm-gallery-panel-output" class="wfm-gallery-panel active">
<!-- Toolbar -->
<div class="wfm-toolbar">
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
@@ -1866,6 +2183,12 @@
<div class="wfm-gallery-info-row" id="wfm-gallery-info-size"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-date"></div>
</div>
<!-- ImagePromptサイドカー ( .txt / ponyxlWildcardsVault 由来のプロンプトがある画像のみ表示) -->
<div class="wfm-gallery-info-section" id="wfm-gallery-image-prompt-section" style="display:none;">
<hr class="wfm-group-divider">
<div class="wfm-gallery-section-title">Prompt</div>
<div id="wfm-gallery-image-prompt-text" class="wfm-gallery-image-prompt-text"></div>
</div>
<hr class="wfm-group-divider">
<!-- タグ -->
<div class="wfm-gallery-info-section">
@@ -1905,6 +2228,149 @@
</div>
</div>
</div>
</div>
<!-- /Output ギャラリーパネル -->
<!-- ImagePrompt ギャラリーパネル -->
<div id="wfm-gallery-panel-imageprompt" class="wfm-gallery-panel">
<div class="wfm-toolbar">
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
<div class="wfm-search-wrap">
<input type="text" id="wfm-imageprompt-search" class="wfm-search-input" placeholder="Search prompts...">
<button class="wfm-search-clear-btn" id="wfm-imageprompt-search-clear-btn" title="Clear search">✕</button>
</div>
<span id="wfm-imageprompt-count" class="wfm-nodes-count"></span>
<button id="wfm-imageprompt-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
</div>
</div>
<div class="wfm-gallery-layout">
<!-- 左: フォルダツリー (ws_image_prompt ルート) -->
<div id="wfm-imageprompt-tree-panel" class="wfm-gallery-tree-panel">
<div class="wfm-gallery-tree-header">
<span id="wfm-imageprompt-tree-title" class="wfm-gallery-tree-title">Folders</span>
</div>
<div id="wfm-imageprompt-tree" class="wfm-gallery-tree">
<p class="wfm-placeholder" id="wfm-imageprompt-tree-placeholder">Loading...</p>
</div>
</div>
<!-- 中: サムネイル一覧 -->
<div id="wfm-imageprompt-list-panel" class="wfm-gallery-list-panel">
<div id="wfm-imageprompt-grid" class="wfm-gallery-grid wfm-gallery-view-thumb">
<p class="wfm-placeholder" id="wfm-imageprompt-list-placeholder">Select a folder to browse images.</p>
</div>
</div>
<!-- 右: プロンプトビルダーパネル (2カラム: 選択/チップ | 最終プロンプト) -->
<div id="wfm-imageprompt-detail-panel" class="wfm-gallery-detail-panel wfm-imageprompt-detail-panel">
<div class="wfm-imageprompt-detail-col wfm-imageprompt-detail-col-left">
<div class="wfm-gallery-detail-header">
<span id="wfm-imageprompt-selected-name" class="wfm-gallery-detail-filename"></span>
</div>
<div id="wfm-imageprompt-selected-preview" class="wfm-gallery-detail-preview wfm-gallery-preview-container">
<span id="wfm-imageprompt-no-selection" class="wfm-placeholder">No selection</span>
</div>
<div class="wfm-imageprompt-section">
<div id="wfm-imageprompt-selected-prompt-title" class="wfm-gallery-section-title">Prompt</div>
<textarea id="wfm-imageprompt-selected-prompt" class="wfm-imageprompt-prompt-box" rows="4" placeholder="No prompt data for this image."></textarea>
<div class="wfm-imageprompt-btn-row">
<button id="wfm-imageprompt-save-prompt-btn" class="wfm-btn wfm-btn-sm" disabled>Save</button>
<button id="wfm-imageprompt-add-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled>+ Add</button>
</div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-imageprompt-section">
<div class="wfm-gallery-detail-header" style="padding:0;background:none;border:none;display:flex;justify-content:space-between;align-items:center;">
<span id="wfm-imageprompt-chips-title" class="wfm-gallery-section-title">Selected Prompts</span>
<button id="wfm-imageprompt-clear-btn" class="wfm-btn wfm-btn-sm">Clear All</button>
</div>
<div id="wfm-imageprompt-chips" class="wfm-imageprompt-chips"></div>
</div>
</div>
<div class="wfm-imageprompt-detail-col wfm-imageprompt-detail-col-right wfm-imageprompt-final-section">
<div id="wfm-imageprompt-final-prompt-title" class="wfm-gallery-section-title">Final Prompt</div>
<textarea id="wfm-imageprompt-final-prompt" class="wfm-imageprompt-prompt-box wfm-imageprompt-final-textarea"></textarea>
<button id="wfm-imageprompt-copy-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:6px;">Copy</button>
</div>
</div>
</div>
</div>
<!-- /ImagePrompt ギャラリーパネル -->
<!-- Style_Catalog ギャラリーパネル -->
<div id="wfm-gallery-panel-styleCatalog" class="wfm-gallery-panel">
<div class="wfm-toolbar">
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
<div class="wfm-search-wrap">
<input type="text" id="wfm-stylecatalog-search" class="wfm-search-input" placeholder="Search...">
<button class="wfm-search-clear-btn" id="wfm-stylecatalog-search-clear-btn" title="Clear search">✕</button>
</div>
<span id="wfm-stylecatalog-count" class="wfm-nodes-count"></span>
<button id="wfm-stylecatalog-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
</div>
</div>
<div class="wfm-gallery-layout">
<!-- 左: フォルダツリー (ws_style_catalog ルート) -->
<div id="wfm-stylecatalog-tree-panel" class="wfm-gallery-tree-panel">
<div class="wfm-gallery-tree-header">
<span id="wfm-stylecatalog-tree-title" class="wfm-gallery-tree-title">Folders</span>
</div>
<div id="wfm-stylecatalog-tree" class="wfm-gallery-tree">
<p class="wfm-placeholder" id="wfm-stylecatalog-tree-placeholder">Loading...</p>
</div>
</div>
<!-- 中: サムネイル一覧 -->
<div id="wfm-stylecatalog-list-panel" class="wfm-gallery-list-panel">
<div id="wfm-stylecatalog-grid" class="wfm-gallery-grid wfm-gallery-view-thumb">
<p class="wfm-placeholder" id="wfm-stylecatalog-list-placeholder">Select a folder to browse images.</p>
</div>
</div>
<!-- 右: Positive/Negativeプロンプト表示パネル -->
<div id="wfm-stylecatalog-detail-panel" class="wfm-gallery-detail-panel">
<div class="wfm-gallery-detail-header">
<span id="wfm-stylecatalog-selected-name" class="wfm-gallery-detail-filename"></span>
</div>
<div id="wfm-stylecatalog-selected-preview" class="wfm-gallery-detail-preview wfm-gallery-preview-container">
<span id="wfm-stylecatalog-no-selection" class="wfm-placeholder">No selection</span>
</div>
<div class="wfm-imageprompt-section">
<div class="wfm-gallery-detail-header" style="padding:0;background:none;border:none;display:flex;justify-content:space-between;align-items:center;">
<span id="wfm-stylecatalog-positive-title" class="wfm-gallery-section-title">Positive</span>
<button id="wfm-stylecatalog-copy-positive-btn" class="wfm-btn wfm-btn-sm" disabled>Copy</button>
</div>
<div id="wfm-stylecatalog-positive-text" class="wfm-gallery-image-prompt-text"></div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-imageprompt-section">
<div class="wfm-gallery-detail-header" style="padding:0;background:none;border:none;display:flex;justify-content:space-between;align-items:center;">
<span id="wfm-stylecatalog-negative-title" class="wfm-gallery-section-title">Negative</span>
<button id="wfm-stylecatalog-copy-negative-btn" class="wfm-btn wfm-btn-sm" disabled>Copy</button>
</div>
<div id="wfm-stylecatalog-negative-text" class="wfm-gallery-image-prompt-text"></div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-imageprompt-section">
<button id="wfm-stylecatalog-select-style-btn" class="wfm-btn wfm-btn-primary" style="width:100%;" disabled>Select as Style</button>
<button id="wfm-stylecatalog-load-genui-btn" class="wfm-btn" style="width:100%;margin-top:6px;" disabled>Load in GenerateUI</button>
<button id="wfm-stylecatalog-open-metadata-btn" class="wfm-btn" style="width:100%;margin-top:6px;" disabled>Open in Metadata Tab</button>
</div>
</div>
</div>
</div>
<!-- /Style_Catalog ギャラリーパネル -->
</section>
<!-- Image Edit Tab -->
@@ -1937,7 +2403,8 @@
<button class="wfm-btn wfm-btn-sm" id="ie-redo-btn" title="Redo (Ctrl+Y)">↪</button>
<button class="wfm-btn wfm-btn-sm" id="ie-save-btn">Save PNG</button>
<button class="wfm-btn wfm-btn-sm" id="ie-save-gallery-btn" title="Save to Gallery root folder">Save to Gallery</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-upload-comfy-btn">Send to ComfyUI</button>
<button class="wfm-btn wfm-btn-sm" id="ie-upload-comfy-btn">Save to Input</button>
<button class="wfm-btn wfm-btn-sm" id="ie-send-workflow-btn" title="Upload and write this image's filename into the selected node's image widget (e.g. LoadImage)">Send to Workflow</button>
</div>
<!-- Tool options -->
@@ -2346,6 +2813,9 @@
<label class="wfm-ai-radio-label">
<input type="radio" name="wfm-ai-backend" value="lemonade"> Lemonade
</label>
<label class="wfm-ai-radio-label">
<input type="radio" name="wfm-ai-backend" value="unsloth"> Unsloth
</label>
</div>
</div>
<div class="wfm-ai-settings-section">
@@ -2368,6 +2838,19 @@
<button id="wfm-ai-model-refresh-btn" class="wfm-btn">Refresh</button>
</div>
</div>
<div class="wfm-ai-settings-section">
<div class="wfm-ai-settings-title" id="wfm-ai-settings-generation-title">Generation</div>
<div class="wfm-ai-settings-row">
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="wfm-ai-thinking-mode-checkbox">
<span id="wfm-ai-thinking-mode-label">Thinking mode</span>
</label>
</div>
<div class="wfm-ai-settings-row">
<label class="wfm-ai-label" id="wfm-ai-max-tokens-label">Max tokens</label>
<input type="number" id="wfm-ai-max-tokens" class="wfm-input" min="0" step="1" placeholder="0 = unlimited">
</div>
</div>
<div class="wfm-ai-settings-section">
<div class="wfm-ai-settings-title" id="wfm-ai-settings-freelang-title">Free language</div>
<div class="wfm-ai-settings-row">
+128
View File
@@ -0,0 +1,128 @@
"""
Style/Prompt ギャラリー用シードデータ投入スクリプト。
comfyui_prompt_gallery / prompt_builder_proto 形式の wildcard データ
(pack: set: category: leaf: [tag_string] という4階層YAML + thumbnails/ 配下の対応画像)
から、画像とタグ文字列の組が揃っているエントリだけを ws_style_prompt 用フォルダへコピーする。
- テキストのみのワイルドカード(画像が無いもの)は対象外
- サムネイル画像はあってもYAML側にタグが無いもの(プレースホルダー画像等)も対象外
- コピー先には画像と同名の .txt サイドカー(タグ文字列そのまま)を書き込む
Usage:
python tools/import_style_prompt_seed.py --source <wildcard_dataディレクトリ> --dest <ws_style_promptディレクトリ>
"""
import argparse
import re
import shutil
from pathlib import Path
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
def clean_tags(text: str) -> str:
text = text.strip()
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
text = re.sub(r"\s*,\s*", ", ", text)
return text
def parse_yaml_leaves(yaml_path: Path) -> dict[str, str]:
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
戻り値: {leaf_name(小文字): タグ文字列}
"""
leaves: dict[str, str] = {}
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
for i, line in enumerate(lines):
trimmed = line.strip()
if not trimmed or trimmed.startswith("#"):
continue
if not trimmed.endswith(":"):
continue
if i + 1 >= len(lines):
continue
next_trimmed = lines[i + 1].strip()
if not next_trimmed.startswith("-"):
continue
key = trimmed[:-1].strip()
tags = next_trimmed[1:].strip()
if not tags or key.lower() == "skip":
continue
leaves[key.lower()] = clean_tags(tags)
return leaves
def import_category(cat_dir: Path, dest_root: Path) -> tuple[int, int, int]:
"""1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)"""
category = cat_dir.name
yaml_files = sorted(cat_dir.glob("*.yaml")) + sorted(cat_dir.glob("*.yml"))
leaves: dict[str, str] = {}
for yf in yaml_files:
leaves.update(parse_yaml_leaves(yf))
copied = 0
images_without_prompt = 0
# "thumbnails" と、preview違いの "thumbnails_option2"(例: alchemist.preview3.jpeg)の両方を対象にする
for thumb_dir_name in ("thumbnails", "thumbnails_option2"):
thumbnails_dir = cat_dir / thumb_dir_name
if not thumbnails_dir.is_dir():
continue
for img_path in sorted(thumbnails_dir.rglob("*")):
if not img_path.is_file() or img_path.suffix.lower() not in IMAGE_EXTS:
continue
# "alchemist.preview3.jpeg" のような二重拡張子にも対応するため、最初の "." より前をリーフ名候補にする
leaf_candidate = img_path.name.split(".")[0].lower()
tags = leaves.get(leaf_candidate)
if tags is None:
images_without_prompt += 1
continue
rel_parts = img_path.relative_to(thumbnails_dir).parts
# 先頭1階層(パック名。例: "ponyxl")は全カテゴリ共通で冗長なため省く
dest_rel = Path(*rel_parts[1:]) if len(rel_parts) > 1 else Path(rel_parts[0])
dest_path = dest_root / category / dest_rel
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(img_path, dest_path)
dest_path.with_suffix(".txt").write_text(tags, encoding="utf-8")
copied += 1
return copied, images_without_prompt, len(leaves)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, help="wildcard_data ディレクトリ")
parser.add_argument("--dest", required=True, help="投入先 ws_style_prompt ディレクトリ")
args = parser.parse_args()
source = Path(args.source)
dest = Path(args.dest)
if not source.is_dir():
raise SystemExit(f"source not found: {source}")
dest.mkdir(parents=True, exist_ok=True)
categories = [
d for d in sorted(source.iterdir())
if d.is_dir() and (d / "thumbnails").is_dir()
]
if not categories:
raise SystemExit(f"No category with a thumbnails/ subfolder found under {source}")
total_copied = 0
total_skipped = 0
for cat_dir in categories:
copied, skipped, leaf_total = import_category(cat_dir, dest)
print(f"[{cat_dir.name}] copied={copied} images_without_prompt={skipped} yaml_leaves={leaf_total}")
total_copied += copied
total_skipped += skipped
print(f"\nDone. {len(categories)} categories, {total_copied} images copied, {total_skipped} skipped (no matching prompt).")
print(f"Destination: {dest}")
if __name__ == "__main__":
main()
+436 -50
View File
@@ -583,6 +583,136 @@ window.wfmReceiveWorkflow = async (data) => {
await loadDataOnCanvas(data);
};
// ============================================
// Cross-window bridge for SPA Mask Editor One integration
// ============================================
// WFS Image Edit タブから呼ばれる。「Send to Workflow」の image ウィジェット探索
// (_wfmFindImageWidget)と同じ「選択中ノード優先・フォールバック」方式で
// MaskEditorOne ノードをComfyUIキャンバス上から探す。
function _wfmFindMaskEditorOneNode() {
const graph = app.graph;
if (!graph) return null;
let selected = [];
try {
const sel = app.canvas?.selected_nodes;
if (sel) selected = Object.values(sel);
} catch { /* ignore */ }
const pools = [selected, graph._nodes || []];
for (const pool of pools) {
for (const node of pool) {
if (node?.type === "MaskEditorOne") return node;
}
}
return null;
}
// 見つけた MaskEditorOne ノードへ画像を送り込み、そのノード自身の「Edit Mask」ウィジェット
// (node._editMaskWidget.callback)を疑似クリックしてモーダルを開く。モーダルの overlay DOM
// (.me-overlay) が非表示に戻ったタイミングで Apply 後のマスク(node._maskDataUrl、グレースケール
// PNG data URL)を呼び出し元へ返す。Cancel/×で閉じた場合はマスクが更新されていないため null を返す。
window.wfmOpenMaskEditorForNode = async (imageDataUrl, maskDataUrl) => {
const node = _wfmFindMaskEditorOneNode();
if (!node) {
throw new Error("No Mask Editor One node found (select one on the canvas, or add one to the workflow)");
}
if (typeof node._editMaskWidget?.callback !== "function") {
throw new Error("MaskEditorOne node is missing its Edit Mask control (unsupported version?)");
}
const nodeId = node.id;
const resp = await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(nodeId), bg_image_b64: imageDataUrl }),
});
if (!resp.ok) throw new Error("Failed to send image to Mask Editor One");
// WFS側で既にマスクレイヤーを描いていた場合、それを初期マスクとしてそのまま引き継ぐ
// (applyImageAndMaskToMaskEditorOneNode の layer_data 形式と同じスキーマ)
if (maskDataUrl) {
const layerWidget = node.widgets?.find((w) => w.name === "layer_data");
if (layerWidget) {
layerWidget.value = JSON.stringify({
layers: [{ visible: true, opacity: 1, operation: "add", imageData: maskDataUrl }],
});
}
}
// ノード側のプレビュー/モーダル背景を、送信した画像に同期させる
node._bgDataUrl = imageDataUrl;
node._bgImg = await new Promise((r) => {
const img = new Image();
img.onload = () => r(img);
img.onerror = () => r(null);
img.src = imageDataUrl;
});
node._previewMode = "image";
node.setDirtyCanvas?.(true, true);
const prevMaskUrl = node._maskDataUrl;
node._editMaskWidget.callback();
// openMaskEditor() は同期的に overlay DOM を構築・表示するため、callback() 直後に取得できる
const overlays = [...document.querySelectorAll(".me-overlay")];
const overlay = overlays.find((o) => o.style.display !== "none") || overlays[overlays.length - 1];
if (!overlay) throw new Error("Mask Editor One modal did not open");
window.focus();
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (overlay.style.display === "none") {
observer.disconnect();
resolve(node._maskDataUrl && node._maskDataUrl !== prevMaskUrl ? node._maskDataUrl : null);
}
});
observer.observe(overlay, { attributes: true, attributeFilter: ["style"] });
});
};
// ============================================
// Cross-window bridge for SPA "Send to Workflow"
// ============================================
// WFS Image Edit タブから呼ばれる。アップロード済みの画像ファイル名を、現在ComfyUIキャンバス上で
// 選択中のノードの "image" ウィジェット(LoadImage / LoadImage 互換ノード)へ書き込む。
// 選択ノードに該当ウィジェットが無ければグラフ内の最初の該当ノードにフォールバックする。
// chat_TE カスタムノードの "Send to workflow" ボタンと同じロジック。
function _wfmFindImageWidget() {
const graph = app.graph;
if (!graph) return null;
let selected = [];
try {
const sel = app.canvas?.selected_nodes;
if (sel) selected = Object.values(sel);
} catch { /* ignore */ }
const pools = [selected, graph._nodes || []];
for (const pool of pools) {
for (const node of pool) {
const widget = node?.widgets?.find((w) => w.name === "image");
if (widget) return widget;
}
}
return null;
}
window.wfmSendImageToSelectedNode = (filename) => {
const widget = _wfmFindImageWidget();
if (!widget) {
throw new Error("No image widget found (select a LoadImage-type node)");
}
if (widget.options?.values && !widget.options.values.includes(filename)) {
widget.options.values.unshift(filename);
}
widget.value = filename;
widget.callback?.(widget.value);
app.graph.setDirtyCanvas(true, true);
return true;
};
const loadWorkflowOnCanvas = async (filename) => {
const displayName = filename.replace(/\.json$/i, "");
showToast(`Loading "${displayName}"...`, "info");
@@ -2939,6 +3069,58 @@ function _isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes(
function _isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
function _isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
function _resolveEditPlusText(mode, textEdit, text1, text2) {
const t1 = typeof text1 === "string" ? text1 : "";
const edit = typeof textEdit === "string" ? textEdit : "";
const insert = typeof text2 === "string" && text2 !== "" ? text2 : edit;
switch (mode) {
case "RAW": return t1;
case "EDIT": return edit;
case "front": return t1 ? `${insert}, ${t1}` : insert;
case "back": return t1 ? `${t1}, ${insert}` : insert;
default: return t1 || edit;
}
}
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
function _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
if (depth > 6) return null;
const srcNode = nodeMap.get(srcId);
if (!srcNode) return null;
const srcType = srcNode.type ?? "";
if (_isPromptStylerNode(srcType)) {
const v = srcNode.widgets_values?.[slot];
return (v && typeof v === "string") ? v : null;
}
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
for (const name of ["on_false", "on_true"]) {
const inp = srcNode.inputs.find(i => i.name === name);
if (inp?.link == null) continue;
const originId = linkOrigin.get(inp.link);
const originSlot = linkSlot.get(inp.link) ?? 0;
if (originId == null) continue;
const text = _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
if (text) return text;
}
return null;
}
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
if (v && typeof v === "string") return v;
// "source" = PreviewAny(値タップ中継), "string_a" = StringConcatenate(LoRAトリガーワード
// 連結等)— Krea-2のプロンプト強化配線で使われる中継ノード
if (Array.isArray(srcNode.inputs)) {
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string", "source", "string_a"].includes(i.name));
if (nextInput?.link != null) {
const originId = linkOrigin.get(nextInput.link);
const originSlot = linkSlot.get(nextInput.link) ?? 0;
if (originId != null) return _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
}
}
return null;
}
function _extractPromptsFromNodeSet(nodes, links) {
const nodeMap = new Map();
for (const n of nodes) nodeMap.set(n.id, n);
@@ -2951,7 +3133,22 @@ function _extractPromptsFromNodeSet(nodes, links) {
}
const textMap = new Map();
for (const n of nodes) {
if (!_isTextEncoderNode(n.type ?? "")) continue;
const type = n.type ?? "";
if (type === "CLIPTextEncodeEditPlus") {
const resolveNamedInput = (name) => {
if (!Array.isArray(n.inputs)) return null;
const inp = n.inputs.find(i => i.name === name);
if (inp?.link == null) return null;
const originId = linkOrigin.get(inp.link);
const originSlot = linkSlot.get(inp.link) ?? 0;
if (originId == null) return null;
return _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
};
const combined = _resolveEditPlusText(n.widgets_values?.[1], n.widgets_values?.[0], resolveNamedInput("text1"), resolveNamedInput("text2"));
if (combined) textMap.set(n.id, combined);
continue;
}
if (!_isTextEncoderNode(type)) continue;
const text = n.widgets_values?.[0];
if (text && typeof text === "string") { textMap.set(n.id, text); }
else if (Array.isArray(n.inputs)) {
@@ -2959,12 +3156,8 @@ function _extractPromptsFromNodeSet(nodes, links) {
if (textInput?.link != null) {
const originId = linkOrigin.get(textInput.link);
const originSlot = linkSlot.get(textInput.link) ?? 0;
const srcNode = originId != null ? nodeMap.get(originId) : null;
if (srcNode) {
const srcType = srcNode.type ?? "";
if (_isPromptStylerNode(srcType)) { const v = srcNode.widgets_values?.[originSlot]; if (v && typeof v === "string") textMap.set(n.id, v); }
else { const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0]; if (v && typeof v === "string") textMap.set(n.id, v); }
}
const text2 = _resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
if (text2) textMap.set(n.id, text2);
}
}
}
@@ -2973,24 +3166,44 @@ function _extractPromptsFromNodeSet(nodes, links) {
for (const n of nodes) {
if (!_isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
foundSampler = true;
for (const inp of n.inputs) {
// SamplerCustomAdvanced doesn't hold positive/negative directly — resolve through its
// "guider" input (CFGGuider/DualCFGGuider/BasicGuider) instead.
let inputsToScan = n.inputs;
const guiderInput = n.inputs.find(inp => inp.name === "guider");
if (guiderInput?.link != null) {
const guiderId = linkOrigin.get(guiderInput.link);
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
}
for (const inp of inputsToScan) {
if (!inp || inp.link == null) continue;
const originId = linkOrigin.get(inp.link);
if (originId == null) continue;
const name = inp.name ?? "";
const isPos = name === "positive" || name.startsWith("positive");
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
const isNeg = name === "negative" || name.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit — 1ノードでprompt/negative_prompt両方を直接持つため、
// textMap(1ノード1テキスト)では両ロールを表現できない
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 1ノードでprompt/negative_prompt両方を
// 直接持つため、textMap(1ノード1テキスト)では両ロールを表現できない
const srcNode = nodeMap.get(originId);
if (srcNode?.type === "TextEncodeMageFlowEdit") {
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
const txt = srcNode.widgets_values?.[widgetIdx];
if (txt && typeof txt === "string") { if (isPos) pos.add(txt); else neg.add(txt); }
continue;
}
const txt = textMap.get(originId);
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
// conditioning through its own positive/negative inputs — follow one more hop.
let resolvedOriginId = originId;
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
if (innerInput?.link != null) {
const innerId = linkOrigin.get(innerInput.link);
if (innerId != null) resolvedOriginId = innerId;
}
}
const txt = textMap.get(resolvedOriginId);
if (!txt) continue;
if (isPos) pos.add(txt); else neg.add(txt);
}
@@ -3040,13 +3253,29 @@ function _extractMarkdownNoteModels(wf) {
return hasAny ? result : null;
}
function _resolveLinkedText(wf, srcId, slot) {
function _resolveLinkedText(wf, srcId, slot, depth = 0) {
if (depth > 6) return null;
const src = wf[String(srcId)];
if (!src || typeof src !== "object") return null;
const ct = src.class_type ?? "";
if (_isPromptStylerNode(ct)) { const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative; return (v && typeof v === "string") ? v : null; }
const keys = slot === 0 ? ["text_positive", "text", "text_g", "prompt"] : ["text_negative", "text_l"];
for (const k of keys) { const v = src.inputs?.[k]; if (v && typeof v === "string") return v; }
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
if (ct === "ComfySwitchNode") {
for (const key of ["on_false", "on_true"]) {
const v = src.inputs?.[key];
if (Array.isArray(v)) { const text = _resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1); if (text) return text; }
else if (typeof v === "string" && v) return v;
}
return null;
}
// "source" = PreviewAny の値タップ中継、"string_a" = StringConcatenate の主オペランド
const keys = slot === 0 ? ["text_positive", "text", "text_g", "prompt", "value", "source", "string_a"] : ["text_negative", "text_l", "source", "string_a"];
for (const k of keys) {
const v = src.inputs?.[k];
if (typeof v === "string" && v) return v;
if (Array.isArray(v)) { const text = _resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1); if (text) return text; }
}
return null;
}
@@ -3059,8 +3288,23 @@ function _extractPromptsAPI(wf) {
}
const textMap = new Map();
for (const [id, n] of Object.entries(wf)) {
if (!n || !_isTextEncoderNode(n.class_type ?? "")) continue;
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
if (!n) continue;
const ct = n.class_type ?? "";
if (ct === "CLIPTextEncodeEditPlus") {
const resolveField = (key) => {
const v = n.inputs?.[key];
if (typeof v === "string") return v;
if (Array.isArray(v)) return _resolveLinkedText(wf, v[0], v[1] ?? 0);
return null;
};
const combined = _resolveEditPlusText(n.inputs?.mode, n.inputs?.text_edit, resolveField("text1"), resolveField("text2"));
if (combined) textMap.set(id, combined);
continue;
}
if (!_isTextEncoderNode(ct)) continue;
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
// model text encoders use this key instead of "text"/"text_g".
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
if (raw && typeof raw === "string") { textMap.set(id, raw); }
else if (Array.isArray(raw)) { const txt = _resolveLinkedText(wf, raw[0], raw[1] ?? 0); if (txt) textMap.set(id, txt); }
}
@@ -3069,20 +3313,34 @@ function _extractPromptsAPI(wf) {
for (const n of Object.values(wf)) {
if (!n || !_isSamplerNode(n.class_type ?? "")) continue;
foundSampler = true;
for (const [key, val] of Object.entries(n.inputs ?? {})) {
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
// node's inputs instead so the loop below can find the positive/negative-role keys.
let inputsToScan = n.inputs ?? {};
if (Array.isArray(n.inputs?.guider)) {
const guiderNode = wf[String(n.inputs.guider[0])];
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
}
for (const [key, val] of Object.entries(inputsToScan)) {
if (!Array.isArray(val)) continue;
const isPos = key === "positive" || key.startsWith("positive");
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
const isNeg = key === "negative" || key.startsWith("negative");
if (!isPos && !isNeg) continue;
// TextEncodeMageFlowEdit — 1ノードでprompt/negative_prompt両方を直接持つため、
// textMap(1ノード1テキスト)では両ロールを表現できない
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — 1ノードでprompt/negative_prompt両方を
// 直接持つため、textMap(1ノード1テキスト)では両ロールを表現できない
const srcNode = wf[String(val[0])];
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
const txt = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
if (txt && typeof txt === "string") { if (isPos) pos.add(txt); else neg.add(txt); }
continue;
}
const txt = textMap.get(String(val[0]));
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
// conditioning through its own positive/negative inputs — follow one more hop.
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
: String(val[0]);
const txt = textMap.get(resolvedId);
if (!txt) continue;
if (isPos) pos.add(txt); else neg.add(txt);
}
@@ -3494,11 +3752,38 @@ const AI_BACKEND_DEFAULT_URLS = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234",
lemonade: "http://localhost:13305",
unsloth: "http://localhost:8888",
};
function getAiBackendDefaultUrl(backend) {
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
}
// Server relays the request, attaching the Unsloth API key from .env so it
// never reaches the frontend — see py/routes/unsloth_routes.py.
async function aiUnslothProxy(baseUrl, path, method, payload) {
const r = await fetch("/api/wfm/unsloth/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl, path, method, payload }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.message || `HTTP ${r.status}`);
return data;
}
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
// `reasoning_content` field. Fold it back into a <think> block so the
// existing Thinking-mode show/strip logic (_aiApplyGenOptions/aiStripThinkingTags)
// still applies uniformly. Without this, a low max_tokens can make the model
// spend its whole budget reasoning and return an empty `content` with no
// visible explanation.
function _aiUnslothContent(message) {
const reasoning = message?.reasoning_content;
const content = message?.content || "";
return reasoning ? `<think>${reasoning}</think>${content}` : content;
}
function isValidAiUrl(url) {
try {
const u = new URL(url);
@@ -3517,22 +3802,51 @@ function saveAiCfg(patch) {
return d;
}
async function aiCallLLM(url, backend, model, prompt) {
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
// <think>タグ除去でも担保する(aiStripThinkingTagsを参照)。
function _aiApplyGenOptions(body, backend, settings) {
const maxTokens = parseInt(settings?.maxTokens, 10);
if (backend === "ollama") {
body.think = !!settings?.thinkingMode;
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
} else if (maxTokens > 0) {
body.max_tokens = maxTokens;
}
return body;
}
function aiStripThinkingTags(text) {
return (text || "")
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
.trim();
}
async function aiCallLLM(url, backend, model, prompt, settings = {}) {
if (backend === "ollama") {
const r = await fetch(`${url}/api/generate`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, stream: false }),
body: JSON.stringify(_aiApplyGenOptions({ model, prompt, stream: false }, backend, settings)),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).response || "";
const text = (await r.json()).response || "";
return settings.thinkingMode ? text : aiStripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _aiApplyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
const d = await aiUnslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _aiUnslothContent(d.choices?.[0]?.message);
return settings.thinkingMode ? text : aiStripThinkingTags(text);
} else {
const r = await fetch(`${url}/v1/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], stream: false }),
body: JSON.stringify(_aiApplyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings)),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
return d.choices?.[0]?.message?.content || "";
const text = d.choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : aiStripThinkingTags(text);
}
}
@@ -3548,54 +3862,99 @@ function aiFileToBase64(file) {
});
}
async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType) {
async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
if (backend === "ollama") {
const r = await fetch(`${url}/api/generate`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
body: JSON.stringify(_aiApplyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).response || "";
const text = (await r.json()).response || "";
return settings.thinkingMode ? text : aiStripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _aiApplyGenOptions({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}, backend, settings);
const d = await aiUnslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _aiUnslothContent(d.choices?.[0]?.message);
return settings.thinkingMode ? text : aiStripThinkingTags(text);
} else {
const r = await fetch(`${url}/v1/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(_aiApplyGenOptions({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}),
}, backend, settings)),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).choices?.[0]?.message?.content || "";
const text = (await r.json()).choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : aiStripThinkingTags(text);
}
}
async function aiCallChat(url, backend, model, messages) {
async function aiCallChat(url, backend, model, messages, settings = {}) {
if (backend === "ollama") {
const r = await fetch(`${url}/api/chat`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages, stream: false }),
body: JSON.stringify(_aiApplyGenOptions({ model, messages, stream: false }, backend, settings)),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).message?.content || "";
const text = (await r.json()).message?.content || "";
return settings.thinkingMode ? text : aiStripThinkingTags(text);
} else if (backend === "unsloth") {
const body = _aiApplyGenOptions({ model, messages, stream: false }, backend, settings);
const d = await aiUnslothProxy(url, "/v1/chat/completions", "POST", body);
const text = _aiUnslothContent(d.choices?.[0]?.message);
return settings.thinkingMode ? text : aiStripThinkingTags(text);
} else {
const r = await fetch(`${url}/v1/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages, stream: false }),
body: JSON.stringify(_aiApplyGenOptions({ model, messages, stream: false }, backend, settings)),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).choices?.[0]?.message?.content || "";
const text = (await r.json()).choices?.[0]?.message?.content || "";
return settings.thinkingMode ? text : aiStripThinkingTags(text);
}
}
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
function cleanAiTranslationOutput(raw) {
let s = (raw || "").trim();
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
if (s.length >= 2) {
const first = s[0], last = s[s.length - 1];
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
if (pairs[first] === last) s = s.slice(1, -1).trim();
}
return s;
}
// Heuristic: if the cleaned output is (near-)identical to the input, the
// model most likely echoed the source instead of translating it.
function aiLooksUntranslated(input, output) {
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
const a = normalize(input), b = normalize(output);
return a.length > 0 && a === b;
}
async function aiFetchModels(url, backend) {
if (backend === "ollama") {
const r = await fetch(`${url}/api/tags`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return ((await r.json()).models || []).map(m => m.name);
} else if (backend === "unsloth") {
const d = await aiUnslothProxy(url, "/v1/models", "GET");
return (d.data || []).map(m => m.id);
} else {
const r = await fetch(`${url}/v1/models`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
@@ -3747,6 +4106,7 @@ const renderAiTab = (container) => {
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="ollama"> Ollama</label>
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lmstudio"> LM Studio</label>
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lemonade"> Lemonade</label>
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="unsloth"> Unsloth</label>
</div>
</div>
<div class="wfm-nlp-ai-sec">
@@ -3766,6 +4126,18 @@ const renderAiTab = (container) => {
<button id="wfm-nlp-ai-model-refresh" class="wfm-nlp-ai-btn">↻</button>
</div>
</div>
<div class="wfm-nlp-ai-sec">
<div class="wfm-nlp-ai-sec-title">Generation</div>
<div class="wfm-nlp-ai-row">
<label class="wfm-nlp-ai-lbl" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="wfm-nlp-ai-thinking-mode"> Thinking mode
</label>
</div>
<div class="wfm-nlp-ai-row">
<span class="wfm-nlp-ai-lbl">Max tokens</span>
<input type="number" id="wfm-nlp-ai-max-tokens" class="wfm-nlp-ai-input" min="0" step="1" placeholder="0 = unlimited" style="flex:1;">
</div>
</div>
<div class="wfm-nlp-ai-sec">
<div class="wfm-nlp-ai-sec-title">Free language</div>
<div class="wfm-nlp-ai-row">
@@ -3819,6 +4191,11 @@ const setupAiHandlers = (container) => {
if (freeSrcInput && cfg.freeSrcLang) freeSrcInput.value = cfg.freeSrcLang;
if (freeDstInput && cfg.freeDstLang) freeDstInput.value = cfg.freeDstLang;
const thinkingModeInput = container.querySelector("#wfm-nlp-ai-thinking-mode");
if (thinkingModeInput) thinkingModeInput.checked = !!cfg.thinkingMode;
const maxTokensInput = container.querySelector("#wfm-nlp-ai-max-tokens");
if (maxTokensInput && cfg.maxTokens) maxTokensInput.value = cfg.maxTokens;
const srcSel = container.querySelector("#wfm-nlp-ai-src");
const dstSel = container.querySelector("#wfm-nlp-ai-dst");
if (srcSel && cfg.srcLang) srcSel.value = cfg.srcLang;
@@ -3850,10 +4227,13 @@ const setupAiHandlers = (container) => {
const srcLang = srcSel?.value || "ja";
const dstLang = dstSel?.value || "en";
const srcName = srcLang === "free" ? (c.freeSrcLang || "Auto") : AI_LANG_NAMES[srcLang];
const dstName = dstLang === "free" ? (c.freeDstLang || "English") : AI_LANG_NAMES[dstLang];
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
const prompt = `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
const srcName = srcLang === "free" ? (c.freeSrcLang || "").trim() : AI_LANG_NAMES[srcLang];
const dstName = dstLang === "free" ? (c.freeDstLang || "English").trim() || "English" : AI_LANG_NAMES[dstLang];
const fromPart = srcName ? ` from ${srcName}` : "";
const systemPrompt = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
+ `no surrounding quotes, and do not repeat or echo the original text.`;
const messages = [{ role: "system", content: systemPrompt }, { role: "user", content: text }];
const transBtn = container.querySelector("#wfm-nlp-ai-trans-btn");
const statusEl = container.querySelector("#wfm-nlp-ai-status");
@@ -3864,10 +4244,14 @@ const setupAiHandlers = (container) => {
outputEl.value = "";
try {
const result = await aiCallLLM(url, backend, model, prompt);
outputEl.value = result.trim();
const result = await aiCallChat(url, backend, model, messages, c);
const cleaned = cleanAiTranslationOutput(result);
outputEl.value = cleaned;
statusEl.textContent = "完了";
statusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";
if (srcLang !== dstLang && aiLooksUntranslated(text, cleaned)) {
showToast("モデルが原文をそのまま返しました。翻訳されていない可能性があります。別のモデルをお試しください。", "error");
}
} catch (err) {
statusEl.textContent = `エラー: ${err.message}`;
statusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
@@ -3941,11 +4325,13 @@ const setupAiHandlers = (container) => {
const model = container.querySelector("#wfm-nlp-ai-model")?.value || "";
const freeSrcLang = container.querySelector("#wfm-nlp-ai-free-src")?.value?.trim() || "";
const freeDstLang = container.querySelector("#wfm-nlp-ai-free-dst")?.value?.trim() || "";
const thinkingMode = !!container.querySelector("#wfm-nlp-ai-thinking-mode")?.checked;
const maxTokens = Math.max(0, parseInt(container.querySelector("#wfm-nlp-ai-max-tokens")?.value, 10) || 0);
if (url && !isValidAiUrl(url)) {
showToast("URLは http:// または https:// で始まる必要があります", "error");
return;
}
saveAiCfg({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
saveAiCfg({ backend, backendUrl: url, model, freeSrcLang, freeDstLang, thinkingMode, maxTokens });
showToast("Settings saved", "success");
});
@@ -3991,7 +4377,7 @@ const setupAiHandlers = (container) => {
const messagesToSend = skillPrompt
? [{ role: "system", content: skillPrompt }, ...chatHistory]
: chatHistory;
const reply = await aiCallChat(url, backend, model, messagesToSend);
const reply = await aiCallChat(url, backend, model, messagesToSend, c);
chatHistory.push({ role: "assistant", content: reply });
appendChatBubble("assistant", reply);
chatStatusEl.textContent = "";
@@ -4223,7 +4609,7 @@ const setupAiHandlers = (container) => {
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
try {
const result = await aiCallLLM(url, backend, model, prompt);
const result = await aiCallLLM(url, backend, model, prompt, c);
vlmResult.value = result.trim()
.split("\n")
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
@@ -4248,7 +4634,7 @@ const setupAiHandlers = (container) => {
vlmResult.value = "";
try {
const result = await aiCallVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
const result = await aiCallVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType, c);
vlmResult.value = result.trim();
vlmStatus.textContent = "完了";
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";